feat: ship moderation v2 verification across API, CLI, and UI

This commit is contained in:
Arthurzkv
2026-02-15 14:40:25 -06:00
parent 652beef9c1
commit 2b9acf51a8
25 changed files with 6518 additions and 4844 deletions
File diff suppressed because it is too large Load Diff
+306 -218
View File
@@ -1,9 +1,9 @@
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'
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
import { publishVersionForUser } from '../skills'
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { api, internal } from "../_generated/api";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
import { publishVersionForUser } from "../skills";
import {
MAX_RAW_FILE_BYTES,
getPathSegments,
@@ -15,96 +15,102 @@ import {
softDeleteErrorToResponse,
text,
toOptionalNumber,
} from './shared'
} from "./shared";
type SearchSkillEntry = {
score: number
score: number;
skill: {
slug?: string
displayName?: string
summary?: string | null
updatedAt?: number
} | null
version: { version?: string; createdAt?: number } | null
}
slug?: string;
displayName?: string;
summary?: string | null;
updatedAt?: number;
} | null;
version: { version?: string; createdAt?: number } | null;
};
type ListSkillsResult = {
items: Array<{
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'skillVersions'>
}
latestVersion: { version: string; createdAt: number; changelog: string } | null
}>
nextCursor: string | null
}
_id: Id<"skills">;
slug: string;
displayName: string;
summary?: string;
tags: Record<string, Id<"skillVersions">>;
stats: unknown;
createdAt: number;
updatedAt: number;
latestVersionId?: Id<"skillVersions">;
};
latestVersion: { version: string; createdAt: number; changelog: string } | null;
}>;
nextCursor: string | null;
};
type SkillFile = Doc<'skillVersions'>['files'][number]
type SkillFile = Doc<"skillVersions">["files"][number];
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
} | null
latestVersion: Doc<'skillVersions'> | null
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
_id: Id<"skills">;
slug: string;
displayName: string;
summary?: string;
ownerUserId: Id<"users">;
tags: Record<string, Id<"skillVersions">>;
stats: unknown;
createdAt: number;
updatedAt: number;
} | null;
latestVersion: Doc<"skillVersions"> | null;
owner: { _id: Id<"users">; handle?: string; displayName?: string; image?: string } | null;
moderationInfo?: {
isPendingScan: boolean
isMalwareBlocked: boolean
isSuspicious: boolean
isHiddenByMod: boolean
isRemoved: boolean
reason?: string
} | null
} | null
isPendingScan: boolean;
isMalwareBlocked: boolean;
isSuspicious: boolean;
isHiddenByMod: boolean;
isRemoved: boolean;
verdict?: "clean" | "suspicious" | "malicious";
reasonCodes?: string[];
summary?: string;
engineVersion?: string;
updatedAt?: number;
reason?: string;
} | null;
} | null;
type ListVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
version: string;
createdAt: number;
changelog: string;
changelogSource?: "auto" | "user";
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
nextCursor: string | null
}
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
contentType?: string;
}>;
softDeletedAt?: number;
}>;
nextCursor: string | null;
};
export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const url = new URL(request.url)
const query = url.searchParams.get('q')?.trim() ?? ''
const limit = toOptionalNumber(url.searchParams.get('limit'))
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true'
const url = new URL(request.url);
const query = url.searchParams.get("q")?.trim() ?? "";
const limit = toOptionalNumber(url.searchParams.get("limit"));
const highlightedOnly = url.searchParams.get("highlightedOnly") === "true";
if (!query) return json({ results: [] }, 200, rate.headers)
if (!query) return json({ results: [] }, 200, rate.headers);
const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
highlightedOnly: highlightedOnly || undefined,
})) as SearchSkillEntry[]
})) as SearchSkillEntry[];
return json(
{
@@ -119,73 +125,77 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) {
},
200,
rate.headers,
)
);
}
export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const url = new URL(request.url)
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
const hash = url.searchParams.get('hash')?.trim().toLowerCase()
if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers)
if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers)
const url = new URL(request.url);
const slug = url.searchParams.get("slug")?.trim().toLowerCase();
const hash = url.searchParams.get("hash")?.trim().toLowerCase();
if (!slug || !hash) return text("Missing slug or hash", 400, rate.headers);
if (!/^[a-f0-9]{64}$/.test(hash)) return text("Invalid hash", 400, rate.headers);
const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash })
if (!resolved) return text('Skill not found', 404, rate.headers)
const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash });
if (!resolved) return text("Skill not found", 404, rate.headers);
return json({ slug, match: resolved.match, latestVersion: resolved.latestVersion }, 200, rate.headers)
return json(
{ slug, match: resolved.match, latestVersion: resolved.latestVersion },
200,
rate.headers,
);
}
type SkillListSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
| "updated"
| "downloads"
| "stars"
| "installsCurrent"
| "installsAllTime"
| "trending";
function parseListSort(value: string | null): SkillListSort {
const normalized = value?.trim().toLowerCase()
if (normalized === 'downloads') return 'downloads'
if (normalized === 'stars' || normalized === 'rating') return 'stars'
const normalized = value?.trim().toLowerCase();
if (normalized === "downloads") return "downloads";
if (normalized === "stars" || normalized === "rating") return "stars";
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current'
normalized === "installs" ||
normalized === "install" ||
normalized === "installscurrent" ||
normalized === "installs-current"
) {
return 'installsCurrent'
return "installsCurrent";
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return 'installsAllTime'
if (normalized === "installsalltime" || normalized === "installs-all-time") {
return "installsAllTime";
}
if (normalized === 'trending') return 'trending'
return 'updated'
if (normalized === "trending") return "trending";
return "updated";
}
export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
const sort = parseListSort(url.searchParams.get('sort'))
const cursor = sort === 'trending' ? undefined : rawCursor
const url = new URL(request.url);
const limit = toOptionalNumber(url.searchParams.get("limit"));
const rawCursor = url.searchParams.get("cursor")?.trim() || undefined;
const sort = parseListSort(url.searchParams.get("sort"));
const cursor = sort === "trending" ? undefined : rawCursor;
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
})) as ListSkillsResult
})) as ListSkillsResult;
// Batch resolve all tags in a single query instead of N queries
const resolvedTagsList = await resolveTagsBatch(
ctx,
result.items.map((item) => item.skill.tags),
)
);
const items = result.items.map((item, idx) => ({
slug: item.skill.slug,
@@ -202,9 +212,9 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
changelog: item.latestVersion.changelog,
}
: null,
}))
}));
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers);
}
async function describeOwnerVisibleSkillState(
@@ -212,68 +222,71 @@ async function describeOwnerVisibleSkillState(
request: Request,
slug: string,
): Promise<{ status: number; message: string } | null> {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return null
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug });
if (!skill) return null;
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request)
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId)
if (!isOwner) return null
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId);
if (!isOwner) return null;
if (skill.softDeletedAt) {
return {
status: 410,
message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`,
}
};
}
if (skill.moderationStatus === 'hidden') {
if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') {
if (skill.moderationStatus === "hidden") {
if (
skill.moderationReason === "pending.scan" ||
skill.moderationReason === "scanner.vt.pending"
) {
return {
status: 423,
message: 'Skill is hidden while security scan is pending. Try again in a few minutes.',
}
message: "Skill is hidden while security scan is pending. Try again in a few minutes.",
};
}
if (skill.moderationReason === 'quality.low') {
if (skill.moderationReason === "quality.low") {
return {
status: 403,
message:
'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete <slug>" after review.',
}
};
}
return {
status: 403,
message: `Skill is hidden by moderation${
skill.moderationReason ? ` (${skill.moderationReason})` : ''
skill.moderationReason ? ` (${skill.moderationReason})` : ""
}.`,
}
};
}
if (skill.moderationStatus === 'removed') {
return { status: 410, message: 'Skill has been removed by moderation.' }
if (skill.moderationStatus === "removed") {
return { status: 410, message: "Skill has been removed by moderation." };
}
return null
return null;
}
export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const second = segments[1]
const third = segments[2]
const segments = getPathSegments(request, "/api/v1/skills/");
if (segments.length === 0) return text("Missing slug", 400, rate.headers);
const slug = segments[0]?.trim().toLowerCase() ?? "";
const second = segments[1];
const third = segments[2];
if (segments.length === 1) {
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
if (!result?.skill) {
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug)
if (hidden) return text(hidden.message, hidden.status, rate.headers)
return text('Skill not found', 404, rate.headers)
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug);
if (hidden) return text(hidden.message, hidden.status, rate.headers);
return text("Skill not found", 404, rate.headers);
}
const [tags] = await resolveTagsBatch(ctx, [result.skill.tags])
const [tags] = await resolveTagsBatch(ctx, [result.skill.tags]);
return json(
{
skill: {
@@ -304,26 +317,101 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
? {
isSuspicious: result.moderationInfo.isSuspicious ?? false,
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
verdict: result.moderationInfo.verdict ?? "clean",
reasonCodes: result.moderationInfo.reasonCodes ?? [],
summary: result.moderationInfo.summary ?? null,
engineVersion: result.moderationInfo.engineVersion ?? null,
updatedAt: result.moderationInfo.updatedAt ?? null,
}
: null,
},
200,
rate.headers,
)
);
}
if (second === 'versions' && segments.length === 2) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
if (second === "moderation" && segments.length === 2) {
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
if (!result?.skill) {
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug);
if (hidden) return text(hidden.message, hidden.status, rate.headers);
return text("Skill not found", 404, rate.headers);
}
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === result.skill.ownerUserId);
let isStaff = false;
if (apiTokenUserId) {
const caller = await ctx.runQuery(internal.users.getByIdInternal, { userId: apiTokenUserId });
if (caller?.role === "admin" || caller?.role === "moderator") {
isStaff = true;
}
}
const mod = result.moderationInfo;
const isFlagged = Boolean(mod?.isSuspicious || mod?.isMalwareBlocked);
if (!isOwner && !isStaff && !isFlagged) {
return text("Moderation details unavailable", 404, rate.headers);
}
const allEvidence =
(
result.skill as {
moderationEvidence?: Array<{
code: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
evidence: string;
}>;
}
).moderationEvidence ?? [];
const evidence =
isOwner || isStaff
? allEvidence
: allEvidence.map((entry) => ({
code: entry.code,
severity: entry.severity,
file: entry.file,
line: entry.line,
message: entry.message,
evidence: "",
}));
return json(
{
moderation: mod
? {
isSuspicious: mod.isSuspicious ?? false,
isMalwareBlocked: mod.isMalwareBlocked ?? false,
verdict: mod.verdict ?? "clean",
reasonCodes: mod.reasonCodes ?? [],
summary: mod.summary ?? null,
engineVersion: mod.engineVersion ?? null,
updatedAt: mod.updatedAt ?? null,
evidence,
legacyReason: isOwner || isStaff ? (mod.reason ?? null) : null,
}
: null,
},
200,
rate.headers,
);
}
if (second === "versions" && segments.length === 2) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug });
if (!skill || skill.softDeletedAt) return text("Skill not found", 404, rate.headers);
const url = new URL(request.url);
const limit = toOptionalNumber(url.searchParams.get("limit"));
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
const result = (await ctx.runQuery(api.skills.listVersionsPage, {
skillId: skill._id,
limit,
cursor,
})) as ListVersionsResult
})) as ListVersionsResult;
const items = result.items
.filter((version) => !version.softDeletedAt)
@@ -332,21 +420,21 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
}))
}));
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers);
}
if (second === 'versions' && third && segments.length === 3) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
if (second === "versions" && third && segments.length === 3) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug });
if (!skill || skill.softDeletedAt) return text("Skill not found", 404, rate.headers);
const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skill._id,
version: third,
})
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
});
if (!version) return text("Version not found", 404, rate.headers);
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
return json(
{
@@ -366,46 +454,46 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
},
200,
rate.headers,
)
);
}
if (second === 'file' && segments.length === 2) {
const url = new URL(request.url)
const path = url.searchParams.get('path')?.trim()
if (!path) return text('Missing path', 400, rate.headers)
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
if (second === "file" && segments.length === 2) {
const url = new URL(request.url);
const path = url.searchParams.get("path")?.trim();
if (!path) return text("Missing path", 400, rate.headers);
const versionParam = url.searchParams.get("version")?.trim();
const tagParam = url.searchParams.get("tag")?.trim();
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
if (!skillResult?.skill) return text("Skill not found", 404, rate.headers);
let version = skillResult.latestVersion
let version = skillResult.latestVersion;
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skillResult.skill._id,
version: versionParam,
})
});
} else if (tagParam) {
const versionId = skillResult.skill.tags[tagParam]
const versionId = skillResult.skill.tags[tagParam];
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
version = await ctx.runQuery(api.skills.getVersionById, { versionId });
}
}
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
if (!version) return text("Version not found", 404, rate.headers);
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
const normalized = path.trim()
const normalizedLower = normalized.toLowerCase()
const normalized = path.trim();
const normalizedLower = normalized.toLowerCase();
const file =
version.files.find((entry) => entry.path === normalized) ??
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
if (!file) return text('File not found', 404, rate.headers)
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower);
if (!file) return text("File not found", 404, rate.headers);
if (file.size > MAX_RAW_FILE_BYTES) return text("File exceeds 200KB limit", 413, rate.headers);
const blob = await ctx.storage.get(file.storageId)
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
const blob = await ctx.storage.get(file.storageId);
if (!blob) return text("File missing in storage", 410, rate.headers);
const textContent = await blob.text();
return safeTextFileResponse({
textContent,
path: file.path,
@@ -413,83 +501,83 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
sha256: file.sha256,
size: file.size,
headers: rate.headers,
})
});
}
return text('Not found', 404, rate.headers)
return text("Not found", 404, rate.headers);
}
export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
try {
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
if (!parseBearerToken(request)) return text("Unauthorized", 401, rate.headers);
} catch {
return text('Unauthorized', 401, rate.headers)
return text("Unauthorized", 401, rate.headers);
}
const { userId } = await requireApiTokenUser(ctx, request)
const { userId } = await requireApiTokenUser(ctx, request);
const contentType = request.headers.get('content-type') ?? ''
const contentType = request.headers.get("content-type") ?? "";
try {
if (contentType.includes('application/json')) {
const body = await request.json()
const payload = parsePublishBody(body)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
if (contentType.includes("application/json")) {
const body = await request.json();
const payload = parsePublishBody(body);
const result = await publishVersionForUser(ctx, userId, payload);
return json({ ok: true, ...result }, 200, rate.headers);
}
if (contentType.includes('multipart/form-data')) {
const payload = await parseMultipartPublish(ctx, request)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
if (contentType.includes("multipart/form-data")) {
const payload = await parseMultipartPublish(ctx, request);
const result = await publishVersionForUser(ctx, userId, payload);
return json({ ok: true, ...result }, 200, rate.headers);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Publish failed'
return text(message, 400, rate.headers)
const message = error instanceof Error ? error.message : "Publish failed";
return text(message, 400, rate.headers);
}
return text('Unsupported content type', 415, rate.headers)
return text("Unsupported content type", 415, rate.headers);
}
export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 2 || segments[1] !== 'undelete') {
return text('Not found', 404, rate.headers)
const segments = getPathSegments(request, "/api/v1/skills/");
if (segments.length !== 2 || segments[1] !== "undelete") {
return text("Not found", 404, rate.headers);
}
const slug = segments[0]?.trim().toLowerCase() ?? ''
const slug = segments[0]?.trim().toLowerCase() ?? "";
try {
const { userId } = await requireApiTokenUser(ctx, request)
const { userId } = await requireApiTokenUser(ctx, request);
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: false,
})
return json({ ok: true }, 200, rate.headers)
});
return json({ ok: true }, 200, rate.headers);
} catch (error) {
return softDeleteErrorToResponse('skill', error, rate.headers)
return softDeleteErrorToResponse("skill", error, rate.headers);
}
}
export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const segments = getPathSegments(request, "/api/v1/skills/");
if (segments.length !== 1) return text("Not found", 404, rate.headers);
const slug = segments[0]?.trim().toLowerCase() ?? "";
try {
const { userId } = await requireApiTokenUser(ctx, request)
const { userId } = await requireApiTokenUser(ctx, request);
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: true,
})
return json({ ok: true }, 200, rate.headers)
});
return json({ ok: true }, 200, rate.headers);
} catch (error) {
return softDeleteErrorToResponse('skill', error, rate.headers)
return softDeleteErrorToResponse("skill", error, rate.headers);
}
}
+28 -28
View File
@@ -1,49 +1,49 @@
import type { Doc } from '../_generated/dataModel'
import type { Doc } from "../_generated/dataModel";
const FLAG_RULES: Array<{ flag: string; pattern: RegExp }> = [
// Known-bad / known-suspicious identifiers.
// NOTE: keep these narrowly scoped; use staff review to confirm removals.
{
flag: 'blocked.malware',
pattern: /(keepcold131\/ClawdAuthenticatorTool|ClawdAuthenticatorTool)/i,
},
{ flag: 'suspicious.keyword', pattern: /(malware|stealer|phish|phishing|keylogger)/i },
{ flag: 'suspicious.secrets', pattern: /(api[-_ ]?key|token|password|private key|secret)/i },
{ flag: 'suspicious.crypto', pattern: /(wallet|seed phrase|mnemonic|crypto)/i },
{ flag: 'suspicious.webhook', pattern: /(discord\.gg|webhook|hooks\.slack)/i },
{ flag: 'suspicious.script', pattern: /(curl[^\n]+\|\s*(sh|bash))/i },
{ flag: 'suspicious.url_shortener', pattern: /(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)/i },
]
const KNOWN_BLOCKED_SIGNATURE = /(keepcold131\/ClawdAuthenticatorTool|ClawdAuthenticatorTool)/i;
const SUSPICIOUS_INSTALL_URL = /https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\//i;
const SUSPICIOUS_RAW_IP_URL = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/i;
const SUSPICIOUS_SCRIPT_PIPE = /curl[^\n]+\|\s*(sh|bash)/i;
export function deriveModerationFlags({
skill,
parsed,
files,
}: {
skill: Pick<Doc<'skills'>, 'slug' | 'displayName' | 'summary'>
parsed: Doc<'skillVersions'>['parsed']
files: Doc<'skillVersions'>['files']
skill: Pick<Doc<"skills">, "slug" | "displayName" | "summary">;
parsed: Doc<"skillVersions">["parsed"];
files: Doc<"skillVersions">["files"];
}) {
const text = [
skill.slug,
skill.displayName,
skill.summary ?? '',
skill.summary ?? "",
JSON.stringify(parsed?.frontmatter ?? {}),
JSON.stringify(parsed?.metadata ?? {}),
JSON.stringify((parsed as { moltbot?: unknown } | undefined)?.moltbot ?? {}),
...files.map((file) => file.path),
]
.filter(Boolean)
.join('\n')
.join("\n");
const flags = new Set<string>()
for (const rule of FLAG_RULES) {
if (rule.pattern.test(text)) {
flags.add(rule.flag)
}
const flags = new Set<string>();
if (KNOWN_BLOCKED_SIGNATURE.test(text)) {
flags.add("blocked.malware");
}
return Array.from(flags)
// Context-aware suspicious checks only. Avoid broad keyword-only flags to reduce false positives.
if (
SUSPICIOUS_INSTALL_URL.test(text) ||
SUSPICIOUS_RAW_IP_URL.test(text) ||
SUSPICIOUS_SCRIPT_PIPE.test(text)
) {
flags.add("flagged.suspicious");
}
const always = (parsed?.frontmatter as Record<string, unknown> | undefined)?.always;
if (always === true || always === "true") {
flags.add("flagged.suspicious");
}
return Array.from(flags);
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildModerationSnapshot, runStaticModerationScan } from "./moderationEngine";
describe("moderationEngine", () => {
it("does not flag benign token/password docs text alone", () => {
const result = runStaticModerationScan({
slug: "demo",
displayName: "Demo",
summary: "A normal integration skill",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 64 }],
fileContents: [
{
path: "SKILL.md",
content:
"This skill requires API token and password from the official provider settings.",
},
],
});
expect(result.reasonCodes).toEqual([]);
expect(result.status).toBe("clean");
});
it("flags dynamic eval usage as suspicious", () => {
const result = runStaticModerationScan({
slug: "demo",
displayName: "Demo",
summary: "A normal integration skill",
frontmatter: {},
metadata: {},
files: [{ path: "index.ts", size: 64 }],
fileContents: [{ path: "index.ts", content: "const value = eval(code)" }],
});
expect(result.reasonCodes).toContain("suspicious.dynamic_code_execution");
expect(result.status).toBe("suspicious");
});
it("upgrades merged verdict to malicious when VT is malicious", () => {
const snapshot = buildModerationSnapshot({
staticScan: {
status: "suspicious",
reasonCodes: ["suspicious.dynamic_code_execution"],
findings: [],
summary: "",
engineVersion: "v2.0.0",
checkedAt: Date.now(),
},
vtStatus: "malicious",
});
expect(snapshot.verdict).toBe("malicious");
expect(snapshot.reasonCodes).toContain("malicious.vt_malicious");
});
});
+360
View File
@@ -0,0 +1,360 @@
import type { Doc, Id } from "../_generated/dataModel";
import {
legacyFlagsFromVerdict,
MODERATION_ENGINE_VERSION,
normalizeReasonCodes,
type ModerationFinding,
REASON_CODES,
summarizeReasonCodes,
type ModerationVerdict,
verdictFromCodes,
} from "./moderationReasonCodes";
type TextFile = { path: string; content: string };
export type StaticScanInput = {
slug: string;
displayName: string;
summary?: string;
frontmatter: Record<string, unknown>;
metadata?: unknown;
files: Array<{ path: string; size: number }>;
fileContents: TextFile[];
};
export type StaticScanResult = {
status: ModerationVerdict;
reasonCodes: string[];
findings: ModerationFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
};
export type ModerationSnapshot = {
verdict: ModerationVerdict;
reasonCodes: string[];
evidence: ModerationFinding[];
summary: string;
engineVersion: string;
evaluatedAt: number;
sourceVersionId?: Id<"skillVersions">;
legacyFlags?: string[];
};
const MANIFEST_EXTENSION = /\.(json|yaml|yml|toml)$/i;
const MARKDOWN_EXTENSION = /\.(md|markdown|mdx)$/i;
const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/i;
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
function truncateEvidence(evidence: string, maxLen = 160) {
if (evidence.length <= maxLen) return evidence;
return `${evidence.slice(0, maxLen)}`;
}
function addFinding(
findings: ModerationFinding[],
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
) {
findings.push({ ...finding, evidence: truncateEvidence(finding.evidence.trim()) });
}
function findFirstLine(content: string, pattern: RegExp) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (pattern.test(lines[i])) {
return { line: i + 1, text: lines[i] };
}
}
return { line: 1, text: lines[0] ?? "" };
}
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
if (!CODE_EXTENSION.test(path)) return;
const hasChildProcess = /child_process/.test(content);
if (hasChildProcess) {
const match = findFirstLine(
content,
/\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/,
);
if (match.text) {
addFinding(findings, {
code: REASON_CODES.DANGEROUS_EXEC,
severity: "critical",
file: path,
line: match.line,
message: "Shell command execution detected (child_process).",
evidence: match.text,
});
}
}
const dynamicCodeMatch = findFirstLine(content, /\beval\s*\(|new\s+Function\s*\(/);
if (dynamicCodeMatch.text && /\beval\s*\(|new\s+Function\s*\(/.test(content)) {
addFinding(findings, {
code: REASON_CODES.DYNAMIC_CODE,
severity: "critical",
file: path,
line: dynamicCodeMatch.line,
message: "Dynamic code execution detected.",
evidence: dynamicCodeMatch.text,
});
}
if (/stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i.test(content)) {
const match = findFirstLine(content, /stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i);
addFinding(findings, {
code: REASON_CODES.CRYPTO_MINING,
severity: "critical",
file: path,
line: match.line,
message: "Possible crypto mining behavior detected.",
evidence: match.text,
});
}
const wsMatch = content.match(/new\s+WebSocket\s*\(\s*["']wss?:\/\/[^"']*:(\d+)/);
if (wsMatch) {
const port = Number.parseInt(wsMatch[1] ?? "", 10);
if (Number.isFinite(port) && !STANDARD_PORTS.has(port)) {
const match = findFirstLine(content, /new\s+WebSocket\s*\(/);
addFinding(findings, {
code: REASON_CODES.SUSPICIOUS_NETWORK,
severity: "warn",
file: path,
line: match.line,
message: "WebSocket connection to non-standard port detected.",
evidence: match.text,
});
}
}
const hasFileRead = /readFileSync|readFile/.test(content);
const hasNetworkSend = /\bfetch\b|http\.request|\baxios\b/.test(content);
if (hasFileRead && hasNetworkSend) {
const match = findFirstLine(content, /readFileSync|readFile/);
addFinding(findings, {
code: REASON_CODES.EXFILTRATION,
severity: "warn",
file: path,
line: match.line,
message: "File read combined with network send (possible exfiltration).",
evidence: match.text,
});
}
const hasProcessEnv = /process\.env/.test(content);
if (hasProcessEnv && hasNetworkSend) {
const match = findFirstLine(content, /process\.env/);
addFinding(findings, {
code: REASON_CODES.CREDENTIAL_HARVEST,
severity: "critical",
file: path,
line: match.line,
message: "Environment variable access combined with network send.",
evidence: match.text,
});
}
if (
/(\\x[0-9a-fA-F]{2}){6,}/.test(content) ||
/(?:atob|Buffer\.from)\s*\(\s*["'][A-Za-z0-9+/=]{200,}["']/.test(content)
) {
const match = findFirstLine(content, /(\\x[0-9a-fA-F]{2}){6,}|(?:atob|Buffer\.from)\s*\(/);
addFinding(findings, {
code: REASON_CODES.OBFUSCATED_CODE,
severity: "warn",
file: path,
line: match.line,
message: "Potential obfuscated payload detected.",
evidence: match.text,
});
}
}
function scanMarkdownFile(path: string, content: string, findings: ModerationFinding[]) {
if (!MARKDOWN_EXTENSION.test(path)) return;
if (
/ignore\s+(all\s+)?previous\s+instructions/i.test(content) ||
/system\s*prompt\s*[:=]/i.test(content) ||
/you\s+are\s+now\s+(a|an)\b/i.test(content)
) {
const match = findFirstLine(
content,
/ignore\s+(all\s+)?previous\s+instructions|system\s*prompt\s*[:=]|you\s+are\s+now\s+(a|an)\b/i,
);
addFinding(findings, {
code: REASON_CODES.INJECTION_INSTRUCTIONS,
severity: "warn",
file: path,
line: match.line,
message: "Prompt-injection style instruction pattern detected.",
evidence: match.text,
});
}
}
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
if (!MANIFEST_EXTENSION.test(path)) return;
if (
/https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\//i.test(content) ||
/https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/i.test(content)
) {
const match = findFirstLine(
content,
/https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\/|https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/i,
);
addFinding(findings, {
code: REASON_CODES.SUSPICIOUS_INSTALL_SOURCE,
severity: "warn",
file: path,
line: match.line,
message: "Install source points to URL shortener or raw IP.",
evidence: match.text,
});
}
}
export function runStaticModerationScan(input: StaticScanInput): StaticScanResult {
const findings: ModerationFinding[] = [];
const files = [...input.fileContents].sort((a, b) => a.path.localeCompare(b.path));
for (const file of files) {
scanCodeFile(file.path, file.content, findings);
scanMarkdownFile(file.path, file.content, findings);
scanManifestFile(file.path, file.content, findings);
}
const installJson = JSON.stringify(input.metadata ?? {});
if (/https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\//i.test(installJson)) {
addFinding(findings, {
code: REASON_CODES.SUSPICIOUS_INSTALL_SOURCE,
severity: "warn",
file: "metadata",
line: 1,
message: "Install metadata references shortener URL.",
evidence: installJson,
});
}
const alwaysValue = input.frontmatter.always;
if (alwaysValue === true || alwaysValue === "true") {
addFinding(findings, {
code: REASON_CODES.MANIFEST_PRIVILEGED_ALWAYS,
severity: "warn",
file: "SKILL.md",
line: 1,
message: "Skill is configured with always=true (persistent invocation).",
evidence: "always: true",
});
}
const identityText = `${input.slug}\n${input.displayName}\n${input.summary ?? ""}`;
if (/keepcold131\/ClawdAuthenticatorTool|ClawdAuthenticatorTool/i.test(identityText)) {
addFinding(findings, {
code: REASON_CODES.KNOWN_BLOCKED_SIGNATURE,
severity: "critical",
file: "metadata",
line: 1,
message: "Matched a known blocked malware signature.",
evidence: identityText,
});
}
findings.sort((a, b) =>
`${a.code}:${a.file}:${a.line}:${a.message}`.localeCompare(
`${b.code}:${b.file}:${b.line}:${b.message}`,
),
);
const reasonCodes = normalizeReasonCodes(findings.map((f) => f.code));
const status = verdictFromCodes(reasonCodes);
return {
status,
reasonCodes,
findings,
summary: summarizeReasonCodes(reasonCodes),
engineVersion: MODERATION_ENGINE_VERSION,
checkedAt: Date.now(),
};
}
function dedupeEvidence(evidence: ModerationFinding[]) {
const seen = new Set<string>();
const out: ModerationFinding[] = [];
for (const item of evidence) {
const key = `${item.code}:${item.file}:${item.line}:${item.message}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out.slice(0, 40);
}
export function buildModerationSnapshot(params: {
staticScan?: StaticScanResult;
vtStatus?: string;
llmStatus?: string;
existingReasonCodes?: string[];
existingEvidence?: ModerationFinding[];
sourceVersionId?: Id<"skillVersions">;
}): ModerationSnapshot {
const reasonCodes = [...(params.existingReasonCodes ?? [])];
const evidence = [...(params.existingEvidence ?? [])];
if (params.staticScan) {
reasonCodes.push(...params.staticScan.reasonCodes);
evidence.push(...params.staticScan.findings);
}
const vt = params.vtStatus?.trim().toLowerCase();
if (vt === "malicious") reasonCodes.push("malicious.vt_malicious");
if (vt === "suspicious") reasonCodes.push("suspicious.vt_suspicious");
const llm = params.llmStatus?.trim().toLowerCase();
if (llm === "malicious") reasonCodes.push("malicious.llm_malicious");
if (llm === "suspicious") reasonCodes.push("suspicious.llm_suspicious");
const normalizedCodes = normalizeReasonCodes(reasonCodes);
const verdict = verdictFromCodes(normalizedCodes);
const normalizedEvidence = dedupeEvidence(evidence);
return {
verdict,
reasonCodes: normalizedCodes,
evidence: normalizedEvidence,
summary: summarizeReasonCodes(normalizedCodes),
engineVersion: MODERATION_ENGINE_VERSION,
evaluatedAt: Date.now(),
sourceVersionId: params.sourceVersionId,
legacyFlags: legacyFlagsFromVerdict(verdict),
};
}
export function resolveSkillVerdict(
skill: Pick<
Doc<"skills">,
"moderationVerdict" | "moderationFlags" | "moderationReason" | "moderationReasonCodes"
>,
): ModerationVerdict {
if (skill.moderationVerdict) return skill.moderationVerdict;
if (skill.moderationFlags?.includes("blocked.malware")) return "malicious";
if (skill.moderationFlags?.includes("flagged.suspicious")) return "suspicious";
if (
skill.moderationReason?.startsWith("scanner.") &&
skill.moderationReason.endsWith(".malicious")
) {
return "malicious";
}
if (
skill.moderationReason?.startsWith("scanner.") &&
skill.moderationReason.endsWith(".suspicious")
) {
return "suspicious";
}
if ((skill.moderationReasonCodes ?? []).some((code) => code.startsWith("malicious."))) {
return "malicious";
}
if ((skill.moderationReasonCodes ?? []).length > 0) return "suspicious";
return "clean";
}
+60
View File
@@ -0,0 +1,60 @@
export type ModerationVerdict = "clean" | "suspicious" | "malicious";
export type ModerationFindingSeverity = "info" | "warn" | "critical";
export type ModerationFinding = {
code: string;
severity: ModerationFindingSeverity;
file: string;
line: number;
message: string;
evidence: string;
};
export const MODERATION_ENGINE_VERSION = "v2.0.0";
export const REASON_CODES = {
DANGEROUS_EXEC: "suspicious.dangerous_exec",
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
CREDENTIAL_HARVEST: "malicious.env_harvesting",
EXFILTRATION: "suspicious.potential_exfiltration",
OBFUSCATED_CODE: "suspicious.obfuscated_code",
SUSPICIOUS_NETWORK: "suspicious.suspicious_network",
CRYPTO_MINING: "malicious.crypto_mining",
INJECTION_INSTRUCTIONS: "suspicious.prompt_injection_instructions",
SUSPICIOUS_INSTALL_SOURCE: "suspicious.install_untrusted_source",
MANIFEST_PRIVILEGED_ALWAYS: "suspicious.privileged_always",
KNOWN_BLOCKED_SIGNATURE: "malicious.known_blocked_signature",
} as const;
const MALICIOUS_CODES = new Set<string>([
REASON_CODES.CREDENTIAL_HARVEST,
REASON_CODES.CRYPTO_MINING,
REASON_CODES.KNOWN_BLOCKED_SIGNATURE,
]);
export function normalizeReasonCodes(codes: string[]) {
return Array.from(new Set(codes.filter(Boolean))).sort((a, b) => a.localeCompare(b));
}
export function summarizeReasonCodes(codes: string[]) {
if (codes.length === 0) return "No suspicious patterns detected.";
const top = codes.slice(0, 3).join(", ");
const extra = codes.length > 3 ? ` (+${codes.length - 3} more)` : "";
return `Detected: ${top}${extra}`;
}
export function verdictFromCodes(codes: string[]): ModerationVerdict {
const normalized = normalizeReasonCodes(codes);
if (normalized.some((code) => MALICIOUS_CODES.has(code) || code.startsWith("malicious."))) {
return "malicious";
}
if (normalized.length > 0) return "suspicious";
return "clean";
}
export function legacyFlagsFromVerdict(verdict: ModerationVerdict) {
if (verdict === "malicious") return ["blocked.malware"];
if (verdict === "suspicious") return ["flagged.suspicious"];
return undefined;
}
+55 -49
View File
@@ -1,45 +1,46 @@
import type { Doc } from '../_generated/dataModel'
import type { Doc } from "../_generated/dataModel";
import { resolveSkillVerdict } from "./moderationEngine";
export type PublicUser = Pick<
Doc<'users'>,
'_id' | '_creationTime' | 'handle' | 'name' | 'displayName' | 'image' | 'bio'
>
Doc<"users">,
"_id" | "_creationTime" | "handle" | "name" | "displayName" | "image" | "bio"
>;
export type PublicSkill = Pick<
Doc<'skills'>,
| '_id'
| '_creationTime'
| 'slug'
| 'displayName'
| 'summary'
| 'ownerUserId'
| 'canonicalSkillId'
| 'forkOf'
| 'latestVersionId'
| 'tags'
| 'badges'
| 'stats'
| 'createdAt'
| 'updatedAt'
>
Doc<"skills">,
| "_id"
| "_creationTime"
| "slug"
| "displayName"
| "summary"
| "ownerUserId"
| "canonicalSkillId"
| "forkOf"
| "latestVersionId"
| "tags"
| "badges"
| "stats"
| "createdAt"
| "updatedAt"
>;
export type PublicSoul = Pick<
Doc<'souls'>,
| '_id'
| '_creationTime'
| 'slug'
| 'displayName'
| 'summary'
| 'ownerUserId'
| 'latestVersionId'
| 'tags'
| 'stats'
| 'createdAt'
| 'updatedAt'
>
Doc<"souls">,
| "_id"
| "_creationTime"
| "slug"
| "displayName"
| "summary"
| "ownerUserId"
| "latestVersionId"
| "tags"
| "stats"
| "createdAt"
| "updatedAt"
>;
export function toPublicUser(user: Doc<'users'> | null | undefined): PublicUser | null {
if (!user || user.deletedAt || user.deactivatedAt) return null
export function toPublicUser(user: Doc<"users"> | null | undefined): PublicUser | null {
if (!user || user.deletedAt || user.deactivatedAt) return null;
return {
_id: user._id,
_creationTime: user._creationTime,
@@ -48,30 +49,35 @@ export function toPublicUser(user: Doc<'users'> | null | undefined): PublicUser
displayName: user.displayName,
image: user.image,
bio: user.bio,
}
};
}
export function toPublicSkill(skill: Doc<'skills'> | null | undefined): PublicSkill | null {
if (!skill || skill.softDeletedAt) return null
if (skill.moderationStatus && skill.moderationStatus !== 'active') return null
if (skill.moderationFlags?.includes('blocked.malware')) return null
export function toPublicSkill(skill: Doc<"skills"> | null | undefined): PublicSkill | null {
if (!skill || skill.softDeletedAt) return null;
if (skill.moderationStatus && skill.moderationStatus !== "active") return null;
if (
resolveSkillVerdict(skill) === "malicious" ||
skill.moderationFlags?.includes("blocked.malware")
) {
return null;
}
const stats = {
downloads:
typeof skill.statsDownloads === 'number'
typeof skill.statsDownloads === "number"
? skill.statsDownloads
: (skill.stats?.downloads ?? 0),
stars: typeof skill.statsStars === 'number' ? skill.statsStars : (skill.stats?.stars ?? 0),
stars: typeof skill.statsStars === "number" ? skill.statsStars : (skill.stats?.stars ?? 0),
installsCurrent:
typeof skill.statsInstallsCurrent === 'number'
typeof skill.statsInstallsCurrent === "number"
? skill.statsInstallsCurrent
: (skill.stats?.installsCurrent ?? 0),
installsAllTime:
typeof skill.statsInstallsAllTime === 'number'
typeof skill.statsInstallsAllTime === "number"
? skill.statsInstallsAllTime
: (skill.stats?.installsAllTime ?? 0),
versions: skill.stats?.versions ?? 0,
comments: skill.stats?.comments ?? 0,
}
};
return {
_id: skill._id,
_creationTime: skill._creationTime,
@@ -87,11 +93,11 @@ export function toPublicSkill(skill: Doc<'skills'> | null | undefined): PublicSk
stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
}
};
}
export function toPublicSoul(soul: Doc<'souls'> | null | undefined): PublicSoul | null {
if (!soul || soul.softDeletedAt) return null
export function toPublicSoul(soul: Doc<"souls"> | null | undefined): PublicSoul | null {
if (!soul || soul.softDeletedAt) return null;
return {
_id: soul._id,
_creationTime: soul._creationTime,
@@ -104,5 +110,5 @@ export function toPublicSoul(soul: Doc<'souls'> | null | undefined): PublicSoul
stats: soul.stats,
createdAt: soul.createdAt,
updatedAt: soul.updatedAt,
}
};
}
+174 -158
View File
@@ -1,21 +1,22 @@
import { ConvexError } from 'convex/values'
import semver from 'semver'
import { api, internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx, MutationCtx } from '../_generated/server'
import { getSkillBadgeMap, isSkillHighlighted } from './badges'
import { generateChangelogForPublish } from './changelog'
import { generateEmbedding } from './embeddings'
import { requireGitHubAccountAge } from './githubAccount'
import type { PublicUser } from './public'
import { ConvexError } from "convex/values";
import semver from "semver";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx, MutationCtx } from "../_generated/server";
import type { PublicUser } from "./public";
import type { WebhookSkillPayload } from "./webhooks";
import { api, internal } from "../_generated/api";
import { getSkillBadgeMap, isSkillHighlighted } from "./badges";
import { generateChangelogForPublish } from "./changelog";
import { generateEmbedding } from "./embeddings";
import { requireGitHubAccountAge } from "./githubAccount";
import { runStaticModerationScan } from "./moderationEngine";
import {
computeQualitySignals,
evaluateQuality,
getTrustTier,
type QualityAssessment,
toStructuralFingerprint,
} from './skillQuality'
import { generateSkillSummary } from './skillSummary'
} from "./skillQuality";
import {
buildEmbeddingText,
getFrontmatterMetadata,
@@ -25,169 +26,169 @@ import {
parseClawdisMetadata,
parseFrontmatter,
sanitizePath,
} from './skills'
import type { WebhookSkillPayload } from './webhooks'
} from "./skills";
import { generateSkillSummary } from "./skillSummary";
const MAX_TOTAL_BYTES = 50 * 1024 * 1024
const MAX_FILES_FOR_EMBEDDING = 40
const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000
const QUALITY_ACTIVITY_LIMIT = 60
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
const MAX_FILES_FOR_EMBEDDING = 40;
const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000;
const QUALITY_ACTIVITY_LIMIT = 60;
export type PublishResult = {
skillId: Id<'skills'>
versionId: Id<'skillVersions'>
embeddingId: Id<'skillEmbeddings'>
}
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
embeddingId: Id<"skillEmbeddings">;
};
export type PublishVersionArgs = {
slug: string
displayName: string
version: string
changelog: string
tags?: string[]
forkOf?: { slug: string; version?: string }
slug: string;
displayName: string;
version: string;
changelog: string;
tags?: string[];
forkOf?: { slug: string; version?: string };
source?: {
kind: 'github'
url: string
repo: string
ref: string
commit: string
path: string
importedAt: number
}
kind: "github";
url: string;
repo: string;
ref: string;
commit: string;
path: string;
importedAt: number;
};
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
}
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
contentType?: string;
}>;
};
export type PublishOptions = {
bypassGitHubAccountAge?: boolean
bypassNewSkillRateLimit?: boolean
bypassQualityGate?: boolean
skipBackup?: boolean
skipWebhook?: boolean
}
bypassGitHubAccountAge?: boolean;
bypassNewSkillRateLimit?: boolean;
bypassQualityGate?: boolean;
skipBackup?: boolean;
skipWebhook?: boolean;
};
export async function publishVersionForUser(
ctx: ActionCtx,
userId: Id<'users'>,
userId: Id<"users">,
args: PublishVersionArgs,
options: PublishOptions = {},
): Promise<PublishResult> {
const version = args.version.trim()
const slug = args.slug.trim().toLowerCase()
const displayName = args.displayName.trim()
if (!slug || !displayName) throw new ConvexError('Slug and display name required')
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError('Slug must be lowercase and url-safe')
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!semver.valid(version)) {
throw new ConvexError('Version must be valid semver')
throw new ConvexError("Version must be valid semver");
}
if (!options.bypassGitHubAccountAge) {
await requireGitHubAccountAge(ctx, userId)
await requireGitHubAccountAge(ctx, userId);
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
})) as Doc<'skills'> | null
const isNewSkill = !existingSkill
})) as Doc<"skills"> | null;
const isNewSkill = !existingSkill;
const suppliedChangelog = args.changelog.trim()
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
const sanitizedFiles = args.files.map((file) => ({
...file,
path: sanitizePath(file.path),
}))
}));
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError('Invalid file paths')
throw new ConvexError("Invalid file paths");
}
const safeFiles = sanitizedFiles.map((file) => ({
...file,
path: file.path as string,
}))
}));
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
throw new ConvexError('Only text-based files are allowed')
throw new ConvexError("Only text-based files are allowed");
}
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0);
if (totalBytes > MAX_TOTAL_BYTES) {
throw new ConvexError('Skill bundle exceeds 50MB limit')
throw new ConvexError("Skill bundle exceeds 50MB limit");
}
const readmeFile = safeFiles.find(
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new ConvexError('SKILL.md is required')
(file) => file.path?.toLowerCase() === "skill.md" || file.path?.toLowerCase() === "skills.md",
);
if (!readmeFile) throw new ConvexError("SKILL.md is required");
const readmeText = await fetchText(ctx, readmeFile.storageId)
const frontmatter = parseFrontmatter(readmeText)
const clawdis = parseClawdisMetadata(frontmatter)
const readmeText = await fetchText(ctx, readmeFile.storageId);
const frontmatter = parseFrontmatter(readmeText);
const clawdis = parseClawdisMetadata(frontmatter);
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
userId,
})) as Doc<'users'> | null
const ownerCreatedAt = owner?.createdAt ?? owner?._creationTime ?? Date.now()
const now = Date.now()
const frontmatterMetadata = getFrontmatterMetadata(frontmatter)
})) as Doc<"users"> | null;
const ownerCreatedAt = owner?.createdAt ?? owner?._creationTime ?? Date.now();
const now = Date.now();
const frontmatterMetadata = getFrontmatterMetadata(frontmatter);
// Check for description in metadata.description (nested) or description (direct frontmatter field)
const metadataDescription =
frontmatterMetadata &&
typeof frontmatterMetadata === 'object' &&
typeof frontmatterMetadata === "object" &&
!Array.isArray(frontmatterMetadata) &&
typeof (frontmatterMetadata as Record<string, unknown>).description === 'string'
typeof (frontmatterMetadata as Record<string, unknown>).description === "string"
? ((frontmatterMetadata as Record<string, unknown>).description as string)
: undefined
const directDescription = getFrontmatterValue(frontmatter, 'description')
: undefined;
const directDescription = getFrontmatterValue(frontmatter, "description");
// Prioritize the new description from frontmatter over the existing skill summary
// This ensures updates to the description are reflected on subsequent publishes (#301)
const summaryFromFrontmatter = metadataDescription ?? directDescription
const summaryFromFrontmatter = metadataDescription ?? directDescription;
const summary = await generateSkillSummary({
slug,
displayName,
readmeText,
currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined,
})
});
let qualityAssessment: QualityAssessment | null = null
let qualityAssessment: QualityAssessment | null = null;
if (isNewSkill && !options.bypassQualityGate) {
const ownerActivity = (await ctx.runQuery(internal.skills.getOwnerSkillActivityInternal, {
ownerUserId: userId,
limit: QUALITY_ACTIVITY_LIMIT,
})) as Array<{
slug: string
summary?: string
createdAt: number
latestVersionId?: Id<'skillVersions'>
}>
slug: string;
summary?: string;
createdAt: number;
latestVersionId?: Id<"skillVersions">;
}>;
const trustTier = getTrustTier(now - ownerCreatedAt, ownerActivity.length)
const trustTier = getTrustTier(now - ownerCreatedAt, ownerActivity.length);
const qualitySignals = computeQualitySignals({
readmeText,
summary,
})
});
const recentCandidates = ownerActivity.filter(
(entry) =>
entry.slug !== slug && entry.createdAt >= now - QUALITY_WINDOW_MS && entry.latestVersionId,
)
let similarRecentCount = 0
);
let similarRecentCount = 0;
for (const entry of recentCandidates) {
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: entry.latestVersionId as Id<'skillVersions'>,
})) as Doc<'skillVersions'> | null
if (!version) continue
versionId: entry.latestVersionId as Id<"skillVersions">,
})) as Doc<"skillVersions"> | null;
if (!version) continue;
const candidateReadmeFile = version.files.find((file) => {
const lower = file.path.toLowerCase()
return lower === 'skill.md' || lower === 'skills.md'
})
if (!candidateReadmeFile) continue
const candidateText = await fetchText(ctx, candidateReadmeFile.storageId)
const lower = file.path.toLowerCase();
return lower === "skill.md" || lower === "skills.md";
});
if (!candidateReadmeFile) continue;
const candidateText = await fetchText(ctx, candidateReadmeFile.storageId);
if (toStructuralFingerprint(candidateText) === qualitySignals.structuralFingerprint) {
similarRecentCount += 1
similarRecentCount += 1;
}
}
@@ -195,52 +196,66 @@ export async function publishVersionForUser(
signals: qualitySignals,
trustTier,
similarRecentCount,
})
if (qualityAssessment.decision === 'reject') {
throw new ConvexError(qualityAssessment.reason)
});
if (qualityAssessment.decision === "reject") {
throw new ConvexError(qualityAssessment.reason);
}
}
const metadata = mergeSourceIntoMetadata(frontmatterMetadata, args.source, qualityAssessment)
const metadata = mergeSourceIntoMetadata(frontmatterMetadata, args.source, qualityAssessment);
const otherFiles = [] as Array<{ path: string; content: string }>
const fileContents: Array<{ path: string; content: string }> = [
{ path: readmeFile.path, content: readmeText },
];
for (const file of safeFiles) {
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
const content = await fetchText(ctx, file.storageId)
otherFiles.push({ path: file.path, content })
if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break
if (!file.path || file.storageId === readmeFile.storageId) continue;
if (!isTextFile(file.path, file.contentType ?? undefined)) continue;
const content = await fetchText(ctx, file.storageId);
fileContents.push({ path: file.path, content });
}
const otherFiles = fileContents
.filter((file) => !file.path.toLowerCase().endsWith(".md"))
.slice(0, MAX_FILES_FOR_EMBEDDING);
const staticScan = runStaticModerationScan({
slug,
displayName,
summary,
frontmatter,
metadata,
files: safeFiles.map((file) => ({ path: file.path, size: file.size })),
fileContents,
});
const embeddingText = buildEmbeddingText({
frontmatter,
readme: readmeText,
otherFiles,
})
});
const fingerprintPromise = hashSkillFiles(
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
)
);
const changelogPromise =
changelogSource === 'user'
changelogSource === "user"
? Promise.resolve(suppliedChangelog)
: generateChangelogForPublish(ctx, {
slug,
version,
readmeText,
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
})
});
const embeddingPromise = generateEmbedding(embeddingText)
const embeddingPromise = generateEmbedding(embeddingText);
const [fingerprint, changelogText, embedding] = await Promise.all([
fingerprintPromise,
changelogPromise,
embeddingPromise.catch((error) => {
throw new ConvexError(formatEmbeddingError(error))
throw new ConvexError(formatEmbeddingError(error));
}),
])
]);
const publishResult = (await ctx.runMutation(internal.skills.insertVersion, {
userId,
@@ -279,17 +294,18 @@ export async function publishVersionForUser(
signals: qualityAssessment.signals,
}
: undefined,
})) as PublishResult
staticScan,
})) as PublishResult;
await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, {
versionId: publishResult.versionId,
})
});
await ctx.scheduler.runAfter(0, internal.llmEval.evaluateWithLlm, {
versionId: publishResult.versionId,
})
});
const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? 'unknown'
const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? "unknown";
if (!options.skipBackup) {
void ctx.scheduler
@@ -302,8 +318,8 @@ export async function publishVersionForUser(
publishedAt: Date.now(),
})
.catch((error) => {
console.error('GitHub backup scheduling failed', error)
})
console.error("GitHub backup scheduling failed", error);
});
}
if (!options.skipWebhook) {
@@ -311,21 +327,21 @@ export async function publishVersionForUser(
slug,
version,
displayName,
})
});
}
return publishResult
return publishResult;
}
function mergeSourceIntoMetadata(
metadata: unknown,
source: PublishVersionArgs['source'],
source: PublishVersionArgs["source"],
qualityAssessment: QualityAssessment | null = null,
) {
const base =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? { ...(metadata as Record<string, unknown>) }
: {}
: {};
if (source) {
base.source = {
@@ -336,7 +352,7 @@ function mergeSourceIntoMetadata(
commit: source.commit,
path: source.path,
importedAt: source.importedAt,
}
};
}
if (qualityAssessment) {
@@ -348,10 +364,10 @@ function mergeSourceIntoMetadata(
signals: qualityAssessment.signals,
reason: qualityAssessment.reason,
evaluatedAt: Date.now(),
}
};
}
return Object.keys(base).length ? base : undefined
return Object.keys(base).length ? base : undefined;
}
export const __test = {
@@ -359,15 +375,15 @@ export const __test = {
computeQualitySignals,
evaluateQuality,
toStructuralFingerprint,
}
};
export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<'skills'>) {
const skill = await ctx.db.get(skillId)
if (!skill) return
const owner = await ctx.db.get(skill.ownerUserId)
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<"skills">) {
const skill = await ctx.db.get(skillId);
if (!skill) return;
const owner = await ctx.db.get(skill.ownerUserId);
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const badges = await getSkillBadgeMap(ctx, skillId)
const badges = await getSkillBadgeMap(ctx, skillId);
const payload: WebhookSkillPayload = {
slug: skill.slug,
displayName: skill.displayName,
@@ -376,33 +392,33 @@ export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<'ski
ownerHandle: owner?.handle ?? owner?.name ?? undefined,
highlighted: isSkillHighlighted({ badges }),
tags: Object.keys(skill.tags ?? {}),
}
};
await ctx.scheduler.runAfter(0, internal.webhooks.sendDiscordWebhook, {
event: 'skill.highlighted',
event: "skill.highlighted",
skill: payload,
})
});
}
export async function fetchText(
ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
storageId: Id<'_storage'>,
ctx: { storage: { get: (id: Id<"_storage">) => Promise<Blob | null> } },
storageId: Id<"_storage">,
) {
const blob = await ctx.storage.get(storageId)
if (!blob) throw new Error('File missing in storage')
return blob.text()
const blob = await ctx.storage.get(storageId);
if (!blob) throw new Error("File missing in storage");
return blob.text();
}
function formatEmbeddingError(error: unknown) {
if (error instanceof Error) {
if (error.message.includes('OPENAI_API_KEY')) {
return 'OPENAI_API_KEY is not configured.'
if (error.message.includes("OPENAI_API_KEY")) {
return "OPENAI_API_KEY is not configured.";
}
if (error.message.startsWith('Embedding failed')) {
return error.message
if (error.message.startsWith("Embedding failed")) {
return error.message;
}
}
return 'Embedding failed. Please try again.'
return "Embedding failed. Please try again.";
}
async function schedulePublishWebhook(
@@ -411,8 +427,8 @@ async function schedulePublishWebhook(
) {
const result = (await ctx.runQuery(api.skills.getBySlug, {
slug: params.slug,
})) as { skill: Doc<'skills'>; owner: PublicUser | null } | null
if (!result?.skill) return
})) as { skill: Doc<"skills">; owner: PublicUser | null } | null;
if (!result?.skill) return;
const payload: WebhookSkillPayload = {
slug: result.skill.slug,
@@ -422,10 +438,10 @@ async function schedulePublishWebhook(
ownerHandle: result.owner?.handle ?? result.owner?.name ?? undefined,
highlighted: isSkillHighlighted(result.skill),
tags: Object.keys(result.skill.tags ?? {}),
}
};
await ctx.scheduler.runAfter(0, internal.webhooks.sendDiscordWebhook, {
event: 'skill.publish',
event: "skill.publish",
skill: payload,
})
});
}
+11 -6
View File
@@ -1,13 +1,18 @@
import type { Doc } from '../_generated/dataModel'
import type { Doc } from "../_generated/dataModel";
import { resolveSkillVerdict } from "./moderationEngine";
function isScannerSuspiciousReason(reason: string | undefined) {
if (!reason) return false
return reason.startsWith('scanner.') && reason.endsWith('.suspicious')
if (!reason) return false;
return reason.startsWith("scanner.") && reason.endsWith(".suspicious");
}
export function isSkillSuspicious(
skill: Pick<Doc<'skills'>, 'moderationFlags' | 'moderationReason'>,
skill: Pick<
Doc<"skills">,
"moderationVerdict" | "moderationReasonCodes" | "moderationFlags" | "moderationReason"
>,
) {
if (skill.moderationFlags?.includes('flagged.suspicious')) return true
return isScannerSuspiciousReason(skill.moderationReason)
if (resolveSkillVerdict(skill) === "suspicious") return true;
if (skill.moderationFlags?.includes("flagged.suspicious")) return true;
return isScannerSuspiciousReason(skill.moderationReason);
}
+216 -177
View File
@@ -1,7 +1,7 @@
import { authTables } from '@convex-dev/auth/server'
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
import { authTables } from "@convex-dev/auth/server";
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
const users = defineTable({
name: v.optional(v.string()),
@@ -14,7 +14,7 @@ const users = defineTable({
handle: v.optional(v.string()),
displayName: v.optional(v.string()),
bio: v.optional(v.string()),
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
role: v.optional(v.union(v.literal("admin"), v.literal("moderator"), v.literal("user"))),
githubCreatedAt: v.optional(v.number()),
githubFetchedAt: v.optional(v.number()),
githubProfileSyncedAt: v.optional(v.number()),
@@ -26,66 +26,86 @@ const users = defineTable({
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
.index('email', ['email'])
.index('phone', ['phone'])
.index('handle', ['handle'])
.index("email", ["email"])
.index("phone", ["phone"])
.index("handle", ["handle"]);
const skills = defineTable({
slug: v.string(),
displayName: v.string(),
summary: v.optional(v.string()),
resourceId: v.optional(v.string()),
ownerUserId: v.id('users'),
canonicalSkillId: v.optional(v.id('skills')),
ownerUserId: v.id("users"),
canonicalSkillId: v.optional(v.id("skills")),
forkOf: v.optional(
v.object({
skillId: v.id('skills'),
kind: v.union(v.literal('fork'), v.literal('duplicate')),
skillId: v.id("skills"),
kind: v.union(v.literal("fork"), v.literal("duplicate")),
version: v.optional(v.string()),
at: v.number(),
}),
),
latestVersionId: v.optional(v.id('skillVersions')),
tags: v.record(v.string(), v.id('skillVersions')),
latestVersionId: v.optional(v.id("skillVersions")),
tags: v.record(v.string(), v.id("skillVersions")),
softDeletedAt: v.optional(v.number()),
badges: v.optional(
v.object({
redactionApproved: v.optional(
v.object({
byUserId: v.id('users'),
byUserId: v.id("users"),
at: v.number(),
}),
),
highlighted: v.optional(
v.object({
byUserId: v.id('users'),
byUserId: v.id("users"),
at: v.number(),
}),
),
official: v.optional(
v.object({
byUserId: v.id('users'),
byUserId: v.id("users"),
at: v.number(),
}),
),
deprecated: v.optional(
v.object({
byUserId: v.id('users'),
byUserId: v.id("users"),
at: v.number(),
}),
),
}),
),
moderationStatus: v.optional(
v.union(v.literal('active'), v.literal('hidden'), v.literal('removed')),
v.union(v.literal("active"), v.literal("hidden"), v.literal("removed")),
),
moderationNotes: v.optional(v.string()),
moderationReason: v.optional(v.string()),
moderationVerdict: v.optional(
v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
),
moderationReasonCodes: v.optional(v.array(v.string())),
moderationEvidence: v.optional(
v.array(
v.object({
code: v.string(),
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
file: v.string(),
line: v.number(),
message: v.string(),
evidence: v.string(),
}),
),
),
moderationSummary: v.optional(v.string()),
moderationEngineVersion: v.optional(v.string()),
moderationEvaluatedAt: v.optional(v.number()),
moderationSourceVersionId: v.optional(v.id("skillVersions")),
quality: v.optional(
v.object({
score: v.number(),
decision: v.union(v.literal('pass'), v.literal('quarantine'), v.literal('reject')),
trustTier: v.union(v.literal('low'), v.literal('medium'), v.literal('trusted')),
decision: v.union(v.literal("pass"), v.literal("quarantine"), v.literal("reject")),
trustTier: v.union(v.literal("low"), v.literal("medium"), v.literal("trusted")),
similarRecentCount: v.number(),
reason: v.string(),
signals: v.object({
@@ -107,7 +127,7 @@ const skills = defineTable({
scanLastCheckedAt: v.optional(v.number()),
scanCheckCount: v.optional(v.number()),
hiddenAt: v.optional(v.number()),
hiddenBy: v.optional(v.id('users')),
hiddenBy: v.optional(v.id("users")),
reportCount: v.optional(v.number()),
lastReportedAt: v.optional(v.number()),
batch: v.optional(v.string()),
@@ -126,34 +146,34 @@ const skills = defineTable({
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_slug', ['slug'])
.index('by_owner', ['ownerUserId'])
.index('by_updated', ['updatedAt'])
.index('by_stats_downloads', ['statsDownloads', 'updatedAt'])
.index('by_stats_stars', ['statsStars', 'updatedAt'])
.index('by_stats_installs_current', ['statsInstallsCurrent', 'updatedAt'])
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
.index('by_batch', ['batch'])
.index('by_active_updated', ['softDeletedAt', 'updatedAt'])
.index('by_active_created', ['softDeletedAt', 'createdAt'])
.index('by_active_name', ['softDeletedAt', 'displayName'])
.index('by_active_stats_downloads', ['softDeletedAt', 'statsDownloads', 'updatedAt'])
.index('by_active_stats_stars', ['softDeletedAt', 'statsStars', 'updatedAt'])
.index('by_active_stats_installs_all_time', [
'softDeletedAt',
'statsInstallsAllTime',
'updatedAt',
.index("by_slug", ["slug"])
.index("by_owner", ["ownerUserId"])
.index("by_updated", ["updatedAt"])
.index("by_stats_downloads", ["statsDownloads", "updatedAt"])
.index("by_stats_stars", ["statsStars", "updatedAt"])
.index("by_stats_installs_current", ["statsInstallsCurrent", "updatedAt"])
.index("by_stats_installs_all_time", ["statsInstallsAllTime", "updatedAt"])
.index("by_batch", ["batch"])
.index("by_active_updated", ["softDeletedAt", "updatedAt"])
.index("by_active_created", ["softDeletedAt", "createdAt"])
.index("by_active_name", ["softDeletedAt", "displayName"])
.index("by_active_stats_downloads", ["softDeletedAt", "statsDownloads", "updatedAt"])
.index("by_active_stats_stars", ["softDeletedAt", "statsStars", "updatedAt"])
.index("by_active_stats_installs_all_time", [
"softDeletedAt",
"statsInstallsAllTime",
"updatedAt",
])
.index('by_canonical', ['canonicalSkillId'])
.index('by_fork_of', ['forkOf.skillId'])
.index("by_canonical", ["canonicalSkillId"])
.index("by_fork_of", ["forkOf.skillId"]);
const souls = defineTable({
slug: v.string(),
displayName: v.string(),
summary: v.optional(v.string()),
ownerUserId: v.id('users'),
latestVersionId: v.optional(v.id('soulVersions')),
tags: v.record(v.string(), v.id('soulVersions')),
ownerUserId: v.id("users"),
latestVersionId: v.optional(v.id("soulVersions")),
tags: v.record(v.string(), v.id("soulVersions")),
softDeletedAt: v.optional(v.number()),
stats: v.object({
downloads: v.number(),
@@ -164,21 +184,21 @@ const souls = defineTable({
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_slug', ['slug'])
.index('by_owner', ['ownerUserId'])
.index('by_updated', ['updatedAt'])
.index("by_slug", ["slug"])
.index("by_owner", ["ownerUserId"])
.index("by_updated", ["updatedAt"]);
const skillVersions = defineTable({
skillId: v.id('skills'),
skillId: v.id("skills"),
version: v.string(),
fingerprint: v.optional(v.string()),
changelog: v.string(),
changelogSource: v.optional(v.union(v.literal('auto'), v.literal('user'))),
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id('_storage'),
storageId: v.id("_storage"),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
@@ -189,7 +209,7 @@ const skillVersions = defineTable({
clawdis: v.optional(v.any()),
moltbot: v.optional(v.any()),
}),
createdBy: v.id('users'),
createdBy: v.id("users"),
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
sha256hash: v.optional(v.string()),
@@ -224,22 +244,41 @@ const skillVersions = defineTable({
checkedAt: v.number(),
}),
),
staticScan: v.optional(
v.object({
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
reasonCodes: v.array(v.string()),
findings: v.array(
v.object({
code: v.string(),
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
file: v.string(),
line: v.number(),
message: v.string(),
evidence: v.string(),
}),
),
summary: v.string(),
engineVersion: v.string(),
checkedAt: v.number(),
}),
),
})
.index('by_skill', ['skillId'])
.index('by_skill_version', ['skillId', 'version'])
.index('by_sha256hash', ['sha256hash'])
.index("by_skill", ["skillId"])
.index("by_skill_version", ["skillId", "version"])
.index("by_sha256hash", ["sha256hash"]);
const soulVersions = defineTable({
soulId: v.id('souls'),
soulId: v.id("souls"),
version: v.string(),
fingerprint: v.optional(v.string()),
changelog: v.string(),
changelogSource: v.optional(v.union(v.literal('auto'), v.literal('user'))),
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id('_storage'),
storageId: v.id("_storage"),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
@@ -250,75 +289,75 @@ const soulVersions = defineTable({
clawdis: v.optional(v.any()),
moltbot: v.optional(v.any()),
}),
createdBy: v.id('users'),
createdBy: v.id("users"),
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
})
.index('by_soul', ['soulId'])
.index('by_soul_version', ['soulId', 'version'])
.index("by_soul", ["soulId"])
.index("by_soul_version", ["soulId", "version"]);
const skillVersionFingerprints = defineTable({
skillId: v.id('skills'),
versionId: v.id('skillVersions'),
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
fingerprint: v.string(),
createdAt: v.number(),
})
.index('by_version', ['versionId'])
.index('by_fingerprint', ['fingerprint'])
.index('by_skill_fingerprint', ['skillId', 'fingerprint'])
.index("by_version", ["versionId"])
.index("by_fingerprint", ["fingerprint"])
.index("by_skill_fingerprint", ["skillId", "fingerprint"]);
const skillBadges = defineTable({
skillId: v.id('skills'),
skillId: v.id("skills"),
kind: v.union(
v.literal('highlighted'),
v.literal('official'),
v.literal('deprecated'),
v.literal('redactionApproved'),
v.literal("highlighted"),
v.literal("official"),
v.literal("deprecated"),
v.literal("redactionApproved"),
),
byUserId: v.id('users'),
byUserId: v.id("users"),
at: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_skill_kind', ['skillId', 'kind'])
.index('by_kind_at', ['kind', 'at'])
.index("by_skill", ["skillId"])
.index("by_skill_kind", ["skillId", "kind"])
.index("by_kind_at", ["kind", "at"]);
const soulVersionFingerprints = defineTable({
soulId: v.id('souls'),
versionId: v.id('soulVersions'),
soulId: v.id("souls"),
versionId: v.id("soulVersions"),
fingerprint: v.string(),
createdAt: v.number(),
})
.index('by_version', ['versionId'])
.index('by_fingerprint', ['fingerprint'])
.index('by_soul_fingerprint', ['soulId', 'fingerprint'])
.index("by_version", ["versionId"])
.index("by_fingerprint", ["fingerprint"])
.index("by_soul_fingerprint", ["soulId", "fingerprint"]);
const skillEmbeddings = defineTable({
skillId: v.id('skills'),
versionId: v.id('skillVersions'),
ownerId: v.id('users'),
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
ownerId: v.id("users"),
embedding: v.array(v.number()),
isLatest: v.boolean(),
isApproved: v.boolean(),
visibility: v.string(),
updatedAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_version', ['versionId'])
.vectorIndex('by_embedding', {
vectorField: 'embedding',
.index("by_skill", ["skillId"])
.index("by_version", ["versionId"])
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: EMBEDDING_DIMENSIONS,
filterFields: ['visibility'],
})
filterFields: ["visibility"],
});
const skillDailyStats = defineTable({
skillId: v.id('skills'),
skillId: v.id("skills"),
day: v.number(),
downloads: v.number(),
installs: v.number(),
updatedAt: v.number(),
})
.index('by_skill_day', ['skillId', 'day'])
.index('by_day', ['day'])
.index("by_skill_day", ["skillId", "day"])
.index("by_day", ["day"]);
const skillLeaderboards = defineTable({
kind: v.string(),
@@ -327,33 +366,33 @@ const skillLeaderboards = defineTable({
rangeEndDay: v.number(),
items: v.array(
v.object({
skillId: v.id('skills'),
skillId: v.id("skills"),
score: v.number(),
installs: v.number(),
downloads: v.number(),
}),
),
}).index('by_kind', ['kind', 'generatedAt'])
}).index("by_kind", ["kind", "generatedAt"]);
const skillStatBackfillState = defineTable({
key: v.string(),
cursor: v.optional(v.string()),
doneAt: v.optional(v.number()),
updatedAt: v.number(),
}).index('by_key', ['key'])
}).index("by_key", ["key"]);
const skillStatEvents = defineTable({
skillId: v.id('skills'),
skillId: v.id("skills"),
kind: v.union(
v.literal('download'),
v.literal('star'),
v.literal('unstar'),
v.literal('comment'),
v.literal('uncomment'),
v.literal('install_new'),
v.literal('install_reactivate'),
v.literal('install_deactivate'),
v.literal('install_clear'),
v.literal("download"),
v.literal("star"),
v.literal("unstar"),
v.literal("comment"),
v.literal("uncomment"),
v.literal("install_new"),
v.literal("install_reactivate"),
v.literal("install_deactivate"),
v.literal("install_clear"),
),
delta: v.optional(
v.object({
@@ -364,97 +403,97 @@ const skillStatEvents = defineTable({
occurredAt: v.number(),
processedAt: v.optional(v.number()),
})
.index('by_unprocessed', ['processedAt'])
.index('by_skill', ['skillId'])
.index("by_unprocessed", ["processedAt"])
.index("by_skill", ["skillId"]);
const skillStatUpdateCursors = defineTable({
key: v.string(),
cursorCreationTime: v.optional(v.number()),
updatedAt: v.number(),
}).index('by_key', ['key'])
}).index("by_key", ["key"]);
const soulEmbeddings = defineTable({
soulId: v.id('souls'),
versionId: v.id('soulVersions'),
ownerId: v.id('users'),
soulId: v.id("souls"),
versionId: v.id("soulVersions"),
ownerId: v.id("users"),
embedding: v.array(v.number()),
isLatest: v.boolean(),
isApproved: v.boolean(),
visibility: v.string(),
updatedAt: v.number(),
})
.index('by_soul', ['soulId'])
.index('by_version', ['versionId'])
.vectorIndex('by_embedding', {
vectorField: 'embedding',
.index("by_soul", ["soulId"])
.index("by_version", ["versionId"])
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: EMBEDDING_DIMENSIONS,
filterFields: ['visibility'],
})
filterFields: ["visibility"],
});
const comments = defineTable({
skillId: v.id('skills'),
userId: v.id('users'),
skillId: v.id("skills"),
userId: v.id("users"),
body: v.string(),
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
deletedBy: v.optional(v.id('users')),
deletedBy: v.optional(v.id("users")),
})
.index('by_skill', ['skillId'])
.index('by_user', ['userId'])
.index("by_skill", ["skillId"])
.index("by_user", ["userId"]);
const skillReports = defineTable({
skillId: v.id('skills'),
userId: v.id('users'),
skillId: v.id("skills"),
userId: v.id("users"),
reason: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_skill_createdAt', ['skillId', 'createdAt'])
.index('by_user', ['userId'])
.index('by_skill_user', ['skillId', 'userId'])
.index("by_skill", ["skillId"])
.index("by_skill_createdAt", ["skillId", "createdAt"])
.index("by_user", ["userId"])
.index("by_skill_user", ["skillId", "userId"]);
const soulComments = defineTable({
soulId: v.id('souls'),
userId: v.id('users'),
soulId: v.id("souls"),
userId: v.id("users"),
body: v.string(),
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
deletedBy: v.optional(v.id('users')),
deletedBy: v.optional(v.id("users")),
})
.index('by_soul', ['soulId'])
.index('by_user', ['userId'])
.index("by_soul", ["soulId"])
.index("by_user", ["userId"]);
const stars = defineTable({
skillId: v.id('skills'),
userId: v.id('users'),
skillId: v.id("skills"),
userId: v.id("users"),
createdAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_user', ['userId'])
.index('by_skill_user', ['skillId', 'userId'])
.index("by_skill", ["skillId"])
.index("by_user", ["userId"])
.index("by_skill_user", ["skillId", "userId"]);
const soulStars = defineTable({
soulId: v.id('souls'),
userId: v.id('users'),
soulId: v.id("souls"),
userId: v.id("users"),
createdAt: v.number(),
})
.index('by_soul', ['soulId'])
.index('by_user', ['userId'])
.index('by_soul_user', ['soulId', 'userId'])
.index("by_soul", ["soulId"])
.index("by_user", ["userId"])
.index("by_soul_user", ["soulId", "userId"]);
const auditLogs = defineTable({
actorUserId: v.id('users'),
actorUserId: v.id("users"),
action: v.string(),
targetType: v.string(),
targetId: v.string(),
metadata: v.optional(v.any()),
createdAt: v.number(),
})
.index('by_actor', ['actorUserId'])
.index('by_target', ['targetType', 'targetId'])
.index("by_actor", ["actorUserId"])
.index("by_target", ["targetType", "targetId"]);
const vtScanLogs = defineTable({
type: v.union(v.literal('daily_rescan'), v.literal('backfill'), v.literal('pending_poll')),
type: v.union(v.literal("daily_rescan"), v.literal("backfill"), v.literal("pending_poll")),
total: v.number(),
updated: v.number(),
unchanged: v.number(),
@@ -469,10 +508,10 @@ const vtScanLogs = defineTable({
),
durationMs: v.number(),
createdAt: v.number(),
}).index('by_type_date', ['type', 'createdAt'])
}).index("by_type_date", ["type", "createdAt"]);
const apiTokens = defineTable({
userId: v.id('users'),
userId: v.id("users"),
label: v.string(),
prefix: v.string(),
tokenHash: v.string(),
@@ -480,8 +519,8 @@ const apiTokens = defineTable({
lastUsedAt: v.optional(v.number()),
revokedAt: v.optional(v.number()),
})
.index('by_user', ['userId'])
.index('by_hash', ['tokenHash'])
.index("by_user", ["userId"])
.index("by_hash", ["tokenHash"]);
const rateLimits = defineTable({
key: v.string(),
@@ -490,74 +529,74 @@ const rateLimits = defineTable({
limit: v.number(),
updatedAt: v.number(),
})
.index('by_key_window', ['key', 'windowStart'])
.index('by_key', ['key'])
.index("by_key_window", ["key", "windowStart"])
.index("by_key", ["key"]);
const downloadDedupes = defineTable({
skillId: v.id('skills'),
skillId: v.id("skills"),
identityHash: v.string(),
hourStart: v.number(),
createdAt: v.number(),
})
.index('by_skill_identity_hour', ['skillId', 'identityHash', 'hourStart'])
.index('by_hour', ['hourStart'])
.index("by_skill_identity_hour", ["skillId", "identityHash", "hourStart"])
.index("by_hour", ["hourStart"]);
const reservedSlugs = defineTable({
slug: v.string(),
originalOwnerUserId: v.id('users'),
originalOwnerUserId: v.id("users"),
deletedAt: v.number(),
expiresAt: v.number(),
reason: v.optional(v.string()),
releasedAt: v.optional(v.number()),
})
.index('by_slug', ['slug'])
.index('by_slug_active_deletedAt', ['slug', 'releasedAt', 'deletedAt'])
.index('by_owner', ['originalOwnerUserId'])
.index('by_expiry', ['expiresAt'])
.index("by_slug", ["slug"])
.index("by_slug_active_deletedAt", ["slug", "releasedAt", "deletedAt"])
.index("by_owner", ["originalOwnerUserId"])
.index("by_expiry", ["expiresAt"]);
const githubBackupSyncState = defineTable({
key: v.string(),
cursor: v.optional(v.string()),
updatedAt: v.number(),
}).index('by_key', ['key'])
}).index("by_key", ["key"]);
const userSyncRoots = defineTable({
userId: v.id('users'),
userId: v.id("users"),
rootId: v.string(),
label: v.string(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiredAt: v.optional(v.number()),
})
.index('by_user', ['userId'])
.index('by_user_root', ['userId', 'rootId'])
.index("by_user", ["userId"])
.index("by_user_root", ["userId", "rootId"]);
const userSkillInstalls = defineTable({
userId: v.id('users'),
skillId: v.id('skills'),
userId: v.id("users"),
skillId: v.id("skills"),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
activeRoots: v.number(),
lastVersion: v.optional(v.string()),
})
.index('by_user', ['userId'])
.index('by_user_skill', ['userId', 'skillId'])
.index('by_skill', ['skillId'])
.index("by_user", ["userId"])
.index("by_user_skill", ["userId", "skillId"])
.index("by_skill", ["skillId"]);
const userSkillRootInstalls = defineTable({
userId: v.id('users'),
userId: v.id("users"),
rootId: v.string(),
skillId: v.id('skills'),
skillId: v.id("skills"),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
lastVersion: v.optional(v.string()),
removedAt: v.optional(v.number()),
})
.index('by_user', ['userId'])
.index('by_user_root', ['userId', 'rootId'])
.index('by_user_root_skill', ['userId', 'rootId', 'skillId'])
.index('by_user_skill', ['userId', 'skillId'])
.index('by_skill', ['skillId'])
.index("by_user", ["userId"])
.index("by_user_root", ["userId", "rootId"])
.index("by_user_root_skill", ["userId", "rootId", "skillId"])
.index("by_user_skill", ["userId", "skillId"])
.index("by_skill", ["skillId"]);
export default defineSchema({
...authTables,
@@ -591,4 +630,4 @@ export default defineSchema({
userSyncRoots,
userSkillInstalls,
userSkillRootInstalls,
})
});
+1765 -1543
View File
File diff suppressed because it is too large Load Diff
+550 -458
View File
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -1,5 +1,5 @@
---
summary: 'Security + moderation controls (reports, bans, upload gating).'
summary: "Security + moderation controls (reports, bans, upload gating)."
read_when:
- Working on moderation or abuse controls
- Reviewing upload restrictions
@@ -74,3 +74,27 @@ read_when:
skills.
- Word counting is language-aware (`Intl.Segmenter` with fallback), reducing
false positives for non-space-separated languages.
## Moderation v2 (reason codes + evidence)
- Skills now carry normalized moderation fields:
- `moderationVerdict`: `clean | suspicious | malicious`
- `moderationReasonCodes`: stable reason-code list
- `moderationEvidence`: capped finding snippets (`code`, `severity`, `file`, `line`, `message`, `evidence`)
- `moderationEngineVersion`, `moderationEvaluatedAt`, `moderationSourceVersionId`
- Legacy fields (`moderationReason`, `moderationFlags`) remain for compatibility and are kept in sync.
- Public API responses still include `isSuspicious` and `isMalwareBlocked`, plus additive fields (`verdict`, `reasonCodes`, `summary`, `engineVersion`, `updatedAt`).
- Detailed moderation endpoint:
- `GET /api/v1/skills/:slug/moderation`
- owner/staff receive full evidence
- public callers receive sanitized evidence for flagged skills only
Policy:
- `malicious`: blocked from install/download.
- `suspicious`: visible with warnings; CLI install/update requires explicit confirm (or `--force` in non-interactive mode).
- `pending`: publish-time quarantine behavior unchanged.
Backfill:
- `vt.backfillModerationV2` recomputes normalized moderation fields for historical published skills in bounded batches.
+33 -7
View File
@@ -9,6 +9,7 @@ read_when:
# ClawHub — product + implementation spec (v1)
## Goals
- onlycrabs.ai mode for sharing `SOUL.md` bundles (host-based entry point).
- Minimal, fast SPA for browsing and publishing agent skills.
- Skills stored in Convex (files + metadata + versions + stats).
@@ -19,12 +20,14 @@ read_when:
- Moderation: badges + comment delete; audit everything.
## Non-goals (v1)
- Paid features, private skills, or binary assets.
- GitHub App sync beyond backups (future phase).
## Core objects
### User
- `authId` (from Convex Auth provider)
- `handle` (GitHub login)
- `name`, `bio`
@@ -33,6 +36,7 @@ read_when:
- `createdAt`, `updatedAt`
### Skill
- `slug` (unique)
- `displayName`
- `ownerUserId`
@@ -46,11 +50,16 @@ read_when:
- `moderationStatus`: `active | hidden | removed`
- `moderationFlags`: `string[]` (automatic detection)
- `moderationNotes`, `moderationReason`
- `moderationVerdict`: `clean | suspicious | malicious` (normalized decision)
- `moderationReasonCodes`: `string[]` (stable machine-readable reason IDs)
- `moderationEvidence`: finding snippets (`code`, `severity`, `file`, `line`, `message`, `evidence`)
- `moderationSummary`, `moderationEngineVersion`, `moderationEvaluatedAt`, `moderationSourceVersionId`
- `hiddenAt`, `hiddenBy`, `lastReviewedAt`, `reportCount`
- `stats`: `{ downloads, stars, versions, comments }`
- `createdAt`, `updatedAt`
### SkillVersion
- `skillId`
- `version` (semver string)
- `tag` (string, optional; `latest` always maintained separately)
@@ -58,12 +67,15 @@ read_when:
- `files`: list of file metadata
- `path`, `size`, `storageId`, `sha256`
- `parsed` (metadata extracted from SKILL.md)
- `staticScan`: deterministic static-analysis payload (`status`, `reasonCodes`, `findings`, `summary`, `engineVersion`, `checkedAt`)
- `vectorDocId` (if using RAG component) OR `embeddingId`
- `createdBy`, `createdAt`
- `softDeletedAt` (nullable)
### Parsed Skill Metadata
From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- `name`, `description`, `homepage`, `website`, `url`, `emoji`
- `metadata.clawdis`: `always`, `skillKey`, `primaryEnv`, `emoji`, `homepage`, `os`,
`requires` (`bins`, `anyBins`, `env`, `config`), `install[]`, `nix` (`plugin`, `systems`),
@@ -72,9 +84,8 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- Nix plugins are different from regular skills; they bundle the skill pack, the CLI binary, and config flags/requirements together.
- `metadata` in frontmatter is YAML (object) preferred; legacy JSON-string accepted.
### Soul
- `slug` (unique)
- `displayName`
- `ownerUserId`
@@ -86,6 +97,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- `createdAt`, `updatedAt`
### SoulVersion
- `soulId`
- `version` (semver string)
- `tag` (string, optional; `latest` always maintained separately)
@@ -98,22 +110,27 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- `softDeletedAt` (nullable)
### SoulComment
- `soulId`, `userId`, `body`
- `softDeletedAt`, `deletedBy`
- `createdAt`
### SoulStar
- `soulId`, `userId`, `createdAt`
### Comment
- `skillId`, `userId`, `body`
- `softDeletedAt`, `deletedBy`
- `createdAt`
### Star
- `skillId`, `userId`, `createdAt`
### AuditLog
- `actorUserId`
- `action` (enum: `badge.set`, `badge.unset`, `comment.delete`, `role.change`)
- `targetType` / `targetId`
@@ -121,6 +138,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- `createdAt`
## Auth + roles
- Convex Auth with GitHub OAuth App.
- Default role `user`; bootstrap `steipete` to `admin` on first login.
- Management console: moderators can hide/restore skills + mark duplicates + ban users; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills).
@@ -128,37 +146,42 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- Reporting: any user can report skills; per-user cap 20 active reports; skills auto-hide after >3 unique reports (mods can review/unhide/delete/ban).
## Upload flow (50MB per version)
1) Client requests upload session.
2) Client uploads each file via Convex upload URLs (no binaries, text only).
3) Client submits metadata + file list + changelog + version + tags.
4) Server validates:
1. Client requests upload session.
2. Client uploads each file via Convex upload URLs (no binaries, text only).
3. Client submits metadata + file list + changelog + version + tags.
4. Server validates:
- total size ≤ 50MB
- file extensions/text content
- SKILL.md exists and frontmatter parseable
- version uniqueness
- GitHub account age ≥ 7 days
5) Server stores files + metadata, sets `latest` tag, updates stats.
5. Server stores files + metadata, sets `latest` tag, updates stats.
Soul upload flow: same as skills (including GitHub account age checks), but only `SOUL.md` is allowed.
Seed data lives in `convex/seed.ts` for local dev.
## Versioning + tags
- Each upload is a new `SkillVersion`.
- `latest` tag always points to most recent version unless user re-tags.
- Rollback: move `latest` (and optionally other tags) to an older version.
- Changelog is optional.
## Search
- Vector search over: SKILL.md + other text files + metadata summary (souls index SOUL.md).
- Convex embeddings + vector index.
- Filters: tag, owner, `redactionApproved` only, min stars, updatedAt.
## Download API
- JSON API for skill metadata + versions.
- Download endpoint returns zip of a version (HTTP action).
- Soft-delete versions; downloads remain for non-deleted versions only.
## UI (SPA)
- Home: search + filters + trending/featured + “Highlighted” badge.
- Skill detail: README render, files list, version history, tags, stats, badges.
- Upload/edit: file picker + version + tag + changelog.
@@ -166,14 +189,17 @@ Seed data lives in `convex/seed.ts` for local dev.
- Admin: user role management + badge approvals + audit log.
## Testing + quality
- Vitest 4 with >=70% global coverage.
- Lint: Biome + Oxlint (type-aware).
## Vercel
- Env vars: Convex deployment URLs + GitHub OAuth client + OpenAI key (if used) + GitHub App backup credentials.
- SPA feel: client-side transitions, prefetching, optimistic UI.
## Open questions (carry forward)
- Embeddings provider key + rate limits.
- Zip generation memory limits (optimize with streaming if needed).
- GitHub App repo sync (phase 2).
+264 -263
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env node
import { stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { Command } from 'commander'
import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
import { Command } from "commander";
import { stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { GlobalOpts } from "./cli/types.js";
import { getCliBuildLabel, getCliVersion } from "./cli/buildInfo.js";
import { resolveClawdbotDefaultWorkspace } from "./cli/clawdbotConfig.js";
import { cmdLoginFlow, cmdLogout, cmdWhoami } from "./cli/commands/auth.js";
import {
cmdDeleteSkill,
cmdHideSkill,
cmdUndeleteSkill,
cmdUnhideSkill,
} from './cli/commands/delete.js'
import { cmdInspect } from './cli/commands/inspect.js'
import { cmdBanUser, cmdSetRole } from './cli/commands/moderation.js'
import { cmdPublish } from './cli/commands/publish.js'
} from "./cli/commands/delete.js";
import { cmdInspect } from "./cli/commands/inspect.js";
import { cmdBanUser, cmdSetRole } from "./cli/commands/moderation.js";
import { cmdPublish } from "./cli/commands/publish.js";
import {
cmdExplore,
cmdInstall,
@@ -21,364 +22,364 @@ import {
cmdSearch,
cmdUninstall,
cmdUpdate,
} from './cli/commands/skills.js'
import { cmdStarSkill } from './cli/commands/star.js'
import { cmdSync } from './cli/commands/sync.js'
import { cmdUnstarSkill } from './cli/commands/unstar.js'
import { configureCommanderHelp, styleEnvBlock, styleTitle } from './cli/helpStyle.js'
import { DEFAULT_REGISTRY, DEFAULT_SITE } from './cli/registry.js'
import type { GlobalOpts } from './cli/types.js'
import { fail } from './cli/ui.js'
import { readGlobalConfig } from './config.js'
} from "./cli/commands/skills.js";
import { cmdStarSkill } from "./cli/commands/star.js";
import { cmdSync } from "./cli/commands/sync.js";
import { cmdUnstarSkill } from "./cli/commands/unstar.js";
import { configureCommanderHelp, styleEnvBlock, styleTitle } from "./cli/helpStyle.js";
import { DEFAULT_REGISTRY, DEFAULT_SITE } from "./cli/registry.js";
import { fail } from "./cli/ui.js";
import { readGlobalConfig } from "./config.js";
const program = new Command()
.name('clawhub')
.name("clawhub")
.description(
`${styleTitle(`ClawHub CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
'install, update, search, and publish agent skills.',
"install, update, search, and publish agent skills.",
)}`,
)
.version(getCliVersion(), '-V, --cli-version', 'Show CLI version')
.option('--workdir <dir>', 'Working directory (default: cwd)')
.option('--dir <dir>', 'Skills directory (relative to workdir, default: skills)')
.option('--site <url>', 'Site base URL (for browser login)')
.option('--registry <url>', 'Registry API base URL')
.option('--no-input', 'Disable prompts')
.version(getCliVersion(), "-V, --cli-version", "Show CLI version")
.option("--workdir <dir>", "Working directory (default: cwd)")
.option("--dir <dir>", "Skills directory (relative to workdir, default: skills)")
.option("--site <url>", "Site base URL (for browser login)")
.option("--registry <url>", "Registry API base URL")
.option("--no-input", "Disable prompts")
.showHelpAfterError()
.showSuggestionAfterError()
.addHelpText(
'after',
"after",
styleEnvBlock(
'\nEnv:\n CLAWHUB_SITE\n CLAWHUB_REGISTRY\n CLAWHUB_WORKDIR\n (CLAWDHUB_* supported)\n',
"\nEnv:\n CLAWHUB_SITE\n CLAWHUB_REGISTRY\n CLAWHUB_WORKDIR\n (CLAWDHUB_* supported)\n",
),
)
);
configureCommanderHelp(program)
configureCommanderHelp(program);
async function resolveGlobalOpts(): Promise<GlobalOpts> {
const raw = program.opts<{ workdir?: string; dir?: string; site?: string; registry?: string }>()
const workdir = await resolveWorkdir(raw.workdir)
const dir = resolve(workdir, raw.dir ?? 'skills')
const site = raw.site ?? process.env.CLAWHUB_SITE ?? process.env.CLAWDHUB_SITE ?? DEFAULT_SITE
const raw = program.opts<{ workdir?: string; dir?: string; site?: string; registry?: string }>();
const workdir = await resolveWorkdir(raw.workdir);
const dir = resolve(workdir, raw.dir ?? "skills");
const site = raw.site ?? process.env.CLAWHUB_SITE ?? process.env.CLAWDHUB_SITE ?? DEFAULT_SITE;
const registrySource = raw.registry
? 'cli'
? "cli"
: process.env.CLAWHUB_REGISTRY || process.env.CLAWDHUB_REGISTRY
? 'env'
: 'default'
? "env"
: "default";
const registry =
raw.registry ??
process.env.CLAWHUB_REGISTRY ??
process.env.CLAWDHUB_REGISTRY ??
DEFAULT_REGISTRY
return { workdir, dir, site, registry, registrySource }
DEFAULT_REGISTRY;
return { workdir, dir, site, registry, registrySource };
}
function isInputAllowed() {
const globalFlags = program.opts<{ input?: boolean }>()
return globalFlags.input !== false
const globalFlags = program.opts<{ input?: boolean }>();
return globalFlags.input !== false;
}
async function resolveWorkdir(explicit?: string) {
if (explicit?.trim()) return resolve(explicit.trim())
const envWorkdir = process.env.CLAWHUB_WORKDIR?.trim() ?? process.env.CLAWDHUB_WORKDIR?.trim()
if (envWorkdir) return resolve(envWorkdir)
if (explicit?.trim()) return resolve(explicit.trim());
const envWorkdir = process.env.CLAWHUB_WORKDIR?.trim() ?? process.env.CLAWDHUB_WORKDIR?.trim();
if (envWorkdir) return resolve(envWorkdir);
const cwd = resolve(process.cwd())
const hasMarker = await hasClawhubMarker(cwd)
if (hasMarker) return cwd
const cwd = resolve(process.cwd());
const hasMarker = await hasClawhubMarker(cwd);
if (hasMarker) return cwd;
const clawdbotWorkspace = await resolveClawdbotDefaultWorkspace()
return clawdbotWorkspace ? resolve(clawdbotWorkspace) : cwd
const clawdbotWorkspace = await resolveClawdbotDefaultWorkspace();
return clawdbotWorkspace ? resolve(clawdbotWorkspace) : cwd;
}
async function hasClawhubMarker(workdir: string) {
const lockfile = join(workdir, '.clawhub', 'lock.json')
if (await pathExists(lockfile)) return true
const markerDir = join(workdir, '.clawhub')
if (await pathExists(markerDir)) return true
const legacyLockfile = join(workdir, '.clawdhub', 'lock.json')
if (await pathExists(legacyLockfile)) return true
const legacyMarkerDir = join(workdir, '.clawdhub')
return pathExists(legacyMarkerDir)
const lockfile = join(workdir, ".clawhub", "lock.json");
if (await pathExists(lockfile)) return true;
const markerDir = join(workdir, ".clawhub");
if (await pathExists(markerDir)) return true;
const legacyLockfile = join(workdir, ".clawdhub", "lock.json");
if (await pathExists(legacyLockfile)) return true;
const legacyMarkerDir = join(workdir, ".clawdhub");
return pathExists(legacyMarkerDir);
}
async function pathExists(path: string) {
try {
await stat(path)
return true
await stat(path);
return true;
} catch {
return false
return false;
}
}
program
.command('login')
.description('Log in (opens browser or stores token)')
.option('--token <token>', 'API token')
.option('--label <label>', 'Token label (browser flow only)', 'CLI token')
.option('--no-browser', 'Do not open browser (requires --token)')
.command("login")
.description("Log in (opens browser or stores token)")
.option("--token <token>", "API token")
.option("--label <label>", "Token label (browser flow only)", "CLI token")
.option("--no-browser", "Do not open browser (requires --token)")
.action(async (options) => {
const opts = await resolveGlobalOpts()
await cmdLoginFlow(opts, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdLoginFlow(opts, options, isInputAllowed());
});
program
.command('logout')
.description('Remove stored token')
.command("logout")
.description("Remove stored token")
.action(async () => {
const opts = await resolveGlobalOpts()
await cmdLogout(opts)
})
const opts = await resolveGlobalOpts();
await cmdLogout(opts);
});
program
.command('whoami')
.description('Validate token')
.command("whoami")
.description("Validate token")
.action(async () => {
const opts = await resolveGlobalOpts()
await cmdWhoami(opts)
})
const opts = await resolveGlobalOpts();
await cmdWhoami(opts);
});
const auth = program
.command('auth')
.description('Authentication commands')
.command("auth")
.description("Authentication commands")
.showHelpAfterError()
.showSuggestionAfterError()
.showSuggestionAfterError();
auth
.command('login')
.description('Log in (opens browser or stores token)')
.option('--token <token>', 'API token')
.option('--label <label>', 'Token label (browser flow only)', 'CLI token')
.option('--no-browser', 'Do not open browser (requires --token)')
.command("login")
.description("Log in (opens browser or stores token)")
.option("--token <token>", "API token")
.option("--label <label>", "Token label (browser flow only)", "CLI token")
.option("--no-browser", "Do not open browser (requires --token)")
.action(async (options) => {
const opts = await resolveGlobalOpts()
await cmdLoginFlow(opts, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdLoginFlow(opts, options, isInputAllowed());
});
auth
.command('logout')
.description('Remove stored token')
.command("logout")
.description("Remove stored token")
.action(async () => {
const opts = await resolveGlobalOpts()
await cmdLogout(opts)
})
const opts = await resolveGlobalOpts();
await cmdLogout(opts);
});
auth
.command('whoami')
.description('Validate token')
.command("whoami")
.description("Validate token")
.action(async () => {
const opts = await resolveGlobalOpts()
await cmdWhoami(opts)
})
const opts = await resolveGlobalOpts();
await cmdWhoami(opts);
});
program
.command('search')
.description('Vector search skills')
.argument('<query...>', 'Query string')
.option('--limit <n>', 'Max results', (value) => Number.parseInt(value, 10))
.command("search")
.description("Vector search skills")
.argument("<query...>", "Query string")
.option("--limit <n>", "Max results", (value) => Number.parseInt(value, 10))
.action(async (queryParts, options) => {
const opts = await resolveGlobalOpts()
const query = queryParts.join(' ').trim()
await cmdSearch(opts, query, options.limit)
})
const opts = await resolveGlobalOpts();
const query = queryParts.join(" ").trim();
await cmdSearch(opts, query, options.limit);
});
program
.command('install')
.description('Install into <dir>/<slug>')
.argument('<slug>', 'Skill slug')
.option('--version <version>', 'Version to install')
.option('--force', 'Overwrite existing folder')
.command("install")
.description("Install into <dir>/<slug>")
.argument("<slug>", "Skill slug")
.option("--version <version>", "Version to install")
.option("--force", "Overwrite existing folder")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdInstall(opts, slug, options.version, options.force)
})
const opts = await resolveGlobalOpts();
await cmdInstall(opts, slug, options.version, options.force);
});
program
.command('update')
.description('Update installed skills')
.argument('[slug]', 'Skill slug')
.option('--all', 'Update all installed skills')
.option('--version <version>', 'Update to specific version (single slug only)')
.option('--force', 'Overwrite when local files do not match any version')
.command("update")
.description("Update installed skills")
.argument("[slug]", "Skill slug")
.option("--all", "Update all installed skills")
.option("--version <version>", "Update to specific version (single slug only)")
.option("--force", "Overwrite when local files do not match any version")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUpdate(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdUpdate(opts, slug, options, isInputAllowed());
});
program
.command('uninstall')
.description('Uninstall a skill')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("uninstall")
.description("Uninstall a skill")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUninstall(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdUninstall(opts, slug, options, isInputAllowed());
});
program
.command('list')
.description('List installed skills (from lockfile)')
.command("list")
.description("List installed skills (from lockfile)")
.action(async () => {
const opts = await resolveGlobalOpts()
await cmdList(opts)
})
const opts = await resolveGlobalOpts();
await cmdList(opts);
});
program
.command('explore')
.description('Browse latest updated skills from the registry')
.command("explore")
.description("Browse latest updated skills from the registry")
.option(
'--limit <n>',
'Number of skills to show (max 200)',
"--limit <n>",
"Number of skills to show (max 200)",
(value) => Number.parseInt(value, 10),
25,
)
.option(
'--sort <order>',
'Sort by newest, downloads, rating, installs, installsAllTime, or trending',
'newest',
"--sort <order>",
"Sort by newest, downloads, rating, installs, installsAllTime, or trending",
"newest",
)
.option('--json', 'Output JSON')
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts()
const opts = await resolveGlobalOpts();
const limit =
typeof options.limit === 'number' && Number.isFinite(options.limit) ? options.limit : 25
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
})
typeof options.limit === "number" && Number.isFinite(options.limit) ? options.limit : 25;
await cmdExplore(opts, { limit, sort: options.sort, json: options.json });
});
program
.command('inspect')
.description('Fetch skill metadata and files without installing')
.argument('<slug>', 'Skill slug')
.option('--version <version>', 'Version to inspect')
.option('--tag <tag>', 'Tag to inspect (default: latest)')
.option('--versions', 'List version history (first page)')
.option('--limit <n>', 'Max versions to list (1-200)', (value) => Number.parseInt(value, 10))
.option('--files', 'List files for the selected version')
.option('--file <path>', 'Fetch raw file content (text <= 200KB)')
.option('--json', 'Output JSON')
.command("inspect")
.description("Fetch skill metadata and files without installing")
.argument("<slug>", "Skill slug")
.option("--version <version>", "Version to inspect")
.option("--tag <tag>", "Tag to inspect (default: latest)")
.option("--versions", "List version history (first page)")
.option("--limit <n>", "Max versions to list (1-200)", (value) => Number.parseInt(value, 10))
.option("--files", "List files for the selected version")
.option("--file <path>", "Fetch raw file content (text <= 200KB)")
.option("--moderation", "Fetch moderation reasons and evidence")
.option("--json", "Output JSON")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdInspect(opts, slug, options)
})
const opts = await resolveGlobalOpts();
await cmdInspect(opts, slug, options);
});
program
.command('publish')
.description('Publish skill from folder')
.argument('<path>', 'Skill folder path')
.option('--slug <slug>', 'Skill slug')
.option('--name <name>', 'Display name')
.option('--version <version>', 'Version (semver)')
.option('--fork-of <slug[@version]>', 'Mark as a fork of an existing skill')
.option('--changelog <text>', 'Changelog text')
.option('--tags <tags>', 'Comma-separated tags', 'latest')
.command("publish")
.description("Publish skill from folder")
.argument("<path>", "Skill folder path")
.option("--slug <slug>", "Skill slug")
.option("--name <name>", "Display name")
.option("--version <version>", "Version (semver)")
.option("--fork-of <slug[@version]>", "Mark as a fork of an existing skill")
.option("--changelog <text>", "Changelog text")
.option("--tags <tags>", "Comma-separated tags", "latest")
.action(async (folder, options) => {
const opts = await resolveGlobalOpts()
await cmdPublish(opts, folder, options)
})
const opts = await resolveGlobalOpts();
await cmdPublish(opts, folder, options);
});
program
.command('delete')
.description('Soft-delete a skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("delete")
.description("Soft-delete a skill (moderator/admin only)")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdDeleteSkill(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdDeleteSkill(opts, slug, options, isInputAllowed());
});
program
.command('hide')
.description('Hide a skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("hide")
.description("Hide a skill (moderator/admin only)")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdHideSkill(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdHideSkill(opts, slug, options, isInputAllowed());
});
program
.command('undelete')
.description('Restore a hidden skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("undelete")
.description("Restore a hidden skill (moderator/admin only)")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdUndeleteSkill(opts, slug, options, isInputAllowed());
});
program
.command('unhide')
.description('Unhide a skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("unhide")
.description("Unhide a skill (moderator/admin only)")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUnhideSkill(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdUnhideSkill(opts, slug, options, isInputAllowed());
});
program
.command('ban-user')
.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')
.option('--fuzzy', 'Resolve handle via fuzzy user search (admin only)')
.option('--reason <reason>', 'Ban reason (optional)')
.option('--yes', 'Skip confirmation')
.command("ban-user")
.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")
.option("--fuzzy", "Resolve handle via fuzzy user search (admin only)")
.option("--reason <reason>", "Ban reason (optional)")
.option("--yes", "Skip confirmation")
.action(async (handleOrId, options) => {
const opts = await resolveGlobalOpts()
await cmdBanUser(opts, handleOrId, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdBanUser(opts, handleOrId, options, isInputAllowed());
});
program
.command('set-role')
.description('Change a user role (admin only)')
.argument('<handleOrId>', 'User handle (default) or user id')
.argument('<role>', 'user | moderator | admin')
.option('--id', 'Treat argument as user id')
.option('--fuzzy', 'Resolve handle via fuzzy user search (admin only)')
.option('--yes', 'Skip confirmation')
.command("set-role")
.description("Change a user role (admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.argument("<role>", "user | moderator | admin")
.option("--id", "Treat argument as user id")
.option("--fuzzy", "Resolve handle via fuzzy user search (admin only)")
.option("--yes", "Skip confirmation")
.action(async (handleOrId, role, options) => {
const opts = await resolveGlobalOpts()
await cmdSetRole(opts, handleOrId, role, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdSetRole(opts, handleOrId, role, options, isInputAllowed());
});
program
.command('star')
.description('Add a skill to your highlights')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("star")
.description("Add a skill to your highlights")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdStarSkill(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdStarSkill(opts, slug, options, isInputAllowed());
});
program
.command('unstar')
.description('Remove a skill from your highlights')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.command("unstar")
.description("Remove a skill from your highlights")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUnstarSkill(opts, slug, options, isInputAllowed())
})
const opts = await resolveGlobalOpts();
await cmdUnstarSkill(opts, slug, options, isInputAllowed());
});
program
.command('sync')
.description('Scan local skills and publish new/updated ones')
.option('--root <dir...>', 'Extra scan roots (one or more)')
.option('--all', 'Upload all new/updated skills without prompting')
.option('--dry-run', 'Show what would be uploaded')
.option('--bump <type>', 'Version bump for updates (patch|minor|major)', 'patch')
.option('--changelog <text>', 'Changelog to use for updates (non-interactive)')
.option('--tags <tags>', 'Comma-separated tags', 'latest')
.option('--concurrency <n>', 'Concurrent registry checks (default: 4)', '4')
.command("sync")
.description("Scan local skills and publish new/updated ones")
.option("--root <dir...>", "Extra scan roots (one or more)")
.option("--all", "Upload all new/updated skills without prompting")
.option("--dry-run", "Show what would be uploaded")
.option("--bump <type>", "Version bump for updates (patch|minor|major)", "patch")
.option("--changelog <text>", "Changelog to use for updates (non-interactive)")
.option("--tags <tags>", "Comma-separated tags", "latest")
.option("--concurrency <n>", "Concurrent registry checks (default: 4)", "4")
.action(async (options) => {
const opts = await resolveGlobalOpts()
const bump = String(options.bump ?? 'patch') as 'patch' | 'minor' | 'major'
if (!['patch', 'minor', 'major'].includes(bump)) fail('--bump must be patch|minor|major')
const concurrencyRaw = Number(options.concurrency ?? 4)
const concurrency = Number.isFinite(concurrencyRaw) ? Math.round(concurrencyRaw) : 4
if (concurrency < 1 || concurrency > 32) fail('--concurrency must be between 1 and 32')
const opts = await resolveGlobalOpts();
const bump = String(options.bump ?? "patch") as "patch" | "minor" | "major";
if (!["patch", "minor", "major"].includes(bump)) fail("--bump must be patch|minor|major");
const concurrencyRaw = Number(options.concurrency ?? 4);
const concurrency = Number.isFinite(concurrencyRaw) ? Math.round(concurrencyRaw) : 4;
if (concurrency < 1 || concurrency > 32) fail("--concurrency must be between 1 and 32");
await cmdSync(
opts,
{
@@ -391,21 +392,21 @@ program
concurrency,
},
isInputAllowed(),
)
})
);
});
program.action(async () => {
const opts = await resolveGlobalOpts()
const cfg = await readGlobalConfig()
const opts = await resolveGlobalOpts();
const cfg = await readGlobalConfig();
if (cfg?.token) {
await cmdSync(opts, {}, isInputAllowed())
return
await cmdSync(opts, {}, isInputAllowed());
return;
}
program.outputHelp()
process.exitCode = 0
})
program.outputHelp();
process.exitCode = 0;
});
void program.parseAsync(process.argv).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
fail(message)
})
const message = error instanceof Error ? error.message : String(error);
fail(message);
});
@@ -1,25 +1,25 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiRoutes } from '../../schema/index.js'
import type { GlobalOpts } from '../types'
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GlobalOpts } from "../types";
import { ApiRoutes } from "../../schema/index.js";
const mockApiRequest = vi.fn()
const mockFetchText = vi.fn()
vi.mock('../../http.js', () => ({
const mockApiRequest = vi.fn();
const mockFetchText = vi.fn();
vi.mock("../../http.js", () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
fetchText: (...args: unknown[]) => mockFetchText(...args),
}))
}));
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
vi.mock('../registry.js', () => ({
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
vi.mock("../registry.js", () => ({
getRegistry: () => mockGetRegistry(),
}))
}));
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined)
vi.mock('../authToken.js', () => ({
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
vi.mock("../authToken.js", () => ({
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
}))
}));
const mockSpinner = {
stop: vi.fn(),
@@ -27,102 +27,139 @@ const mockSpinner = {
start: vi.fn(),
succeed: vi.fn(),
isSpinning: false,
text: '',
}
vi.mock('../ui.js', () => ({
text: "",
};
vi.mock("../ui.js", () => ({
createSpinner: vi.fn(() => mockSpinner),
fail: (message: string) => {
throw new Error(message)
throw new Error(message);
},
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}))
}));
const { cmdInspect } = await import('./inspect')
const { cmdInspect } = await import("./inspect");
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const mockWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawhub.ai',
registry: 'https://clawhub.ai',
registrySource: 'default',
}
workdir: "/work",
dir: "/work/skills",
site: "https://clawhub.ai",
registry: "https://clawhub.ai",
registrySource: "default",
};
}
afterEach(() => {
vi.clearAllMocks()
mockLog.mockClear()
mockWrite.mockClear()
})
vi.clearAllMocks();
mockLog.mockClear();
mockWrite.mockClear();
});
describe('cmdInspect', () => {
it('fetches latest version files when --files is set', async () => {
describe("cmdInspect", () => {
it("fetches latest version files when --files is set", async () => {
mockApiRequest
.mockResolvedValueOnce({
skill: {
slug: 'demo',
displayName: 'Demo',
slug: "demo",
displayName: "Demo",
summary: null,
tags: { latest: '1.2.3' },
tags: { latest: "1.2.3" },
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '1.2.3', createdAt: 3, changelog: 'init' },
latestVersion: { version: "1.2.3", createdAt: 3, changelog: "init" },
owner: null,
})
.mockResolvedValueOnce({
skill: { slug: 'demo', displayName: 'Demo' },
version: { version: '1.2.3', createdAt: 3, changelog: 'init', files: [] },
})
skill: { slug: "demo", displayName: "Demo" },
version: { version: "1.2.3", createdAt: 3, changelog: "init", files: [] },
});
await cmdInspect(makeOpts(), 'demo', { files: true })
await cmdInspect(makeOpts(), "demo", { files: true });
const firstArgs = mockApiRequest.mock.calls[0]?.[1]
const secondArgs = mockApiRequest.mock.calls[1]?.[1]
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
const firstArgs = mockApiRequest.mock.calls[0]?.[1];
const secondArgs = mockApiRequest.mock.calls[1]?.[1];
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent("demo")}`);
expect(secondArgs?.path).toBe(
`${ApiRoutes.skills}/${encodeURIComponent('demo')}/versions/${encodeURIComponent('1.2.3')}`,
)
})
`${ApiRoutes.skills}/${encodeURIComponent("demo")}/versions/${encodeURIComponent("1.2.3")}`,
);
});
it('uses tag param when fetching a file', async () => {
it("uses tag param when fetching a file", async () => {
mockApiRequest
.mockResolvedValueOnce({
skill: {
slug: 'demo',
displayName: 'Demo',
slug: "demo",
displayName: "Demo",
summary: null,
tags: { latest: '2.0.0' },
tags: { latest: "2.0.0" },
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' },
latestVersion: { version: "2.0.0", createdAt: 3, changelog: "init" },
owner: null,
})
.mockResolvedValueOnce({
skill: { slug: 'demo', displayName: 'Demo' },
version: { version: '2.0.0', createdAt: 3, changelog: 'init', files: [] },
})
mockFetchText.mockResolvedValue('content')
skill: { slug: "demo", displayName: "Demo" },
version: { version: "2.0.0", createdAt: 3, changelog: "init", files: [] },
});
mockFetchText.mockResolvedValue("content");
await cmdInspect(makeOpts(), 'demo', { file: 'SKILL.md', tag: 'latest' })
await cmdInspect(makeOpts(), "demo", { file: "SKILL.md", tag: "latest" });
const fetchArgs = mockFetchText.mock.calls[0]?.[1]
const url = new URL(String(fetchArgs?.url))
expect(url.pathname).toBe('/api/v1/skills/demo/file')
expect(url.searchParams.get('path')).toBe('SKILL.md')
expect(url.searchParams.get('tag')).toBe('latest')
expect(url.searchParams.get('version')).toBeNull()
})
const fetchArgs = mockFetchText.mock.calls[0]?.[1];
const url = new URL(String(fetchArgs?.url));
expect(url.pathname).toBe("/api/v1/skills/demo/file");
expect(url.searchParams.get("path")).toBe("SKILL.md");
expect(url.searchParams.get("tag")).toBe("latest");
expect(url.searchParams.get("version")).toBeNull();
});
it('rejects when both version and tag are provided', async () => {
it("rejects when both version and tag are provided", async () => {
await expect(
cmdInspect(makeOpts(), 'demo', { version: '1.0.0', tag: 'latest' }),
).rejects.toThrow('Use either --version or --tag')
})
})
cmdInspect(makeOpts(), "demo", { version: "1.0.0", tag: "latest" }),
).rejects.toThrow("Use either --version or --tag");
});
it("fetches moderation details when --moderation is set", async () => {
mockApiRequest
.mockResolvedValueOnce({
skill: {
slug: "demo",
displayName: "Demo",
summary: null,
tags: { latest: "2.0.0" },
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: "2.0.0", createdAt: 3, changelog: "init" },
owner: null,
})
.mockResolvedValueOnce({
moderation: {
isSuspicious: true,
isMalwareBlocked: false,
verdict: "suspicious",
reasonCodes: ["suspicious.dangerous_exec"],
updatedAt: 123,
engineVersion: "v2.0.0",
summary: "Detected: suspicious.dangerous_exec",
legacyReason: "scanner.vt.suspicious",
evidence: [],
},
});
await cmdInspect(makeOpts(), "demo", { moderation: true });
const moderationArgs = mockApiRequest.mock.calls[1]?.[1];
expect(moderationArgs?.path).toBe(
`${ApiRoutes.skills}/${encodeURIComponent("demo")}/moderation`,
);
});
});
+239 -149
View File
@@ -1,109 +1,144 @@
import { apiRequest, fetchText } from '../../http.js'
import type { GlobalOpts } from "../types.js";
import { apiRequest, fetchText } from "../../http.js";
import {
ApiRoutes,
ApiV1SkillModerationResponseSchema,
ApiV1SkillResponseSchema,
ApiV1SkillVersionListResponseSchema,
ApiV1SkillVersionResponseSchema,
} from '../../schema/index.js'
import { getOptionalAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError } from '../ui.js'
} from "../../schema/index.js";
import { getOptionalAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import { createSpinner, fail, formatError } from "../ui.js";
type InspectOptions = {
version?: string
tag?: string
versions?: boolean
limit?: number
files?: boolean
file?: string
json?: boolean
}
version?: string;
tag?: string;
versions?: boolean;
limit?: number;
files?: boolean;
file?: string;
moderation?: boolean;
json?: boolean;
};
type FileEntry = {
path: string
size: number | null
sha256: string | null
contentType: string | null
}
path: string;
size: number | null;
sha256: string | null;
contentType: string | null;
};
export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) {
const trimmed = slug.trim()
if (!trimmed) fail('Slug required')
if (options.version && options.tag) fail('Use either --version or --tag')
const trimmed = slug.trim();
if (!trimmed) fail("Slug required");
if (options.version && options.tag) fail("Use either --version or --tag");
const token = await getOptionalAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching skill')
const token = await getOptionalAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner("Fetching skill");
try {
const skillResult = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
{ method: "GET", path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
ApiV1SkillResponseSchema,
)
);
if (!skillResult.skill) {
spinner.fail('Skill not found')
return
spinner.fail("Skill not found");
return;
}
const skill = skillResult.skill
const tags = normalizeTags(skill.tags)
const latestVersion = skillResult.latestVersion?.version ?? tags.latest ?? null
const taggedVersion = options.tag ? (tags[options.tag] ?? null) : null
const skill = skillResult.skill;
const tags = normalizeTags(skill.tags);
const latestVersion = skillResult.latestVersion?.version ?? tags.latest ?? null;
const taggedVersion = options.tag ? (tags[options.tag] ?? null) : null;
if (options.tag && !taggedVersion) {
spinner.fail(`Unknown tag "${options.tag}"`)
return
spinner.fail(`Unknown tag "${options.tag}"`);
return;
}
const requestedVersion = options.version ?? taggedVersion ?? null
const requestedVersion = options.version ?? taggedVersion ?? null;
let versionResult: { version: unknown; skill: unknown } | null = null
let versionResult: { version: unknown; skill: unknown } | null = null;
if (options.files || options.file || options.version || options.tag) {
const targetVersion = requestedVersion ?? latestVersion
if (!targetVersion) fail('Could not resolve latest version')
spinner.text = `Fetching ${trimmed}@${targetVersion}`
const targetVersion = requestedVersion ?? latestVersion;
if (!targetVersion) fail("Could not resolve latest version");
spinner.text = `Fetching ${trimmed}@${targetVersion}`;
versionResult = await apiRequest(
registry,
{
method: 'GET',
method: "GET",
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
targetVersion,
)}`,
token,
},
ApiV1SkillVersionResponseSchema,
)
);
}
let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null
let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null;
if (options.versions) {
const limit = clampLimit(options.limit ?? 25, 25)
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
url.searchParams.set('limit', String(limit))
spinner.text = `Fetching versions (${limit})`
const limit = clampLimit(options.limit ?? 25, 25);
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry);
url.searchParams.set("limit", String(limit));
spinner.text = `Fetching versions (${limit})`;
versionsList = await apiRequest(
registry,
{ method: 'GET', url: url.toString(), token },
{ method: "GET", url: url.toString(), token },
ApiV1SkillVersionListResponseSchema,
)
);
}
let fileContent: string | null = null
let fileContent: string | null = null;
if (options.file) {
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
url.searchParams.set('path', options.file)
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry);
url.searchParams.set("path", options.file);
if (options.version) {
url.searchParams.set('version', options.version)
url.searchParams.set("version", options.version);
} else if (options.tag) {
url.searchParams.set('tag', options.tag)
url.searchParams.set("tag", options.tag);
} else if (latestVersion) {
url.searchParams.set('version', latestVersion)
url.searchParams.set("version", latestVersion);
}
spinner.text = `Fetching ${options.file}`
fileContent = await fetchText(registry, { url: url.toString(), token })
spinner.text = `Fetching ${options.file}`;
fileContent = await fetchText(registry, { url: url.toString(), token });
}
spinner.stop()
let moderationResult: {
moderation: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict: "clean" | "suspicious" | "malicious";
reasonCodes: string[];
updatedAt?: number | null;
engineVersion?: string | null;
summary?: string | null;
legacyReason?: string | null;
evidence: Array<{
code: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
evidence: string;
}>;
} | null;
} | null = null;
if (options.moderation) {
spinner.text = "Fetching moderation";
moderationResult = await apiRequest(
registry,
{
method: "GET",
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/moderation`,
token,
},
ApiV1SkillModerationResponseSchema,
);
}
spinner.stop();
const output = {
skill: skillResult.skill,
@@ -112,183 +147,238 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
version: versionResult?.version ?? null,
versions: versionsList?.items ?? null,
file: options.file ? { path: options.file, content: fileContent } : null,
}
moderation: moderationResult?.moderation ?? null,
};
if (options.json) {
console.log(JSON.stringify(output, null, 2))
return
console.log(JSON.stringify(output, null, 2));
return;
}
const shouldPrintMeta = !options.file || options.files || options.versions || options.version
const shouldPrintMeta = !options.file || options.files || options.versions || options.version;
if (shouldPrintMeta) {
printSkillSummary({
skill,
latestVersion: skillResult.latestVersion,
owner: skillResult.owner,
})
});
}
if (shouldPrintMeta && versionResult?.version) {
printVersionSummary(versionResult.version)
printVersionSummary(versionResult.version);
}
if (versionsList?.items && Array.isArray(versionsList.items)) {
if (versionsList.items.length === 0) {
console.log('No versions found.')
console.log("No versions found.");
} else {
console.log('Versions:')
console.log("Versions:");
for (const item of versionsList.items) {
console.log(formatVersionLine(item))
console.log(formatVersionLine(item));
}
}
}
if (versionResult?.version) {
const files = normalizeFiles((versionResult.version as { files?: unknown }).files)
const files = normalizeFiles((versionResult.version as { files?: unknown }).files);
if (options.files) {
if (files.length === 0) {
console.log('No files found.')
console.log("No files found.");
} else {
console.log('Files:')
console.log("Files:");
for (const file of files) {
console.log(formatFileLine(file))
console.log(formatFileLine(file));
}
}
}
}
if (options.file && fileContent !== null) {
if (shouldPrintMeta) console.log(`\n${options.file}:\n`)
process.stdout.write(fileContent)
if (!fileContent.endsWith('\n')) process.stdout.write('\n')
if (shouldPrintMeta) console.log(`\n${options.file}:\n`);
process.stdout.write(fileContent);
if (!fileContent.endsWith("\n")) process.stdout.write("\n");
}
if (options.moderation) {
printModeration(moderationResult?.moderation ?? null);
}
} catch (error) {
spinner.fail(formatError(error))
throw error
spinner.fail(formatError(error));
throw error;
}
}
function printModeration(
moderation: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict: "clean" | "suspicious" | "malicious";
reasonCodes: string[];
updatedAt?: number | null;
engineVersion?: string | null;
summary?: string | null;
legacyReason?: string | null;
evidence: Array<{
code: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
evidence: string;
}>;
} | null,
) {
if (!moderation) {
console.log("Moderation: unavailable");
return;
}
console.log("Moderation:");
console.log(`Verdict: ${moderation.verdict}`);
console.log(`Suspicious: ${moderation.isSuspicious ? "yes" : "no"}`);
console.log(`Malware blocked: ${moderation.isMalwareBlocked ? "yes" : "no"}`);
if (moderation.summary) console.log(`Summary: ${moderation.summary}`);
if (moderation.engineVersion) console.log(`Engine: ${moderation.engineVersion}`);
if (typeof moderation.updatedAt === "number") {
console.log(`Updated: ${formatTimestamp(moderation.updatedAt)}`);
}
if (moderation.reasonCodes.length > 0) {
console.log(`Reason codes: ${moderation.reasonCodes.join(", ")}`);
}
if (moderation.legacyReason) {
console.log(`Legacy reason: ${moderation.legacyReason}`);
}
if (moderation.evidence.length > 0) {
console.log("Evidence:");
for (const finding of moderation.evidence.slice(0, 10)) {
const snippet = finding.evidence?.trim() ? ` :: ${truncate(finding.evidence, 100)}` : "";
console.log(
`- [${finding.severity}] ${finding.code} ${finding.file}:${finding.line} ${finding.message}${snippet}`,
);
}
}
}
function printSkillSummary(result: {
skill: {
slug: string
displayName: string
summary?: string | null
tags?: unknown
stats?: unknown
createdAt: number
updatedAt: number
}
latestVersion?: { version: string; createdAt: number; changelog: string } | null
owner?: { handle?: string | null; displayName?: string | null; image?: string | null } | null
slug: string;
displayName: string;
summary?: string | null;
tags?: unknown;
stats?: unknown;
createdAt: number;
updatedAt: number;
};
latestVersion?: { version: string; createdAt: number; changelog: string } | null;
owner?: { handle?: string | null; displayName?: string | null; image?: string | null } | null;
}) {
const { skill } = result
console.log(`${skill.slug} ${skill.displayName}`)
if (skill.summary) console.log(`Summary: ${skill.summary}`)
const owner = result.owner?.handle || result.owner?.displayName
if (owner) console.log(`Owner: ${owner}`)
console.log(`Created: ${formatTimestamp(skill.createdAt)}`)
console.log(`Updated: ${formatTimestamp(skill.updatedAt)}`)
const { skill } = result;
console.log(`${skill.slug} ${skill.displayName}`);
if (skill.summary) console.log(`Summary: ${skill.summary}`);
const owner = result.owner?.handle || result.owner?.displayName;
if (owner) console.log(`Owner: ${owner}`);
console.log(`Created: ${formatTimestamp(skill.createdAt)}`);
console.log(`Updated: ${formatTimestamp(skill.updatedAt)}`);
if (result.latestVersion?.version) {
console.log(`Latest: ${result.latestVersion.version}`)
console.log(`Latest: ${result.latestVersion.version}`);
}
const tags = normalizeTags(skill.tags)
const tagEntries = Object.entries(tags)
const tags = normalizeTags(skill.tags);
const tagEntries = Object.entries(tags);
if (tagEntries.length > 0) {
console.log(`Tags: ${tagEntries.map(([tag, version]) => `${tag}=${version}`).join(', ')}`)
console.log(`Tags: ${tagEntries.map(([tag, version]) => `${tag}=${version}`).join(", ")}`);
}
}
function printVersionSummary(version: unknown) {
if (!version || typeof version !== 'object') return
const entry = version as { version?: unknown; createdAt?: unknown; changelog?: unknown }
const value = typeof entry.version === 'string' ? entry.version : null
if (!value) return
console.log(`Selected: ${value}`)
if (typeof entry.createdAt === 'number') {
console.log(`Selected At: ${formatTimestamp(entry.createdAt)}`)
if (!version || typeof version !== "object") return;
const entry = version as { version?: unknown; createdAt?: unknown; changelog?: unknown };
const value = typeof entry.version === "string" ? entry.version : null;
if (!value) return;
console.log(`Selected: ${value}`);
if (typeof entry.createdAt === "number") {
console.log(`Selected At: ${formatTimestamp(entry.createdAt)}`);
}
if (typeof entry.changelog === 'string' && entry.changelog.trim()) {
console.log(`Changelog: ${truncate(entry.changelog, 120)}`)
if (typeof entry.changelog === "string" && entry.changelog.trim()) {
console.log(`Changelog: ${truncate(entry.changelog, 120)}`);
}
}
function normalizeTags(tags: unknown): Record<string, string> {
if (!tags || typeof tags !== 'object') return {}
const entries = Object.entries(tags as Record<string, unknown>)
const resolved: Record<string, string> = {}
if (!tags || typeof tags !== "object") return {};
const entries = Object.entries(tags as Record<string, unknown>);
const resolved: Record<string, string> = {};
for (const [tag, version] of entries) {
if (typeof version === 'string') resolved[tag] = version
if (typeof version === "string") resolved[tag] = version;
}
return resolved
return resolved;
}
function normalizeFiles(files: unknown): FileEntry[] {
if (!Array.isArray(files)) return []
if (!Array.isArray(files)) return [];
return files
.map((file) => {
if (!file || typeof file !== 'object') return null
if (!file || typeof file !== "object") return null;
const entry = file as {
path?: unknown
size?: unknown
sha256?: unknown
contentType?: unknown
}
if (typeof entry.path !== 'string') return null
const size = typeof entry.size === 'number' ? entry.size : Number(entry.size)
const sha256 = typeof entry.sha256 === 'string' ? entry.sha256 : null
const contentType = typeof entry.contentType === 'string' ? entry.contentType : null
path?: unknown;
size?: unknown;
sha256?: unknown;
contentType?: unknown;
};
if (typeof entry.path !== "string") return null;
const size = typeof entry.size === "number" ? entry.size : Number(entry.size);
const sha256 = typeof entry.sha256 === "string" ? entry.sha256 : null;
const contentType = typeof entry.contentType === "string" ? entry.contentType : null;
return {
path: entry.path,
size: Number.isFinite(size) ? size : null,
sha256,
contentType,
}
};
})
.filter((entry): entry is FileEntry => Boolean(entry))
.filter((entry): entry is FileEntry => Boolean(entry));
}
function formatVersionLine(item: unknown) {
if (!item || typeof item !== 'object') return '-'
const entry = item as { version?: unknown; createdAt?: unknown; changelog?: unknown }
const version = typeof entry.version === 'string' ? entry.version : '?'
if (!item || typeof item !== "object") return "-";
const entry = item as { version?: unknown; createdAt?: unknown; changelog?: unknown };
const version = typeof entry.version === "string" ? entry.version : "?";
const createdAt =
typeof entry.createdAt === 'number' ? formatTimestamp(entry.createdAt) : 'unknown'
const changelog = typeof entry.changelog === 'string' ? entry.changelog : ''
const snippet = changelog ? ` ${truncate(changelog, 80)}` : ''
return `${version} ${createdAt}${snippet}`
typeof entry.createdAt === "number" ? formatTimestamp(entry.createdAt) : "unknown";
const changelog = typeof entry.changelog === "string" ? entry.changelog : "";
const snippet = changelog ? ` ${truncate(changelog, 80)}` : "";
return `${version} ${createdAt}${snippet}`;
}
function formatFileLine(file: FileEntry) {
const size = file.size === null ? '?' : formatBytes(file.size)
const sha = file.sha256 ?? '?'
const type = file.contentType ? ` ${file.contentType}` : ''
return `${file.path} ${size} ${sha}${type}`
const size = file.size === null ? "?" : formatBytes(file.size);
const sha = file.sha256 ?? "?";
const type = file.contentType ? ` ${file.contentType}` : "";
return `${file.path} ${size} ${sha}${type}`;
}
function formatTimestamp(timestamp: number) {
if (!Number.isFinite(timestamp)) return 'unknown'
return new Date(timestamp).toISOString()
if (!Number.isFinite(timestamp)) return "unknown";
return new Date(timestamp).toISOString();
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes)) return '?'
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let index = 0
if (!Number.isFinite(bytes)) return "?";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
value /= 1024;
index += 1;
}
const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10
return `${rounded}${units[index]}`
const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded}${units[index]}`;
}
function clampLimit(limit: number, fallback: number) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, Math.round(limit)), 200)
if (!Number.isFinite(limit)) return fallback;
return Math.min(Math.max(1, Math.round(limit)), 200);
}
function truncate(str: string, maxLen: number) {
if (str.length <= maxLen) return str
return `${str.slice(0, maxLen - 3)}...`
if (str.length <= maxLen) return str;
return `${str.slice(0, maxLen - 3)}...`;
}
+327 -226
View File
@@ -1,14 +1,16 @@
import { mkdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import semver from 'semver'
import { apiRequest, downloadZip } from '../../http.js'
import { mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import semver from "semver";
import type { GlobalOpts, ResolveResult } from "../types.js";
import { apiRequest, downloadZip } from "../../http.js";
import {
ApiRoutes,
ApiV1SearchResponseSchema,
ApiV1SkillListResponseSchema,
ApiV1SkillResolveResponseSchema,
ApiV1SkillResponseSchema,
} from '../../schema/index.js'
} from "../../schema/index.js";
import { scanLocalSkillFiles } from "../../security/staticScan.js";
import {
extractZipToDir,
hashSkillFiles,
@@ -17,53 +19,124 @@ import {
readSkillOrigin,
writeLockfile,
writeSkillOrigin,
} from '../../skills.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts, ResolveResult } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
import { getOptionalAuthToken } from '../authToken.js'
} from "../../skills.js";
import { getOptionalAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
function normalizeSkillSlugOrFail(raw: string) {
const slug = raw.trim()
if (!slug) fail('Slug required')
const slug = raw.trim();
if (!slug) fail("Slug required");
// Safety: never allow path traversal or nested paths to become filesystem operations.
if (slug.includes('/') || slug.includes('\\') || slug.includes('..')) {
fail(`Invalid slug: ${slug}`)
if (slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
fail(`Invalid slug: ${slug}`);
}
return slug
return slug;
}
function isSafeSkillSlug(slug: string) {
return Boolean(slug) && !slug.includes('/') && !slug.includes('\\') && !slug.includes('..')
return Boolean(slug) && !slug.includes("/") && !slug.includes("\\") && !slug.includes("..");
}
type SkillModeration =
| {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict?: "clean" | "suspicious" | "malicious";
reasonCodes?: string[];
updatedAt?: number | null;
engineVersion?: string | null;
summary?: string | null;
}
| null
| undefined;
function formatModerationContext(moderation: SkillModeration) {
if (!moderation) return "";
const lines: string[] = [];
if (moderation.summary) lines.push(` Summary: ${moderation.summary}`);
const reasonCodes = moderation.reasonCodes ?? [];
if (reasonCodes.length > 0) {
lines.push(` Reason codes: ${reasonCodes.slice(0, 5).join(", ")}`);
}
if (moderation.engineVersion) lines.push(` Engine: ${moderation.engineVersion}`);
return lines.length > 0 ? `\n${lines.join("\n")}\n` : "\n";
}
async function verifyLocalModeration(params: {
slug: string;
targetDir: string;
moderation: SkillModeration;
force: boolean;
promptLabel: string;
allowPrompt: boolean;
}) {
const moderation = params.moderation;
if (!moderation) return true;
if (!Array.isArray(moderation.reasonCodes) || moderation.reasonCodes.length === 0) {
return true;
}
const serverCodes = [...(moderation.reasonCodes ?? [])].sort((a, b) => a.localeCompare(b));
const localFiles = await listTextFiles(params.targetDir);
const localScan = scanLocalSkillFiles(localFiles);
const localCodes = [...localScan.reasonCodes].sort((a, b) => a.localeCompare(b));
const same =
serverCodes.length === localCodes.length &&
serverCodes.every((value, idx) => value === localCodes[idx]);
if (same) return true;
const serverOnly = serverCodes.filter((code) => !localCodes.includes(code));
const localOnly = localCodes.filter((code) => !serverCodes.includes(code));
console.log(
`\n⚠️ Verification mismatch for "${params.slug}".` +
"\n Local static scan differs from published moderation reason codes.",
);
if (serverOnly.length > 0) console.log(` Server-only: ${serverOnly.join(", ")}`);
if (localOnly.length > 0) console.log(` Local-only: ${localOnly.join(", ")}`);
if (localScan.findings.length > 0) {
const hints = localScan.findings
.slice(0, 3)
.map((finding) => `${finding.code} (${finding.file}:${finding.line})`)
.join("; ");
console.log(` Local evidence: ${hints}`);
}
if (params.force) return true;
if (params.allowPrompt) {
const confirm = await promptConfirm(params.promptLabel);
return confirm;
}
fail("Use --force in non-interactive mode when moderation verification mismatches");
}
export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) {
if (!query) fail('Query required')
if (!query) fail("Query required");
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Searching')
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner("Searching");
try {
const url = new URL(ApiRoutes.search, registry)
url.searchParams.set('q', query)
if (typeof limit === 'number' && Number.isFinite(limit)) {
url.searchParams.set('limit', String(limit))
const url = new URL(ApiRoutes.search, registry);
url.searchParams.set("q", query);
if (typeof limit === "number" && Number.isFinite(limit)) {
url.searchParams.set("limit", String(limit));
}
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
{ method: "GET", url: url.toString() },
ApiV1SearchResponseSchema,
)
);
spinner.stop()
spinner.stop();
for (const entry of result.results) {
const slug = entry.slug ?? 'unknown'
const name = entry.displayName ?? slug
const version = entry.version ? ` v${entry.version}` : ''
console.log(`${slug}${version} ${name} (${entry.score.toFixed(3)})`)
const slug = entry.slug ?? "unknown";
const name = entry.displayName ?? slug;
const version = entry.version ? ` v${entry.version}` : "";
console.log(`${slug}${version} ${name} (${entry.score.toFixed(3)})`);
}
} catch (error) {
spinner.fail(formatError(error))
throw error
spinner.fail(formatError(error));
throw error;
}
}
@@ -73,57 +146,70 @@ export async function cmdInstall(
versionFlag?: string,
force = false,
) {
const trimmed = normalizeSkillSlugOrFail(slug)
const trimmed = normalizeSkillSlugOrFail(slug);
const token = await getOptionalAuthToken()
const token = await getOptionalAuthToken();
const registry = await getRegistry(opts, { cache: true })
await mkdir(opts.dir, { recursive: true })
const target = join(opts.dir, trimmed)
const registry = await getRegistry(opts, { cache: true });
await mkdir(opts.dir, { recursive: true });
const target = join(opts.dir, trimmed);
if (!force) {
const exists = await fileExists(target)
if (exists) fail(`Already installed: ${target} (use --force)`)
const exists = await fileExists(target);
if (exists) fail(`Already installed: ${target} (use --force)`);
} else {
await rm(target, { recursive: true, force: true })
await rm(target, { recursive: true, force: true });
}
const spinner = createSpinner(`Resolving ${trimmed}`)
const spinner = createSpinner(`Resolving ${trimmed}`);
try {
// Fetch skill metadata including moderation status
const skillMeta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
{ method: "GET", path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
ApiV1SkillResponseSchema,
)
);
// Check moderation status before proceeding
if (skillMeta.moderation?.isMalwareBlocked) {
spinner.fail(`Blocked: ${trimmed} is flagged as malicious`)
fail('This skill has been flagged as malware and cannot be installed.')
spinner.fail(`Blocked: ${trimmed} is flagged as malicious`);
fail("This skill has been flagged as malware and cannot be installed.");
}
if (skillMeta.moderation?.isSuspicious && !force) {
spinner.stop()
spinner.stop();
console.log(
`\n⚠️ Warning: "${trimmed}" is flagged as suspicious by VirusTotal Code Insight.\n` +
' This skill may contain risky patterns (crypto keys, external APIs, eval, etc.)\n' +
' Review the skill code before use.\n',
)
" This skill may contain risky patterns (crypto keys, external APIs, eval, etc.)" +
formatModerationContext(skillMeta.moderation) +
" Review the skill code before use.\n",
);
if (isInteractive()) {
const confirm = await promptConfirm('Install anyway?')
if (!confirm) fail('Installation cancelled')
spinner.start(`Resolving ${trimmed}`)
const confirm = await promptConfirm("Install anyway?");
if (!confirm) fail("Installation cancelled");
spinner.start(`Resolving ${trimmed}`);
} else {
fail('Use --force to install suspicious skills in non-interactive mode')
fail("Use --force to install suspicious skills in non-interactive mode");
}
}
const resolvedVersion = versionFlag ?? skillMeta.latestVersion?.version ?? null
if (!resolvedVersion) fail('Could not resolve latest version')
const resolvedVersion = versionFlag ?? skillMeta.latestVersion?.version ?? null;
if (!resolvedVersion) fail("Could not resolve latest version");
spinner.text = `Downloading ${trimmed}@${resolvedVersion}`
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion, token })
await extractZipToDir(zip, target)
spinner.text = `Downloading ${trimmed}@${resolvedVersion}`;
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion, token });
await extractZipToDir(zip, target);
const verified = await verifyLocalModeration({
slug: trimmed,
targetDir: target,
moderation: skillMeta.moderation,
force,
promptLabel: "Install despite moderation verification mismatch?",
allowPrompt: isInteractive(),
});
if (!verified) {
await rm(target, { recursive: true, force: true });
fail("Installation cancelled");
}
await writeSkillOrigin(target, {
version: 1,
@@ -131,18 +217,18 @@ export async function cmdInstall(
slug: trimmed,
installedVersion: resolvedVersion,
installedAt: Date.now(),
})
});
const lock = await readLockfile(opts.workdir)
const lock = await readLockfile(opts.workdir);
lock.skills[trimmed] = {
version: resolvedVersion,
installedAt: Date.now(),
}
await writeLockfile(opts.workdir, lock)
spinner.succeed(`OK. Installed ${trimmed} -> ${target}`)
};
await writeLockfile(opts.workdir, lock);
spinner.succeed(`OK. Installed ${trimmed} -> ${target}`);
} catch (error) {
spinner.fail(formatError(error))
throw error
spinner.fail(formatError(error));
throw error;
}
}
@@ -152,159 +238,174 @@ export async function cmdUpdate(
options: { all?: boolean; version?: string; force?: boolean },
inputAllowed: boolean,
) {
const slug = slugArg ? normalizeSkillSlugOrFail(slugArg) : undefined
const all = Boolean(options.all)
if (!slug && !all) fail('Provide <slug> or --all')
if (slug && all) fail('Use either <slug> or --all')
if (options.version && !slug) fail('--version requires a single <slug>')
if (options.version && !semver.valid(options.version)) fail('--version must be valid semver')
const allowPrompt = isInteractive() && inputAllowed
const slug = slugArg ? normalizeSkillSlugOrFail(slugArg) : undefined;
const all = Boolean(options.all);
if (!slug && !all) fail("Provide <slug> or --all");
if (slug && all) fail("Use either <slug> or --all");
if (options.version && !slug) fail("--version requires a single <slug>");
if (options.version && !semver.valid(options.version)) fail("--version must be valid semver");
const allowPrompt = isInteractive() && inputAllowed;
const token = await getOptionalAuthToken()
const token = await getOptionalAuthToken();
const registry = await getRegistry(opts, { cache: true })
const lock = await readLockfile(opts.workdir)
const slugs = slug ? [slug] : Object.keys(lock.skills).filter(isSafeSkillSlug)
const registry = await getRegistry(opts, { cache: true });
const lock = await readLockfile(opts.workdir);
const slugs = slug ? [slug] : Object.keys(lock.skills).filter(isSafeSkillSlug);
if (slugs.length === 0) {
console.log('No installed skills.')
return
console.log("No installed skills.");
return;
}
for (const entry of slugs) {
const spinner = createSpinner(`Checking ${entry}`)
const spinner = createSpinner(`Checking ${entry}`);
try {
const target = join(opts.dir, entry)
const exists = await fileExists(target)
const target = join(opts.dir, entry);
const exists = await fileExists(target);
// Always fetch skill metadata to check moderation status
const skillMeta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}`, token },
{ method: "GET", path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}`, token },
ApiV1SkillResponseSchema,
)
);
// Check moderation status before proceeding
if (skillMeta.moderation?.isMalwareBlocked) {
spinner.fail(`${entry}: blocked as malicious`)
console.log(' This skill has been flagged as malware and cannot be updated.')
continue
spinner.fail(`${entry}: blocked as malicious`);
console.log(" This skill has been flagged as malware and cannot be updated.");
continue;
}
if (skillMeta.moderation?.isSuspicious && !options.force) {
spinner.stop()
spinner.stop();
console.log(
`\n⚠️ Warning: "${entry}" is flagged as suspicious by VirusTotal Code Insight.\n` +
' This skill may contain risky patterns (crypto keys, external APIs, eval, etc.)\n',
)
" This skill may contain risky patterns (crypto keys, external APIs, eval, etc.)" +
formatModerationContext(skillMeta.moderation) +
"\n",
);
if (allowPrompt) {
const confirm = await promptConfirm('Update anyway?')
const confirm = await promptConfirm("Update anyway?");
if (!confirm) {
console.log(`${entry}: skipped`)
continue
console.log(`${entry}: skipped`);
continue;
}
spinner.start(`Checking ${entry}`)
spinner.start(`Checking ${entry}`);
} else {
console.log(`${entry}: skipped (use --force to update suspicious skills)`)
continue
console.log(`${entry}: skipped (use --force to update suspicious skills)`);
continue;
}
}
let localFingerprint: string | null = null
let localFingerprint: string | null = null;
if (exists) {
const filesOnDisk = await listTextFiles(target)
const filesOnDisk = await listTextFiles(target);
if (filesOnDisk.length > 0) {
const hashed = hashSkillFiles(filesOnDisk)
localFingerprint = hashed.fingerprint
const hashed = hashSkillFiles(filesOnDisk);
localFingerprint = hashed.fingerprint;
}
}
let resolveResult: ResolveResult
let resolveResult: ResolveResult;
if (localFingerprint) {
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token)
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token);
} else {
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null }
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null };
}
const latest = resolveResult.latestVersion?.version ?? null
const matched = resolveResult.match?.version ?? null
const latest = resolveResult.latestVersion?.version ?? null;
const matched = resolveResult.match?.version ?? null;
if (matched && lock.skills[entry]?.version !== matched) {
lock.skills[entry] = {
version: matched,
installedAt: lock.skills[entry]?.installedAt ?? Date.now(),
}
};
}
if (!latest) {
spinner.fail(`${entry}: not found`)
continue
spinner.fail(`${entry}: not found`);
continue;
}
if (!matched && localFingerprint && !options.force) {
spinner.stop()
spinner.stop();
if (!allowPrompt) {
console.log(`${entry}: local changes (no match). Use --force to overwrite.`)
continue
console.log(`${entry}: local changes (no match). Use --force to overwrite.`);
continue;
}
const confirm = await promptConfirm(
`${entry}: local changes (no match). Overwrite with ${options.version ?? latest}?`,
)
);
if (!confirm) {
console.log(`${entry}: skipped`)
continue
console.log(`${entry}: skipped`);
continue;
}
spinner.start(`Updating ${entry} -> ${options.version ?? latest}`)
spinner.start(`Updating ${entry} -> ${options.version ?? latest}`);
}
const targetVersion = options.version ?? latest
const targetVersion = options.version ?? latest;
if (options.version) {
if (matched && matched === targetVersion) {
spinner.succeed(`${entry}: already at ${matched}`)
continue
spinner.succeed(`${entry}: already at ${matched}`);
continue;
}
} else if (matched && semver.valid(matched) && semver.gte(matched, targetVersion)) {
spinner.succeed(`${entry}: up to date (${matched})`)
continue
spinner.succeed(`${entry}: up to date (${matched})`);
continue;
}
if (spinner.isSpinning) {
spinner.text = `Updating ${entry} -> ${targetVersion}`
spinner.text = `Updating ${entry} -> ${targetVersion}`;
} else {
spinner.start(`Updating ${entry} -> ${targetVersion}`)
spinner.start(`Updating ${entry} -> ${targetVersion}`);
}
await rm(target, { recursive: true, force: true });
const zip = await downloadZip(registry, { slug: entry, version: targetVersion, token });
await extractZipToDir(zip, target);
const verified = await verifyLocalModeration({
slug: entry,
targetDir: target,
moderation: skillMeta.moderation,
force: Boolean(options.force),
promptLabel: `${entry}: continue despite moderation verification mismatch?`,
allowPrompt,
});
if (!verified) {
await rm(target, { recursive: true, force: true });
console.log(`${entry}: skipped`);
continue;
}
await rm(target, { recursive: true, force: true })
const zip = await downloadZip(registry, { slug: entry, version: targetVersion, token })
await extractZipToDir(zip, target)
const existingOrigin = await readSkillOrigin(target)
const existingOrigin = await readSkillOrigin(target);
await writeSkillOrigin(target, {
version: 1,
registry: existingOrigin?.registry ?? registry,
slug: existingOrigin?.slug ?? entry,
installedVersion: targetVersion,
installedAt: existingOrigin?.installedAt ?? Date.now(),
})
});
lock.skills[entry] = { version: targetVersion, installedAt: Date.now() }
spinner.succeed(`${entry}: updated -> ${targetVersion}`)
lock.skills[entry] = { version: targetVersion, installedAt: Date.now() };
spinner.succeed(`${entry}: updated -> ${targetVersion}`);
} catch (error) {
spinner.fail(formatError(error))
throw error
spinner.fail(formatError(error));
throw error;
}
}
await writeLockfile(opts.workdir, lock)
await writeLockfile(opts.workdir, lock);
}
export async function cmdList(opts: GlobalOpts) {
const lock = await readLockfile(opts.workdir)
const entries = Object.entries(lock.skills)
const lock = await readLockfile(opts.workdir);
const entries = Object.entries(lock.skills);
if (entries.length === 0) {
console.log('No installed skills.')
return
console.log("No installed skills.");
return;
}
for (const [slug, entry] of entries) {
console.log(`${slug} ${entry.version ?? 'latest'}`)
console.log(`${slug} ${entry.version ?? "latest"}`);
}
}
@@ -314,172 +415,172 @@ export async function cmdUninstall(
options: { yes?: boolean } = {},
inputAllowed: boolean,
) {
const trimmed = normalizeSkillSlugOrFail(slug)
const trimmed = normalizeSkillSlugOrFail(slug);
const lock = await readLockfile(opts.workdir)
const lock = await readLockfile(opts.workdir);
if (!lock.skills[trimmed]) {
fail(`Not installed: ${trimmed}`)
fail(`Not installed: ${trimmed}`);
}
const allowPrompt = isInteractive() && inputAllowed
const allowPrompt = isInteractive() && inputAllowed;
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const confirm = await promptConfirm(`Uninstall ${trimmed}?`)
if (!allowPrompt) fail("Pass --yes (no input)");
const confirm = await promptConfirm(`Uninstall ${trimmed}?`);
if (!confirm) {
console.log('Cancelled.')
return
console.log("Cancelled.");
return;
}
}
const spinner = createSpinner(`Uninstalling ${trimmed}`)
const spinner = createSpinner(`Uninstalling ${trimmed}`);
try {
const target = join(opts.dir, trimmed)
const target = join(opts.dir, trimmed);
await rm(target, { recursive: true, force: true })
await rm(target, { recursive: true, force: true });
delete lock.skills[trimmed]
await writeLockfile(opts.workdir, lock)
delete lock.skills[trimmed];
await writeLockfile(opts.workdir, lock);
spinner.succeed(`Uninstalled ${trimmed}`)
spinner.succeed(`Uninstalled ${trimmed}`);
} catch (error) {
spinner.fail(formatError(error))
throw error
spinner.fail(formatError(error));
throw error;
}
}
type ExploreSort = 'newest' | 'downloads' | 'rating' | 'installs' | 'installsAllTime' | 'trending'
type ExploreSort = "newest" | "downloads" | "rating" | "installs" | "installsAllTime" | "trending";
type ApiExploreSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
| "updated"
| "downloads"
| "stars"
| "installsCurrent"
| "installsAllTime"
| "trending";
export async function cmdExplore(
opts: GlobalOpts,
options: { limit?: number; sort?: string; json?: boolean } = {},
) {
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching latest skills')
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner("Fetching latest skills");
try {
const url = new URL(ApiRoutes.skills, registry)
const boundedLimit = clampLimit(options.limit ?? 25)
const { apiSort } = resolveExploreSort(options.sort)
url.searchParams.set('limit', String(boundedLimit))
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
const url = new URL(ApiRoutes.skills, registry);
const boundedLimit = clampLimit(options.limit ?? 25);
const { apiSort } = resolveExploreSort(options.sort);
url.searchParams.set("limit", String(boundedLimit));
if (apiSort !== "updated") url.searchParams.set("sort", apiSort);
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
{ method: "GET", url: url.toString() },
ApiV1SkillListResponseSchema,
)
);
spinner.stop()
spinner.stop();
if (options.json) {
console.log(JSON.stringify(result, null, 2))
return
console.log(JSON.stringify(result, null, 2));
return;
}
if (result.items.length === 0) {
console.log('No skills found.')
return
console.log("No skills found.");
return;
}
for (const item of result.items) {
console.log(formatExploreLine(item))
console.log(formatExploreLine(item));
}
} catch (error) {
spinner.fail(formatError(error))
throw error
spinner.fail(formatError(error));
throw error;
}
}
export function formatExploreLine(item: {
slug: string
summary?: string | null
updatedAt: number
latestVersion?: { version: string } | null
slug: string;
summary?: string | null;
updatedAt: number;
latestVersion?: { version: string } | null;
}) {
const version = item.latestVersion?.version ?? '?'
const age = formatRelativeTime(item.updatedAt)
const summary = item.summary ? ` ${truncate(item.summary, 50)}` : ''
return `${item.slug} v${version} ${age}${summary}`
const version = item.latestVersion?.version ?? "?";
const age = formatRelativeTime(item.updatedAt);
const summary = item.summary ? ` ${truncate(item.summary, 50)}` : "";
return `${item.slug} v${version} ${age}${summary}`;
}
export function clampLimit(limit: number, fallback = 25) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, limit), 200)
if (!Number.isFinite(limit)) return fallback;
return Math.min(Math.max(1, limit), 200);
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30)
return `${months}mo ago`
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return 'just now'
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return "just now";
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str
return `${str.slice(0, maxLen - 1)}`
if (str.length <= maxLen) return str;
return `${str.slice(0, maxLen - 1)}`;
}
function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExploreSort } {
const normalized = raw?.trim().toLowerCase()
if (!normalized || normalized === 'newest' || normalized === 'updated') {
return { sort: 'newest', apiSort: 'updated' }
const normalized = raw?.trim().toLowerCase();
if (!normalized || normalized === "newest" || normalized === "updated") {
return { sort: "newest", apiSort: "updated" };
}
if (normalized === 'downloads' || normalized === 'download') {
return { sort: 'downloads', apiSort: 'downloads' }
if (normalized === "downloads" || normalized === "download") {
return { sort: "downloads", apiSort: "downloads" };
}
if (normalized === 'rating' || normalized === 'stars' || normalized === 'star') {
return { sort: 'rating', apiSort: 'stars' }
if (normalized === "rating" || normalized === "stars" || normalized === "star") {
return { sort: "rating", apiSort: "stars" };
}
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current' ||
normalized === 'current'
normalized === "installs" ||
normalized === "install" ||
normalized === "installscurrent" ||
normalized === "installs-current" ||
normalized === "current"
) {
return { sort: 'installs', apiSort: 'installsCurrent' }
return { sort: "installs", apiSort: "installsCurrent" };
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return { sort: 'installsAllTime', apiSort: 'installsAllTime' }
if (normalized === "installsalltime" || normalized === "installs-all-time") {
return { sort: "installsAllTime", apiSort: "installsAllTime" };
}
if (normalized === 'trending') {
return { sort: 'trending', apiSort: 'trending' }
if (normalized === "trending") {
return { sort: "trending", apiSort: "trending" };
}
fail(
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
)
);
}
async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
url.searchParams.set('hash', hash)
const url = new URL(ApiRoutes.resolve, registry);
url.searchParams.set("slug", slug);
url.searchParams.set("hash", hash);
return apiRequest(
registry,
{ method: 'GET', url: url.toString(), token },
{ method: "GET", url: url.toString(), token },
ApiV1SkillResolveResponseSchema,
)
);
}
async function fileExists(path: string) {
try {
await stat(path)
return true
await stat(path);
return true;
} catch {
return false
return false;
}
}
+204 -178
View File
@@ -1,288 +1,314 @@
import { type inferred, type } from 'arktype'
import { type inferred, type } from "arktype";
export const GlobalConfigSchema = type({
registry: 'string',
token: 'string?',
})
export type GlobalConfig = (typeof GlobalConfigSchema)[inferred]
registry: "string",
token: "string?",
});
export type GlobalConfig = (typeof GlobalConfigSchema)[inferred];
export const WellKnownConfigSchema = type({
apiBase: 'string',
authBase: 'string?',
minCliVersion: 'string?',
apiBase: "string",
authBase: "string?",
minCliVersion: "string?",
}).or({
registry: 'string',
authBase: 'string?',
minCliVersion: 'string?',
})
export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred]
registry: "string",
authBase: "string?",
minCliVersion: "string?",
});
export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred];
export const LockfileSchema = type({
version: '1',
version: "1",
skills: {
'[string]': {
version: 'string|null',
installedAt: 'number',
"[string]": {
version: "string|null",
installedAt: "number",
},
},
})
export type Lockfile = (typeof LockfileSchema)[inferred]
});
export type Lockfile = (typeof LockfileSchema)[inferred];
export const ApiCliWhoamiResponseSchema = type({
user: {
handle: 'string|null',
handle: "string|null",
},
})
});
export const ApiSearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
version: 'string|null?',
score: 'number',
slug: "string?",
displayName: "string?",
version: "string|null?",
score: "number",
}).array(),
})
});
export const ApiSkillMetaResponseSchema = type({
latestVersion: type({
version: 'string',
version: "string",
}).optional(),
skill: 'unknown|null?',
})
skill: "unknown|null?",
});
export const ApiCliUploadUrlResponseSchema = type({
uploadUrl: 'string',
})
uploadUrl: "string",
});
export const ApiUploadFileResponseSchema = type({
storageId: 'string',
})
storageId: "string",
});
export const CliPublishFileSchema = type({
path: 'string',
size: 'number',
storageId: 'string',
sha256: 'string',
contentType: 'string?',
})
export type CliPublishFile = (typeof CliPublishFileSchema)[inferred]
path: "string",
size: "number",
storageId: "string",
sha256: "string",
contentType: "string?",
});
export type CliPublishFile = (typeof CliPublishFileSchema)[inferred];
export const CliPublishRequestSchema = type({
slug: 'string',
displayName: 'string',
version: 'string',
changelog: 'string',
tags: 'string[]?',
slug: "string",
displayName: "string",
version: "string",
changelog: "string",
tags: "string[]?",
forkOf: type({
slug: 'string',
version: 'string?',
slug: "string",
version: "string?",
}).optional(),
files: CliPublishFileSchema.array(),
})
export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred]
});
export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred];
export const ApiCliPublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
})
ok: "true",
skillId: "string",
versionId: "string",
});
export const CliSkillDeleteRequestSchema = type({
slug: 'string',
})
export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred]
slug: "string",
});
export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred];
export const ApiCliSkillDeleteResponseSchema = type({
ok: 'true',
})
ok: "true",
});
export const ApiSkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
})
match: type({ version: "string" }).or("null"),
latestVersion: type({ version: "string" }).or("null"),
});
export const CliTelemetrySyncRequestSchema = type({
roots: type({
rootId: 'string',
label: 'string',
rootId: "string",
label: "string",
skills: type({
slug: 'string',
version: 'string|null?',
slug: "string",
version: "string|null?",
}).array(),
}).array(),
})
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred]
});
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
export const ApiCliTelemetrySyncResponseSchema = type({
ok: 'true',
})
ok: "true",
});
export const ApiV1WhoamiResponseSchema = type({
user: {
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
},
})
});
export const ApiV1UserSearchResponseSchema = type({
items: type({
userId: 'string',
handle: 'string|null',
displayName: 'string|null?',
name: 'string|null?',
userId: "string",
handle: "string|null",
displayName: "string|null?",
name: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
}).array(),
total: 'number',
})
total: "number",
});
export const ApiV1SearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
summary: 'string|null?',
version: 'string|null?',
score: 'number',
updatedAt: 'number?',
slug: "string?",
displayName: "string?",
summary: "string|null?",
version: "string|null?",
score: "number",
updatedAt: "number?",
}).array(),
})
});
export const ApiV1SkillListResponseSchema = type({
items: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
slug: "string",
displayName: "string",
summary: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
updatedAt: "number",
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
version: "string",
createdAt: "number",
changelog: "string",
}).optional(),
}).array(),
nextCursor: 'string|null',
})
nextCursor: "string|null",
});
export const ApiV1SkillResponseSchema = type({
skill: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
}).or('null'),
slug: "string",
displayName: "string",
summary: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
updatedAt: "number",
}).or("null"),
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).or('null'),
version: "string",
createdAt: "number",
changelog: "string",
}).or("null"),
owner: type({
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
}).or('null'),
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
}).or("null"),
moderation: type({
isSuspicious: 'boolean',
isMalwareBlocked: 'boolean',
isSuspicious: "boolean",
isMalwareBlocked: "boolean",
verdict: '"clean"|"suspicious"|"malicious"?',
reasonCodes: "string[]?",
updatedAt: "number|null?",
engineVersion: "string|null?",
summary: "string|null?",
})
.or('null')
.or("null")
.optional(),
})
});
export const ApiV1SkillModerationResponseSchema = type({
moderation: type({
isSuspicious: "boolean",
isMalwareBlocked: "boolean",
verdict: '"clean"|"suspicious"|"malicious"',
reasonCodes: "string[]",
updatedAt: "number|null?",
engineVersion: "string|null?",
summary: "string|null?",
legacyReason: "string|null?",
evidence: type({
code: "string",
severity: '"info"|"warn"|"critical"',
file: "string",
line: "number",
message: "string",
evidence: "string",
}).array(),
}).or("null"),
});
export const ApiV1SkillVersionListResponseSchema = type({
items: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
version: "string",
createdAt: "number",
changelog: "string",
changelogSource: '"auto"|"user"|null?',
}).array(),
nextCursor: 'string|null',
})
nextCursor: "string|null",
});
export const ApiV1SkillVersionResponseSchema = type({
version: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
version: "string",
createdAt: "number",
changelog: "string",
changelogSource: '"auto"|"user"|null?',
files: 'unknown?',
}).or('null'),
files: "unknown?",
}).or("null"),
skill: type({
slug: 'string',
displayName: 'string',
}).or('null'),
})
slug: "string",
displayName: "string",
}).or("null"),
});
export const ApiV1SkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
})
match: type({ version: "string" }).or("null"),
latestVersion: type({ version: "string" }).or("null"),
});
export const ApiV1PublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
})
ok: "true",
skillId: "string",
versionId: "string",
});
export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
ok: "true",
});
export const ApiV1BanUserResponseSchema = type({
ok: 'true',
alreadyBanned: 'boolean',
deletedSkills: 'number',
})
ok: "true",
alreadyBanned: "boolean",
deletedSkills: "number",
});
export const ApiV1SetRoleResponseSchema = type({
ok: 'true',
ok: "true",
role: '"admin"|"moderator"|"user"',
})
});
export const ApiV1StarResponseSchema = type({
ok: 'true',
starred: 'boolean',
alreadyStarred: 'boolean',
})
ok: "true",
starred: "boolean",
alreadyStarred: "boolean",
});
export const ApiV1UnstarResponseSchema = type({
ok: 'true',
unstarred: 'boolean',
alreadyUnstarred: 'boolean',
})
ok: "true",
unstarred: "boolean",
alreadyUnstarred: "boolean",
});
export const SkillInstallSpecSchema = type({
id: 'string?',
id: "string?",
kind: '"brew"|"node"|"go"|"uv"',
label: 'string?',
bins: 'string[]?',
formula: 'string?',
tap: 'string?',
package: 'string?',
module: 'string?',
})
export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred]
label: "string?",
bins: "string[]?",
formula: "string?",
tap: "string?",
package: "string?",
module: "string?",
});
export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred];
export const ClawdisRequiresSchema = type({
bins: 'string[]?',
anyBins: 'string[]?',
env: 'string[]?',
config: 'string[]?',
})
export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred]
bins: "string[]?",
anyBins: "string[]?",
env: "string[]?",
config: "string[]?",
});
export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred];
export const ClawdisSkillMetadataSchema = type({
always: 'boolean?',
skillKey: 'string?',
primaryEnv: 'string?',
emoji: 'string?',
homepage: 'string?',
os: 'string[]?',
always: "boolean?",
skillKey: "string?",
primaryEnv: "string?",
emoji: "string?",
homepage: "string?",
os: "string[]?",
requires: ClawdisRequiresSchema.optional(),
install: SkillInstallSpecSchema.array().optional(),
})
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred]
});
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred];
@@ -0,0 +1,227 @@
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
const CODES = {
dangerousExec: "suspicious.dangerous_exec",
dynamicCode: "suspicious.dynamic_code_execution",
credentialHarvest: "malicious.env_harvesting",
exfiltration: "suspicious.potential_exfiltration",
obfuscatedCode: "suspicious.obfuscated_code",
suspiciousNetwork: "suspicious.suspicious_network",
cryptoMining: "malicious.crypto_mining",
injectionInstructions: "suspicious.prompt_injection_instructions",
installSource: "suspicious.install_untrusted_source",
privilegedAlways: "suspicious.privileged_always",
knownSignature: "malicious.known_blocked_signature",
} as const;
export type LocalScanFinding = {
code: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
evidence: string;
};
export type LocalStaticScanResult = {
reasonCodes: string[];
findings: LocalScanFinding[];
};
function firstLine(content: string, pattern: RegExp) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (pattern.test(lines[i])) return { line: i + 1, text: lines[i] };
}
return { line: 1, text: lines[0] ?? "" };
}
function pushFinding(findings: LocalScanFinding[], finding: LocalScanFinding) {
findings.push({
...finding,
evidence: finding.evidence.trim().slice(0, 140),
});
}
function scanContent(path: string, content: string, findings: LocalScanFinding[]) {
if (/\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/i.test(path)) {
if (
/child_process/.test(content) &&
/\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/.test(content)
) {
const match = firstLine(
content,
/\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/,
);
pushFinding(findings, {
code: CODES.dangerousExec,
severity: "critical",
file: path,
line: match.line,
message: "Shell command execution detected (child_process).",
evidence: match.text,
});
}
if (/\beval\s*\(|new\s+Function\s*\(/.test(content)) {
const match = firstLine(content, /\beval\s*\(|new\s+Function\s*\(/);
pushFinding(findings, {
code: CODES.dynamicCode,
severity: "critical",
file: path,
line: match.line,
message: "Dynamic code execution detected.",
evidence: match.text,
});
}
if (/stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i.test(content)) {
const match = firstLine(content, /stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i);
pushFinding(findings, {
code: CODES.cryptoMining,
severity: "critical",
file: path,
line: match.line,
message: "Possible crypto mining behavior detected.",
evidence: match.text,
});
}
const ws = content.match(/new\s+WebSocket\s*\(\s*["']wss?:\/\/[^"']*:(\d+)/);
if (ws) {
const port = Number.parseInt(ws[1] ?? "", 10);
if (Number.isFinite(port) && !STANDARD_PORTS.has(port)) {
const match = firstLine(content, /new\s+WebSocket\s*\(/);
pushFinding(findings, {
code: CODES.suspiciousNetwork,
severity: "warn",
file: path,
line: match.line,
message: "WebSocket connection to non-standard port detected.",
evidence: match.text,
});
}
}
if (
/readFileSync|readFile/.test(content) &&
/\bfetch\b|http\.request|\baxios\b/.test(content)
) {
const match = firstLine(content, /readFileSync|readFile/);
pushFinding(findings, {
code: CODES.exfiltration,
severity: "warn",
file: path,
line: match.line,
message: "File read combined with network send (possible exfiltration).",
evidence: match.text,
});
}
if (/process\.env/.test(content) && /\bfetch\b|http\.request|\baxios\b/.test(content)) {
const match = firstLine(content, /process\.env/);
pushFinding(findings, {
code: CODES.credentialHarvest,
severity: "critical",
file: path,
line: match.line,
message: "Environment variable access combined with network send.",
evidence: match.text,
});
}
if (
/(\\x[0-9a-fA-F]{2}){6,}/.test(content) ||
/(?:atob|Buffer\.from)\s*\(\s*["'][A-Za-z0-9+/=]{200,}["']/.test(content)
) {
const match = firstLine(content, /(\\x[0-9a-fA-F]{2}){6,}|(?:atob|Buffer\.from)\s*\(/);
pushFinding(findings, {
code: CODES.obfuscatedCode,
severity: "warn",
file: path,
line: match.line,
message: "Potential obfuscated payload detected.",
evidence: match.text,
});
}
}
if (/\.(md|markdown|mdx)$/i.test(path)) {
if (
/ignore\s+(all\s+)?previous\s+instructions/i.test(content) ||
/system\s*prompt\s*[:=]/i.test(content) ||
/you\s+are\s+now\s+(a|an)\b/i.test(content)
) {
const match = firstLine(
content,
/ignore\s+(all\s+)?previous\s+instructions|system\s*prompt\s*[:=]|you\s+are\s+now\s+(a|an)\b/i,
);
pushFinding(findings, {
code: CODES.injectionInstructions,
severity: "warn",
file: path,
line: match.line,
message: "Prompt-injection style instruction pattern detected.",
evidence: match.text,
});
}
if (/^\s*always\s*:\s*true\b/im.test(content)) {
const match = firstLine(content, /^\s*always\s*:\s*true\b/im);
pushFinding(findings, {
code: CODES.privilegedAlways,
severity: "warn",
file: path,
line: match.line,
message: "Skill sets always=true (persistent invocation).",
evidence: match.text,
});
}
}
if (/\.(json|yaml|yml|toml)$/i.test(path)) {
if (
/https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\//i.test(content) ||
/https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/i.test(content)
) {
const match = firstLine(
content,
/https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\/|https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/i,
);
pushFinding(findings, {
code: CODES.installSource,
severity: "warn",
file: path,
line: match.line,
message: "Install source points to URL shortener or raw IP.",
evidence: match.text,
});
}
}
if (/keepcold131\/ClawdAuthenticatorTool|ClawdAuthenticatorTool/i.test(content)) {
const match = firstLine(content, /keepcold131\/ClawdAuthenticatorTool|ClawdAuthenticatorTool/i);
pushFinding(findings, {
code: CODES.knownSignature,
severity: "critical",
file: path,
line: match.line,
message: "Matched known blocked malware signature.",
evidence: match.text,
});
}
}
export function scanLocalSkillFiles(
files: Array<{ relPath: string; bytes: Uint8Array }>,
): LocalStaticScanResult {
const decoder = new TextDecoder();
const findings: LocalScanFinding[] = [];
for (const file of files) {
const content = decoder.decode(file.bytes);
scanContent(file.relPath, content, findings);
}
findings.sort((a, b) =>
`${a.code}:${a.file}:${a.line}:${a.message}`.localeCompare(
`${b.code}:${b.file}:${b.line}:${b.message}`,
),
);
const reasonCodes = Array.from(new Set(findings.map((f) => f.code))).sort((a, b) =>
a.localeCompare(b),
);
return { reasonCodes, findings };
}
+39
View File
@@ -135,6 +135,16 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
image?: string | null | undefined;
};
}, {}>;
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
userId: string;
handle: string | null;
displayName?: string | null | undefined;
name?: string | null | undefined;
role?: "user" | "admin" | "moderator" | null | undefined;
}[];
total: number;
}, {}>;
export declare const ApiV1SearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
results: {
score: number;
@@ -182,6 +192,35 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
displayName?: string | null | undefined;
image?: string | null | undefined;
} | null;
moderation?: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict?: "clean" | "suspicious" | "malicious" | undefined;
reasonCodes?: string[] | undefined;
updatedAt?: number | null | undefined;
engineVersion?: string | null | undefined;
summary?: string | null | undefined;
} | null | undefined;
}, {}>;
export declare const ApiV1SkillModerationResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
moderation: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
verdict: "clean" | "suspicious" | "malicious";
reasonCodes: string[];
evidence: {
code: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
evidence: string;
}[];
updatedAt?: number | null | undefined;
engineVersion?: string | null | undefined;
summary?: string | null | undefined;
legacyReason?: string | null | undefined;
} | null;
}, {}>;
export declare const ApiV1SkillVersionListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
+41
View File
@@ -110,6 +110,16 @@ export const ApiV1WhoamiResponseSchema = type({
image: 'string|null?',
},
});
export const ApiV1UserSearchResponseSchema = type({
items: type({
userId: 'string',
handle: 'string|null',
displayName: 'string|null?',
name: 'string|null?',
role: '"admin"|"moderator"|"user"|null?',
}).array(),
total: 'number',
});
export const ApiV1SearchResponseSchema = type({
results: type({
slug: 'string?',
@@ -157,6 +167,37 @@ export const ApiV1SkillResponseSchema = type({
displayName: 'string|null?',
image: 'string|null?',
}).or('null'),
moderation: type({
isSuspicious: 'boolean',
isMalwareBlocked: 'boolean',
verdict: '"clean"|"suspicious"|"malicious"?',
reasonCodes: 'string[]?',
updatedAt: 'number|null?',
engineVersion: 'string|null?',
summary: 'string|null?',
})
.or('null')
.optional(),
});
export const ApiV1SkillModerationResponseSchema = type({
moderation: type({
isSuspicious: 'boolean',
isMalwareBlocked: 'boolean',
verdict: '"clean"|"suspicious"|"malicious"',
reasonCodes: 'string[]',
updatedAt: 'number|null?',
engineVersion: 'string|null?',
summary: 'string|null?',
legacyReason: 'string|null?',
evidence: type({
code: 'string',
severity: '"info"|"warn"|"critical"',
file: 'string',
line: 'number',
message: 'string',
evidence: 'string',
}).array(),
}).or('null'),
});
export const ApiV1SkillVersionListResponseSchema = type({
items: type({
File diff suppressed because one or more lines are too long
+220 -188
View File
@@ -1,303 +1,335 @@
import { type inferred, type } from 'arktype'
import { type inferred, type } from "arktype";
export const GlobalConfigSchema = type({
registry: 'string',
token: 'string?',
})
export type GlobalConfig = (typeof GlobalConfigSchema)[inferred]
registry: "string",
token: "string?",
});
export type GlobalConfig = (typeof GlobalConfigSchema)[inferred];
export const WellKnownConfigSchema = type({
apiBase: 'string',
authBase: 'string?',
minCliVersion: 'string?',
apiBase: "string",
authBase: "string?",
minCliVersion: "string?",
}).or({
registry: 'string',
authBase: 'string?',
minCliVersion: 'string?',
})
export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred]
registry: "string",
authBase: "string?",
minCliVersion: "string?",
});
export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred];
export const LockfileSchema = type({
version: '1',
version: "1",
skills: {
'[string]': {
version: 'string|null',
installedAt: 'number',
"[string]": {
version: "string|null",
installedAt: "number",
},
},
})
export type Lockfile = (typeof LockfileSchema)[inferred]
});
export type Lockfile = (typeof LockfileSchema)[inferred];
export const ApiCliWhoamiResponseSchema = type({
user: {
handle: 'string|null',
handle: "string|null",
},
})
});
export const ApiSearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
version: 'string|null?',
score: 'number',
slug: "string?",
displayName: "string?",
version: "string|null?",
score: "number",
}).array(),
})
});
export const ApiSkillMetaResponseSchema = type({
latestVersion: type({
version: 'string',
version: "string",
}).optional(),
skill: 'unknown|null?',
})
skill: "unknown|null?",
});
export const ApiCliUploadUrlResponseSchema = type({
uploadUrl: 'string',
})
uploadUrl: "string",
});
export const ApiUploadFileResponseSchema = type({
storageId: 'string',
})
storageId: "string",
});
export const CliPublishFileSchema = type({
path: 'string',
size: 'number',
storageId: 'string',
sha256: 'string',
contentType: 'string?',
})
export type CliPublishFile = (typeof CliPublishFileSchema)[inferred]
path: "string",
size: "number",
storageId: "string",
sha256: "string",
contentType: "string?",
});
export type CliPublishFile = (typeof CliPublishFileSchema)[inferred];
export const PublishSourceSchema = type({
kind: '"github"',
url: 'string',
repo: 'string',
ref: 'string',
commit: 'string',
path: 'string',
importedAt: 'number',
})
url: "string",
repo: "string",
ref: "string",
commit: "string",
path: "string",
importedAt: "number",
});
export const CliPublishRequestSchema = type({
slug: 'string',
displayName: 'string',
version: 'string',
changelog: 'string',
tags: 'string[]?',
slug: "string",
displayName: "string",
version: "string",
changelog: "string",
tags: "string[]?",
source: PublishSourceSchema.optional(),
forkOf: type({
slug: 'string',
version: 'string?',
slug: "string",
version: "string?",
}).optional(),
files: CliPublishFileSchema.array(),
})
export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred]
});
export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred];
export const ApiCliPublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
})
ok: "true",
skillId: "string",
versionId: "string",
});
export const CliSkillDeleteRequestSchema = type({
slug: 'string',
})
export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred]
slug: "string",
});
export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred];
export const ApiCliSkillDeleteResponseSchema = type({
ok: 'true',
})
ok: "true",
});
export const ApiSkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
})
match: type({ version: "string" }).or("null"),
latestVersion: type({ version: "string" }).or("null"),
});
export const CliTelemetrySyncRequestSchema = type({
roots: type({
rootId: 'string',
label: 'string',
rootId: "string",
label: "string",
skills: type({
slug: 'string',
version: 'string|null?',
slug: "string",
version: "string|null?",
}).array(),
}).array(),
})
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred]
});
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
export const ApiCliTelemetrySyncResponseSchema = type({
ok: 'true',
})
ok: "true",
});
export const ApiV1WhoamiResponseSchema = type({
user: {
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
},
})
});
export const ApiV1UserSearchResponseSchema = type({
items: type({
userId: 'string',
handle: 'string|null',
displayName: 'string|null?',
name: 'string|null?',
userId: "string",
handle: "string|null",
displayName: "string|null?",
name: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
}).array(),
total: 'number',
})
total: "number",
});
export const ApiV1SearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
summary: 'string|null?',
version: 'string|null?',
score: 'number',
updatedAt: 'number?',
slug: "string?",
displayName: "string?",
summary: "string|null?",
version: "string|null?",
score: "number",
updatedAt: "number?",
}).array(),
})
});
export const ApiV1SkillListResponseSchema = type({
items: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
slug: "string",
displayName: "string",
summary: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
updatedAt: "number",
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
version: "string",
createdAt: "number",
changelog: "string",
}).optional(),
}).array(),
nextCursor: 'string|null',
})
nextCursor: "string|null",
});
export const ApiV1SkillResponseSchema = type({
skill: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
}).or('null'),
slug: "string",
displayName: "string",
summary: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
updatedAt: "number",
}).or("null"),
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).or('null'),
version: "string",
createdAt: "number",
changelog: "string",
}).or("null"),
owner: type({
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
}).or('null'),
})
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
}).or("null"),
moderation: type({
isSuspicious: "boolean",
isMalwareBlocked: "boolean",
verdict: '"clean"|"suspicious"|"malicious"?',
reasonCodes: "string[]?",
updatedAt: "number|null?",
engineVersion: "string|null?",
summary: "string|null?",
})
.or("null")
.optional(),
});
export const ApiV1SkillModerationResponseSchema = type({
moderation: type({
isSuspicious: "boolean",
isMalwareBlocked: "boolean",
verdict: '"clean"|"suspicious"|"malicious"',
reasonCodes: "string[]",
updatedAt: "number|null?",
engineVersion: "string|null?",
summary: "string|null?",
legacyReason: "string|null?",
evidence: type({
code: "string",
severity: '"info"|"warn"|"critical"',
file: "string",
line: "number",
message: "string",
evidence: "string",
}).array(),
}).or("null"),
});
export const ApiV1SkillVersionListResponseSchema = type({
items: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
version: "string",
createdAt: "number",
changelog: "string",
changelogSource: '"auto"|"user"|null?',
}).array(),
nextCursor: 'string|null',
})
nextCursor: "string|null",
});
export const ApiV1SkillVersionResponseSchema = type({
version: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
version: "string",
createdAt: "number",
changelog: "string",
changelogSource: '"auto"|"user"|null?',
files: 'unknown?',
}).or('null'),
files: "unknown?",
}).or("null"),
skill: type({
slug: 'string',
displayName: 'string',
}).or('null'),
})
slug: "string",
displayName: "string",
}).or("null"),
});
export const ApiV1SkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
})
match: type({ version: "string" }).or("null"),
latestVersion: type({ version: "string" }).or("null"),
});
export const ApiV1PublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
})
ok: "true",
skillId: "string",
versionId: "string",
});
export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
ok: "true",
});
export const ApiV1SetRoleResponseSchema = type({
ok: 'true',
ok: "true",
role: '"admin"|"moderator"|"user"',
})
});
export const ApiV1StarResponseSchema = type({
ok: 'true',
starred: 'boolean',
alreadyStarred: 'boolean',
})
ok: "true",
starred: "boolean",
alreadyStarred: "boolean",
});
export const ApiV1UnstarResponseSchema = type({
ok: 'true',
unstarred: 'boolean',
alreadyUnstarred: 'boolean',
})
ok: "true",
unstarred: "boolean",
alreadyUnstarred: "boolean",
});
export const SkillInstallSpecSchema = type({
id: 'string?',
id: "string?",
kind: '"brew"|"node"|"go"|"uv"',
label: 'string?',
bins: 'string[]?',
formula: 'string?',
tap: 'string?',
package: 'string?',
module: 'string?',
})
export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred]
label: "string?",
bins: "string[]?",
formula: "string?",
tap: "string?",
package: "string?",
module: "string?",
});
export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred];
export const NixPluginSpecSchema = type({
plugin: 'string',
systems: 'string[]?',
})
export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred]
plugin: "string",
systems: "string[]?",
});
export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred];
export const ClawdbotConfigSpecSchema = type({
requiredEnv: 'string[]?',
stateDirs: 'string[]?',
example: 'string?',
})
export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred]
requiredEnv: "string[]?",
stateDirs: "string[]?",
example: "string?",
});
export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred];
export const ClawdisRequiresSchema = type({
bins: 'string[]?',
anyBins: 'string[]?',
env: 'string[]?',
config: 'string[]?',
})
export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred]
bins: "string[]?",
anyBins: "string[]?",
env: "string[]?",
config: "string[]?",
});
export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred];
export const ClawdisSkillMetadataSchema = type({
always: 'boolean?',
skillKey: 'string?',
primaryEnv: 'string?',
emoji: 'string?',
homepage: 'string?',
os: 'string[]?',
cliHelp: 'string?',
always: "boolean?",
skillKey: "string?",
primaryEnv: "string?",
emoji: "string?",
homepage: "string?",
os: "string[]?",
cliHelp: "string?",
requires: ClawdisRequiresSchema.optional(),
install: SkillInstallSpecSchema.array().optional(),
nix: NixPluginSpecSchema.optional(),
config: ClawdbotConfigSpecSchema.optional(),
})
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred]
});
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred];
File diff suppressed because it is too large Load Diff