feat: add owner rescan security surfaces

This commit is contained in:
Patrick Erichsen
2026-04-28 00:39:44 -07:00
parent 4cda4a1fa4
commit ef2846b2e4
54 changed files with 3577 additions and 2049 deletions
+26 -2
View File
@@ -78,6 +78,8 @@ describe("devSeed rescan UX fixtures", () => {
flaggedSkillMd: "# Flagged skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
};
await seedRescanUxFixturesHandler({ db } as never, args as never);
@@ -96,14 +98,36 @@ describe("devSeed rescan UX fixtures", () => {
moderationVerdict: "malicious",
}),
);
expect(tables.packages).toHaveLength(1);
expect(tables.packages?.[0]).toEqual(
expect(tables.packages).toHaveLength(2);
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "malicious",
}),
);
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "suspicious",
}),
);
const scannedPackage = tables.packages?.find(
(pkg) => pkg.name === "local-scanned-runtime-plugin",
);
const scannedRelease = tables.packageReleases?.find(
(release) => release.packageId === scannedPackage?._id,
);
expect(scannedRelease).toEqual(
expect.objectContaining({
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: expect.objectContaining({ status: "clean" }),
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
staticScan: expect.objectContaining({ status: "suspicious" }),
}),
);
const skillRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
+281 -6
View File
@@ -2,11 +2,13 @@ import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalMutation as rawInternalMutation } from "./_generated/server";
import { internalAction, internalMutation } from "./functions";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
import { normalizePackageName } from "./lib/packageRegistry";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
import { generateToken, hashToken } from "./lib/tokens";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
type SeedSkillSpec = {
@@ -32,6 +34,7 @@ type SeedMutationResult = Record<string, unknown>;
const LOCAL_SEED_HANDLE = "local";
const FLAGGED_SKILL_SLUG = "local-flagged-wallet-sync";
const FLAGGED_PLUGIN_NAME = "local-flagged-runtime-plugin";
const SCANNED_PLUGIN_NAME = "local-scanned-runtime-plugin";
const FLAGGED_SKILL_MD = `---
name: local-flagged-wallet-sync
description: Local dev fixture for flagged dashboard and rescan UI.
@@ -47,6 +50,17 @@ const FLAGGED_PLUGIN_README = `# Local Flagged Runtime Plugin
This seeded plugin is intentionally flagged so local development can exercise plugin owner
inventory and cap-exhausted rescan UI.
`;
const SCANNED_PLUGIN_README = `# Local Scanned Runtime Plugin
This seeded plugin is public and intentionally has completed scan results so local development can
preview plugin scanner detail pages without owner-only visibility.
`;
type RoleHelpFixtureUser = {
handle: string;
displayName: string;
role: "admin" | "user";
};
const SEED_SKILLS: SeedSkillSpec[] = [
{
@@ -368,9 +382,11 @@ async function seedNixSkillsHandler(
results.push({ slug: spec.slug, ...result });
}
const [flaggedSkillStorageId, flaggedPluginStorageId] = await Promise.all([
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
await Promise.all([
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
]);
const fixtureResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedRescanUxFixturesMutation,
@@ -380,6 +396,8 @@ async function seedNixSkillsHandler(
flaggedSkillMd: FLAGGED_SKILL_MD,
flaggedPluginStorageId,
flaggedPluginReadme: FLAGGED_PLUGIN_README,
scannedPluginStorageId,
scannedPluginReadme: SCANNED_PLUGIN_README,
},
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
@@ -506,8 +524,8 @@ async function findSeedSkillFixture(ctx: MutationCtx) {
.unique();
}
async function deleteSeedPluginFixture(ctx: MutationCtx) {
const existing = await findSeedPluginFixture(ctx);
async function deleteSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
const existing = await findSeedPluginFixtureByName(ctx, name);
if (!existing) return;
const releases = await ctx.db
@@ -521,13 +539,29 @@ async function deleteSeedPluginFixture(ctx: MutationCtx) {
await ctx.db.delete(existing._id);
}
async function findSeedPluginFixture(ctx: MutationCtx) {
async function deleteSeedPluginFixture(ctx: MutationCtx) {
await deleteSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
}
async function deleteScannedPluginFixture(ctx: MutationCtx) {
await deleteSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
async function findSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
return await ctx.db
.query("packages")
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(FLAGGED_PLUGIN_NAME)))
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(name)))
.unique();
}
async function findSeedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
}
async function findScannedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
function staticMaliciousScan(now: number) {
return {
status: "malicious" as const,
@@ -548,6 +582,26 @@ function staticMaliciousScan(now: number) {
};
}
function staticSuspiciousScan(now: number) {
return {
status: "suspicious" as const,
reasonCodes: ["suspicious.local_dev_fixture"],
findings: [
{
code: "suspicious.local_dev_fixture",
severity: "warn" as const,
file: "README.md",
line: 3,
message: "Local dev fixture exercises scanner evidence UI for a public plugin.",
evidence: "runtime plugin requests local tool execution",
},
],
summary: "Local dev fixture completed static analysis with a suspicious finding.",
engineVersion: "local-dev-fixture",
checkedAt: now,
};
}
async function insertCompletedRescanRequests(
ctx: MutationCtx,
params:
@@ -602,6 +656,8 @@ type SeedRescanUxFixturesArgs = {
flaggedSkillMd: string;
flaggedPluginStorageId: Id<"_storage">;
flaggedPluginReadme: string;
scannedPluginStorageId: Id<"_storage">;
scannedPluginReadme: string;
};
export async function seedRescanUxFixturesHandler(
@@ -610,7 +666,8 @@ export async function seedRescanUxFixturesHandler(
) {
const existingSkill = await findSeedSkillFixture(ctx);
const existingPlugin = await findSeedPluginFixture(ctx);
if (existingSkill && existingPlugin && !args.reset) {
const existingScannedPlugin = await findScannedPluginFixture(ctx);
if (existingSkill && existingPlugin && existingScannedPlugin && !args.reset) {
return {
ok: true,
skipped: true,
@@ -620,15 +677,19 @@ export async function seedRescanUxFixturesHandler(
flaggedSkillVersionId: existingSkill.latestVersionId,
flaggedPluginId: existingPlugin._id,
flaggedPluginReleaseId: existingPlugin.latestReleaseId,
scannedPluginId: existingScannedPlugin._id,
scannedPluginReleaseId: existingScannedPlugin.latestReleaseId,
};
}
await deleteSeedSkillFixture(ctx);
await deleteSeedPluginFixture(ctx);
await deleteScannedPluginFixture(ctx);
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const staticScan = staticMaliciousScan(now);
const scannedStaticScan = staticSuspiciousScan(now);
const skillId = await ctx.db.insert("skills", {
slug: FLAGGED_SKILL_SLUG,
@@ -859,6 +920,136 @@ export async function seedRescanUxFixturesHandler(
count: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
now,
});
const scannedPackageId = await ctx.db.insert("packages", {
name: SCANNED_PLUGIN_NAME,
normalizedName: normalizePackageName(SCANNED_PLUGIN_NAME),
displayName: "Local Scanned Runtime Plugin",
summary: "Seeded public plugin with completed security scans for scanner page previews.",
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "local.scanned.runtime",
sourceRepo: "openclaw/local-dev-fixture",
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: ["dev-tools", "security"],
executesCode: true,
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
scanStatus: "suspicious",
stats: { downloads: 7, installs: 1, stars: 1, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const scannedPackageReleaseId = await ctx.db.insert("packageReleases", {
packageId: scannedPackageId,
version: "0.1.0",
changelog: "Seeded public scanned release for plugin scanner page previews.",
summary: "Seeded scanned plugin release.",
distTags: ["latest"],
files: [
{
path: "README.md",
size: args.scannedPluginReadme.length,
storageId: args.scannedPluginStorageId,
sha256: "seeded-scanned-plugin",
contentType: "text/markdown",
},
],
integritySha256: "seeded-scanned-plugin-integrity",
extractedPackageJson: {
name: SCANNED_PLUGIN_NAME,
version: "0.1.0",
},
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: {
status: "clean",
verdict: "clean",
analysis: "Local dev fixture scanned clean by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "medium",
summary: "Local dev fixture flagged for review because it executes local tools.",
dimensions: [
{
name: "execution",
label: "Local execution",
rating: "concern",
detail: "Runtime plugin executes local tooling and should be reviewed before install.",
},
],
guidance: "Review the runtime command surface before trusting this plugin.",
findings: "The fixture is intentionally safe, but models a plugin with reviewable behavior.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan: scannedStaticScan,
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(scannedPackageId, {
latestReleaseId: scannedPackageReleaseId,
latestVersionSummary: {
version: "0.1.0",
createdAt: now,
changelog: "Seeded public scanned release for plugin scanner page previews.",
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
},
tags: { latest: scannedPackageReleaseId },
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 5,
totalStars: 1,
@@ -874,6 +1065,8 @@ export async function seedRescanUxFixturesHandler(
flaggedSkillVersionId: skillVersionId,
flaggedPluginId: packageId,
flaggedPluginReleaseId: packageReleaseId,
scannedPluginId: scannedPackageId,
scannedPluginReleaseId: scannedPackageReleaseId,
};
}
@@ -884,10 +1077,92 @@ export const seedRescanUxFixturesMutation = internalMutation({
flaggedSkillMd: v.string(),
flaggedPluginStorageId: v.id("_storage"),
flaggedPluginReadme: v.string(),
scannedPluginStorageId: v.id("_storage"),
scannedPluginReadme: v.string(),
},
handler: seedRescanUxFixturesHandler,
});
export const seedCliRoleHelpFixtures = rawInternalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const admin = await upsertRoleHelpFixtureUser(ctx, {
handle: "cli-admin",
displayName: "CLI Admin",
role: "admin",
});
const user = await upsertRoleHelpFixtureUser(ctx, {
handle: "cli-user",
displayName: "CLI User",
role: "user",
});
const adminToken = await replaceRoleHelpFixtureToken(ctx, admin._id, now);
const userToken = await replaceRoleHelpFixtureToken(ctx, user._id, now);
return {
ok: true,
admin: { handle: admin.handle, role: admin.role, token: adminToken },
user: { handle: user.handle, role: user.role, token: userToken },
};
},
});
async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixtureUser) {
const now = Date.now();
const existing = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", user.handle))
.unique();
const patch = {
handle: user.handle,
displayName: user.displayName,
role: user.role,
deletedAt: undefined,
deactivatedAt: undefined,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return { ...existing, ...patch };
}
const userId = await ctx.db.insert("users", {
...patch,
createdAt: now,
});
const created = await ctx.db.get(userId);
if (!created) throw new Error(`Failed to create ${user.handle}`);
return created;
}
async function replaceRoleHelpFixtureToken(
ctx: MutationCtx,
userId: Id<"users">,
now: number,
) {
const existingTokens = await ctx.db
.query("apiTokens")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
for (const token of existingTokens) {
if (token.label === "CLI role help e2e") {
await ctx.db.patch(token._id, { revokedAt: now });
}
}
const { token, prefix } = generateToken();
await ctx.db.insert("apiTokens", {
userId,
label: "CLI role help e2e",
prefix,
tokenHash: await hashToken(token),
createdAt: now,
lastUsedAt: undefined,
revokedAt: undefined,
});
return token;
}
export const seedSkillMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
+81
View File
@@ -2114,6 +2114,7 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200);
const json = await response.json();
expect(json.user.handle).toBe("p");
expect(json.user.role).toBeNull();
});
it("delete and undelete require auth", async () => {
@@ -2162,6 +2163,86 @@ describe("httpApiV1 handlers", () => {
expect(response2.status).toBe(200);
});
it("skill rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "skill",
name: args.slug,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/demo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "skill",
name: "demo",
remainingRequests: 2,
maxRequests: 3,
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", slug: "demo" }),
);
});
it("package rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "package",
name: args.name,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/packages/%40scope%2Fdemo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "package",
name: "@scope/demo",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", name: "@scope/demo" }),
);
});
it("transfer request requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
+26
View File
@@ -62,6 +62,7 @@ const internalRefs = internal as unknown as {
getReleaseByPackageAndVersionInternal: unknown;
getReleaseByIdInternal: unknown;
insertAuditLogInternal: unknown;
requestRescanForApiTokenInternal: unknown;
softDeletePackageInternal: unknown;
};
packagePublishTokens: {
@@ -823,6 +824,31 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments[1] === "rescan" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.packages.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
name: segments[0]!,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
return text("Not found", 404);
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { api, internal } from "../_generated/api";
import { normalizeTextContentType } from "clawhub-schema";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
@@ -231,6 +231,16 @@ type SkillSecuritySnapshot = {
};
};
const internalRefs = internal as unknown as {
skills: {
requestRescanForApiTokenInternal: unknown;
};
};
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runMutation(ref as never, args as never)) as T;
}
function isDefinitiveSecurityStatus(
status: NormalizedSecurityStatus | null | undefined,
): status is "clean" | "suspicious" | "malicious" {
@@ -1165,6 +1175,29 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
}
}
if (segments.length === 2 && action === "rescan") {
if (!slug) return text("Slug required", 400, rate.headers);
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.skills.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
slug,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (action === "transfer") {
return handleSkillsTransferPost(ctx, request, segments, rate.headers);
}
+1
View File
@@ -15,6 +15,7 @@ export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
role: user.role ?? null,
},
},
200,
+9
View File
@@ -2614,6 +2614,15 @@ describe("packages public queries", () => {
})),
};
}
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
+110 -11
View File
@@ -11,7 +11,14 @@ import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { action, internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import {
action,
internalAction,
internalMutation,
internalQuery,
mutation,
query,
} from "./functions";
import {
assertAdmin,
assertModerator,
@@ -48,10 +55,7 @@ import {
import { tokenize } from "./lib/searchText";
import { hashSkillFiles } from "./lib/skills";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import {
getLatestPackageRescanTarget,
insertPackageRescanRequest,
} from "./model/packages/rescans";
import { getLatestPackageRescanTarget, insertPackageRescanRequest } from "./model/packages/rescans";
import {
assertCanRequestRescan,
buildRescanState,
@@ -264,6 +268,7 @@ type DashboardPackageListItem = {
createdAt: number;
updatedAt: number;
pendingReview?: true;
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
latestRelease: {
version: string;
createdAt: number;
@@ -434,6 +439,13 @@ async function toDashboardPackageListItem(
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
rescanState:
latestRelease && !latestRelease.softDeletedAt
? await buildRescanState(ctx, {
kind: "plugin",
artifactId: latestRelease._id,
})
: null,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? {
@@ -2226,7 +2238,9 @@ export const insertReleaseInternal = internalMutation({
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
.unique();
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
throw new ConvexError(
`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`,
);
}
}
@@ -2664,11 +2678,14 @@ async function markPackageRescanRequest(
status: "completed" | "failed",
error?: string,
) {
await ctx.runMutation(internalRefs.rescanRequests.markStatusInternal as never, {
requestId,
status,
error,
} as never);
await ctx.runMutation(
internalRefs.rescanRequests.markStatusInternal as never,
{
requestId,
status,
error,
} as never,
);
}
export const getRescanState = query({
@@ -2696,6 +2713,39 @@ export const getRescanState = query({
},
});
export const getOwnerRescanStateByName = query({
args: {
name: v.string(),
},
handler: async (ctx, args) => {
const viewerUserId = await getOptionalViewerUserId(ctx);
if (!viewerUserId) return null;
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) return null;
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.softDeletedAt) return null;
const actor = await ctx.db.get(viewerUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) return null;
if (actor.role !== "admin") {
const canAccess = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
if (!canAccess) return null;
}
return {
targetKind: "plugin" as const,
targetVersion: release.version,
packageReleaseId: release._id,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: release._id,
})),
};
},
});
export const requestRescan = mutation({
args: {
packageId: v.id("packages"),
@@ -2730,6 +2780,55 @@ export const requestRescan = mutation({
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
name: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
const target = await getLatestPackageRescanTarget(ctx, pkg._id);
await assertCanManageOwnedResource(ctx, {
actor,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
const requestId = await insertPackageRescanRequest(ctx, actor, target);
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
requestId,
releaseId: target.release._id,
});
const state = await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
return {
ok: true,
targetKind: "package" as const,
name: target.pkg.normalizedName,
version: target.release.version,
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
remainingRequests: state.remainingRequests,
maxRequests: state.maxRequests,
pendingRequestId: requestId,
};
},
});
export const dispatchPackageRescanInternal = internalAction({
args: {
requestId: v.id("rescanRequests"),
+57
View File
@@ -1144,6 +1144,7 @@ type DashboardSkillListItem = {
isSuspicious?: boolean;
pendingReview?: true;
qualityDecision?: NonNullable<Doc<"skills">["quality"]>["decision"];
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
latestVersion: {
version: string;
createdAt: number;
@@ -1423,6 +1424,13 @@ async function toDashboardSkillListItem(
? true
: undefined,
qualityDecision: skill.quality?.decision,
rescanState:
latestVersion && !latestVersion.softDeletedAt
? await buildRescanState(ctx, {
kind: "skill",
artifactId: latestVersion._id,
})
: null,
latestVersion:
latestVersion && !latestVersion.softDeletedAt
? {
@@ -4188,6 +4196,55 @@ export const requestRescan = mutation({
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
slug: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug.trim().toLowerCase());
const skill = resolved.skill;
if (!skill) throw new ConvexError("Skill not found");
const target = await getLatestSkillRescanTarget(ctx, skill._id);
await assertCanManageOwnedResource(ctx, {
actor,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "skill",
artifactId: target.version._id,
});
const requestId = await insertSkillRescanRequest(ctx, actor, target);
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
requestId,
skillId: target.skill._id,
versionId: target.version._id,
});
const state = await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
});
return {
ok: true,
targetKind: "skill" as const,
name: target.skill.slug,
version: target.version.version,
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
remainingRequests: state.remainingRequests,
maxRequests: state.maxRequests,
pendingRequestId: requestId,
};
},
});
export const dispatchSkillRescanInternal: ReturnType<typeof internalAction> = internalAction({
args: {
requestId: v.id("rescanRequests"),
+138
View File
@@ -33,6 +33,14 @@ function mustGetToken() {
return null;
}
function getAdminToken() {
return process.env.CLAWHUB_E2E_ADMIN_TOKEN?.trim() || null;
}
function getUserToken() {
return process.env.CLAWHUB_E2E_USER_TOKEN?.trim() || null;
}
function getRegistry() {
return (
process.env.CLAWHUB_REGISTRY?.trim() ||
@@ -73,7 +81,19 @@ function allowLiveMutations() {
return value === "1" || value?.toLowerCase() === "true";
}
function shouldSeedRoleHelpTokens() {
const value = process.env.CLAWHUB_E2E_SEED_CLI_ROLE_HELP?.trim();
return value === "1" || value?.toLowerCase() === "true";
}
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
const itIfAdminAndUserTokens =
getAdminToken() && getUserToken() || shouldSeedRoleHelpTokens() ? it : it.skip;
type RoleHelpTokens = {
adminToken: string;
userToken: string;
};
async function makeTempConfig(registry: string, token: string | null) {
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
@@ -86,6 +106,67 @@ async function makeTempConfig(registry: string, token: string | null) {
return { dir, path };
}
async function resolveRoleHelpTokens(registry: string): Promise<RoleHelpTokens> {
const adminToken = getAdminToken();
const userToken = getUserToken();
if (adminToken && userToken) return { adminToken, userToken };
if (!shouldSeedRoleHelpTokens()) {
throw new Error(
"Missing CLAWHUB_E2E_ADMIN_TOKEN/CLAWHUB_E2E_USER_TOKEN or CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1",
);
}
if (!isLocalRegistry(registry)) {
throw new Error("CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1 only works against local registries");
}
const result = spawnSync(
"bunx",
["convex", "run", "--no-push", "devSeed:seedCliRoleHelpFixtures"],
{
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
},
);
if (result.status !== 0) {
throw new Error(`Failed to seed role help fixtures:\n${result.stderr || result.stdout}`);
}
const parsed = JSON.parse(extractLastJsonObject(result.stdout)) as {
admin?: { token?: unknown };
user?: { token?: unknown };
};
if (typeof parsed.admin?.token !== "string" || typeof parsed.user?.token !== "string") {
throw new Error("Role help fixture seed did not return admin and user tokens");
}
return { adminToken: parsed.admin.token, userToken: parsed.user.token };
}
function isLocalRegistry(registry: string) {
try {
const hostname = new URL(registry).hostname;
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
} catch {
return false;
}
}
function extractLastJsonObject(output: string) {
const trimmed = output.trim();
for (let index = 0; index < trimmed.length; index += 1) {
if (trimmed[index] !== "{") continue;
const candidate = trimmed.slice(index);
try {
JSON.parse(candidate);
return candidate;
} catch {
// Keep scanning for the actual JSON payload if Convex printed status lines first.
}
}
throw new Error(`No JSON object in convex run output:\n${output}`);
}
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
@@ -197,6 +278,63 @@ describe("clawhub e2e", () => {
}
});
itIfAdminAndUserTokens("shows staff CLI commands only in admin help", async () => {
const registry = getRegistry();
const site = getSite();
const { adminToken, userToken } = await resolveRoleHelpTokens(registry);
async function expectRole(token: string, expectedRole: "admin" | "user") {
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
const response = await fetchWithTimeout(whoamiUrl.toString(), {
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
expect(response.ok).toBe(true);
const whoami = parseArk(
ApiV1WhoamiResponseSchema,
(await response.json()) as unknown,
"Whoami",
);
expect(whoami.user.role).toBe(expectedRole);
}
await expectRole(adminToken, "admin");
await expectRole(userToken, "user");
const adminCfg = await makeTempConfig(registry, adminToken);
const userCfg = await makeTempConfig(registry, userToken);
try {
const baseEnv = { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" };
const adminResult = spawnSync(
"bun",
["clawhub", "--registry", registry, "--site", site, "--help"],
{
cwd: process.cwd(),
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: adminCfg.path },
encoding: "utf8",
},
);
const userResult = spawnSync(
"bun",
["clawhub", "--registry", registry, "--site", site, "--help"],
{
cwd: process.cwd(),
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: userCfg.path },
encoding: "utf8",
},
);
expect(adminResult.status).toBe(0);
expect(adminResult.stdout).toContain("ban-user");
expect(adminResult.stdout).toContain("set-role");
expect(userResult.status).toBe(0);
expect(userResult.stdout).not.toContain("ban-user");
expect(userResult.stdout).not.toContain("set-role");
} finally {
await rm(adminCfg.dir, { recursive: true, force: true });
await rm(userCfg.dir, { recursive: true, force: true });
}
});
it("sync dry-run finds skills from an explicit root", async () => {
const registry = getRegistry();
const site = getSite();
+29 -2
View File
@@ -2,6 +2,7 @@
import { stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Command } from "commander";
import { shouldShowAdminCommandsInHelp } from "./cli/adminHelp.js";
import { getCliBuildLabel, getCliVersion } from "./cli/buildInfo.js";
import { resolveClawdbotDefaultWorkspace } from "./cli/clawdbotConfig.js";
import { cmdLoginFlow, cmdLogout, cmdWhoami } from "./cli/commands/auth.js";
@@ -23,6 +24,7 @@ import {
cmdSetPackageTrustedPublisher,
} from "./cli/commands/packages.js";
import { cmdPublish } from "./cli/commands/publish.js";
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
import {
cmdExplore,
cmdInstall,
@@ -47,6 +49,9 @@ import type { GlobalOpts } from "./cli/types.js";
import { fail } from "./cli/ui.js";
import { readGlobalConfig } from "./config.js";
const showAdminCommandsInHelp = await shouldShowAdminCommandsInHelp();
const adminCommandOptions = showAdminCommandsInHelp ? undefined : { hidden: true };
const program = new Command()
.name("clawhub")
.description(
@@ -451,6 +456,17 @@ trustedPublisherCmd
await cmdDeletePackageTrustedPublisher(opts, name, options);
});
packageCmd
.command("rescan")
.description("Request a security rescan for the latest published package release")
.argument("<name>", "Package name")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
await cmdRescanPackage(opts, name, options, isInputAllowed());
});
skill
.command("rename")
.description("Rename a published skill and keep the old slug as a redirect")
@@ -473,8 +489,19 @@ skill
await cmdMergeSkill(opts, sourceSlug, targetSlug, options, isInputAllowed());
});
skill
.command("rescan")
.description("Request a security rescan for the latest published skill version")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
await cmdRescanSkill(opts, slug, options, isInputAllowed());
});
program
.command("ban-user")
.command("ban-user", adminCommandOptions)
.description("Ban a user and delete owned skills (moderator/admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.option("--id", "Treat argument as user id")
@@ -487,7 +514,7 @@ program
});
program
.command("set-role")
.command("set-role", adminCommandOptions)
.description("Change a user role (admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.argument("<role>", "user | moderator | admin")
@@ -0,0 +1,87 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
const mockReadGlobalConfig = vi.fn(
async () => null as { registry?: string; token?: string } | null,
);
vi.mock("../config.js", () => ({
readGlobalConfig: () => mockReadGlobalConfig(),
}));
const mockApiRequest = vi.fn();
vi.mock("../http.js", () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
}));
const { isHelpRequest, shouldShowAdminCommandsInHelp } = await import("./adminHelp");
afterEach(() => {
vi.clearAllMocks();
});
describe("admin help gating", () => {
it("treats root help-like invocations as help requests", () => {
expect(isHelpRequest(["node", "clawhub", "--help"])).toBe(true);
expect(isHelpRequest(["node", "clawhub", "help"])).toBe(true);
expect(isHelpRequest(["node", "clawhub"])).toBe(true);
expect(isHelpRequest(["node", "clawhub", "search", "weather"])).toBe(false);
});
it("does not hide commands for normal command execution", async () => {
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "ban-user", "demo"],
}),
).resolves.toBe(true);
expect(mockReadGlobalConfig).not.toHaveBeenCalled();
});
it("hides admin commands from help when logged out", async () => {
mockReadGlobalConfig.mockResolvedValueOnce(null);
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(false);
expect(mockApiRequest).not.toHaveBeenCalled();
});
it("shows admin commands in help for stored admin tokens", async () => {
mockReadGlobalConfig.mockResolvedValueOnce({
registry: "https://registry.example",
token: "clh_admin",
});
mockApiRequest.mockResolvedValueOnce({
user: { handle: "p", role: "admin" },
});
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(true);
expect(mockApiRequest).toHaveBeenCalledWith(
"https://registry.example",
expect.objectContaining({ path: "/api/v1/whoami", token: "clh_admin" }),
expect.anything(),
);
});
it("hides admin commands in help for non-admin tokens", async () => {
mockReadGlobalConfig.mockResolvedValueOnce({
registry: "https://registry.example",
token: "clh_user",
});
mockApiRequest.mockResolvedValueOnce({
user: { handle: "p", role: "moderator" },
});
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { readGlobalConfig } from "../config.js";
import { apiRequest } from "../http.js";
import { ApiRoutes, ApiV1WhoamiResponseSchema } from "../schema/index.js";
import { DEFAULT_REGISTRY } from "./registry.js";
type AdminHelpDeps = {
argv?: string[];
env?: NodeJS.ProcessEnv;
};
export function isHelpRequest(argv: string[] = process.argv) {
const args = argv.slice(2);
return args.length === 0 || args[0] === "help" || args.includes("--help") || args.includes("-h");
}
export async function shouldShowAdminCommandsInHelp(deps: AdminHelpDeps = {}) {
if (!isHelpRequest(deps.argv)) return true;
const env = deps.env ?? process.env;
const cfg = await readGlobalConfig();
const token = cfg?.token?.trim();
if (!token) return false;
const registry =
readFlagValue(deps.argv ?? process.argv, "--registry")?.trim() ||
env.CLAWHUB_REGISTRY?.trim() ||
env.CLAWDHUB_REGISTRY?.trim() ||
cfg?.registry?.trim() ||
DEFAULT_REGISTRY;
try {
const whoami = await apiRequest(
registry,
{ method: "GET", path: ApiRoutes.whoami, token },
ApiV1WhoamiResponseSchema,
);
return whoami.user.role === "admin";
} catch {
return false;
}
}
function readFlagValue(argv: string[], flag: string) {
const args = argv.slice(2);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) return args[index + 1];
if (arg?.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
}
return undefined;
}
@@ -0,0 +1,91 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
createRegistryModuleMocks,
createUiModuleMocks,
makeGlobalOpts,
} from "../../../test/cliCommandTestKit.js";
const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../registry.js", () => registryMocks.moduleFactory());
vi.mock("../../http.js", () => httpMocks.moduleFactory());
vi.mock("../ui.js", () => uiMocks.moduleFactory());
const { cmdRescanPackage, cmdRescanSkill } = await import("./rescan");
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
const response = {
ok: true,
targetKind: "skill",
name: "demo",
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
afterEach(() => {
vi.clearAllMocks();
mockLog.mockClear();
});
describe("rescan commands", () => {
it("requires --yes when input is disabled", async () => {
await expect(cmdRescanSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
await expect(cmdRescanPackage(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("posts skill rescans to the skill rescan endpoint", async () => {
httpMocks.apiRequest.mockResolvedValueOnce(response);
await cmdRescanSkill(makeGlobalOpts(), "Demo", { yes: true }, false);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/rescan" }),
expect.anything(),
);
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Requested skill rescan for demo@1.2.3 (2/3 remaining)",
);
});
it("posts package rescans to the package rescan endpoint", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
...response,
targetKind: "package",
name: "@scope/demo",
});
await cmdRescanPackage(makeGlobalOpts(), "@scope/demo", { yes: true }, false);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: "POST",
path: "/api/v1/packages/%40scope%2Fdemo/rescan",
}),
expect.anything(),
);
});
it("prints JSON output", async () => {
httpMocks.apiRequest.mockResolvedValueOnce(response);
await cmdRescanSkill(makeGlobalOpts(), "demo", { yes: true, json: true }, false);
expect(uiMocks.spinner.stop).toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
});
});
@@ -0,0 +1,91 @@
import { apiRequest } from "../../http.js";
import {
ApiRoutes,
ApiV1RescanResponseSchema,
parseArk,
type ApiV1RescanResponse,
} from "../../schema/index.js";
import { requireAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import type { GlobalOpts } from "../types.js";
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
type RescanTargetKind = "skill" | "package";
type RescanOptions = {
yes?: boolean;
json?: boolean;
};
export async function cmdRescanSkill(
opts: GlobalOpts,
slugArg: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase();
if (!slug) fail("Slug required");
return requestRescan(opts, "skill", slug, options, inputAllowed);
}
export async function cmdRescanPackage(
opts: GlobalOpts,
nameArg: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const name = nameArg.trim();
if (!name) fail("Package name required");
return requestRescan(opts, "package", name, options, inputAllowed);
}
async function requestRescan(
opts: GlobalOpts,
targetKind: RescanTargetKind,
name: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const allowPrompt = isInteractive() && inputAllowed !== false;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Request latest ${targetKind} rescan for ${name}?`);
if (!ok) return undefined;
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner(`Requesting ${targetKind} rescan for ${name}`);
try {
const result = await apiRequest(
registry,
{
method: "POST",
path: buildRescanPath(targetKind, name),
token,
},
ApiV1RescanResponseSchema,
);
const parsed = parseArk(ApiV1RescanResponseSchema, result, "Rescan response");
if (options.json) {
spinner.stop();
console.log(JSON.stringify(parsed, null, 2));
} else {
spinner.succeed(formatRescanSuccess(parsed));
}
return parsed;
} catch (error) {
spinner.fail(formatError(error));
throw error;
}
}
function buildRescanPath(targetKind: RescanTargetKind, name: string) {
const root = targetKind === "skill" ? ApiRoutes.skills : ApiRoutes.packages;
return `${root}/${encodeURIComponent(name)}/rescan`;
}
function formatRescanSuccess(result: ApiV1RescanResponse) {
const label = result.targetKind === "skill" ? "skill" : "package";
return `OK. Requested ${label} rescan for ${result.name}@${result.version} (${result.remainingRequests}/${result.maxRequests} remaining)`;
}
+13
View File
@@ -134,6 +134,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
@@ -273,6 +274,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: "true",
});
export const ApiV1RescanResponseSchema = type({
ok: "true",
targetKind: '"skill"|"package"',
name: "string",
version: "string",
status: '"in_progress"|"completed"|"failed"',
remainingRequests: "number",
maxRequests: "number",
pendingRequestId: "string?",
});
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
export const ApiV1SkillRenameResponseSchema = type({
ok: "true",
slug: "string",
+1
View File
@@ -134,6 +134,7 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
role?: "user" | "admin" | "moderator" | null | undefined;
};
}, {}>;
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+1
View File
@@ -110,6 +110,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
export const ApiV1UserSearchResponseSchema = type({
File diff suppressed because one or more lines are too long
+13
View File
@@ -135,6 +135,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
@@ -282,6 +283,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: "true",
});
export const ApiV1RescanResponseSchema = type({
ok: "true",
targetKind: '"skill"|"package"',
name: "string",
version: "string",
status: '"in_progress"|"completed"|"failed"',
remainingRequests: "number",
maxRequests: "number",
pendingRequestId: "string?",
});
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
export const ApiV1SkillRenameResponseSchema = type({
ok: "true",
slug: "string",
+11 -6
View File
@@ -124,7 +124,7 @@ describe("Header", () => {
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
@@ -132,22 +132,27 @@ describe("Header", () => {
render(<Header />);
expect(
screen.getByRole("button", { name: /Cycle theme mode/i }),
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
);
expect(setModeMock).toHaveBeenCalledWith("dark");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
it("shows Home above Skills in the mobile menu", () => {
+97 -9
View File
@@ -15,17 +15,17 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
(error: unknown) =>
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
);
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
readme: string | null;
rateLimited:
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
rateLimited: {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
};
let paramsMock = { name: "demo-plugin" };
@@ -59,6 +59,14 @@ vi.mock("@tanstack/react-router", () => ({
useParams: () => paramsMock,
useLoaderData: () => loaderDataMock,
}),
useRouterState: ({
select,
}: {
select?: (state: { location: { pathname: string } }) => string;
}) =>
select
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
: `/plugins/${paramsMock.name}`,
Link: ({
children,
to,
@@ -73,6 +81,15 @@ vi.mock("@tanstack/react-router", () => ({
),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../lib/packageApi", () => ({
fetchPackageDetail: vi.fn(),
fetchPackageReadme: vi.fn(),
@@ -86,7 +103,13 @@ vi.mock("../lib/packageApi", () => ({
}));
vi.mock("../components/MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string; className?: string; highlight?: boolean }) => <div>{children}</div>,
MarkdownPreview: ({
children,
}: {
children: string;
className?: string;
highlight?: boolean;
}) => <div>{children}</div>,
}));
async function loadRoute() {
@@ -128,6 +151,14 @@ describe("plugin detail route", () => {
rateLimited: null,
};
isRateLimitedPackageApiErrorMock.mockClear();
useQueryMock.mockReset();
useQueryMock.mockReturnValue(undefined);
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
});
});
it("hides download actions when the plugin has no latest release", async () => {
@@ -188,9 +219,66 @@ describe("plugin detail route", () => {
render(<Component />);
expect(screen.getByText("Security Scan")).toBeTruthy();
expect(screen.getByText("Security Scans")).toBeTruthy();
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
expect(screen.getAllByText("ClawScan").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: /VirusTotal.*Benign/i }).getAttribute("href")).toBe(
"/plugins/demo-plugin/security/virustotal",
);
expect(
screen.getByRole("link", { name: /Static analysis.*Benign/i }).getAttribute("href"),
).toBe("/plugins/demo-plugin/security/static-analysis");
});
it("shows owner-only plugin rescan state in the security summary", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:1" },
});
useQueryMock.mockReturnValue({
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
});
loaderDataMock = {
detail: loaderDataMock.detail,
version: {
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
},
version: {
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
distTags: ["latest"],
files: [],
compatibility: null,
capabilities: null,
verification: null,
sha256hash: "a".repeat(64),
vtAnalysis: null,
llmAnalysis: null,
staticScan: null,
},
},
readme: null,
rateLimited: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("button", { name: "Rescan" })).toBeTruthy();
expect(screen.queryByText("Owner rescan")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
});
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
+113 -15
View File
@@ -71,7 +71,7 @@ describe("SkillDetailPage", () => {
});
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -222,18 +222,105 @@ describe("SkillDetailPage", () => {
/>,
);
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,
await screen.findByRole("heading", { name: "Install" });
const securityHeading = screen.getByRole("heading", { name: "Security Scans" });
expect(screen.getAllByRole("heading", { name: "Install" }).length).toBeGreaterThan(0);
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" })).toBeTruthy();
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
expect(securityHeading).toBeTruthy();
expect(screen.getByRole("link", { name: /VirusTotal.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /ClawScan.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Static analysis.*Pending/i })).toBeTruthy();
expect(screen.queryByText(/Like a lobster shell, security has layers/i)).toBeNull();
expect(screen.queryByRole("button", { name: "Rescan" })).toBeNull();
});
it("shows an owner rescan action in the security summary for owned skills", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: ownerId, role: "user" },
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args && !("limit" in args)) {
return {
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
};
}
if (args && typeof args === "object" && "limit" in args) return [];
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: {} },
files: [],
sha256hash: "abc123",
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# Weather",
readmeError: null,
}}
/>,
);
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.getByText("openclaw skills install 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();
expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy();
expect(screen.queryByText("Owner rescan")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -349,7 +436,7 @@ describe("SkillDetailPage", () => {
});
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -506,7 +593,7 @@ describe("SkillDetailPage", () => {
expect(screen.getByText(/Report skill/i)).toBeTruthy();
});
it("shows owner tools for the skill owner", async () => {
it("links owner tools from the detail page and renders them on settings", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -514,7 +601,11 @@ describe("SkillDetailPage", () => {
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "ownerUserId" in args) {
if (
args &&
typeof args === "object" &&
("ownerUserId" in args || "ownerPublisherId" in args)
) {
return [
{ _id: "skills:1", slug: "weather", displayName: "Weather" },
{ _id: "skills:2", slug: "weather-pro", displayName: "Weather Pro" },
@@ -547,7 +638,14 @@ describe("SkillDetailPage", () => {
return undefined;
});
render(<SkillDetailPage slug="weather" />);
const { unmount } = render(<SkillDetailPage slug="weather" />);
const settingsLink = await screen.findByRole("link", { name: /settings/i });
expect(settingsLink.getAttribute("href")).toBe("/steipete/weather/settings");
expect(screen.queryByText(/Owner tools/i)).toBeNull();
unmount();
render(<SkillDetailPage slug="weather" mode="settings" />);
expect(await screen.findByText(/Owner tools/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /Rename and redirect/i })).toBeTruthy();
+61
View File
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
type DetailPageShellProps = {
children: ReactNode;
className?: string;
};
type DetailHeroProps = {
main: ReactNode;
sidebar?: ReactNode;
children?: ReactNode;
className?: string;
topClassName?: string;
mainClassName?: string;
sidebarClassName?: string;
};
type DetailBodyProps = {
children: ReactNode;
className?: string;
mainClassName?: string;
};
export function DetailPageShell({ children, className }: DetailPageShellProps) {
return <div className={cn("skill-detail-stack", className)}>{children}</div>;
}
export function DetailHero({
main,
sidebar,
children,
className,
topClassName,
mainClassName,
sidebarClassName,
}: DetailHeroProps) {
return (
<div className={cn("skill-hero", className)}>
<div className={cn("skill-hero-top", topClassName)}>
<div className="skill-hero-layout">
<div className={cn("skill-hero-main", mainClassName)}>
{main}
{children ? <div className="skill-hero-main-extra">{children}</div> : null}
</div>
{sidebar ? (
<aside className={cn("skill-hero-sidebar", sidebarClassName)}>{sidebar}</aside>
) : null}
</div>
</div>
</div>
);
}
export function DetailBody({ children, className, mainClassName }: DetailBodyProps) {
return (
<div className={cn("detail-layout", className)}>
<div className={cn("detail-main", mainClassName)}>{children}</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useState } from "react";
import {
getScanStatusInfo,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Badge, type BadgeProps } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
type RescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
targetVersion: string;
status: "in_progress" | "completed" | "failed";
createdAt: number;
updatedAt: number;
completedAt?: number;
};
export type DetailRescanState = {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: RescanRequest | null;
latestRequest: RescanRequest | null;
};
type DetailSecuritySummaryProps = {
scannerBasePath: string;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
rescanState?: DetailRescanState | null;
onRequestRescan?: (() => Promise<void>) | null;
};
function statusFromStaticScan(staticScan: DetailSecuritySummaryProps["staticScan"]) {
if (staticScan?.status) return staticScan.status;
return "pending";
}
function badgeVariantForScanStatus(status: string): BadgeProps["variant"] {
const normalized = status.toLowerCase();
if (normalized === "clean" || normalized === "benign") return "success";
if (normalized === "suspicious") return "warning";
if (normalized === "malicious" || normalized === "error") return "destructive";
if (normalized === "pending" || normalized === "queued" || normalized === "loading") {
return "pending";
}
return "compact";
}
function ScannerRow({ href, label, status }: { href: string; label: string; status: string }) {
const info = getScanStatusInfo(status);
return (
<a
href={href}
className="flex min-w-0 items-center justify-between gap-3 rounded-[var(--radius-sm)] px-1 py-2 text-sm !no-underline hover:bg-[color:var(--surface-muted)] hover:!no-underline"
>
<span className="flex min-w-0 items-center gap-2 font-semibold text-[color:var(--ink)]">
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant={badgeVariantForScanStatus(status)}>{info.label}</Badge>
</span>
</a>
);
}
function rescanDisabledReason(state: DetailRescanState | null | undefined) {
if (!state) return null;
if (state.inProgressRequest) return "A rescan is already in progress.";
if (state.remainingRequests <= 0) {
return `Rescan limit reached (${state.requestCount}/${state.maxRequests}).`;
}
if (!state.canRequest) return "This release is not eligible for another rescan.";
return null;
}
export function DetailSecuritySummary({
scannerBasePath,
vtAnalysis,
llmAnalysis,
staticScan,
rescanState,
onRequestRescan,
}: DetailSecuritySummaryProps) {
const [isRequestingRescan, setIsRequestingRescan] = useState(false);
const vtStatus = vtAnalysis?.verdict ?? vtAnalysis?.status ?? "pending";
const llmStatus = llmAnalysis?.verdict ?? llmAnalysis?.status ?? "pending";
const staticStatus = statusFromStaticScan(staticScan);
const rescanButtonDisabledReason = rescanDisabledReason(rescanState);
async function handleRequestRescan() {
if (!onRequestRescan || rescanButtonDisabledReason || isRequestingRescan) return;
setIsRequestingRescan(true);
try {
await onRequestRescan();
} finally {
setIsRequestingRescan(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Security Scans
{rescanState && onRequestRescan ? (
<Button
type="button"
variant="outline"
size="sm"
className="ml-auto"
loading={isRequestingRescan}
disabled={Boolean(rescanButtonDisabledReason)}
title={rescanButtonDisabledReason ?? "Request a fresh scan"}
onClick={() => void handleRequestRescan()}
>
Rescan
</Button>
) : null}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<ScannerRow href={`${scannerBasePath}/virustotal`} label="VirusTotal" status={vtStatus} />
<ScannerRow href={`${scannerBasePath}/openclaw`} label="ClawScan" status={llmStatus} />
<ScannerRow
href={`${scannerBasePath}/static-analysis`}
label="Static analysis"
status={staticStatus}
/>
</div>
</CardContent>
</Card>
);
}
+65 -142
View File
@@ -1,19 +1,17 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useRef, useState } from "react";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Button } from "./ui/button";
@@ -32,7 +30,6 @@ import {
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
@@ -40,13 +37,10 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
const isSoulMode = siteMode === "souls";
@@ -64,7 +58,6 @@ export default function Header() {
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
@@ -72,24 +65,11 @@ export default function Header() {
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode, theme);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
applyTheme(next, theme);
setMode(next);
};
const handleNavSearch = (e: React.FormEvent) => {
@@ -166,48 +146,19 @@ export default function Header() {
</Link>
</SheetClose>
))}
{secondaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label === "Management" ? "Manage" : item.label}
</Link>
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<div className="mobile-nav-section-title">Theme</div>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("system");
setThemeMode(nextThemeMode);
setMobileMenuOpen(false);
}}
>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("light");
setMobileMenuOpen(false);
}}
>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("dark");
setMobileMenuOpen(false);
}}
>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
{mode === "system" ? "System theme" : `${mode} theme`}
</button>
</div>
</SheetContent>
@@ -237,6 +188,32 @@ export default function Header() {
/>
</form>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix),
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</nav>
<div className="nav-actions">
<button
className="navbar-search-mobile-trigger"
@@ -246,41 +223,16 @@ export default function Header() {
>
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-cycle-group" aria-label="Theme controls">
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
<div className="theme-toggle">
<button
type="button"
className="theme-cycle-button"
onClick={() => setThemeMode(nextThemeMode)}
aria-label={`Toggle theme. Current: ${mode}`}
title={`Theme: ${mode}`}
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
@@ -296,6 +248,9 @@ export default function Header() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
@@ -336,9 +291,7 @@ export default function Header() {
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
Sign In
</Button>
</>
)}
@@ -360,52 +313,6 @@ export default function Header() {
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => {
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
data-status={isActiveByPrefix ? "active" : undefined}
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
);
})}
</div>
</nav>
</div>
</header>
);
@@ -424,6 +331,22 @@ function getThemeModeIcon(mode: "system" | "light" | "dark") {
return Moon;
case "system":
default:
return Monitor;
return Sun;
}
}
function getResolvedThemeMode(): "light" | "dark" {
if (typeof document !== "undefined") {
const resolved = document.documentElement.dataset.themeResolved;
if (resolved === "light" || resolved === "dark") return resolved;
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
}
function getNextThemeMode(mode: "system" | "light" | "dark"): "light" | "dark" {
const resolved = mode === "system" ? getResolvedThemeMode() : mode;
return resolved === "dark" ? "light" : "dark";
}
+3 -1
View File
@@ -37,11 +37,13 @@ export function InstallCopyButton({
label = "Copy",
ariaLabel,
className,
showLabel = true,
}: {
text: string;
label?: string;
ariaLabel?: string;
className?: string;
showLabel?: boolean;
}) {
const [copyState, setCopyState] = useState<CopyState>("idle");
const resetTimeoutRef = useRef<number | null>(null);
@@ -94,7 +96,7 @@ export function InstallCopyButton({
) : (
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span aria-live="polite">{buttonLabel}</span>
{showLabel ? <span aria-live="polite">{buttonLabel}</span> : null}
</Button>
);
}
+7 -100
View File
@@ -1,17 +1,11 @@
import { useQuery } from "convex/react";
import { AlertTriangle, ArrowLeft, Clock, ExternalLink, Fingerprint, RotateCw } from "lucide-react";
import { ArrowLeft, Clock, ExternalLink, Fingerprint } from "lucide-react";
import type { ReactNode } from "react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { isModerator } from "../lib/roles";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
getScanStatusInfo,
type LlmAnalysis,
OpenClawIcon,
type StaticFinding,
type VtAnalysis,
VirusTotalIcon,
} from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
@@ -33,7 +27,6 @@ type EntityRef = {
ownerUserId?: Id<"users"> | null;
ownerPublisherId?: Id<"publishers"> | null;
detailPath: string;
securityBasePath: string;
};
type SecurityScannerPageProps = {
@@ -55,22 +48,16 @@ type SecurityScannerPageProps = {
const SCANNER_LABELS: Record<ScannerSlug, string> = {
virustotal: "VirusTotal",
openclaw: "OpenClaw",
openclaw: "ClawScan",
"static-analysis": "Static analysis",
};
const SCANNER_SUMMARIES: Record<ScannerSlug, string> = {
virustotal: "External malware reputation and Code Insight signals for this exact artifact hash.",
openclaw: "OpenClaw's context-aware review of the artifact, metadata, and declared behavior.",
openclaw: "ClawHub's context-aware review of the artifact, metadata, and declared behavior.",
"static-analysis": "Deterministic local checks for risky code patterns and metadata mismatches.",
};
function ScannerIcon({ scanner, className }: { scanner: ScannerSlug; className?: string }) {
if (scanner === "virustotal") return <VirusTotalIcon className={className} />;
if (scanner === "openclaw") return <OpenClawIcon className={className} />;
return <AlertTriangle className={className} aria-hidden="true" />;
}
function formatTime(value?: number | null) {
if (!value) return "Not checked yet";
return new Intl.DateTimeFormat(undefined, {
@@ -109,81 +96,6 @@ function getCheckedAt(props: SecurityScannerPageProps) {
return props.staticScan?.checkedAt ?? null;
}
function RescanPanel({ entity }: { entity: EntityRef }) {
const { me, isAuthenticated } = useAuthStatus();
const publisherMemberships = useQuery(api.publishers.listMine, me ? {} : "skip") as
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
| undefined;
const publisherIds = new Set((publisherMemberships ?? []).map((entry) => entry.publisher._id));
const isOwner =
Boolean(me && entity.ownerUserId && me._id === entity.ownerUserId) ||
Boolean(entity.ownerPublisherId && publisherIds.has(entity.ownerPublisherId)) ||
isModerator(me);
if (!isAuthenticated || !isOwner) {
return (
<Card>
<CardHeader>
<CardTitle>Rescan</CardTitle>
</CardHeader>
<CardContent>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
Owners can request a fresh scan for the latest {entity.kind === "skill" ? "version" : "release"}.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Owner Rescan</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3">
<div className="text-sm text-[color:var(--ink)]">
Rescan controls will submit against the latest {entity.kind === "skill" ? "version" : "release"}.
</div>
<div className="text-xs text-[color:var(--ink-soft)]">
Request counts are capped by the backend per published artifact. This branch does not expose the
rescan mutation yet, so the action is shown disabled instead of pretending it can schedule work.
</div>
<Button type="button" variant="outline" size="sm" disabled className="w-full sm:w-fit">
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
Request rescan
</Button>
</div>
</CardContent>
</Card>
);
}
function ScannerTabs({ entity, active }: { entity: EntityRef; active: ScannerSlug }) {
const scanners: ScannerSlug[] = ["virustotal", "openclaw", "static-analysis"];
return (
<nav className="flex flex-wrap gap-2" aria-label="Security scanners">
{scanners.map((scanner) => {
const isActive = scanner === active;
return (
<a
key={scanner}
href={`${entity.securityBasePath}/${scanner}`}
className={`inline-flex min-h-[34px] items-center gap-2 rounded-[var(--r-btn)] border px-3 py-1.5 text-xs font-semibold no-underline ${
isActive
? "border-[color:var(--accent)] bg-[color:var(--active-bg)] text-[color:var(--ink)]"
: "border-[color:var(--line)] text-[color:var(--ink-soft)] hover:bg-[color:var(--surface-muted)]"
}`}
>
<ScannerIcon scanner={scanner} className="h-3.5 w-3.5" />
{SCANNER_LABELS[scanner]}
</a>
);
})}
</nav>
);
}
export function SecurityScannerPage(props: SecurityScannerPageProps) {
const label = SCANNER_LABELS[props.scanner];
const status = getScannerStatus(props);
@@ -203,7 +115,7 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
Back to {props.entity.kind}
</a>
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-3">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge>{props.entity.kind === "skill" ? "Skill" : "Plugin"}</Badge>
@@ -216,18 +128,14 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
{props.entity.title} · {SCANNER_SUMMARIES[props.scanner]}
</p>
</div>
<ScannerTabs entity={props.entity} active={props.scanner} />
</div>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="security-scanner-layout">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ScannerIcon scanner={props.scanner} className="h-5 w-5" />
Scanner verdict
</CardTitle>
<CardTitle>Scanner verdict</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
@@ -278,7 +186,7 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
<DetailRow label="Verdict">{props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "Pending"}</DetailRow>
<DetailRow label="Confidence">{props.llmAnalysis?.confidence ?? "Not reported"}</DetailRow>
<DetailRow label="Model">{props.llmAnalysis?.model ?? "Not reported"}</DetailRow>
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No OpenClaw analysis has been recorded yet."}</DetailRow>
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No ClawScan analysis has been recorded yet."}</DetailRow>
<DetailRow label="Guidance">{props.llmAnalysis?.guidance ?? null}</DetailRow>
<DetailRow label="Findings">
{props.llmAnalysis?.findings ? (
@@ -375,7 +283,6 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
</div>
<aside className="flex min-w-0 flex-col gap-5">
<RescanPanel entity={props.entity} />
<Card>
<CardHeader>
<CardTitle>Artifact</CardTitle>
+6 -6
View File
@@ -42,8 +42,8 @@ describe("SignInButton", () => {
it("starts GitHub sign-in with the current relative URL by default", async () => {
signInMock.mockResolvedValue({ signingIn: true });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(signInMock).toHaveBeenCalledWith("github", {
@@ -57,8 +57,8 @@ describe("SignInButton", () => {
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
signInMock.mockResolvedValue({ signingIn: false });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
@@ -70,8 +70,8 @@ describe("SignInButton", () => {
signInMock.mockRejectedValue(failure);
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
+3 -2
View File
@@ -12,14 +12,16 @@ type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
export function SignInButton({
redirectTo,
children = "Sign in with GitHub",
children = "Sign In",
...props
}: SignInButtonProps) {
const { signIn } = useAuthActions();
return (
<Button
{...props}
type="button"
variant="primary"
onClick={() => {
clearAuthError();
const next = redirectTo ?? getCurrentRelativeUrl();
@@ -35,7 +37,6 @@ export function SignInButton({
);
});
}}
{...props}
>
{children}
</Button>
+228 -95
View File
@@ -1,17 +1,21 @@
import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import type { ComponentProps } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getUserFacingConvexError } from "../lib/convexError";
import { canManageSkill, isModerator } from "../lib/roles";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { DetailBody, DetailPageShell } from "./DetailPageShell";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import {
buildSkillHref,
formatConfigSnippet,
@@ -22,16 +26,22 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
type SkillDetailPageProps = {
slug: string;
canonicalOwner?: string;
redirectToCanonical?: boolean;
initialData?: SkillPageInitialData | null;
mode?: "detail" | "settings";
};
type SkillFile = Doc<"skillVersions">["files"][number];
const SHOW_SKILL_COMMENTS = false;
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: unknown }).data;
@@ -65,6 +75,7 @@ export function SkillDetailPage({
canonicalOwner,
redirectToCanonical,
initialData,
mode = "detail",
}: SkillDetailPageProps) {
const navigate = useNavigate();
const { isAuthenticated, me } = useAuthStatus();
@@ -83,6 +94,7 @@ export function SkillDetailPage({
const reportSkill = useMutation(api.skills.report);
const updateTags = useMutation(api.skills.updateTags);
const deleteTags = useMutation(api.skills.deleteTags);
const requestRescan = useMutation(api.skills.requestRescan);
const getReadme = useAction(api.skills.getReadme);
const myPublishers = useQuery(api.publishers.listMine) as
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
@@ -145,6 +157,11 @@ export function SkillDetailPage({
: { ownerUserId: skill.ownerUserId, limit: 100 }
: "skip",
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
const canViewOwnerRescanState = isOwner || me?.role === "admin";
const rescanState = useQuery(
api.skills.getRescanState,
canViewOwnerRescanState && skill ? { skillId: skill._id } : "skip",
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
const ownerHandle = owner?.handle ?? null;
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
@@ -237,7 +254,10 @@ export function SkillDetailPage({
useEffect(() => {
let cancelled = false;
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
if (
latestVersion &&
!(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))
) {
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
@@ -325,19 +345,36 @@ export function SkillDetailPage({
}
};
const submitRescanRequest = async () => {
if (!skill) return;
try {
await requestRescan({ skillId: skill._id });
toast.success("Rescan requested.", {
action: {
label: "Dashboard",
onClick: () => {
window.location.href = "/dashboard";
},
},
});
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
}
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
<main className="section detail-page-section" aria-busy="true">
<div role="status" aria-label="Loading skill details">
<SkillDetailSkeleton />
</div>
</main>
);
}
if (result === null || !skill) {
return (
<main className="section">
<main className="section detail-page-section">
<Card>Skill not found.</Card>
</main>
);
@@ -353,10 +390,25 @@ export function SkillDetailPage({
latestTagVersionId === null
? []
: tagEntries.filter(([, versionId]) => versionId !== latestTagVersionId);
const securitySummary = latestVersion ? (
<DetailSecuritySummary
scannerBasePath={`/${encodeURIComponent(
ownerParam ?? ownerHandle ?? "unknown",
)}/${encodeURIComponent(skill.slug)}/security`}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
rescanState={rescanState ?? null}
onRequestRescan={canViewOwnerRescanState ? submitRescanRequest : null}
/>
) : null;
const detailPath = `/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(skill.slug)}`;
const settingsHref = canManage ? `${detailPath}/settings` : null;
return (
<main className="section">
<div className="skill-detail-stack">
<main className="section detail-page-section">
<DetailPageShell>
<SkillHeader
skill={skill}
owner={owner}
@@ -384,102 +436,183 @@ export function SkillDetailPage({
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={currentTagEntries}
historicalTagEntries={historicalTagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
sidebarContent={securitySummary}
settingsHref={settingsHref}
>
{mode === "detail" ? (
<>
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
</Card>
) : null}
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">{configExample}</pre>
</Card>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={currentTagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
<Card className="skill-tag-card">
<CardHeader>
<CardTitle>Version tags</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-tag-row">
{currentTagEntries.length === 0 ? (
<span className="section-subtitle m-0">No tags yet.</span>
) : (
currentTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => deleteTag(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
x
</button>
) : null}
</Badge>
))
)}
</div>
{canManage && historicalTagEntries.length > 0 ? (
<div className="skill-tag-history">
<div className="skill-tag-history-label">Historical tags</div>
<div className="skill-tag-row">
{historicalTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => deleteTag(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
x
</button>
) : null}
</Badge>
))}
</div>
</div>
) : null}
{canManage ? (
<form
onSubmit={(event) => {
event.preventDefault();
submitTag();
}}
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => setTagName(event.target.value)}
placeholder="latest..."
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) =>
setTagVersionId(event.target.value as Id<"skillVersions">)
}
>
{(versions ?? []).map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<Button type="submit">Update Tag</Button>
</form>
) : null}
</CardContent>
</Card>
{SHOW_SKILL_COMMENTS ? (
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">Comments</h2>
<p className="section-subtitle mt-3 mb-0">Loading comments...</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
) : null}
</>
) : null}
</SkillHeader>
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<ClientOnly
fallback={
{mode === "settings" ? (
<DetailBody>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : (
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<h2 className="section-title text-[1.2rem] m-0">Settings unavailable</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
Only the skill owner can manage these settings.
</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
</div>
)}
</DetailBody>
) : null}
</DetailPageShell>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
+154 -265
View File
@@ -1,14 +1,16 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import { Package } from "lucide-react";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
import type { ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { timeAgo } from "../lib/timeAgo";
import { DetailHero } from "./DetailPageShell";
import { SkillInstallCard } from "./SkillInstallCard";
import { SkillInstallSurface } from "./SkillInstallSurface";
import { SkillCommandLineCard } from "./SkillInstallSurface";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
@@ -63,18 +65,11 @@ type SkillHeaderProps = {
hasPluginBundle: boolean;
configRequirements: ClawdisSkillMetadata["config"] | undefined;
cliHelp: string | undefined;
tagEntries: Array<[string, Id<"skillVersions">]>;
historicalTagEntries: Array<[string, Id<"skillVersions">]>;
versionById: Map<Id<"skillVersions">, Doc<"skillVersions">>;
tagName: string;
onTagNameChange: (value: string) => void;
tagVersionId: Id<"skillVersions"> | "";
onTagVersionChange: (value: Id<"skillVersions"> | "") => void;
onTagSubmit: () => void;
onTagDelete: (tag: string) => void;
tagVersions: Doc<"skillVersions">[];
clawdis: ClawdisSkillMetadata | undefined;
osLabels: string[];
sidebarContent?: ReactNode;
settingsHref?: string | null;
children?: ReactNode;
};
export function SkillHeader({
@@ -104,33 +99,14 @@ export function SkillHeader({
hasPluginBundle,
configRequirements,
cliHelp,
tagEntries,
historicalTagEntries,
versionById,
tagName,
onTagNameChange,
tagVersionId,
onTagVersionChange,
onTagSubmit,
onTagDelete,
tagVersions,
clawdis,
osLabels,
sidebarContent,
settingsHref,
children,
}: SkillHeaderProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
Boolean(modInfo?.overrideActive) &&
!modInfo?.isMalwareBlocked &&
!modInfo?.isSuspicious;
const overrideScanMessage = suppressScanResults
? "Security findings were reviewed by staff and cleared for public use."
: null;
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
const ownerSegment = ownerHandle?.trim() || (installOwnerId ? String(installOwnerId) : null);
const scannerBasePath = ownerSegment
? `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}/security`
: null;
return (
<>
@@ -145,16 +121,6 @@ export function SkillHeader({
</p>
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
scan results below.
</p>
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
@@ -194,146 +160,161 @@ export function SkillHeader({
</div>
) : null}
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
<DetailHero
topClassName={hasPluginBundle ? "has-plugin" : undefined}
main={
<>
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{skill.displayName}</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{isStaff || settingsHref ? (
<div className="skill-title-actions">
{isStaff ? (
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<a href={settingsHref}>
<Settings size={14} aria-hidden="true" />
Settings
</a>
</Button>
) : null}
</div>
) : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat">
<Star size={14} aria-hidden="true" /> {formattedStats.stars}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Download size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Package size={14} aria-hidden="true" /> {skill.stats.versions ?? 0} versions
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<History size={14} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<History size={14} aria-hidden="true" /> {formattedStats.installsAllTime}{" "}
all-time
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Calendar size={14} aria-hidden="true" /> Updated {timeAgo(skill.updatedAt)}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Scale size={14} aria-hidden="true" /> {PLATFORM_SKILL_LICENSE}
</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-sidebar">
<div className="skill-actions">
{isAuthenticated ? (
</>
}
sidebar={
<>
{isAuthenticated ? (
<div className="skill-actions">
<button
className={`star-toggle${isStarred ? " is-active" : ""}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
>
<span aria-hidden="true"></span>
<Star size={16} aria-hidden="true" />
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
</div>
</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>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
llmAnalysis={latestVersion?.llmAnalysis as LlmAnalysis | undefined}
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
scannerBasePath={scannerBasePath}
</div>
) : null}
{sidebarContent}
<SkillCommandLineCard
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
<p className="scan-disclaimer">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
</>
}
>
{children}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
@@ -373,99 +354,7 @@ export function SkillHeader({
</div>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</div>
<div className="skill-tag-row">
{tagEntries.length === 0 ? (
<span className="section-subtitle m-0">
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
×
</button>
) : null}
</Badge>
))
)}
</div>
{canManage && historicalTagEntries.length > 0 ? (
<div className="skill-tag-history">
<div className="skill-tag-history-label">Historical tags</div>
<div className="skill-tag-row">
{historicalTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
×
</button>
) : null}
</Badge>
))}
</div>
</div>
) : null}
{canManage ? (
<form
onSubmit={(event) => {
event.preventDefault();
onTagSubmit();
}}
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest…"
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
{tagVersions.map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<Button type="submit">
Update Tag
</Button>
</form>
) : null}
</div>
</DetailHero>
</>
);
}
+27 -8
View File
@@ -3,7 +3,7 @@
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";
import { SkillCommandLineCard, SkillInstallSurface } from "./SkillInstallSurface";
const writeTextMock = vi.fn();
@@ -57,7 +57,7 @@ describe("SkillInstallSurface", () => {
);
expect(screen.getByRole("heading", { name: "Install with OpenClaw" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "CLI Commands" })).toBeNull();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(screen.getAllByText("Install & Setup").length).toBeGreaterThan(0);
@@ -72,9 +72,9 @@ describe("SkillInstallSurface", () => {
expect(screen.getAllByText("Install Only").length).toBeGreaterThan(0);
});
it("switches the ClawHub command and copies the visible CLI command", async () => {
it("defaults to CLI install and can copy the compact prompt tab", async () => {
render(
<SkillInstallSurface
<SkillCommandLineCard
slug="weather"
displayName="Weather"
ownerHandle="steipete"
@@ -82,13 +82,32 @@ describe("SkillInstallSurface", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Use pnpm for ClawHub install command" }));
expect(screen.getByText("pnpm dlx clawhub@latest install weather")).toBeTruthy();
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"false",
);
fireEvent.click(screen.getByRole("button", { name: "Copy ClawHub CLI command" }));
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw CLI command" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("pnpm dlx clawhub@latest install weather");
expect(writeTextMock).toHaveBeenCalledWith("openclaw skills install weather");
});
fireEvent.click(screen.getByRole("tab", { name: "Prompt" }));
expect(screen.getByText(/Install the skill "Weather"/i)).toBeTruthy();
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw prompt" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("After install, inspect the skill metadata"),
);
});
});
});
+113 -108
View File
@@ -5,10 +5,8 @@ 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";
@@ -19,8 +17,6 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
const PACKAGE_MANAGERS: SkillPackageManager[] = ["npm", "pnpm", "bun"];
const PROMPT_OPTIONS: Array<{
description: string;
label: string;
@@ -56,7 +52,6 @@ export function SkillInstallSurface({
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);
@@ -81,10 +76,9 @@ export function SkillInstallSurface({
}, 2000);
};
const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const selectedPrompt =
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const openClawCommand = formatOpenClawInstallCommand(slug);
const clawHubCommand = formatClawHubInstallCommand(slug, packageManager);
const promptPreview = formatOpenClawPrompt({
mode: promptMode,
skillName: displayName,
@@ -130,109 +124,120 @@ export function SkillInstallSurface({
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>
<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">Bare skill slug</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 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>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<span className="skill-install-copy-feedback" aria-live="polite">
{promptFeedback}
</span>
</div>
<pre className="skill-install-command">
<code translate="no">{clawHubCommand}</code>
</pre>
</div>
</article>
</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>
</section>
);
}
export function SkillCommandLineCard({
slug,
displayName,
ownerHandle,
ownerId,
clawdis,
}: SkillInstallSurfaceProps) {
const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli");
const openClawCommand = formatOpenClawInstallCommand(slug);
const promptPreview = formatOpenClawPrompt({
mode: "install-and-setup",
skillName: displayName,
slug,
ownerHandle,
ownerId,
clawdis,
});
return (
<article className="skill-install-command-card">
<div className="skill-install-command-header">
<h3 className="skill-install-panel-title">Install</h3>
<div className="install-switcher-toggle" role="tablist" aria-label="Install option">
<button
type="button"
role="tab"
aria-selected={activeInstallTab === "cli"}
className={`install-switcher-pill${activeInstallTab === "cli" ? " is-active" : ""}`}
onClick={() => setActiveInstallTab("cli")}
>
CLI
</button>
<button
type="button"
role="tab"
aria-selected={activeInstallTab === "prompt"}
className={`install-switcher-pill${
activeInstallTab === "prompt" ? " is-active" : ""
}`}
onClick={() => setActiveInstallTab("prompt")}
>
Prompt
</button>
</div>
</div>
<div className="skill-install-command-wrap">
<pre
className={`skill-install-command${
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
}`}
>
<code translate="no">
{activeInstallTab === "prompt" ? promptPreview : openClawCommand}
</code>
</pre>
<InstallCopyButton
text={activeInstallTab === "prompt" ? promptPreview : openClawCommand}
ariaLabel={
activeInstallTab === "prompt"
? "Copy OpenClaw prompt"
: "Copy OpenClaw CLI command"
}
className="skill-install-command-inline-button"
showLabel={false}
/>
</div>
</article>
);
}
-118
View File
@@ -1,118 +0,0 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
skill: PublicSkill;
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
owner: PublicPublisher | null;
ownerHandle: string | null;
clawdis?: ClawdisSkillMetadata;
osLabels: string[];
tagEntries: Array<[string, Id<"skillVersions">]>;
isMalwareBlocked?: boolean;
isRemoved?: boolean;
nixPlugin?: string;
};
export function SkillMetadataSidebar({
skill,
latestVersion,
owner,
ownerHandle,
clawdis: _clawdis,
osLabels,
tagEntries,
isMalwareBlocked,
isRemoved,
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<div className="detail-meta-bar">
{/* Stats row */}
<div className="meta-bar-stats">
<div className="meta-stat">
<Download size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
<span className="meta-stat-label">downloads</span>
</div>
<div className="meta-stat">
<Star size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
<span className="meta-stat-label">stars</span>
</div>
<div className="meta-stat">
<Package size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
<span className="meta-stat-label">versions</span>
</div>
</div>
{/* Details row */}
<div className="meta-bar-details">
<div className="meta-detail">
<Calendar size={12} aria-hidden="true" />
<span>Updated {timeAgo(skill.updatedAt)}</span>
</div>
{latestVersion?.version ? (
<div className="meta-detail">
<Tag size={12} aria-hidden="true" />
<span>v{latestVersion.version}</span>
</div>
) : null}
<div className="meta-detail">
<Scale size={12} aria-hidden="true" />
<span>{PLATFORM_SKILL_LICENSE}</span>
</div>
{osLabels.length > 0 ? (
<div className="meta-detail">
<span>{osLabels.join(", ")}</span>
</div>
) : null}
</div>
{/* Tags and Publisher row */}
<div className="meta-bar-footer">
<div className="meta-bar-publisher">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="sm"
showName
/>
</div>
{tagEntries.length > 0 ? (
<div className="meta-bar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
) : null}
{showDownload ? (
<Button asChild variant="primary" size="sm">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
<Download size={14} aria-hidden="true" />
Download
</a>
</Button>
) : null}
</div>
</div>
);
}
+9 -33
View File
@@ -1,3 +1,4 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { Badge } from "./ui/badge";
@@ -77,33 +78,8 @@ export function VirusTotalIcon({ className }: { className?: string }) {
);
}
export function OpenClawIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
aria-label="OpenClaw"
>
<title>OpenClaw</title>
<path
d="M12 2C8.5 2 5.5 4 4 7c-2 4-1 8 2 11 1.5 1.5 3.5 2.5 6 2.5s4.5-1 6-2.5c3-3 4-7 2-11-1.5-3-4.5-5-8-5z"
fill="currentColor"
opacity="0.2"
/>
<path
d="M9 8c1-2 3-3 5-2s3 3 2 5l-3 4-2-1 3-4c.5-1 0-2-1-2.5S11 7 10.5 8L8 12l-2-1 3-4z"
fill="currentColor"
/>
<path
d="M15 8c-1-2-3-3-5-2s-3 3-2 5l3 4 2-1-3-4c-.5-1 0-2 1-2.5S14 7 14.5 8L17 12l2-1-4-3z"
fill="currentColor"
opacity="0.6"
/>
</svg>
);
export function ClawScanIcon({ className }: { className?: string }) {
return <ShieldCheck className={className} aria-label="ClawScan" />;
}
export function getScanStatusInfo(status: string) {
@@ -251,7 +227,7 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
return {
className: "benign",
label: "Confirmed safe by external scanners",
text: "Static analysis detected API credential-access patterns, but both VirusTotal and OpenClaw confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
text: "Static analysis detected API credential-access patterns, but both VirusTotal and ClawScan confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
};
}
const hasCritical = findings.some((f) => f.severity === "critical");
@@ -259,13 +235,13 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
return {
className: "suspicious",
label: "Patterns worth reviewing",
text: "These patterns may indicate risky behavior. Check the VirusTotal and OpenClaw results above for context-aware analysis before installing.",
text: "These patterns may indicate risky behavior. Check the VirusTotal and ClawScan results above for context-aware analysis before installing.",
};
}
return {
className: "benign",
label: "About static analysis",
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and OpenClaw results above for context-aware analysis.",
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and ClawScan results above for context-aware analysis.",
};
}
@@ -384,7 +360,7 @@ export function SecurityScanResults({
) : null}
{llmStatusInfo ? (
<div className="version-scan-badge">
<OpenClawIcon className="version-scan-icon version-scan-icon-oc" />
<ClawScanIcon className="version-scan-icon version-scan-icon-oc" />
<span className={llmStatusInfo.className}>{llmStatusInfo.label}</span>
{scannerBasePath ? (
<a
@@ -456,8 +432,8 @@ export function SecurityScanResults({
{llmStatusInfo && llmAnalysis ? (
<div className="scan-result-row">
<div className="scan-result-scanner">
<OpenClawIcon className="scan-result-icon scan-result-icon-oc" />
<span className="scan-result-scanner-name">OpenClaw</span>
<ClawScanIcon className="scan-result-icon scan-result-icon-oc" />
<span className="scan-result-scanner-name">ClawScan</span>
</div>
<div className={`scan-result-status ${llmStatusInfo.className}`}>
{llmStatusInfo.label}
+24 -25
View File
@@ -2,38 +2,37 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
<div className="flex gap-3">
<Skeleton className="h-[44px] w-32 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-36 rounded-[var(--radius-pill)]" />
<main className="section">
<div className="dashboard-header">
<div className="grid gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
</div>
{/* Stats row */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<div className="dashboard-owner-grid">
{["skills", "plugins"].map((section) => (
<div
key={i}
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5"
key={section}
className="dashboard-owner-panel flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5"
>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-16" />
<div className="dashboard-section-header">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-[34px] w-28 rounded-[var(--r-btn)]" />
</div>
<div className="dashboard-list">
{Array.from({ length: section === "skills" ? 2 : 3 }, (_, index) => (
<div key={index} className="dashboard-list-row">
<Skeleton className="h-5 w-48 max-w-full" />
<Skeleton className="h-5 w-96 max-w-full" />
<Skeleton className="h-8 w-24 rounded-[var(--radius-pill)]" />
<Skeleton className="h-8 w-8 rounded-[var(--r-btn)]" />
</div>
))}
</div>
</div>
))}
</div>
{/* Publisher tabs */}
<Skeleton className="mb-6 h-[44px] w-64 rounded-[var(--radius-pill)]" />
{/* Table skeleton */}
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-[var(--radius-sm)]" />
))}
</div>
</div>
</main>
);
}
@@ -2,71 +2,64 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_340px]">
{/* Main column */}
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-6 w-16 rounded-[var(--radius-pill)]" />
<div className="skill-detail-stack">
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<Skeleton className="h-4 w-48" />
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-full max-w-[360px]" />
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
</div>
<Skeleton className="h-5 w-full max-w-[680px]" />
<Skeleton className="h-5 w-3/4 max-w-[520px]" />
</div>
<Skeleton className="h-5 w-full max-w-lg" />
{/* Meta row */}
<div className="flex items-center gap-4">
<Skeleton className="h-6 w-6 rounded-full" />
<div className="flex flex-wrap items-center gap-4">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
{/* README skeleton */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-6">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="mt-2 h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
{/* Tabs skeleton */}
<div className="flex gap-2">
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-28 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-5">
{/* Install card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full rounded-[var(--radius-sm)]" />
<Skeleton className="h-10 w-full rounded-[var(--radius-pill)]" />
</div>
{/* Stats card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-16" />
<div className="w-full space-y-3 lg:max-w-[360px]">
<Skeleton className="h-12 w-full rounded-[var(--r-sm)]" />
<Skeleton className="h-12 w-full rounded-[var(--r-pill)]" />
<div className="grid grid-cols-3 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</div>
</div>
{/* Security card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-full" />
</div>
</div>
<div className="detail-layout">
<div className="detail-main">
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<div className="mb-4 flex flex-wrap gap-2">
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
<Skeleton className="h-10 w-20 rounded-[var(--r-pill)]" />
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
</div>
<div className="space-y-3">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="mt-5 h-5 w-52" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="mb-3 h-6 w-28" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
+7 -6
View File
@@ -7,24 +7,25 @@ const buttonVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
"cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60",
"!no-underline hover:!no-underline",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
],
{
variants: {
variant: {
default:
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--surface-muted)]",
primary:
"border border-accent bg-accent/10 text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
"border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
secondary:
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
destructive:
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:-translate-y-px hover:not-disabled:bg-active-bg hover:not-disabled:shadow-hover",
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
ghost:
"border border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)]",
outline:
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)] hover:not-disabled:shadow-hover",
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
link: "h-auto border border-transparent bg-transparent p-0 text-[color:var(--accent-deep)] underline-offset-4 hover:underline disabled:opacity-60",
},
size: {
+1 -60
View File
@@ -53,10 +53,6 @@ const SOULS_SEARCH = {
focus: undefined,
} as const;
const USERS_SEARCH = { q: undefined } as const;
const MANAGEMENT_SEARCH = { skill: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins | Souls
@@ -100,55 +96,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
},
];
// ---------------------------------------------------------------------------
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Users",
to: "/users",
search: USERS_SEARCH,
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "About",
to: "/about",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Stars",
to: "/stars",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Dashboard",
to: "/dashboard",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Management",
to: "/management",
search: MANAGEMENT_SEARCH,
authRequired: true,
staffOnly: true,
soulModeOnly: false,
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Footer sections
// ---------------------------------------------------------------------------
@@ -199,17 +146,11 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
title: "Community",
items: [
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
},
];
// ---------------------------------------------------------------------------
+21
View File
@@ -35,6 +35,7 @@ import { Route as PackagesNameRouteImport } from './routes/packages/$name'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
@@ -168,6 +169,11 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
path: '/$owner/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => OwnerSlugRoute,
} as any)
const PluginsNameSecurityScannerRoute =
PluginsNameSecurityScannerRouteImport.update({
id: '/security/$scanner',
@@ -208,6 +214,7 @@ export interface FileRoutesByFullPath {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
@@ -238,6 +245,7 @@ export interface FileRoutesByTo {
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
@@ -269,6 +277,7 @@ export interface FileRoutesById {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
@@ -301,6 +310,7 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
@@ -331,6 +341,7 @@ export interface FileRouteTypes {
| '/skills'
| '/souls'
| '/users'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
id:
@@ -361,6 +372,7 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
fileRoutesById: FileRoutesById
@@ -578,6 +590,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/$slug/settings': {
id: '/$owner/$slug/settings'
path: '/settings'
fullPath: '/$owner/$slug/settings'
preLoaderRoute: typeof OwnerSlugSettingsRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/plugins/$name/security/$scanner': {
id: '/plugins/$name/security/$scanner'
path: '/security/$scanner'
@@ -596,10 +615,12 @@ declare module '@tanstack/react-router' {
}
interface OwnerSlugRouteChildren {
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
}
const OwnerSlugRouteChildren: OwnerSlugRouteChildren = {
OwnerSlugSettingsRoute: OwnerSlugSettingsRoute,
OwnerSlugSecurityScannerRoute: OwnerSlugSecurityScannerRoute,
}
+11 -2
View File
@@ -1,4 +1,10 @@
import { createFileRoute, notFound, Outlet, redirect, useRouterState } from "@tanstack/react-router";
import {
createFileRoute,
notFound,
Outlet,
redirect,
useRouterState,
} from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
@@ -73,7 +79,10 @@ function OwnerSkill() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname.includes(`/${encodeURIComponent(slug)}/security/`)) {
if (
pathname.includes(`/${encodeURIComponent(slug)}/security/`) ||
pathname.endsWith(`/${encodeURIComponent(slug)}/settings`)
) {
return <Outlet />;
}
return <SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} />;
@@ -51,7 +51,7 @@ export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
scanner === "virustotal"
? "VirusTotal"
: scanner === "openclaw"
? "OpenClaw"
? "ClawScan"
: "Static analysis";
const meta = buildSkillMeta({
slug: params.slug,
@@ -111,7 +111,6 @@ function SkillSecurityScannerRoute() {
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
securityBasePath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}/security`,
}}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
+63
View File
@@ -0,0 +1,63 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../../components/SkillDetailPage";
import { buildSkillMeta } from "../../../lib/og";
import { fetchSkillPageData } from "../../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug/settings")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/settings",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `Settings · ${meta.title}` },
{
name: "description",
content: `Owner settings for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSettingsRoute,
});
function SkillSettingsRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
return (
<SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} mode="settings" />
);
}
+1 -6
View File
@@ -149,12 +149,7 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
</button>
</p>
) : null}
<SignInButton
variant="primary"
disabled={isLoading}
>
Sign in with GitHub
</SignInButton>
<SignInButton disabled={isLoading} />
</CardContent>
</Card>
</Container>
+292
View File
@@ -0,0 +1,292 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "../../convex/_generated/dataModel";
import { TooltipProvider } from "../components/ui/tooltip";
import { Dashboard } from "./dashboard";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useMutation: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
useMutation: (...args: unknown[]) => mocks.useMutation(...args),
}));
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => mocks.toastSuccess(...args),
error: (...args: unknown[]) => mocks.toastError(...args),
},
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: unknown) => config,
Link: ({
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children: React.ReactNode }) => (
<a href="/test" {...props}>
{children}
</a>
),
}));
type TestSkill = {
_id: Id<"skills">;
_creationTime: number;
slug: string;
displayName: string;
summary: string;
ownerUserId: Id<"users">;
ownerPublisherId: Id<"publishers">;
tags: {};
badges: {};
stats: {
downloads: number;
stars: number;
versions: number;
};
moderationVerdict?: "suspicious" | "malicious";
moderationFlags?: string[];
isSuspicious?: boolean;
createdAt: number;
updatedAt: number;
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
};
rescanState: {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: null | { _id: string; status: "in_progress"; targetVersion: string };
latestRequest: null | { _id: string; status: "completed" | "failed"; targetVersion: string };
};
};
type TestPackage = {
_id: Id<"packages">;
name: string;
displayName: string;
family: "code-plugin";
channel: "community";
isOfficial: false;
runtimeId: string | null;
sourceRepo: string | null;
summary: string;
latestVersion: string;
stats: {
downloads: number;
installs: number;
stars: number;
versions: number;
};
verification: null;
scanStatus: "suspicious" | "malicious";
latestRelease: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
};
rescanState: TestSkill["rescanState"];
};
const me = {
_id: "users:local" as Id<"users">,
handle: "local",
name: "Local Dev",
displayName: "Local Dev",
};
const publishers = [
{
publisher: {
_id: "publishers:local" as Id<"publishers">,
handle: "local",
displayName: "Local",
kind: "user" as const,
},
role: "owner" as const,
},
];
function createSkill(overrides?: Partial<TestSkill>): TestSkill {
return {
_id: "skills:below-cap" as Id<"skills">,
_creationTime: 1,
slug: "local-flagged-skill",
displayName: "Local Flagged Skill",
summary: "Flagged skill fixture.",
ownerUserId: me._id,
ownerPublisherId: publishers[0].publisher._id,
tags: {},
badges: {},
stats: { downloads: 0, stars: 0, versions: 1 },
moderationVerdict: "suspicious",
moderationFlags: ["flagged.suspicious"],
isSuspicious: true,
createdAt: 1,
updatedAt: 1,
latestVersion: {
version: "1.0.0",
createdAt: 1,
vtStatus: "suspicious",
llmStatus: "suspicious",
staticScanStatus: "suspicious",
},
rescanState: {
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
},
...overrides,
};
}
function createPackage(overrides?: Partial<TestPackage>): TestPackage {
return {
_id: "packages:at-cap" as Id<"packages">,
name: "local-flagged-runtime-plugin",
displayName: "Local Flagged Runtime Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: null,
sourceRepo: null,
summary: "Flagged plugin fixture.",
latestVersion: "1.0.0",
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
verification: null,
scanStatus: "malicious",
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "malicious",
llmStatus: "malicious",
staticScanStatus: "malicious",
},
rescanState: {
maxRequests: 3,
requestCount: 3,
remainingRequests: 0,
canRequest: false,
inProgressRequest: null,
latestRequest: null,
},
...overrides,
};
}
function arrangeDashboard({
skills = [],
packages = [],
}: {
skills?: TestSkill[];
packages?: TestPackage[];
}) {
let unscopedQueryCount = 0;
let scopedQueryCount = 0;
mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args === undefined) {
unscopedQueryCount += 1;
return unscopedQueryCount % 2 === 1 ? me : publishers;
}
scopedQueryCount += 1;
return scopedQueryCount % 2 === 1 ? skills : packages;
});
}
function renderDashboard() {
return render(
<TooltipProvider>
<Dashboard />
</TooltipProvider>,
);
}
describe("Dashboard minimal rows", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useMutation.mockReset();
mocks.useMutation.mockReturnValue(vi.fn().mockResolvedValue({}));
mocks.toastSuccess.mockReset();
mocks.toastError.mockReset();
});
it("renders entry links, summaries, and aggregate statuses only", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.getByRole("link", { name: "Local Flagged Skill" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Local Flagged Runtime Plugin" })).toBeTruthy();
expect(screen.getByText("Flagged skill fixture.")).toBeTruthy();
expect(screen.getByText("Flagged plugin fixture.")).toBeTruthy();
expect(screen.getByText("Suspicious")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByRole("button", { name: "Suspicious status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Blocked status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Open actions for Local Flagged Skill" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Open actions for Local Flagged Runtime Plugin" }),
).toBeTruthy();
});
it("renders a skeleton while auth state is loading", () => {
mocks.useQuery.mockReturnValue(undefined);
renderDashboard();
expect(screen.queryByText("Sign in to access your dashboard.")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("does not render row-level actions", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.queryByRole("button", { name: /request rescan/i })).toBeNull();
expect(screen.queryByRole("link", { name: /new version/i })).toBeNull();
expect(screen.queryByRole("link", { name: /new release/i })).toBeNull();
expect(screen.queryByRole("link", { name: /^view$/i })).toBeNull();
});
it("does not render column titles, scanner details, or plugin metadata chips", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.queryByText("Skill")).toBeNull();
expect(screen.queryByText("Plugin")).toBeNull();
expect(screen.queryByText("Summary")).toBeNull();
expect(screen.queryByText("Status")).toBeNull();
expect(screen.queryByText(/^VT:/)).toBeNull();
expect(screen.queryByText(/^LLM:/)).toBeNull();
expect(screen.queryByText(/^ClawScan:/)).toBeNull();
expect(screen.queryByText(/^Static/)).toBeNull();
expect(screen.queryByText(/public surfaces warn or suppress it/i)).toBeNull();
expect(screen.queryByText(/automated security checks found malicious content/i)).toBeNull();
expect(screen.queryByText("Code Plugin")).toBeNull();
expect(screen.queryByText("community")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
expect(screen.queryByText("Limit reached (3/3)")).toBeNull();
});
});
+269 -351
View File
@@ -1,27 +1,22 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import {
AlertTriangle,
ArrowDownToLine,
CheckCircle2,
Clock,
GitBranch,
Package,
Plug,
RotateCw,
ShieldCheck,
Star,
Upload,
} from "lucide-react";
import { useMutation, useQuery } from "convex/react";
import { Clock, Info, MoreVertical, Plus, RotateCw, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import semver from "semver";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { DashboardSkeleton } from "../components/skeletons/DashboardSkeleton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
import { getUserFacingConvexError } from "../lib/convexError";
const emptyPluginPublishSearch = {
ownerHandle: undefined,
@@ -65,6 +60,7 @@ type DashboardSkill = Pick<
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
rescanState?: DashboardRescanState | null;
};
type DashboardPackage = {
@@ -96,13 +92,35 @@ type DashboardPackage = {
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
rescanState?: DashboardRescanState | null;
};
type DashboardRescanState = {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: DashboardRescanRequest | null;
latestRequest: DashboardRescanRequest | null;
};
type DashboardRescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
targetVersion: string;
requestedByUserId: string;
status: "in_progress" | "completed" | "failed";
error?: string;
createdAt: number;
updatedAt: number;
completedAt?: number;
};
export const Route = createFileRoute("/dashboard")({
component: Dashboard,
});
function Dashboard() {
export function Dashboard() {
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
const publishers = useQuery(api.publishers.listMine) as
| Array<{
@@ -147,7 +165,11 @@ function Dashboard() {
}
}, [publishers, selectedPublisherId]);
if (!me) {
if (me === undefined) {
return <DashboardSkeleton />;
}
if (me === null) {
return (
<main className="section">
<Card>Sign in to access your dashboard.</Card>
@@ -205,48 +227,24 @@ function Dashboard() {
<main className="section">
<div className="dashboard-header">
<div>
<h1 className="section-title m-0">Publisher Dashboard</h1>
<p className="section-subtitle m-0">Manage your published skills and plugins.</p>
</div>
<div className="flex gap-2 flex-wrap">
{publishers && publishers.length > 0 ? (
<select
className="input"
value={selectedPublisherId}
onChange={(event) => setSelectedPublisherId(event.target.value)}
>
{publishers.map((entry) => (
<option key={entry.publisher._id} value={entry.publisher._id}>
@{entry.publisher.handle} · {entry.role}
</option>
))}
</select>
) : null}
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
<Button asChild>
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
<h1 className="section-title m-0">Dashboard</h1>
<p className="section-subtitle m-0">
View your published skills and plugins.
</p>
</div>
</div>
<Card className="dashboard-owner-panel">
<div className="dashboard-owner-grid">
<div className="dashboard-owner-grid">
<Card className="dashboard-owner-panel">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Skills</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Hidden skill versions remain visible here while checks are pending.
</p>
</div>
<h2 className="dashboard-collection-title">Skills</h2>
<Button asChild size="sm" className="dashboard-section-action">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Plus className="h-4 w-4" aria-hidden="true" />
New Skill
</Link>
</Button>
</div>
{skills.length === 0 ? (
<div className="dashboard-inline-empty">
@@ -254,36 +252,27 @@ function Dashboard() {
<strong>No skills yet.</strong> Publish your first skill to share it with the
community.
</div>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</Card>
<Card className="dashboard-owner-panel">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Owner-only package view with VirusTotal, static scan, and verification state.
</p>
</div>
<h2 className="dashboard-collection-title">Plugins</h2>
<Button asChild size="sm" className="dashboard-section-action">
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plus className="h-4 w-4" aria-hidden="true" />
New Plugin
</Link>
</Button>
</div>
{packages.length === 0 ? (
<div className="dashboard-inline-empty">
@@ -291,50 +280,27 @@ function Dashboard() {
<strong>No plugins yet.</strong> Publish your first plugin release to validate and
distribute it.
</div>
<Button asChild variant="primary">
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</div>
</Card>
</Card>
</div>
</main>
);
}
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
const status = skillDashboardStatus(skill);
const latestScanLabel = skill.latestVersion
? [
releaseStatusLabel("VT", skill.latestVersion.vtStatus, "unknown"),
releaseStatusLabel("OpenClaw", skill.latestVersion.llmStatus),
releaseStatusLabel("Static", skill.latestVersion.staticScanStatus),
]
: ["No release scan data"];
const showRescan =
status.key !== "visible" ||
skill.latestVersion?.vtStatus === "suspicious" ||
skill.latestVersion?.vtStatus === "malicious" ||
skill.latestVersion?.llmStatus === "suspicious" ||
skill.latestVersion?.llmStatus === "malicious" ||
skill.latestVersion?.staticScanStatus === "suspicious" ||
skill.latestVersion?.staticScanStatus === "malicious";
const detailParams = { owner: ownerHandle ?? "unknown", slug: skill.slug };
const settingsHref = `/${encodeURIComponent(detailParams.owner)}/${encodeURIComponent(
skill.slug,
)}/settings`;
return (
<div className="dashboard-list-row">
@@ -342,74 +308,209 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
<div className="dashboard-list-title">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
params={detailParams}
className="dashboard-skill-name"
>
{skill.displayName}
</Link>
<span className="dashboard-list-id">/{skill.slug}</span>
<Badge variant={status.variant}>
{status.key === "pending" ? <Clock className="h-3 w-3" aria-hidden="true" /> : null}
{status.label}
</Badge>
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span>
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
</span>
</div>
</div>
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />
{status.label}
</span>
<span className="dashboard-inline-status-note">{status.description}</span>
{latestScanLabel.map((label) => (
<span key={label} className="dashboard-inline-status-note">
{label}
</span>
))}
<StatusChipWithTooltip status={status} />
</div>
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
<RowMenu
kind="skill"
targetId={skill._id}
targetLabel={skill.displayName}
settingsHref={settingsHref}
statusLabel={status.label}
rescanState={skill.rescanState ?? null}
/>
</div>
);
}
function StatusChipWithTooltip({
status,
}: {
status: {
key?: string;
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
};
}) {
const showInfo = status.label !== "Visible";
return (
<Badge variant={status.variant} className="dashboard-status-chip">
{status.key === "pending" ? <Clock className="h-3 w-3" aria-hidden="true" /> : null}
{status.label}
{showInfo ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="dashboard-status-info"
aria-label={`${status.label} status reason`}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="end">
{status.description}
</TooltipContent>
</Tooltip>
) : null}
</Badge>
);
}
function packageDashboardStatus(pkg: DashboardPackage): {
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
} {
const releaseStatuses = new Set([
pkg.latestRelease?.vtStatus,
pkg.latestRelease?.llmStatus,
pkg.latestRelease?.staticScanStatus,
]);
if (pkg.scanStatus === "malicious" || releaseStatuses.has("malicious")) {
return {
label: "Blocked",
description: "Security checks found malicious content.",
variant: "destructive",
};
}
if (pkg.scanStatus === "suspicious" || releaseStatuses.has("suspicious")) {
return {
label: "Suspicious",
description: "Security checks flagged this plugin for review.",
variant: "warning",
};
}
if (pkg.scanStatus === "pending" || pkg.pendingReview) {
return {
label: "Pending checks",
description: "Security verification is still running.",
variant: "pending",
};
}
if (pkg.scanStatus === "clean") {
return {
label: "Visible",
description: "Available on public catalog surfaces.",
variant: "success",
};
}
return {
label: "Unknown",
description: "Open the plugin for the latest release and security details.",
variant: "default",
};
}
function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
const status = packageDashboardStatus(pkg);
return (
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
{pkg.displayName}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
Security
</Link>
</Button>
{showRescan ? (
</div>
</div>
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<StatusChipWithTooltip status={status} />
</div>
<RowMenu
kind="plugin"
targetId={pkg._id}
targetLabel={pkg.displayName}
settingsHref={`/plugins/${encodeURIComponent(pkg.name)}`}
statusLabel={status.label}
rescanState={pkg.rescanState ?? null}
/>
</div>
);
}
function canShowDashboardRescan(statusLabel: string, state: DashboardRescanState | null) {
if (statusLabel === "Visible") return false;
if (!state) return true;
return state.canRequest && !state.inProgressRequest && state.remainingRequests > 0;
}
function RowMenu({
kind,
targetId,
targetLabel,
settingsHref,
statusLabel,
rescanState,
}: {
kind: "skill" | "plugin";
targetId: string;
targetLabel: string;
settingsHref: string;
statusLabel: string;
rescanState: DashboardRescanState | null;
}) {
const requestSkillRescan = useMutation(api.skills.requestRescan);
const requestPluginRescan = useMutation(api.packages.requestRescan);
const [isRequesting, setIsRequesting] = useState(false);
const showRescan = canShowDashboardRescan(statusLabel, rescanState);
async function requestRescan() {
if (!showRescan || isRequesting) return;
setIsRequesting(true);
try {
if (kind === "skill") {
await requestSkillRescan({ skillId: targetId as Doc<"skills">["_id"] });
} else {
await requestPluginRescan({ packageId: targetId as Doc<"packages">["_id"] });
}
toast.success(`Rescan requested for ${targetLabel}.`);
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
} finally {
setIsRequesting(false);
}
}
return (
<div className="dashboard-row-menu">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled
title="Owner rescan requests are handled by the rescan request backend."
size="icon-sm"
aria-label={`Open actions for ${targetLabel}`}
>
<RotateCw className="h-3 w-3" aria-hidden="true" />
Request Rescan
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<Button asChild variant="ghost" size="sm">
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
View
</Link>
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="dashboard-row-menu-content">
<DropdownMenuItem asChild>
<a href={settingsHref}>
<Settings className="h-4 w-4" aria-hidden="true" />
Settings
</a>
</DropdownMenuItem>
{showRescan ? (
<DropdownMenuItem disabled={isRequesting} onSelect={() => void requestRescan()}>
<RotateCw className="h-4 w-4" aria-hidden="true" />
{isRequesting ? "Requesting..." : "Request rescan"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -422,6 +523,11 @@ function skillDashboardStatus(skill: DashboardSkill): {
} {
const flags = skill.moderationFlags ?? [];
const reason = skill.moderationReason ?? "";
const versionStatuses = new Set([
skill.latestVersion?.vtStatus,
skill.latestVersion?.llmStatus,
skill.latestVersion?.staticScanStatus,
]);
if (skill.moderationStatus === "removed") {
return {
key: "removed",
@@ -430,7 +536,11 @@ function skillDashboardStatus(skill: DashboardSkill): {
variant: "destructive",
};
}
if (flags.includes("blocked.malware") || skill.moderationVerdict === "malicious") {
if (
flags.includes("blocked.malware") ||
skill.moderationVerdict === "malicious" ||
versionStatuses.has("malicious")
) {
return {
key: "blocked",
label: "Blocked",
@@ -462,7 +572,8 @@ function skillDashboardStatus(skill: DashboardSkill): {
if (
skill.isSuspicious ||
flags.includes("flagged.suspicious") ||
skill.moderationVerdict === "suspicious"
skill.moderationVerdict === "suspicious" ||
versionStatuses.has("suspicious")
) {
return {
key: "suspicious",
@@ -487,196 +598,3 @@ function skillDashboardStatus(skill: DashboardSkill): {
variant: "success",
};
}
function scanStatusLabel(status: string | null | undefined) {
switch (status) {
case "pending":
return "Pending scan";
case "clean":
return "Scan clean";
case "suspicious":
return "Suspicious";
case "malicious":
return "Blocked";
case "not-run":
return "Scan not run";
default:
return null;
}
}
function releaseStatusLabel(
label: string,
status: string | null | undefined,
emptyLabel = "not started",
) {
return `${label}: ${status?.trim() ? status : emptyLabel}`;
}
function PackageStatusTag({
label,
tone,
}: {
label: string;
tone: "default" | "pending" | "warning" | "danger" | "success";
}) {
const variant =
tone === "pending"
? "pending"
: tone === "warning"
? "warning"
: tone === "danger"
? "destructive"
: tone === "success"
? "success"
: "default";
return <Badge variant={variant}>{label}</Badge>;
}
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const scanLabel = scanStatusLabel(pkg.scanStatus);
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
const sourceLabel = pkg.sourceRepo
?.replace(/^https?:\/\/github\.com\//, "")
.replace(/\.git$/, "");
const showRescan =
pkg.scanStatus === "pending" ||
pkg.scanStatus === "suspicious" ||
pkg.scanStatus === "malicious" ||
pkg.latestRelease?.vtStatus === "suspicious" ||
pkg.latestRelease?.vtStatus === "malicious" ||
pkg.latestRelease?.llmStatus === "suspicious" ||
pkg.latestRelease?.llmStatus === "malicious" ||
pkg.latestRelease?.staticScanStatus === "suspicious" ||
pkg.latestRelease?.staticScanStatus === "malicious";
const scanTone =
pkg.scanStatus === "pending"
? "pending"
: pkg.scanStatus === "suspicious"
? "warning"
: pkg.scanStatus === "malicious"
? "danger"
: pkg.scanStatus === "clean"
? "success"
: "default";
const staticTone =
pkg.latestRelease?.staticScanStatus === "suspicious"
? "warning"
: pkg.latestRelease?.staticScanStatus === "malicious"
? "danger"
: pkg.latestRelease?.staticScanStatus === "clean"
? "success"
: "default";
return (
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
{pkg.displayName}
</Link>
<span className="dashboard-list-id">{pkg.name}</span>
</div>
<div className="dashboard-inline-tags">
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
<PackageStatusTag label={pkg.channel} tone="default" />
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
{pkg.verification?.tier ? (
<PackageStatusTag label={pkg.verification.tier} tone="default" />
) : null}
{pkg.latestRelease?.staticScanStatus ? (
<PackageStatusTag
label={`Static ${pkg.latestRelease.staticScanStatus}`}
tone={staticTone}
/>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(pkg.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
</span>
<span>
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
</span>
<span>
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
</span>
{pkg.runtimeId ? (
<span>
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
</span>
) : null}
{sourceLabel ? (
<span>
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
</span>
) : null}
</div>
</div>
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />{" "}
{releaseStatusLabel(
"VT",
pkg.latestRelease?.vtStatus,
pkg.scanStatus === "pending" ? "pending" : "unknown",
)}
</span>
<span className="dashboard-inline-status-item">
<CheckCircle2 size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
</span>
<span className="dashboard-inline-status-item">
<AlertTriangle size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
</span>
</div>
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/plugins/$name" params={{ name: pkg.name }}>
View
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/plugins/$name" params={{ name: pkg.name }}>
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
Security
</Link>
</Button>
{showRescan ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled
title="Owner rescan requests are handled by the rescan request backend."
>
<RotateCw className="h-3 w-3" aria-hidden="true" />
Request Rescan
</Button>
) : null}
</div>
</div>
);
}
+1 -1
View File
@@ -227,7 +227,7 @@ export function ImportGitHub() {
description="You need to be signed in to import skills from GitHub."
>
{!isLoading ? (
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
) : null}
</EmptyState>
</Container>
+326 -330
View File
@@ -1,13 +1,20 @@
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { AlertTriangle, ExternalLink, Copy, Check, Download } from "lucide-react";
import { useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { AlertTriangle, ExternalLink, Download } from "lucide-react";
import type { ComponentProps } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
import { DetailSecuritySummary } from "../../components/DetailSecuritySummary";
import { EmptyState } from "../../components/EmptyState";
import { InstallCopyButton } from "../../components/InstallCopyButton";
import { Container } from "../../components/layout/Container";
import { MarkdownPreview } from "../../components/MarkdownPreview";
import { SecurityScanResults } from "../../components/SkillSecurityScanResults";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { getUserFacingConvexError } from "../../lib/convexError";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import {
fetchPackageDetail,
@@ -19,13 +26,12 @@ import {
type PackageVersionDetail,
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
import { useAuthStatus } from "../../lib/useAuthStatus";
type PluginDetailRateLimitState =
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
type PluginDetailRateLimitState = {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
@@ -143,63 +149,6 @@ function VerifiedBadge() {
);
}
function fallbackCopy(text: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const ok = document.execCommand("copy");
return ok;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
function CopyButton({ text }: { text: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
<Button
variant="outline"
size="sm"
className="w-full shrink-0 sm:w-auto"
onClick={() => {
if (navigator.clipboard?.writeText) {
void navigator.clipboard
.writeText(text)
.then(() => {
setState("copied");
setTimeout(() => setState("idle"), 2000);
})
.catch(() => {
if (fallbackCopy(text)) {
setState("copied");
setTimeout(() => setState("idle"), 2000);
} else {
setState("failed");
setTimeout(() => setState("idle"), 2000);
}
});
} else if (fallbackCopy(text)) {
setState("copied");
setTimeout(() => setState("idle"), 2000);
} else {
setState("failed");
setTimeout(() => setState("idle"), 2000);
}
}}
aria-label="Copy to clipboard"
>
{state === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{state === "copied" ? "Copied" : state === "failed" ? "Failed" : "Copy"}
</Button>
);
}
const CAPABILITY_LABELS: Record<string, string> = {
executesCode: "Executes code",
runtimeId: "Runtime ID",
@@ -235,6 +184,12 @@ function PluginDetailRoute() {
const { name } = Route.useParams();
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
const pathname = useRouterState({ select: (state) => state.location.pathname });
const { isAuthenticated } = useAuthStatus();
const requestPluginRescan = useMutation(api.packages.requestRescan);
const rescanState = useQuery(
api.packages.getOwnerRescanStateByName,
isAuthenticated && detail.package ? { name: detail.package.name } : "skip",
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
if (pathname.includes("/security/")) {
return <Outlet />;
@@ -286,6 +241,26 @@ function PluginDetailRoute() {
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
const verification = latestRelease?.verification ?? pkg.verification;
const requestRescan = async () => {
const packageId = (pkg as { _id?: Id<"packages"> })._id;
if (!packageId) {
toast.error("Could not request a rescan for this plugin.");
return;
}
try {
await requestPluginRescan({ packageId });
toast.success("Rescan requested.", {
action: {
label: "Dashboard",
onClick: () => {
window.location.href = "/dashboard";
},
},
});
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
}
};
const capEntries = capabilities
? Object.entries(capabilities).filter(
@@ -299,12 +274,56 @@ function PluginDetailRoute() {
: [];
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
<div className="flex flex-wrap gap-1.5 mb-2">
<main className="section detail-page-section">
<DetailPageShell>
<DetailHero
main={
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{pkg.displayName}</h1>
{pkg.latestVersion ? (
<span className="plugin-version-badge">v{pkg.latestVersion}</span>
) : null}
{pkg.latestVersion ? (
<div className="skill-title-actions">
<Button asChild variant="outline" size="sm" className="no-underline">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download
</a>
</Button>
</div>
) : null}
</div>
<p className="section-subtitle">{pkg.summary ?? "No summary provided."}</p>
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
<Badge>{familyLabel(pkg.family)}</Badge>
{verification?.tier ? (
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
@@ -317,274 +336,251 @@ function PluginDetailRoute() {
<VerifiedBadge />
</Badge>
) : null}
</div>
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
{pkg.displayName}
{pkg.latestVersion ? (
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
v{pkg.latestVersion}
</span>
) : null}
</h1>
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="break-all font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="opacity-40">&middot;</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
</div>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent" className="mt-3 self-start">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
</div>
</div>
{/* Latest Release */}
{pkg.latestVersion ? (
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
) : null}
</CardContent>
</Card>
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Security Scan */}
{latestRelease ? (
<Card>
<CardContent>
<SecurityScanResults
sha256hash={latestRelease.sha256hash ?? undefined}
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
staticFindings={latestRelease.staticScan?.findings ?? []}
</div>
}
sidebar={
<>
{latestRelease ? (
<DetailSecuritySummary
scannerBasePath={`/plugins/${encodeURIComponent(name)}/security`}
sha256hash={latestRelease.sha256hash ?? null}
vtAnalysis={latestRelease.vtAnalysis ?? null}
llmAnalysis={latestRelease.llmAnalysis ?? null}
staticScan={latestRelease.staticScan ?? null}
rescanState={rescanState ?? null}
onRequestRescan={rescanState ? requestRescan : null}
/>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Readme */}
) : null}
<Card className="skill-install-command-card">
<CardHeader>
<CardTitle>Install</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-install-command-wrap">
<pre className="skill-install-command">
<code>{installSnippet}</code>
</pre>
<InstallCopyButton
text={installSnippet}
ariaLabel="Copy plugin install command"
showLabel={false}
className="skill-install-command-inline-button"
/>
</div>
</CardContent>
</Card>
</>
}
>
{readme ? (
<Card>
<Card className="tab-card">
<CardHeader>
<CardTitle>README</CardTitle>
</CardHeader>
<CardContent>
<MarkdownPreview>{readme}</MarkdownPreview>
</CardContent>
</Card>
) : null}
</div>
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<InstallCopyButton
text={JSON.stringify(capabilities, null, 2)}
ariaLabel="Copy capabilities JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<InstallCopyButton
text={JSON.stringify(compatibility, null, 2)}
ariaLabel="Copy compatibility JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
</DetailHero>
</DetailPageShell>
</main>
);
}
@@ -75,7 +75,7 @@ export const Route = createFileRoute("/plugins/$name/security/$scanner")({
scanner === "virustotal"
? "VirusTotal"
: scanner === "openclaw"
? "OpenClaw"
? "ClawScan"
: "Static analysis";
return {
meta: [
@@ -130,7 +130,6 @@ function PluginSecurityScannerRoute() {
ownerUserId: null,
ownerPublisherId: null,
detailPath: `/plugins/${encodeURIComponent(name)}`,
securityBasePath: `/plugins/${encodeURIComponent(name)}/security`,
}}
sha256hash={release.sha256hash ?? null}
vtAnalysis={release.vtAnalysis ?? null}
+1 -1
View File
@@ -349,7 +349,7 @@ export function Upload() {
title={`Sign in to publish a ${contentLabel}`}
description="You need to be signed in to publish skills on ClawHub."
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</EmptyState>
</Container>
</main>
+1 -1
View File
@@ -165,7 +165,7 @@ export function Settings() {
<Card>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access settings.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</CardContent>
</Card>
</Container>
+1 -1
View File
@@ -32,7 +32,7 @@ function Stars() {
title="Sign in to see your highlights"
description="Star skills for quick access later."
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</EmptyState>
</div>
</main>
+321 -275
View File
@@ -642,6 +642,12 @@ a {
text-decoration: none;
}
a:hover {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
/* Global focus indicator for keyboard navigation */
:focus-visible {
outline: 2px solid var(--accent);
@@ -738,7 +744,7 @@ code {
.navbar-inner {
width: 100%;
padding: 0 var(--space-5);
padding: 0 clamp(48px, 4vw, 88px);
display: flex;
flex-direction: column;
gap: 0;
@@ -746,7 +752,7 @@ code {
.navbar-top {
display: grid;
grid-template-columns: minmax(148px, auto) minmax(200px, 1fr) auto;
grid-template-columns: minmax(148px, auto) minmax(260px, 640px) auto auto;
align-items: center;
gap: 16px;
padding: 12px 0;
@@ -755,7 +761,7 @@ code {
.navbar-search {
width: 100%;
max-width: none;
max-width: 640px;
display: flex;
align-items: center;
gap: var(--space-2);
@@ -854,7 +860,7 @@ code {
}
.navbar-tabs {
display: flex;
display: none;
align-items: center;
justify-content: flex-start;
gap: 4px;
@@ -869,6 +875,15 @@ code {
display: none;
}
.navbar-top-links {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
min-width: 0;
white-space: nowrap;
}
.navbar-tabs-primary,
.navbar-tabs-secondary {
display: inline-flex;
@@ -877,9 +892,9 @@ code {
}
.navbar-tabs-secondary {
margin-left: 8px;
padding-left: 12px;
border-left: 1px solid var(--line);
margin-left: 0;
padding-left: 0;
border-left: 0;
}
.navbar-tab {
@@ -898,7 +913,8 @@ code {
.navbar-tab:hover {
color: var(--ink);
background: var(--hover-bg);
background: var(--surface-muted);
text-decoration: none;
}
.navbar-tab:hover svg {
@@ -909,13 +925,14 @@ code {
.navbar-tab[data-status="active"] {
color: var(--ink);
font-weight: 600;
background: var(--active-bg);
background: var(--surface-muted);
text-decoration: none;
}
.navbar-tab.active svg,
.navbar-tab[data-status="active"] svg {
opacity: 1;
color: var(--accent);
color: var(--ink);
}
.navbar-tab-secondary {
@@ -1118,44 +1135,27 @@ code {
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
min-width: 0;
justify-content: flex-end;
}
.theme-cycle-group {
display: none;
align-items: center;
gap: 8px;
width: 72px;
min-width: 72px;
grid-template-columns: repeat(2, 32px);
justify-content: end;
}
.theme-cycle-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--line);
border: 0;
border-radius: var(--r-btn);
background: var(--surface);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease;
}
.theme-cycle-button:hover {
color: var(--ink);
border-color: var(--border-ui-hover);
background: var(--surface-muted);
background: var(--hover-bg);
}
.theme-cycle-button:focus-visible {
@@ -1163,31 +1163,6 @@ code {
outline-offset: 2px;
}
.theme-mode-toggle button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--r-btn);
cursor: pointer;
color: var(--ink-soft);
opacity: 0.4;
transition:
opacity 0.15s ease,
color 0.15s ease;
}
.theme-mode-toggle button:hover {
opacity: 0.7;
}
.theme-mode-toggle button[data-state="on"] {
opacity: 1;
color: var(--ink);
}
.user-trigger {
display: inline-flex;
align-items: center;
@@ -1363,6 +1338,19 @@ code {
box-sizing: border-box;
}
.section.detail-page-section {
max-width: none;
padding-right: clamp(48px, 4vw, 88px);
padding-left: clamp(48px, 4vw, 88px);
}
.security-scanner-layout {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: var(--space-5);
}
.upload-shell {
position: relative;
}
@@ -2778,8 +2766,8 @@ code {
}
.hero-install-code {
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line));
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 247, 0.9));
border: 1px solid var(--line);
background: var(--surface-muted);
border-radius: 12px;
padding: 12px 14px;
font-size: 0.9rem;
@@ -2797,7 +2785,7 @@ code {
overflow-wrap: anywhere;
word-break: break-word;
tab-size: 2;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ink) 8%, transparent);
}
/* @deprecated — use <Badge> from components/ui/badge.tsx */
@@ -3004,7 +2992,71 @@ code {
display: grid;
gap: 16px;
max-width: 100%;
overflow-x: auto;
overflow-x: hidden;
}
.skill-hero {
display: grid;
gap: 16px;
min-width: 0;
}
.detail-layout {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.detail-main,
.detail-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-4);
}
.detail-sidebar {
align-self: start;
}
.detail-sidebar .detail-meta-bar {
flex-direction: column;
align-items: stretch;
}
.detail-sidebar .meta-bar-stats,
.detail-sidebar .meta-bar-details,
.detail-sidebar .meta-bar-footer {
flex: 0 1 auto;
padding: 0;
border: 0;
}
.detail-sidebar .meta-bar-details,
.detail-sidebar .meta-bar-footer {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.detail-sidebar .meta-bar-footer {
padding-left: 0;
}
.detail-sidebar .meta-bar-footer > .btn {
width: 100%;
margin-left: 0;
}
@media (min-width: 1080px) {
.detail-layout:has(.detail-sidebar) {
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
align-items: start;
}
.detail-sidebar {
position: sticky;
top: 96px;
}
}
/* ── Plugin Detail Page ── */
@@ -3191,34 +3243,64 @@ code {
}
}
.skill-hero {
gap: 14px;
}
.skill-hero-header {
.skill-hero-layout {
display: grid;
grid-template-columns: 1fr minmax(180px, 220px);
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
align-items: start;
gap: 24px;
}
.skill-hero-main {
display: grid;
gap: 40px;
min-width: 0;
}
.skill-hero-main-extra {
display: grid;
gap: 32px;
min-width: 0;
}
/* .skill-hero-top.has-plugin no longer uses a 2-column grid, so no
collapse override is needed here. */
.skill-hero-title {
display: grid;
gap: 10px;
gap: 12px;
min-width: 0;
flex: 1 1 min(360px, 100%);
}
.skill-hero-title-row {
display: flex;
align-items: baseline;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.skill-page-title {
margin: 0;
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.05;
font-weight: 700;
color: var(--ink);
}
.skill-title-actions {
display: flex;
gap: 10px;
margin-left: auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
}
.skill-settings-link {
align-self: center;
}
.skill-hero-note {
font-size: 0.9rem;
color: var(--ink);
@@ -3250,10 +3332,10 @@ code {
}
.bundle-card {
border: 1px solid rgba(255, 255, 255, 0.35);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 20px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.7));
background: var(--surface);
box-shadow: 0 18px 30px rgba(37, 31, 26, 0.08);
}
@@ -3282,8 +3364,9 @@ code {
.bundle-includes span {
padding: 6px 12px;
border-radius: var(--r-pill);
background: rgba(255, 255, 255, 0.16);
color: var(--accent-deep);
border: 1px solid var(--line);
background: var(--surface-muted);
color: var(--ink);
font-size: 0.76rem;
font-weight: 650;
letter-spacing: 0.01em;
@@ -3294,8 +3377,8 @@ code {
gap: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--surface-muted);
border: 1px solid var(--line);
}
.bundle-card .hero-install-code {
@@ -3352,6 +3435,29 @@ code {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.skill-hero-sidebar > .card {
width: 100%;
}
.skill-hero-sidebar .skill-install-panel {
width: 100%;
padding: 18px;
}
.skill-hero-sidebar .skill-install-panel-copy {
font-size: 0.9rem;
}
.skill-hero-sidebar .skill-install-command-card {
width: 100%;
padding: 18px;
}
.skill-hero-sidebar .skill-install-command {
font-size: 0.8rem;
}
.skill-hero-sidebar-meta {
@@ -3423,31 +3529,19 @@ code {
gap: 16px;
}
.skill-install-grid {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.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);
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: var(--surface);
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);
border-color: var(--line);
background: var(--surface);
}
.skill-install-panel-header {
@@ -3627,12 +3721,10 @@ code {
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;
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: var(--surface-muted);
color: var(--ink);
padding: 16px 18px;
font-family: var(--font-mono);
font-size: 0.82rem;
@@ -3641,7 +3733,7 @@ code {
overflow-wrap: anywhere;
word-break: break-word;
font-variant-numeric: tabular-nums;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: none;
}
/*
@@ -3659,33 +3751,57 @@ code {
.skill-install-command-card {
display: grid;
gap: 12px;
gap: 14px;
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);
padding: 20px;
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: var(--surface);
}
.skill-install-command-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
[data-theme-resolved="light"] .skill-install-command-card {
border-color: var(--line);
background: var(--surface);
}
.skill-install-command-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.skill-install-command-wrap {
position: relative;
min-width: 0;
}
.skill-install-command-wrap .skill-install-command {
padding-right: 62px;
}
.skill-install-command.skill-install-prompt-compact code {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.skill-install-command-inline-button {
position: absolute;
top: 12px;
right: 12px;
width: 34px;
padding-inline: 0;
}
.skill-install-command-caption {
margin: 0;
font-size: 0.84rem;
@@ -3720,7 +3836,7 @@ code {
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
text-transform: lowercase;
text-transform: none;
transition:
background-color 0.18s ease,
color 0.18s ease,
@@ -3754,11 +3870,17 @@ code {
}
@media (max-width: 1024px) {
.skill-install-grid {
.skill-hero-layout {
grid-template-columns: 1fr;
}
}
@media (min-width: 1025px) {
.skill-detail-stack > .detail-layout {
width: calc(100% - 384px);
}
}
/* Legacy compatibility — keep for any existing references */
.skill-hero-cta {
display: flex;
@@ -4502,11 +4624,12 @@ code {
display: none;
}
.navbar-top-links {
display: none;
}
.navbar-tabs {
justify-content: flex-start;
gap: 0;
margin: 0 -18px;
padding: 0 18px;
display: none;
}
.navbar-tabs-secondary {
@@ -4589,19 +4712,26 @@ code {
padding: 30px 18px 62px;
}
.security-scanner-layout {
grid-template-columns: 1fr;
}
.section.detail-page-section {
padding-right: 18px;
padding-left: 18px;
}
.section-cta {
justify-content: flex-start;
}
.skill-hero-header {
.skill-hero-layout {
grid-template-columns: 1fr;
}
.skill-hero-sidebar {
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
align-items: center;
flex-direction: column;
align-items: stretch;
}
.skill-hero-sidebar-meta {
@@ -5153,7 +5283,7 @@ code {
}
.markdown a:hover {
color: var(--accent);
color: inherit;
}
.markdown ul,
@@ -5294,33 +5424,6 @@ code {
}
}
@keyframes theme-circle-transition {
0% {
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
}
100% {
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
}
}
html.theme-transition {
view-transition-name: theme;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
html.theme-transition::view-transition-old(theme) {
mix-blend-mode: normal;
animation: none;
z-index: 1;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
html.theme-transition::view-transition-new(theme) {
mix-blend-mode: normal;
z-index: 2;
animation: theme-circle-transition 0.45s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -5332,14 +5435,6 @@ html.theme-transition::view-transition-new(theme) {
/* biome-ignore lint/complexity/noImportantStyles: reduce-motion override */
transition-duration: 0.01ms !important;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
html.theme-transition::view-transition-old(theme),
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
html.theme-transition::view-transition-new(theme) {
/* biome-ignore lint/complexity/noImportantStyles: reduce-motion override */
animation: none !important;
}
}
/* Dashboard styles */
@@ -5370,11 +5465,6 @@ html.theme-transition::view-transition-new(theme) {
gap: 12px;
}
.dashboard-collection-block + .dashboard-collection-block {
border-top: 1px solid var(--card-border);
padding-top: 24px;
}
.dashboard-collection-title {
font-size: 1.6rem;
line-height: 1.1;
@@ -5388,6 +5478,10 @@ html.theme-transition::view-transition-new(theme) {
gap: 16px;
}
.dashboard-section-action {
font-size: 0.92rem;
}
.dashboard-inline-empty {
display: flex;
align-items: center;
@@ -5409,27 +5503,19 @@ html.theme-transition::view-transition-new(theme) {
.dashboard-list {
border: 1px solid var(--card-border);
border-radius: 14px;
overflow: hidden;
overflow: visible;
background: color-mix(in srgb, var(--surface) 90%, transparent);
}
.dashboard-list-header,
.dashboard-list-row {
display: grid;
grid-template-columns: minmax(240px, 1.15fr) minmax(0, 1fr) minmax(240px, 0.95fr) auto;
gap: 16px;
align-items: start;
padding: 14px 18px;
}
.dashboard-list-header {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-muted);
border-bottom: 1px solid var(--card-border);
background: color-mix(in srgb, var(--surface) 96%, transparent);
grid-template-columns: minmax(220px, 1fr) minmax(0, 1.35fr) max-content 32px;
gap: 14px;
align-items: center;
justify-items: start;
margin-inline: -12px;
padding: 12px;
text-align: left;
}
.dashboard-list-row {
@@ -5442,7 +5528,7 @@ html.theme-transition::view-transition-new(theme) {
}
.dashboard-list-row:hover {
background: color-mix(in srgb, var(--accent) 5%, transparent);
background: var(--surface-muted);
}
.dashboard-list-primary {
@@ -5468,28 +5554,6 @@ html.theme-transition::view-transition-new(theme) {
text-overflow: ellipsis;
}
.dashboard-inline-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.dashboard-inline-metrics {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 0.8rem;
color: var(--color-muted);
}
.dashboard-inline-metrics span {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
white-space: nowrap;
}
.dashboard-list-summary {
font-size: 0.9rem;
line-height: 1.5;
@@ -5497,70 +5561,72 @@ html.theme-transition::view-transition-new(theme) {
min-width: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.dashboard-list-status {
display: grid;
gap: 8px;
align-content: start;
align-content: center;
justify-items: start;
min-width: 0;
}
.dashboard-inline-status-item {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
max-width: 100%;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid var(--card-border);
background: color-mix(in srgb, var(--surface) 92%, transparent);
font-size: 0.8rem;
color: var(--color-muted);
}
.dashboard-skill-name {
font-weight: 600;
font-size: 1.1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text);
text-decoration: none;
}
.dashboard-skill-name:hover {
color: var(--color-accent);
color: var(--color-text);
text-decoration: underline;
text-underline-offset: 2px;
}
.dashboard-inline-status-note {
font-size: 0.82rem;
line-height: 1.4;
color: var(--color-muted);
.dashboard-status-chip {
gap: 6px;
min-width: 0;
width: fit-content;
white-space: nowrap;
}
.dashboard-tag-warning {
background: color-mix(in srgb, #f59e0b 18%, transparent);
color: #b45309;
.dashboard-status-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: currentColor;
cursor: default;
opacity: 0.78;
}
.dashboard-tag-danger {
background: color-mix(in srgb, #ef4444 18%, transparent);
color: #b91c1c;
.dashboard-status-info:hover,
.dashboard-status-info:focus-visible {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
}
.dashboard-tag-success {
background: color-mix(in srgb, #10b981 18%, transparent);
color: #047857;
.dashboard-status-info:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dashboard-row-actions {
.dashboard-row-menu {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
flex-shrink: 0;
max-width: 260px;
justify-self: end;
align-items: center;
}
.dashboard-row-menu-content {
min-width: 170px;
}
.btn-sm {
@@ -5582,12 +5648,10 @@ html.theme-transition::view-transition-new(theme) {
}
@media (max-width: 768px) {
.dashboard-list-header,
.dashboard-list-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
grid-template-columns: minmax(180px, 1fr) minmax(0, 1fr) max-content 32px;
}
.dashboard-list-header span:nth-child(2),
.dashboard-list-summary {
display: none;
}
@@ -5605,22 +5669,9 @@ html.theme-transition::view-transition-new(theme) {
align-items: stretch;
}
.dashboard-list-header {
display: none;
}
.dashboard-list-row {
grid-template-columns: 1fr;
gap: 12px;
}
.dashboard-row-actions {
width: 100%;
}
.dashboard-row-actions .btn {
flex: 1;
justify-content: center;
grid-template-columns: minmax(0, 1fr) max-content 32px;
gap: 10px;
}
.dashboard-owner-grid {
@@ -6423,9 +6474,9 @@ html.theme-transition::view-transition-new(theme) {
font-size: 1.15rem;
}
.skill-hero-title h1 {
font-size: 1.25rem;
line-height: 1.3;
.skill-page-title {
font-size: 1.8rem;
line-height: 1.12;
}
.skill-hero-note {
@@ -7787,8 +7838,8 @@ html.theme-transition::view-transition-new(theme) {
}
.footer-bottom a:hover {
color: var(--accent);
border-color: var(--accent);
color: inherit;
border-color: currentColor;
}
@media (max-width: 760px) {
@@ -8274,11 +8325,6 @@ html.theme-transition::view-transition-new(theme) {
border-radius: 0;
}
.theme-mode-toggle {
min-width: 104px;
justify-content: center;
}
@media (max-width: 1100px) {
.theme-picker-label {
display: none;
@@ -8286,14 +8332,9 @@ html.theme-transition::view-transition-new(theme) {
}
@media (max-width: 1100px) and (min-width: 761px) {
.theme-picker-desktop,
.theme-mode-toggle {
.theme-picker-desktop {
display: none;
}
.theme-cycle-group {
display: grid;
}
}
@media (max-width: 900px) {
@@ -9920,14 +9961,11 @@ html.theme-transition::view-transition-new(theme) {
/* Theme toggle buttons */
.theme-family-button,
.theme-cycle-button,
.theme-mode-toggle button,
.theme-mode-toggle {
.theme-cycle-button {
border-radius: var(--r-btn) !important;
}
/* Dashboard/management action buttons */
.dashboard-row-actions .btn,
/* Management action buttons */
.meta-bar-footer > .btn {
border-radius: var(--r-btn) !important;
}
@@ -9998,3 +10036,11 @@ a[class*="rounded-full"] {
.home-v2-search-go {
border-radius: var(--r-btn) !important;
}
@media (min-width: 761px) {
.navbar-inner,
.section.detail-page-section {
padding-right: clamp(48px, 4vw, 88px);
padding-left: clamp(48px, 4vw, 88px);
}
}