mirror of
https://github.com/openclaw/clawhub.git
synced 2026-07-22 03:35:29 -04:00
feat: ship moderation v2 verification across API, CLI, and UI
This commit is contained in:
+770
-700
File diff suppressed because it is too large
Load Diff
+306
-218
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+550
-458
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -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
@@ -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
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}...`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+39
@@ -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: {
|
||||
|
||||
Vendored
+41
@@ -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({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+220
-188
@@ -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];
|
||||
|
||||
+443
-425
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user