mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7c72bb56c |
+2
-5
@@ -10,9 +10,7 @@ dist-ssr
|
||||
*.local
|
||||
.vercel
|
||||
count.txt
|
||||
.env*
|
||||
!.env.local.example
|
||||
!.env.example
|
||||
.env
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
@@ -29,5 +27,4 @@ test-results
|
||||
convex/_generated/
|
||||
skills-lock.json
|
||||
*/skills/*
|
||||
skills/*
|
||||
.codex/*
|
||||
skills/*
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
@@ -26,7 +25,6 @@
|
||||
"@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",
|
||||
@@ -45,13 +43,12 @@
|
||||
"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",
|
||||
@@ -59,7 +56,6 @@
|
||||
"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",
|
||||
@@ -595,8 +591,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -967,26 +961,12 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1303,10 +1283,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1465,8 +1441,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1479,8 +1453,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1623,8 +1595,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
Vendored
+2
-2
@@ -91,7 +91,6 @@ 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";
|
||||
@@ -101,6 +100,7 @@ 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,7 +208,6 @@ 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;
|
||||
@@ -218,6 +217,7 @@ 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;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { internal } from "./_generated/api";
|
||||
import { api, 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;
|
||||
|
||||
@@ -229,7 +229,7 @@ export async function applyCommentScamResultInternalHandler(
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: !banResult.alreadyBanned,
|
||||
alreadyBanned: banResult.alreadyBanned,
|
||||
alreadyBanned: Boolean(banResult.alreadyBanned),
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
};
|
||||
|
||||
@@ -509,7 +509,7 @@ describe("comments mutations", () => {
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
if (id.startsWith("comments:reported-")) return reportedComment;
|
||||
if (String(id).startsWith("comments:reported-")) return reportedComment;
|
||||
if (id === "skills:active") {
|
||||
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
|
||||
@@ -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, isOwner || isStaff),
|
||||
evidence: sanitizeEvidence(mod.evidence, Boolean(isOwner || isStaff)),
|
||||
legacyReason: isOwner || isStaff ? mod.reason : null,
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Scheduler } from "convex/server";
|
||||
|
||||
export function scheduleNextBatchIfNeeded(
|
||||
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
|
||||
scheduler: Scheduler,
|
||||
fn: unknown,
|
||||
args: { cursor?: string } & Record<string, unknown>,
|
||||
args: TArgs,
|
||||
isDone: boolean,
|
||||
continueCursor: string | null,
|
||||
) {
|
||||
|
||||
@@ -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(encodedHeader, "header") as JwtHeader;
|
||||
const payload = parseJsonSegment(encodedPayload, "payload") as JwtPayload;
|
||||
const header = parseJsonSegment<JwtHeader>(encodedHeader, "header");
|
||||
const payload = parseJsonSegment<JwtPayload>(encodedPayload, "payload");
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
@@ -289,9 +289,9 @@ function decodeJwt(jwt: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonSegment(segment: string, label: string): unknown {
|
||||
function parseJsonSegment<T>(segment: string, label: string) {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T;
|
||||
} catch {
|
||||
throw new Error(`Invalid GitHub OIDC ${label}`);
|
||||
}
|
||||
|
||||
@@ -144,13 +144,13 @@ export async function deletePackageSearchDigests(
|
||||
}
|
||||
}
|
||||
|
||||
function hasDigestChanged(
|
||||
existing: Record<string, unknown>,
|
||||
fields: Record<string, unknown>,
|
||||
): boolean {
|
||||
function hasDigestChanged<
|
||||
TExisting extends Record<string, unknown>,
|
||||
TFields extends Record<string, unknown>,
|
||||
>(existing: TExisting, fields: TFields): boolean {
|
||||
for (const key of Object.keys(fields)) {
|
||||
const oldValue = existing[key];
|
||||
const newValue = fields[key];
|
||||
const oldValue = (existing as Record<string, unknown>)[key];
|
||||
const newValue = (fields as Record<string, unknown>)[key];
|
||||
if (oldValue === newValue) continue;
|
||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) return true;
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ function parseDependencyDeclarations(input: unknown): Array<{
|
||||
version?: string;
|
||||
url?: string;
|
||||
repository?: string;
|
||||
} = { name: obj.name.trim(), type: depType };
|
||||
} = { name: String(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 = frontmatter.primaryEnv.trim();
|
||||
metadata.primaryEnv = String(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 = frontmatter.author.trim();
|
||||
if (typeof frontmatter.author === "string") metadata.author = String(frontmatter.author).trim();
|
||||
|
||||
const links = parseSkillLinks(frontmatter.links);
|
||||
if (links) metadata.links = links;
|
||||
|
||||
if (typeof frontmatter.homepage === "string") {
|
||||
metadata.homepage = frontmatter.homepage.trim();
|
||||
metadata.homepage = String(frontmatter.homepage).trim();
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length > 0
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("skills.getPendingScanSkillsInternal", () => {
|
||||
const versionId = skill.latestVersionId as string;
|
||||
return [
|
||||
versionId,
|
||||
{ _id: versionId, sha256hash: `${versionId.slice(-8)}${"f".repeat(56)}` },
|
||||
{ _id: versionId, sha256hash: `${String(versionId).slice(-8)}${"f".repeat(56)}` },
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 = maybeId ?? tableOrId;
|
||||
const key = String(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) =>
|
||||
id.startsWith(`${tableName}:`) ? id : null,
|
||||
String(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 = maybeId ?? tableOrId;
|
||||
const key = String(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) =>
|
||||
id.startsWith(`${tableName}:`) ? id : null,
|
||||
String(id).startsWith(`${tableName}:`) ? id : null,
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
+5
-2
@@ -4794,6 +4794,8 @@ export const escalateByVtInternal = internalMutation({
|
||||
slug: skill.slug,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, skillId: version.skillId, versionId: version._id };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6262,8 +6264,9 @@ 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 =
|
||||
user.trustedPublisher || user.role === "admin" || user.role === "moderator";
|
||||
const isTrustedPublisher = Boolean(
|
||||
user.trustedPublisher || user.role === "admin" || user.role === "moderator",
|
||||
);
|
||||
const staticSnapshot = buildModerationSnapshot({
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 394 KiB |
@@ -0,0 +1,182 @@
|
||||
# Logo Replacement Design
|
||||
|
||||
Date: 2026-04-21
|
||||
Topic: Comprehensive logo replacement using the provided lobster artwork
|
||||
|
||||
## Summary
|
||||
|
||||
Replace every current application logo surface with the user-provided lobster artwork while preserving the existing UI layout and copy. This includes in-app logo images, favicon and install icon assets, and manifest/head wiring. The existing wide social preview image `public/og.png` remains unchanged. Instead, `public/og-logo.png` is included in the replacement asset pack as a standalone logo export and is not wired into site metadata.
|
||||
|
||||
## Goals
|
||||
|
||||
- Replace all current logo imagery with the provided lobster art.
|
||||
- Preserve existing layout structure in header, mobile navigation, and hero content.
|
||||
- Provide dedicated asset files for browser, install, and app surfaces rather than relying on one large source image everywhere.
|
||||
- Keep runtime references stable where possible by replacing existing filenames in place.
|
||||
- Improve browser/device logo behavior by adding standard favicon and touch icon variants.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No header, navigation, or hero layout redesign.
|
||||
- No typography or copy changes to the `ClawHub` wordmark text.
|
||||
- No change to the existing social preview card asset `public/og.png`.
|
||||
- No full vector redraw of the lobster artwork from scratch.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Replace:
|
||||
- `public/clawd-logo.png`
|
||||
- `public/clawd-mark.png`
|
||||
- `public/logo192.png`
|
||||
- `public/logo512.png`
|
||||
- `public/favicon.ico`
|
||||
- Add or update:
|
||||
- `public/favicon-16x16.png`
|
||||
- `public/favicon-32x32.png`
|
||||
- `public/apple-touch-icon.png`
|
||||
- `public/logo.jpg`
|
||||
- `public/logo.svg`
|
||||
- `public/og-logo.png`
|
||||
- Update runtime/browser metadata:
|
||||
- root document link tags in `src/routes/__root.tsx`
|
||||
- `public/manifest.json`
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- `public/og.png`
|
||||
- Any route-level social metadata currently using `og.png`
|
||||
- Any non-logo artwork or unrelated illustration assets
|
||||
|
||||
## Current State
|
||||
|
||||
- The app currently references `public/clawd-logo.png` in the desktop and mobile header.
|
||||
- The homepage hero references `public/clawd-mark.png`.
|
||||
- The root document exposes `/favicon.ico`, `/logo192.png`, and `/manifest.json`.
|
||||
- The web app manifest references `favicon.ico`, `logo192.png`, and `logo512.png`.
|
||||
- The site-wide OG metadata still references `og.png`.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use the provided lobster image as the master artwork and derive a small asset pack tailored to each output surface.
|
||||
|
||||
Why this approach:
|
||||
|
||||
- It satisfies the request to replace the logo everywhere it appears.
|
||||
- It avoids visual degradation from blindly reusing one oversized raster in tiny favicon contexts.
|
||||
- It minimizes application code changes by preserving the established filenames used by the UI.
|
||||
|
||||
## Asset Plan
|
||||
|
||||
### Master Asset
|
||||
|
||||
Create one high-resolution square source derived from the attached lobster artwork. This will be the basis for all exported formats.
|
||||
|
||||
### Replacement Assets
|
||||
|
||||
- `clawd-logo.png`
|
||||
- High-resolution square PNG used by header/mobile brand image references.
|
||||
- `clawd-mark.png`
|
||||
- High-resolution square PNG used by hero/logo-only surfaces.
|
||||
- `logo192.png`
|
||||
- 192×192 install icon.
|
||||
- `logo512.png`
|
||||
- 512×512 install icon.
|
||||
- `favicon.ico`
|
||||
- Multi-size favicon generated from the same master for browser tab use.
|
||||
- `favicon-16x16.png`
|
||||
- Explicit raster favicon for browsers that prefer PNG.
|
||||
- `favicon-32x32.png`
|
||||
- Explicit raster favicon for higher-density tab/bookmark use.
|
||||
- `apple-touch-icon.png`
|
||||
- 180×180 touch icon for iOS home screen usage.
|
||||
- `logo.jpg`
|
||||
- Flattened JPEG export for contexts where a non-transparent logo file is useful.
|
||||
- `logo.svg`
|
||||
- SVG wrapper asset that embeds the logo image in an SVG container so an SVG logo file exists for downstream usage without falsely claiming the art is natively vector.
|
||||
- `og-logo.png`
|
||||
- Logo-focused branded raster asset retained separately from the existing wide social card `og.png`.
|
||||
|
||||
## Runtime Wiring
|
||||
|
||||
### Application UI
|
||||
|
||||
- Keep existing JSX references to `clawd-logo.png` and `clawd-mark.png` unless a clearer dedicated asset path becomes necessary.
|
||||
- Do not replace image elements with text or SVG components.
|
||||
|
||||
### Root Head Tags
|
||||
|
||||
Update `src/routes/__root.tsx` to use dedicated icon assets:
|
||||
|
||||
- `rel="icon"` should include PNG favicon variants in addition to the ICO.
|
||||
- `rel="apple-touch-icon"` should point to `apple-touch-icon.png`.
|
||||
- `rel="manifest"` remains `manifest.json`.
|
||||
- OG/Twitter metadata remains wired to `og.png` and is not changed.
|
||||
|
||||
### Web App Manifest
|
||||
|
||||
Update `public/manifest.json` so install surfaces reference the replacement icon assets. Keep the manifest conservative and omit maskable-specific `purpose` values for this change.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Start from the provided lobster artwork.
|
||||
2. Export optimized raster variants for each target size.
|
||||
3. Replace or add files in `public/`.
|
||||
4. Update root document links and manifest entries.
|
||||
5. Build the app and verify the logo surfaces still render without layout regressions.
|
||||
|
||||
## Error Handling And Risks
|
||||
|
||||
### Small-Size Legibility
|
||||
|
||||
Risk: the artwork is detailed and may lose clarity at favicon sizes.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Generate dedicated 16×16 and 32×32 outputs instead of relying only on browser downscaling.
|
||||
- Prefer the ICO plus PNG favicon set to maximize compatibility.
|
||||
|
||||
### Raster-As-Vector Expectations
|
||||
|
||||
Risk: a pure SVG redraw would be time-consuming and subjective.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Provide `logo.svg` as an SVG container asset, while using raster files for browser/runtime surfaces that need visual fidelity.
|
||||
|
||||
### Unintended Social Preview Changes
|
||||
|
||||
Risk: a broad asset refresh accidentally changes OG behavior.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Explicitly leave `og.png` and its metadata references untouched.
|
||||
- Treat `og-logo.png` as a separate logo asset only.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
- Confirm the generated files exist in `public/` with expected dimensions.
|
||||
- Run the production build to ensure asset references still resolve.
|
||||
- Spot-check the following surfaces:
|
||||
- desktop header brand image
|
||||
- mobile navigation brand image
|
||||
- homepage hero lobster image
|
||||
- browser favicon and touch icon wiring
|
||||
- manifest icon references
|
||||
- Verify that `og.png` remains unchanged and the site metadata still references it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Use minimal code churn: replace files in place where existing paths are already correct.
|
||||
- Add new icon files only where they improve browser/device handling.
|
||||
- Keep the change tightly scoped to branding assets and metadata.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Every current application logo surface displays the provided lobster artwork instead of the previous brand image.
|
||||
- Favicon, touch icon, and install icons resolve to replacement assets.
|
||||
- Header/mobile/hero layout remains unchanged.
|
||||
- `og.png` is not modified.
|
||||
- `og-logo.png` exists as part of the updated asset pack.
|
||||
- The app builds successfully after the change.
|
||||
@@ -1,308 +0,0 @@
|
||||
# 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.
|
||||
+2
-5
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build && bun scripts/copy-og-assets.ts",
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
@@ -52,7 +52,6 @@
|
||||
"@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",
|
||||
@@ -71,13 +70,12 @@
|
||||
"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",
|
||||
@@ -85,7 +83,6 @@
|
||||
"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"
|
||||
|
||||
@@ -178,7 +178,7 @@ function addConfigRoots(
|
||||
|
||||
const extraDirs = config.skills?.load?.extraDirs ?? [];
|
||||
for (const dir of extraDirs) {
|
||||
const resolved = resolveUserPath(dir);
|
||||
const resolved = resolveUserPath(String(dir));
|
||||
if (!resolved) continue;
|
||||
const label = `${prefix}Extra: ${basename(resolved) || resolved}`;
|
||||
pushRoot(roots, labels, resolved, label);
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function cmdLoginFlow(
|
||||
fail("Token required (use --token or remove --no-browser)");
|
||||
}
|
||||
|
||||
const label = (options.label ?? "CLI token").trim() || "CLI token";
|
||||
const label = String(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;
|
||||
|
||||
@@ -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 undefined;
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
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 undefined;
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
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([(expected as { ref?: string }).ref ?? ""])
|
||||
? mockGitHubCommitLookup([String((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 undefined;
|
||||
if (!resolved) return;
|
||||
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 undefined;
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const spinner = createSpinner(`Banning ${resolved.label}`);
|
||||
@@ -90,11 +90,11 @@ export async function cmdSetRole(
|
||||
{ id: options.id, fuzzy: options.fuzzy },
|
||||
allowPrompt,
|
||||
);
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved) return;
|
||||
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 undefined;
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
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;
|
||||
return fail("Role must be user|moderator|admin");
|
||||
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 undefined;
|
||||
if (!confirmed) return;
|
||||
|
||||
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 undefined;
|
||||
if (!confirmed) return;
|
||||
|
||||
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) => file.name ?? "")
|
||||
.map((file) => String(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";
|
||||
return fail("Could not detect package family. Use --family.");
|
||||
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) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]);
|
||||
expect(files.map((file) => String(file.name ?? "")).sort()).toEqual(["SKILL.md", "notes.md"]);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -478,7 +478,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
|
||||
if (normalized === "trending") {
|
||||
return { sort: "trending", apiSort: "trending" };
|
||||
}
|
||||
return fail(
|
||||
fail(
|
||||
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 undefined;
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
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(key)).filter(Boolean) as Candidate[];
|
||||
const selected = picked.map((key) => valueByKey.get(String(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 undefined;
|
||||
if (!confirmed) return;
|
||||
|
||||
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 undefined;
|
||||
if (!confirmed) return;
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
|
||||
@@ -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 undefined;
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
|
||||
@@ -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 answer;
|
||||
return Boolean(answer);
|
||||
}
|
||||
|
||||
export function openInBrowser(url: string) {
|
||||
@@ -70,7 +70,7 @@ export function openInBrowser(url: string) {
|
||||
}
|
||||
|
||||
export function isInteractive() {
|
||||
return process.stdout.isTTY && stdin.isTTY;
|
||||
return Boolean(process.stdout.isTTY && stdin.isTTY);
|
||||
}
|
||||
|
||||
export function createSpinner(text: string) {
|
||||
|
||||
@@ -381,7 +381,7 @@ function getRetryDelayMs(attemptError: unknown, random: () => number): number {
|
||||
cause?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
const attemptNumber = Math.max(1, failed.attemptNumber ?? 1);
|
||||
const attemptNumber = Math.max(1, Number(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}=${value}`);
|
||||
formArgs.push("-F", `${key}=${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ export async function readSkillOrigin(skillFolder: string): Promise<SkillOrigin
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
registry: parsed.registry,
|
||||
slug: parsed.slug,
|
||||
installedVersion: parsed.installedVersion,
|
||||
registry: String(parsed.registry),
|
||||
slug: String(parsed.slug),
|
||||
installedVersion: String(parsed.installedVersion),
|
||||
installedAt: parsed.installedAt,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -152,89 +152,6 @@ 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;
|
||||
@@ -495,6 +412,13 @@ 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();
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,6 @@ 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]);
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
/* @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  images through /_vercel/image", () => {
|
||||
const container = renderMarkdown(``);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +1,94 @@
|
||||
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 { parse } from "@create-markdown/core";
|
||||
import { blocksToHTML, renderAsync, shikiPlugin } from "@create-markdown/preview";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
|
||||
/** Enable Shiki syntax highlighting for code blocks (async). Default: true */
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [highlighter, setHighlighter] = useState<AnyHighlighter | null>(null);
|
||||
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 "";
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (highlight) {
|
||||
loadHighlighter()
|
||||
.then((h) => {
|
||||
if (!cancelled) setHighlighter(h);
|
||||
|
||||
// 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));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Shiki failed to initialize — keep plain rendering.
|
||||
// Shiki failed to load — keep the sync render
|
||||
});
|
||||
} catch {
|
||||
// Parse failed — clear
|
||||
setHtml("");
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [highlight]);
|
||||
|
||||
const rehypePlugins = useMemo<PluggableList>(() => {
|
||||
if (highlight && highlighter) {
|
||||
return [
|
||||
...baseRehype,
|
||||
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }],
|
||||
];
|
||||
}
|
||||
return baseRehype;
|
||||
}, [highlight, highlighter]);
|
||||
}, [children, highlight]);
|
||||
|
||||
return (
|
||||
<div className={cn("markdown", className)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("markdown", className)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,26 +236,29 @@ export function SkillDetailPage({
|
||||
}, [navigate, ownerParam, slug, wantsCanonicalRedirect]);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
});
|
||||
if (!latestVersion) return;
|
||||
if (loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReadme(null);
|
||||
setReadmeError(null);
|
||||
setLoadedReadmeVersionId(latestVersion._id);
|
||||
let cancelled = false;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -2,11 +2,8 @@ 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 })),
|
||||
);
|
||||
@@ -99,9 +96,7 @@ export function SkillDetailTabs({
|
||||
<div className="tab-body">
|
||||
{readmeContent ? (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={REHYPE_PLUGINS}>
|
||||
{readmeContent}
|
||||
</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
|
||||
</div>
|
||||
) : readmeError ? (
|
||||
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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";
|
||||
@@ -124,7 +123,6 @@ 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 (
|
||||
<>
|
||||
@@ -299,14 +297,6 @@ 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>
|
||||
@@ -405,17 +395,13 @@ 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">)}
|
||||
>
|
||||
@@ -426,7 +412,7 @@ export function SkillHeader({
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">
|
||||
Update Tag
|
||||
Update tag
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/* @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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
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,5 +1,8 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
|
||||
@@ -95,21 +95,20 @@ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
|
||||
}, [ensureSoulSeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestVersion) return;
|
||||
setReadme(null);
|
||||
setReadmeError(null);
|
||||
let cancelled = false;
|
||||
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);
|
||||
});
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,5 @@
|
||||
import type { ClawdisSkillMetadata, SkillInstallSpec } from "clawhub-schema";
|
||||
import type { 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,
|
||||
@@ -170,101 +151,6 @@ 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`;
|
||||
|
||||
@@ -141,6 +141,9 @@ 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("");
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -52,18 +52,18 @@ export function normalizePackageUploadPath(
|
||||
return parts.slice(1).join("/") || (parts.at(-1) ?? "");
|
||||
}
|
||||
|
||||
function getRawUploadPath(file: UploadablePackageFile) {
|
||||
function getRawUploadPath<TFile extends UploadablePackageFile>(file: TFile) {
|
||||
return file.webkitRelativePath?.trim() || file.name;
|
||||
}
|
||||
|
||||
function getNormalizedUploadPath(
|
||||
file: UploadablePackageFile,
|
||||
function getNormalizedUploadPath<TFile extends UploadablePackageFile>(
|
||||
file: TFile,
|
||||
options: NormalizePackageUploadPathOptions = {},
|
||||
) {
|
||||
return normalizePackageUploadPath(getRawUploadPath(file), options) || file.name;
|
||||
}
|
||||
|
||||
function shouldStripSharedTopLevelFolder(files: UploadablePackageFile[]) {
|
||||
function shouldStripSharedTopLevelFolder<TFile extends UploadablePackageFile>(files: TFile[]) {
|
||||
if (files.length === 0) return false;
|
||||
const partsList = files
|
||||
.map((file) => getNormalizedUploadPath(file))
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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`,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -29,5 +29,5 @@ export function isDevRuntime() {
|
||||
if (nodeEnv) {
|
||||
return nodeEnv !== "production";
|
||||
}
|
||||
return import.meta.env.DEV;
|
||||
return Boolean(import.meta.env.DEV);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ describe("theme", () => {
|
||||
<button type="button" onClick={() => setMode("dark")}>
|
||||
dark
|
||||
</button>
|
||||
<button type="button" onClick={() => setFamily("claw")}>
|
||||
claw
|
||||
<button type="button" onClick={() => setFamily("hub")}>
|
||||
hub
|
||||
</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] = value;
|
||||
store[key] = String(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: "claw", mode: "light" });
|
||||
expect(getStoredThemeSelection()).toEqual({ theme: "hub", 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", "claw");
|
||||
applyTheme("dark", "hub");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("claw");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("hub");
|
||||
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: "claw" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "hub" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "dark" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("claw");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("hub");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
|
||||
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("claw");
|
||||
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -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)');
|
||||
|
||||
@@ -55,7 +55,7 @@ export function useUnifiedSearch(
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
|
||||
requestRef.current += 1;
|
||||
|
||||
@@ -9,12 +9,11 @@ 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): ReactNode[] {
|
||||
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
|
||||
@@ -463,7 +463,7 @@ export function ImportGitHub() {
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected[file.path]}
|
||||
checked={Boolean(selected[file.path])}
|
||||
onChange={() =>
|
||||
setSelected((prev) => ({ ...prev, [file.path]: !prev[file.path] }))
|
||||
}
|
||||
|
||||
+12
-11
@@ -284,18 +284,19 @@ function SkillsHome() {
|
||||
});
|
||||
}
|
||||
|
||||
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
|
||||
const drawClaw = (ctx: CanvasRenderingContext2D, size: number) => {
|
||||
// Simple lobster claw shape
|
||||
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();
|
||||
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();
|
||||
};
|
||||
|
||||
let raf: number;
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let alive = false;
|
||||
@@ -331,13 +332,13 @@ function SkillsHome() {
|
||||
ctx.restore();
|
||||
}
|
||||
if (alive) {
|
||||
requestAnimationFrame(draw);
|
||||
raf = requestAnimationFrame(draw);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.style.display = "none";
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(draw);
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
|
||||
|
||||
@@ -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)]">
|
||||
{value}
|
||||
{String(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(entry.skill.ownerUserId)}/${entry.skill.slug}`}
|
||||
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
|
||||
>
|
||||
<span>{entry.skill.displayName}</span>
|
||||
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
|
||||
|
||||
-351
@@ -3373,323 +3373,6 @@ 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;
|
||||
@@ -4527,14 +4210,6 @@ code {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.skill-install-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.skill-install-command-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.skill-hero-cta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
@@ -6321,32 +5996,6 @@ 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;
|
||||
}
|
||||
|
||||
+1
-27
@@ -30,31 +30,5 @@
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user