mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: enforce MIT-0 skill licensing
This commit is contained in:
@@ -33,11 +33,19 @@
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
|
||||
- Reject PRs that add skills into source code/repo content directly (for example under `skills/` or seed-only additions intended as published skills). Skills must be uploaded/published via CLI.
|
||||
|
||||
## Git Notes
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
|
||||
## URL Quick Reference
|
||||
- Canonical site: `https://clawhub.ai` (prefer this over legacy domains).
|
||||
- Skill page URL format: `https://clawhub.ai/<owner>/<slug>` (owner handle preferred; falls back to owner id).
|
||||
- Skill API detail URL: `https://clawhub.ai/api/v1/skills/<slug>`.
|
||||
- Skill file URL: `https://clawhub.ai/api/v1/skills/<slug>/file?path=SKILL.md`.
|
||||
- For “full URL?” requests, return the canonical page URL first, then API URL if useful.
|
||||
|
||||
## Configuration & Security
|
||||
- Local env: `.env.local` (never commit secrets).
|
||||
- Convex env holds JWT keys; Vercel only needs `VITE_CONVEX_URL` + `VITE_CONVEX_SITE_URL`.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- CI/Security: add TruffleHog pull-request scanning for verified leaked credentials (#505) (thanks @akses0).
|
||||
|
||||
### Changed
|
||||
- Skills: make published skill licensing explicit and fixed to MIT-0; require publish consent, surface no-attribution messaging in web/CLI/API, and remove per-skill license metadata.
|
||||
- Security/docs: document comment reporting/auto-hide behavior alongside existing skill reporting rules.
|
||||
- Security/moderation: add bounded explainable auto-ban reasons for scam comments and protect moderator/admin accounts from automated bans.
|
||||
- Moderation: banning users now also soft-deletes their authored comments (skill + soul), including legacy cleanup on re-ban.
|
||||
|
||||
@@ -163,6 +163,9 @@ async function cliPublishHandler(ctx: ActionCtx, request: Request) {
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
const args = parsePublishBody(body)
|
||||
if (args.acceptLicenseTerms !== true) {
|
||||
return text('MIT-0 license terms must be accepted to publish skills', 400)
|
||||
}
|
||||
const result = await publishVersionForUser(ctx, userId, args)
|
||||
return json({ ok: true, ...result })
|
||||
} catch (error) {
|
||||
@@ -280,6 +283,7 @@ function parsePublishBody(body: unknown) {
|
||||
displayName: parsed.displayName,
|
||||
version: parsed.version,
|
||||
changelog: parsed.changelog,
|
||||
acceptLicenseTerms: parsed.acceptLicenseTerms,
|
||||
tags,
|
||||
source: parsed.source ?? undefined,
|
||||
forkOf: parsed.forkOf
|
||||
|
||||
@@ -812,6 +812,7 @@ describe('httpApiV1 handlers', () => {
|
||||
displayName: 'Demo',
|
||||
version: '1.0.0',
|
||||
changelog: 'c',
|
||||
acceptLicenseTerms: true,
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
@@ -855,6 +856,7 @@ describe('httpApiV1 handlers', () => {
|
||||
displayName: 'Demo',
|
||||
version: '1.0.0',
|
||||
changelog: '',
|
||||
acceptLicenseTerms: true,
|
||||
tags: ['latest'],
|
||||
}),
|
||||
)
|
||||
@@ -892,6 +894,7 @@ describe('httpApiV1 handlers', () => {
|
||||
displayName: 'Demo',
|
||||
version: '1.0.0',
|
||||
changelog: '',
|
||||
acceptLicenseTerms: true,
|
||||
tags: ['latest'],
|
||||
}),
|
||||
)
|
||||
@@ -988,6 +991,115 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response2.status).toBe(200)
|
||||
})
|
||||
|
||||
it('transfer request requires auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo/transfer', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ toUserHandle: 'alice' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('transfer request succeeds', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) return { _id: 'skills:1', slug: 'demo' }
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
return { ok: true, transferId: 'skillOwnershipTransfers:1', toUserHandle: 'alice', expiresAt: 123 }
|
||||
})
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo/transfer', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer clh_test', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ toUserHandle: '@Alice' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: 'users:1',
|
||||
skillId: 'skills:1',
|
||||
toUserHandle: '@Alice',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('transfer accept returns 404 when no pending request exists', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) return { _id: 'skills:1', slug: 'demo' }
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo/transfer/accept', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('transfer list returns incoming transfers', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate()
|
||||
if ('userId' in args) {
|
||||
return [
|
||||
{
|
||||
_id: 'skillOwnershipTransfers:1',
|
||||
skill: { _id: 'skills:1', slug: 'demo', displayName: 'Demo' },
|
||||
fromUser: { _id: 'users:2', handle: 'alice', displayName: 'Alice' },
|
||||
requestedAt: 100,
|
||||
expiresAt: 200,
|
||||
},
|
||||
]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
const response = await __handlers.transfersGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/transfers/incoming', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const payload = await response.json()
|
||||
expect(payload.transfers).toHaveLength(1)
|
||||
expect(payload.transfers[0]?.skill?.slug).toBe('demo')
|
||||
})
|
||||
|
||||
it('ban user requires auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
@@ -226,6 +226,7 @@ export async function parseMultipartPublish(
|
||||
displayName: string
|
||||
version: string
|
||||
changelog: string
|
||||
acceptLicenseTerms?: boolean
|
||||
tags?: string[]
|
||||
forkOf?: { slug: string; version?: string }
|
||||
files: Array<{
|
||||
@@ -275,6 +276,8 @@ export async function parseMultipartPublish(
|
||||
displayName: payload.displayName,
|
||||
version: payload.version,
|
||||
changelog: typeof payload.changelog === 'string' ? payload.changelog : '',
|
||||
acceptLicenseTerms:
|
||||
typeof payload.acceptLicenseTerms === 'boolean' ? payload.acceptLicenseTerms : undefined,
|
||||
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
|
||||
...(payload.source ? { source: payload.source } : {}),
|
||||
files,
|
||||
@@ -293,6 +296,7 @@ export function parsePublishBody(body: unknown) {
|
||||
displayName: parsed.displayName,
|
||||
version: parsed.version,
|
||||
changelog: parsed.changelog,
|
||||
acceptLicenseTerms: parsed.acceptLicenseTerms,
|
||||
tags,
|
||||
source: parsed.source ?? undefined,
|
||||
forkOf: parsed.forkOf
|
||||
|
||||
+158
-14
@@ -8,8 +8,10 @@ import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
getPathSegments,
|
||||
json,
|
||||
parseJsonPayload,
|
||||
parseMultipartPublish,
|
||||
parsePublishBody,
|
||||
requireApiTokenUserOrResponse,
|
||||
resolveTagsBatch,
|
||||
safeTextFileResponse,
|
||||
softDeleteErrorToResponse,
|
||||
@@ -45,7 +47,10 @@ type ListSkillsResult = {
|
||||
version: string
|
||||
createdAt: number
|
||||
changelog: string
|
||||
parsed?: { clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } } }
|
||||
parsed?: {
|
||||
license?: 'MIT-0'
|
||||
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } }
|
||||
}
|
||||
} | null
|
||||
}>
|
||||
nextCursor: string | null
|
||||
@@ -205,6 +210,7 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
license: item.latestVersion.parsed?.license ?? null,
|
||||
}
|
||||
: null,
|
||||
metadata: item.latestVersion?.parsed?.clawdis
|
||||
@@ -301,6 +307,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
version: result.latestVersion.version,
|
||||
createdAt: result.latestVersion.createdAt,
|
||||
changelog: result.latestVersion.changelog,
|
||||
license: result.latestVersion.parsed?.license ?? null,
|
||||
}
|
||||
: null,
|
||||
metadata: result.latestVersion?.parsed?.clawdis
|
||||
@@ -410,6 +417,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
license: version.parsed?.license ?? null,
|
||||
files: version.files.map((file: SkillFile) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
@@ -490,12 +498,18 @@ export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await request.json()
|
||||
const payload = parsePublishBody(body)
|
||||
if (payload.acceptLicenseTerms !== true) {
|
||||
return text('MIT-0 license terms must be accepted to publish skills', 400, rate.headers)
|
||||
}
|
||||
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)
|
||||
if (payload.acceptLicenseTerms !== true) {
|
||||
return text('MIT-0 license terms must be accepted to publish skills', 400, rate.headers)
|
||||
}
|
||||
const result = await publishVersionForUser(ctx, userId, payload)
|
||||
return json({ ok: true, ...result }, 200, rate.headers)
|
||||
}
|
||||
@@ -507,26 +521,156 @@ export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return text('Unsupported content type', 415, rate.headers)
|
||||
}
|
||||
|
||||
type TransferDecisionAction = 'accept' | 'reject' | 'cancel'
|
||||
|
||||
function transferErrorToResponse(error: unknown, headers: HeadersInit) {
|
||||
const message = error instanceof Error ? error.message : 'Transfer failed'
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes('unauthorized')) return text('Unauthorized', 401, headers)
|
||||
if (lower.includes('forbidden')) return text('Forbidden', 403, headers)
|
||||
if (lower.includes('not found')) return text(message, 404, headers)
|
||||
if (lower.includes('required') || lower.includes('invalid') || lower.includes('pending')) {
|
||||
return text(message, 400, headers)
|
||||
}
|
||||
return text(message, 400, headers)
|
||||
}
|
||||
|
||||
async function resolveTransferContext(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
slug: string,
|
||||
headers: HeadersInit,
|
||||
): Promise<
|
||||
| { ok: true; userId: Id<'users'>; skill: Doc<'skills'> }
|
||||
| { ok: false; response: Response }
|
||||
> {
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, headers)
|
||||
if (!auth.ok) return auth
|
||||
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill || skill.softDeletedAt) return { ok: false, response: text('Skill not found', 404, headers) }
|
||||
|
||||
return { ok: true, userId: auth.userId, skill }
|
||||
}
|
||||
|
||||
async function handleTransferRequest(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
slug: string,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const transferContext = await resolveTransferContext(ctx, request, slug, headers)
|
||||
if (!transferContext.ok) return transferContext.response
|
||||
|
||||
const parsed = await parseJsonPayload(request, headers)
|
||||
if (!parsed.ok) return parsed.response
|
||||
|
||||
const toUserHandleRaw =
|
||||
typeof parsed.payload.toUserHandle === 'string' ? parsed.payload.toUserHandle.trim() : ''
|
||||
if (!toUserHandleRaw) return text('toUserHandle required', 400, headers)
|
||||
const message = typeof parsed.payload.message === 'string' ? parsed.payload.message : undefined
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
|
||||
actorUserId: transferContext.userId,
|
||||
skillId: transferContext.skill._id,
|
||||
toUserHandle: toUserHandleRaw,
|
||||
message,
|
||||
})
|
||||
return json(result, 200, headers)
|
||||
} catch (error) {
|
||||
return transferErrorToResponse(error, headers)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransferDecision(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
slug: string,
|
||||
decision: TransferDecisionAction,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const transferContext = await resolveTransferContext(ctx, request, slug, headers)
|
||||
if (!transferContext.ok) return transferContext.response
|
||||
|
||||
const pendingTransfer =
|
||||
decision === 'cancel'
|
||||
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
fromUserId: transferContext.userId,
|
||||
})
|
||||
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
toUserId: transferContext.userId,
|
||||
})
|
||||
if (!pendingTransfer) return text('No pending transfer found', 404, headers)
|
||||
|
||||
const mutation =
|
||||
decision === 'accept'
|
||||
? internal.skillTransfers.acceptTransferInternal
|
||||
: decision === 'reject'
|
||||
? internal.skillTransfers.rejectTransferInternal
|
||||
: internal.skillTransfers.cancelTransferInternal
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(mutation, {
|
||||
actorUserId: transferContext.userId,
|
||||
transferId: pendingTransfer._id,
|
||||
})
|
||||
return json(result, 200, headers)
|
||||
} catch (error) {
|
||||
return transferErrorToResponse(error, headers)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkillsTransferPost(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
segments: string[],
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
if (!slug) return text('Slug required', 400, headers)
|
||||
|
||||
if (segments.length === 2) {
|
||||
return handleTransferRequest(ctx, request, slug, headers)
|
||||
}
|
||||
if (segments.length === 3) {
|
||||
const decision = segments[2]?.trim().toLowerCase()
|
||||
if (decision === 'accept' || decision === 'reject' || decision === 'cancel') {
|
||||
return handleTransferDecision(ctx, request, slug, decision, headers)
|
||||
}
|
||||
}
|
||||
return text('Not found', 404, headers)
|
||||
}
|
||||
|
||||
export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
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 action = segments[1] ?? ''
|
||||
|
||||
if (segments.length === 2 && action === 'undelete') {
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
|
||||
userId,
|
||||
slug,
|
||||
deleted: false,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('skill', error, rate.headers)
|
||||
}
|
||||
}
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
|
||||
userId,
|
||||
slug,
|
||||
deleted: false,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('skill', error, rate.headers)
|
||||
|
||||
if (action === 'transfer') {
|
||||
return handleSkillsTransferPost(ctx, request, segments, rate.headers)
|
||||
}
|
||||
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
parseFrontmatter,
|
||||
} from './skills'
|
||||
|
||||
const PLATFORM_SKILL_LICENSE = 'MIT-0' as const
|
||||
|
||||
export type ParsedSkillData = {
|
||||
frontmatter: ParsedSkillFrontmatter
|
||||
metadata?: unknown
|
||||
clawdis?: unknown
|
||||
license?: typeof PLATFORM_SKILL_LICENSE
|
||||
}
|
||||
|
||||
export type SkillSummaryBackfillPatch = {
|
||||
@@ -26,7 +29,7 @@ export function buildSkillSummaryBackfillPatch(args: {
|
||||
const summary = getFrontmatterValue(frontmatter, 'description') ?? undefined
|
||||
const metadata = getFrontmatterMetadata(frontmatter)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const parsed: ParsedSkillData = { frontmatter, metadata, clawdis }
|
||||
const parsed: ParsedSkillData = { frontmatter, metadata, clawdis, license: PLATFORM_SKILL_LICENSE }
|
||||
|
||||
const patch: SkillSummaryBackfillPatch = {}
|
||||
if (summary && summary !== args.currentSummary) {
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 PLATFORM_SKILL_LICENSE = 'MIT-0' as const
|
||||
|
||||
export type PublishResult = {
|
||||
skillId: Id<'skills'>
|
||||
@@ -268,6 +269,7 @@ export async function publishVersionForUser(
|
||||
frontmatter,
|
||||
metadata,
|
||||
clawdis,
|
||||
license: PLATFORM_SKILL_LICENSE,
|
||||
},
|
||||
summary,
|
||||
embedding,
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('maintenance backfill', () => {
|
||||
frontmatter: { description: 'Hello world.' },
|
||||
metadata: undefined,
|
||||
clawdis: undefined,
|
||||
license: 'MIT-0',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -193,6 +194,7 @@ describe('maintenance backfill', () => {
|
||||
frontmatter: {},
|
||||
metadata: undefined,
|
||||
clawdis: undefined,
|
||||
license: 'MIT-0',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ const DEFAULT_MAX_BATCHES = 20
|
||||
const MAX_MAX_BATCHES = 200
|
||||
const DEFAULT_EMPTY_SKILL_MAX_README_BYTES = 8000
|
||||
const DEFAULT_EMPTY_SKILL_NOMINATION_THRESHOLD = 3
|
||||
const PLATFORM_SKILL_LICENSE = 'MIT-0' as const
|
||||
|
||||
type BackfillStats = {
|
||||
skillsScanned: number
|
||||
@@ -116,6 +117,7 @@ export const applySkillBackfillPatchInternal = internalMutation({
|
||||
frontmatter: v.record(v.string(), v.any()),
|
||||
metadata: v.optional(v.any()),
|
||||
clawdis: v.optional(v.any()),
|
||||
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@ import { defineSchema, defineTable } from 'convex/server'
|
||||
import { v } from 'convex/values'
|
||||
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
|
||||
|
||||
const PLATFORM_SKILL_LICENSE = 'MIT-0' as const
|
||||
|
||||
const users = defineTable({
|
||||
name: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
@@ -215,6 +217,7 @@ const skillVersions = defineTable({
|
||||
metadata: v.optional(v.any()),
|
||||
clawdis: v.optional(v.any()),
|
||||
moltbot: v.optional(v.any()),
|
||||
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
|
||||
}),
|
||||
createdBy: v.id('users'),
|
||||
createdAt: v.number(),
|
||||
@@ -584,6 +587,7 @@ const reservedSlugs = defineTable({
|
||||
const githubBackupSyncState = defineTable({
|
||||
key: v.string(),
|
||||
cursor: v.optional(v.string()),
|
||||
pruneCursor: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
}).index('by_key', ['key'])
|
||||
|
||||
@@ -625,6 +629,29 @@ const userSkillRootInstalls = defineTable({
|
||||
.index('by_user_skill', ['userId', 'skillId'])
|
||||
.index('by_skill', ['skillId'])
|
||||
|
||||
const skillOwnershipTransfers = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
fromUserId: v.id('users'),
|
||||
toUserId: v.id('users'),
|
||||
status: v.union(
|
||||
v.literal('pending'),
|
||||
v.literal('accepted'),
|
||||
v.literal('rejected'),
|
||||
v.literal('cancelled'),
|
||||
v.literal('expired'),
|
||||
),
|
||||
message: v.optional(v.string()),
|
||||
requestedAt: v.number(),
|
||||
respondedAt: v.optional(v.number()),
|
||||
expiresAt: v.number(),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_from_user', ['fromUserId'])
|
||||
.index('by_to_user', ['toUserId'])
|
||||
.index('by_to_user_status', ['toUserId', 'status'])
|
||||
.index('by_from_user_status', ['fromUserId', 'status'])
|
||||
.index('by_skill_status', ['skillId', 'status'])
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
users,
|
||||
@@ -660,4 +687,5 @@ export default defineSchema({
|
||||
userSyncRoots,
|
||||
userSkillInstalls,
|
||||
userSkillRootInstalls,
|
||||
skillOwnershipTransfers,
|
||||
})
|
||||
|
||||
+14
-1
@@ -61,6 +61,7 @@ export { publishVersionForUser } from './lib/skillPublish'
|
||||
|
||||
type ReadmeResult = { path: string; text: string }
|
||||
type FileTextResult = { path: string; text: string; size: number; sha256: string }
|
||||
const PLATFORM_SKILL_LICENSE = 'MIT-0' as const
|
||||
|
||||
const MAX_DIFF_FILE_BYTES = 200 * 1024
|
||||
const MAX_LIST_LIMIT = 50
|
||||
@@ -527,6 +528,7 @@ type PublicSkillListVersion = Pick<
|
||||
'_id' | '_creationTime' | 'version' | 'createdAt' | 'changelog' | 'changelogSource'
|
||||
> & {
|
||||
parsed?: {
|
||||
license?: typeof PLATFORM_SKILL_LICENSE
|
||||
clawdis?: {
|
||||
os?: string[]
|
||||
nix?: {
|
||||
@@ -610,7 +612,13 @@ function toPublicSkillListVersion(
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource,
|
||||
parsed: version.parsed?.clawdis ? { clawdis: version.parsed.clawdis } : undefined,
|
||||
parsed:
|
||||
version.parsed?.clawdis || version.parsed?.license
|
||||
? {
|
||||
...(version.parsed?.license ? { license: version.parsed.license } : {}),
|
||||
...(version.parsed?.clawdis ? { clawdis: version.parsed.clawdis } : {}),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3007,6 +3015,7 @@ export const publishVersion: ReturnType<typeof action> = action({
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
changelog: v.string(),
|
||||
acceptLicenseTerms: v.optional(v.boolean()),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
forkOf: v.optional(
|
||||
v.object({
|
||||
@@ -3025,6 +3034,9 @@ export const publishVersion: ReturnType<typeof action> = action({
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args): Promise<PublishResult> => {
|
||||
if (args.acceptLicenseTerms !== true) {
|
||||
throw new ConvexError('MIT-0 license terms must be accepted to publish skills')
|
||||
}
|
||||
const { userId } = await requireUserFromAction(ctx)
|
||||
return publishVersionForUser(ctx, userId, args)
|
||||
},
|
||||
@@ -3754,6 +3766,7 @@ export const insertVersion = internalMutation({
|
||||
frontmatter: v.record(v.string(), v.any()),
|
||||
metadata: v.optional(v.any()),
|
||||
clawdis: v.optional(v.any()),
|
||||
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
|
||||
}),
|
||||
summary: v.optional(v.string()),
|
||||
qualityAssessment: v.optional(
|
||||
|
||||
+19
@@ -136,6 +136,8 @@ Stores your API token + cached registry URL.
|
||||
|
||||
- Publishes via `POST /api/v1/skills` (multipart).
|
||||
- Requires semver: `--version 1.2.3`.
|
||||
- Publishing a skill means it is released under `MIT-0` on ClawHub.
|
||||
- Published skills are free to use, modify, and redistribute without attribution.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
@@ -159,6 +161,23 @@ Stores your API token + cached registry URL.
|
||||
- Unhide a skill (owner, moderator, or admin).
|
||||
- Alias for `undelete`.
|
||||
|
||||
### `transfer`
|
||||
|
||||
- Ownership transfer workflow.
|
||||
- Subcommands:
|
||||
- `transfer request <slug> <handle> [--message "..."] [--yes]`
|
||||
- `transfer list [--outgoing]`
|
||||
- `transfer accept <slug> [--yes]`
|
||||
- `transfer reject <slug> [--yes]`
|
||||
- `transfer cancel <slug> [--yes]`
|
||||
- Endpoints:
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
- `POST /api/v1/skills/{slug}/transfer/reject`
|
||||
- `POST /api/v1/skills/{slug}/transfer/cancel`
|
||||
- `GET /api/v1/transfers/incoming`
|
||||
- `GET /api/v1/transfers/outgoing`
|
||||
|
||||
### `ban-user <handleOrId>`
|
||||
|
||||
- Ban a user and delete owned skills (moderator/admin only).
|
||||
|
||||
@@ -149,3 +149,10 @@ Limits (server-side):
|
||||
|
||||
- Each publish creates a new version (semver).
|
||||
- Tags are string pointers to a version; `latest` is commonly used.
|
||||
|
||||
## License
|
||||
|
||||
- All skills published on ClawHub are licensed under `MIT-0`.
|
||||
- Anyone may use, modify, and redistribute published skills, including commercially.
|
||||
- Attribution is not required.
|
||||
- Do not add conflicting license terms in `SKILL.md`; ClawHub does not support per-skill license overrides.
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('cmdInspect', () => {
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.2.3', createdAt: 3, changelog: 'init' },
|
||||
latestVersion: { version: '1.2.3', createdAt: 3, changelog: 'init', license: 'MIT-0' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -107,7 +107,7 @@ describe('cmdInspect', () => {
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' },
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init', license: 'MIT-0' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -138,7 +138,7 @@ describe('cmdInspect', () => {
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' },
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init', license: 'MIT-0' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -159,6 +159,7 @@ describe('cmdInspect', () => {
|
||||
|
||||
await cmdInspect(makeOpts(), 'demo', { version: '2.0.0' })
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('License: MIT-0'))
|
||||
expect(mockLog).toHaveBeenCalledWith('Security: SUSPICIOUS')
|
||||
expect(mockLog).toHaveBeenCalledWith('Warnings: yes')
|
||||
expect(mockLog).toHaveBeenCalledWith('Checked: 2023-11-14T22:13:20.000Z')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiRequest, fetchText, registryUrl } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
@@ -131,6 +133,8 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
printSkillSummary({
|
||||
skill,
|
||||
latestVersion: skillResult.latestVersion,
|
||||
versionLicense:
|
||||
(versionResult?.version as { license?: string | null } | undefined)?.license ?? null,
|
||||
owner: skillResult.owner,
|
||||
})
|
||||
}
|
||||
@@ -186,7 +190,13 @@ function printSkillSummary(result: {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
latestVersion?: { version: string; createdAt: number; changelog: string } | null
|
||||
latestVersion?: {
|
||||
version: string
|
||||
createdAt: number
|
||||
changelog: string
|
||||
license?: string | null
|
||||
} | null
|
||||
versionLicense?: string | null
|
||||
owner?: { handle?: string | null; displayName?: string | null; image?: string | null } | null
|
||||
}) {
|
||||
const { skill } = result
|
||||
@@ -199,6 +209,9 @@ function printSkillSummary(result: {
|
||||
if (result.latestVersion?.version) {
|
||||
console.log(`Latest: ${result.latestVersion.version}`)
|
||||
}
|
||||
console.log(
|
||||
`License: ${result.versionLicense ?? result.latestVersion?.license ?? PLATFORM_SKILL_LICENSE} (${PLATFORM_SKILL_LICENSE_SUMMARY})`,
|
||||
)
|
||||
const tags = normalizeTags(skill.tags)
|
||||
const tagEntries = Object.entries(tags)
|
||||
if (tagEntries.length > 0) {
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('cmdPublish', () => {
|
||||
expect(payload.displayName).toBe('My Skill')
|
||||
expect(payload.version).toBe('1.0.0')
|
||||
expect(payload.changelog).toBe('')
|
||||
expect(payload.acceptLicenseTerms).toBe(true)
|
||||
expect(payload.tags).toEqual(['latest'])
|
||||
const files = publishForm.getAll('files') as Array<Blob & { name?: string }>
|
||||
expect(files.map((file) => String(file.name ?? '')).sort()).toEqual(['SKILL.md', 'notes.md'])
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function cmdPublish(
|
||||
displayName,
|
||||
version,
|
||||
changelog,
|
||||
acceptLicenseTerms: true,
|
||||
tags,
|
||||
...(forkOf ? { forkOf } : {}),
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export type { ArkValidator } from './ark.js'
|
||||
export { formatArkErrors, parseArk } from './ark.js'
|
||||
export {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from 'clawhub-schema'
|
||||
export { ApiRoutes, LegacyApiRoutes } from './routes.js'
|
||||
export * from './schemas.js'
|
||||
export * from './textFiles.js'
|
||||
|
||||
@@ -72,6 +72,7 @@ export const CliPublishRequestSchema = type({
|
||||
displayName: 'string',
|
||||
version: 'string',
|
||||
changelog: 'string',
|
||||
acceptLicenseTerms: 'boolean?',
|
||||
tags: 'string[]?',
|
||||
forkOf: type({
|
||||
slug: 'string',
|
||||
@@ -160,6 +161,7 @@ export const ApiV1SkillListResponseSchema = type({
|
||||
version: 'string',
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
license: '"MIT-0"|null?',
|
||||
}).optional(),
|
||||
}).array(),
|
||||
nextCursor: 'string|null',
|
||||
@@ -179,6 +181,7 @@ export const ApiV1SkillResponseSchema = type({
|
||||
version: 'string',
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
license: '"MIT-0"|null?',
|
||||
}).or('null'),
|
||||
owner: type({
|
||||
handle: 'string|null',
|
||||
@@ -209,6 +212,7 @@ export const ApiV1SkillVersionResponseSchema = type({
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
changelogSource: '"auto"|"user"|null?',
|
||||
license: '"MIT-0"|null?',
|
||||
files: 'unknown?',
|
||||
}).or('null'),
|
||||
skill: type({
|
||||
@@ -232,6 +236,42 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1TransferRequestResponseSchema = type({
|
||||
ok: 'true',
|
||||
transferId: 'string',
|
||||
toUserHandle: 'string',
|
||||
expiresAt: 'number',
|
||||
})
|
||||
|
||||
export const ApiV1TransferDecisionResponseSchema = type({
|
||||
ok: 'true',
|
||||
skillSlug: 'string?',
|
||||
})
|
||||
|
||||
export const ApiV1TransferListResponseSchema = type({
|
||||
transfers: type({
|
||||
_id: 'string',
|
||||
skill: type({
|
||||
_id: 'string',
|
||||
slug: 'string',
|
||||
displayName: 'string',
|
||||
}),
|
||||
fromUser: type({
|
||||
_id: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null',
|
||||
}).optional(),
|
||||
toUser: type({
|
||||
_id: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null',
|
||||
}).optional(),
|
||||
message: 'string?',
|
||||
requestedAt: 'number',
|
||||
expiresAt: 'number',
|
||||
}).array(),
|
||||
})
|
||||
|
||||
export const ApiV1BanUserResponseSchema = type({
|
||||
ok: 'true',
|
||||
alreadyBanned: 'boolean',
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
export type { ArkValidator } from './ark.js';
|
||||
export { formatArkErrors, parseArk } from './ark.js';
|
||||
export * from './license.js';
|
||||
export { ApiRoutes, LegacyApiRoutes } from './routes.js';
|
||||
export * from './schemas.js';
|
||||
export * from './textFiles.js';
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
export { formatArkErrors, parseArk } from './ark.js';
|
||||
export * from './license.js';
|
||||
export { ApiRoutes, LegacyApiRoutes } from './routes.js';
|
||||
export * from './schemas.js';
|
||||
export * from './textFiles.js';
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACxD,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACpD,cAAc,cAAc,CAAA;AAC5B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACxD,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA"}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { type inferred } from 'arktype';
|
||||
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
|
||||
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
|
||||
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
|
||||
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
|
||||
export declare const SkillPlatformLicenseSchema: import("arktype/internal/variants/string.ts").StringType<"MIT-0", {}>;
|
||||
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { type } from 'arktype';
|
||||
export const PLATFORM_SKILL_LICENSE = 'MIT-0';
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution';
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY = 'Free to use, modify, and redistribute. No attribution required.';
|
||||
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html';
|
||||
export const SkillPlatformLicenseSchema = type('"MIT-0"');
|
||||
//# sourceMappingURL=license.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAA;AAE7C,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAA;AACtD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAA;AACxE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAA;AAC5E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAA;AAEzF,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAA"}
|
||||
Vendored
+37
@@ -78,6 +78,7 @@ export declare const CliPublishRequestSchema: import("arktype/internal/variants/
|
||||
sha256: string;
|
||||
contentType?: string | undefined;
|
||||
}[];
|
||||
acceptLicenseTerms?: boolean | undefined;
|
||||
tags?: string[] | undefined;
|
||||
source?: {
|
||||
kind: "github";
|
||||
@@ -168,6 +169,7 @@ export declare const ApiV1SkillListResponseSchema: import("arktype/internal/vari
|
||||
version: string;
|
||||
createdAt: number;
|
||||
changelog: string;
|
||||
license?: "MIT-0" | null | undefined;
|
||||
} | undefined;
|
||||
}[];
|
||||
nextCursor: string | null;
|
||||
@@ -186,6 +188,7 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
|
||||
version: string;
|
||||
createdAt: number;
|
||||
changelog: string;
|
||||
license?: "MIT-0" | null | undefined;
|
||||
} | null;
|
||||
owner: {
|
||||
handle: string | null;
|
||||
@@ -214,6 +217,7 @@ export declare const ApiV1SkillVersionResponseSchema: import("arktype/internal/v
|
||||
createdAt: number;
|
||||
changelog: string;
|
||||
changelogSource?: "user" | "auto" | null | undefined;
|
||||
license?: "MIT-0" | null | undefined;
|
||||
files?: unknown;
|
||||
security?: {
|
||||
status: "clean" | "suspicious" | "malicious" | "pending" | "error";
|
||||
@@ -243,6 +247,39 @@ export declare const ApiV1PublishResponseSchema: import("arktype/internal/varian
|
||||
export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
}, {}>;
|
||||
export declare const ApiV1TransferRequestResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
transferId: string;
|
||||
toUserHandle: string;
|
||||
expiresAt: number;
|
||||
}, {}>;
|
||||
export declare const ApiV1TransferDecisionResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
skillSlug?: string | undefined;
|
||||
}, {}>;
|
||||
export declare const ApiV1TransferListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
transfers: {
|
||||
_id: string;
|
||||
skill: {
|
||||
_id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
};
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
fromUser?: {
|
||||
_id: string;
|
||||
handle: string | null;
|
||||
displayName: string | null;
|
||||
} | undefined;
|
||||
toUser?: {
|
||||
_id: string;
|
||||
handle: string | null;
|
||||
displayName: string | null;
|
||||
} | undefined;
|
||||
message?: string | undefined;
|
||||
}[];
|
||||
}, {}>;
|
||||
export declare const ApiV1SetRoleResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
role: "user" | "admin" | "moderator";
|
||||
|
||||
Vendored
+38
@@ -1,4 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import { SkillPlatformLicenseSchema } from './license.js';
|
||||
export const GlobalConfigSchema = type({
|
||||
registry: 'string',
|
||||
token: 'string?',
|
||||
@@ -67,6 +68,7 @@ export const CliPublishRequestSchema = type({
|
||||
displayName: 'string',
|
||||
version: 'string',
|
||||
changelog: 'string',
|
||||
acceptLicenseTerms: 'boolean?',
|
||||
tags: 'string[]?',
|
||||
source: PublishSourceSchema.optional(),
|
||||
forkOf: type({
|
||||
@@ -143,6 +145,7 @@ export const ApiV1SkillListResponseSchema = type({
|
||||
version: 'string',
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
license: SkillPlatformLicenseSchema.or('null').optional(),
|
||||
}).optional(),
|
||||
}).array(),
|
||||
nextCursor: 'string|null',
|
||||
@@ -161,6 +164,7 @@ export const ApiV1SkillResponseSchema = type({
|
||||
version: 'string',
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
license: SkillPlatformLicenseSchema.or('null').optional(),
|
||||
}).or('null'),
|
||||
owner: type({
|
||||
handle: 'string|null',
|
||||
@@ -189,6 +193,7 @@ export const ApiV1SkillVersionResponseSchema = type({
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
changelogSource: '"auto"|"user"|null?',
|
||||
license: SkillPlatformLicenseSchema.or('null').optional(),
|
||||
files: 'unknown?',
|
||||
security: SecurityStatusSchema.optional(),
|
||||
}).or('null'),
|
||||
@@ -209,6 +214,39 @@ export const ApiV1PublishResponseSchema = type({
|
||||
export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
});
|
||||
export const ApiV1TransferRequestResponseSchema = type({
|
||||
ok: 'true',
|
||||
transferId: 'string',
|
||||
toUserHandle: 'string',
|
||||
expiresAt: 'number',
|
||||
});
|
||||
export const ApiV1TransferDecisionResponseSchema = type({
|
||||
ok: 'true',
|
||||
skillSlug: 'string?',
|
||||
});
|
||||
export const ApiV1TransferListResponseSchema = type({
|
||||
transfers: type({
|
||||
_id: 'string',
|
||||
skill: type({
|
||||
_id: 'string',
|
||||
slug: 'string',
|
||||
displayName: 'string',
|
||||
}),
|
||||
fromUser: type({
|
||||
_id: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null',
|
||||
}).optional(),
|
||||
toUser: type({
|
||||
_id: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null',
|
||||
}).optional(),
|
||||
message: 'string?',
|
||||
requestedAt: 'number',
|
||||
expiresAt: 'number',
|
||||
}).array(),
|
||||
});
|
||||
export const ApiV1SetRoleResponseSchema = type({
|
||||
ok: 'true',
|
||||
role: '"admin"|"moderator"|"user"',
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
export type { ArkValidator } from './ark.js'
|
||||
export { formatArkErrors, parseArk } from './ark.js'
|
||||
export * from './license.js'
|
||||
export { ApiRoutes, LegacyApiRoutes } from './routes.js'
|
||||
export * from './schemas.js'
|
||||
export * from './textFiles.js'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type inferred, type } from 'arktype'
|
||||
|
||||
export const PLATFORM_SKILL_LICENSE = 'MIT-0' as const
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution' as const
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY =
|
||||
'Free to use, modify, and redistribute. No attribution required.' as const
|
||||
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html' as const
|
||||
|
||||
export const SkillPlatformLicenseSchema = type('"MIT-0"')
|
||||
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred]
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type inferred, type } from 'arktype'
|
||||
import { SkillPlatformLicenseSchema } from './license.js'
|
||||
|
||||
export const GlobalConfigSchema = type({
|
||||
registry: 'string',
|
||||
@@ -82,6 +83,7 @@ export const CliPublishRequestSchema = type({
|
||||
displayName: 'string',
|
||||
version: 'string',
|
||||
changelog: 'string',
|
||||
acceptLicenseTerms: 'boolean?',
|
||||
tags: 'string[]?',
|
||||
source: PublishSourceSchema.optional(),
|
||||
forkOf: type({
|
||||
@@ -171,6 +173,7 @@ export const ApiV1SkillListResponseSchema = type({
|
||||
version: 'string',
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
license: SkillPlatformLicenseSchema.or('null').optional(),
|
||||
}).optional(),
|
||||
}).array(),
|
||||
nextCursor: 'string|null',
|
||||
@@ -190,6 +193,7 @@ export const ApiV1SkillResponseSchema = type({
|
||||
version: 'string',
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
license: SkillPlatformLicenseSchema.or('null').optional(),
|
||||
}).or('null'),
|
||||
owner: type({
|
||||
handle: 'string|null',
|
||||
@@ -221,6 +225,7 @@ export const ApiV1SkillVersionResponseSchema = type({
|
||||
createdAt: 'number',
|
||||
changelog: 'string',
|
||||
changelogSource: '"auto"|"user"|null?',
|
||||
license: SkillPlatformLicenseSchema.or('null').optional(),
|
||||
files: 'unknown?',
|
||||
security: SecurityStatusSchema.optional(),
|
||||
}).or('null'),
|
||||
@@ -245,6 +250,42 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1TransferRequestResponseSchema = type({
|
||||
ok: 'true',
|
||||
transferId: 'string',
|
||||
toUserHandle: 'string',
|
||||
expiresAt: 'number',
|
||||
})
|
||||
|
||||
export const ApiV1TransferDecisionResponseSchema = type({
|
||||
ok: 'true',
|
||||
skillSlug: 'string?',
|
||||
})
|
||||
|
||||
export const ApiV1TransferListResponseSchema = type({
|
||||
transfers: type({
|
||||
_id: 'string',
|
||||
skill: type({
|
||||
_id: 'string',
|
||||
slug: 'string',
|
||||
displayName: 'string',
|
||||
}),
|
||||
fromUser: type({
|
||||
_id: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null',
|
||||
}).optional(),
|
||||
toUser: type({
|
||||
_id: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null',
|
||||
}).optional(),
|
||||
message: 'string?',
|
||||
requestedAt: 'number',
|
||||
expiresAt: 'number',
|
||||
}).array(),
|
||||
})
|
||||
|
||||
export const ApiV1SetRoleResponseSchema = type({
|
||||
ok: 'true',
|
||||
role: '"admin"|"moderator"|"user"',
|
||||
|
||||
@@ -122,6 +122,13 @@ describe('SkillDetailPage', () => {
|
||||
|
||||
render(<SkillDetailPage slug="weather" />)
|
||||
|
||||
expect(
|
||||
(
|
||||
await screen.findAllByText(
|
||||
/free to use, modify, and redistribute\. no attribution required\./i,
|
||||
)
|
||||
).length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/Reports require a reason\. Abuse may result in a ban\./i)).toBeNull()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /report/i }))
|
||||
|
||||
@@ -94,6 +94,11 @@ describe('Upload route', () => {
|
||||
const file = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
|
||||
const input = screen.getByTestId('upload-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /i have the rights to this skill and agree to publish it under mit-0/i,
|
||||
}),
|
||||
)
|
||||
|
||||
const publishButton = screen.getByRole('button', { name: /publish/i }) as HTMLButtonElement
|
||||
expect(await screen.findByText(/All checks passed/i)).toBeTruthy()
|
||||
@@ -124,6 +129,11 @@ describe('Upload route', () => {
|
||||
|
||||
const input = screen.getByTestId('upload-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [zipFile] } })
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /i have the rights to this skill and agree to publish it under mit-0/i,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('notes.txt', {}, { timeout: 3000 })).toBeTruthy()
|
||||
expect(screen.getByText('SKILL.md')).toBeTruthy()
|
||||
@@ -152,6 +162,11 @@ describe('Upload route', () => {
|
||||
|
||||
const input = screen.getByTestId('upload-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /i have the rights to this skill and agree to publish it under mit-0/i,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('SKILL.md')).toBeTruthy()
|
||||
expect(await screen.findByText(/All checks passed/i)).toBeTruthy()
|
||||
@@ -217,6 +232,11 @@ describe('Upload route', () => {
|
||||
const junk = new File(['junk'], '.DS_Store', { type: 'application/octet-stream' })
|
||||
const input = screen.getByTestId('upload-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [skill, junk] } })
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /i have the rights to this skill and agree to publish it under mit-0/i,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('SKILL.md')).toBeTruthy()
|
||||
expect(screen.queryByText('.DS_Store')).toBeNull()
|
||||
@@ -246,6 +266,11 @@ describe('Upload route', () => {
|
||||
const file = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
|
||||
const input = screen.getByTestId('upload-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /i have the rights to this skill and agree to publish it under mit-0/i,
|
||||
}),
|
||||
)
|
||||
const publishButton = screen.getByRole('button', { name: /publish/i }) as HTMLButtonElement
|
||||
await screen.findByText(/All checks passed/i)
|
||||
fireEvent.click(publishButton)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ClawdisSkillMetadata } from 'clawhub-schema'
|
||||
import {
|
||||
type ClawdisSkillMetadata,
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from 'clawhub-schema'
|
||||
import { Package } from 'lucide-react'
|
||||
import type { Doc, Id } from '../../convex/_generated/dataModel'
|
||||
import { getSkillBadges } from '../lib/badges'
|
||||
@@ -188,6 +192,9 @@ export function SkillHeader({
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skill-hero-note">
|
||||
<strong>{PLATFORM_SKILL_LICENSE}</strong> · {PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</div>
|
||||
<div className="stat">
|
||||
⭐ {formattedStats.stars} · <Package size={14} aria-hidden="true" />{' '}
|
||||
{formattedStats.downloads} · {formatCompactStat(skill.stats.installsCurrent ?? 0)} current
|
||||
@@ -220,6 +227,7 @@ export function SkillHeader({
|
||||
{badge}
|
||||
</div>
|
||||
))}
|
||||
<div className="tag tag-accent">{PLATFORM_SKILL_LICENSE}</div>
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<div className={`tag${isAutoHidden || isRemoved ? ' tag-accent' : ''}`}>
|
||||
{staffVisibilityTag}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ClawdisSkillMetadata } from 'clawhub-schema'
|
||||
import {
|
||||
type ClawdisSkillMetadata,
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from 'clawhub-schema'
|
||||
import { formatInstallCommand, formatInstallLabel } from './skillDetailUtils'
|
||||
|
||||
type SkillInstallCardProps = {
|
||||
@@ -25,12 +30,32 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
const hasInstallSpecs = installSpecs.length > 0
|
||||
const hasDependencies = dependencies.length > 0
|
||||
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation)
|
||||
const hasLicense = true
|
||||
|
||||
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) return null
|
||||
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="skill-hero-content">
|
||||
<div className="skill-hero-panels">
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: '1rem', margin: 0 }}>
|
||||
License
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
<div className="tag tag-accent">{PLATFORM_SKILL_LICENSE}</div>
|
||||
<div className="stat">
|
||||
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<strong>Terms</strong>
|
||||
<a href={PLATFORM_SKILL_LICENSE_URL} target="_blank" rel="noopener noreferrer">
|
||||
{PLATFORM_SKILL_LICENSE_URL}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasRuntimeRequirements ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: '1rem', margin: 0 }}>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from 'clawhub-schema'
|
||||
import { useAction, useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import semver from 'semver'
|
||||
@@ -64,6 +69,7 @@ export function Upload() {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [version, setVersion] = useState('1.0.0')
|
||||
const [tags, setTags] = useState('latest')
|
||||
const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false)
|
||||
const [changelog, setChangelog] = useState('')
|
||||
const [changelogStatus, setChangelogStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>(
|
||||
'idle',
|
||||
@@ -242,6 +248,9 @@ export function Upload() {
|
||||
if (parsedTags.length === 0) {
|
||||
issues.push('At least one tag is required.')
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
issues.push('Accept the MIT-0 license terms to publish this skill.')
|
||||
}
|
||||
if (files.length === 0) {
|
||||
issues.push('Add at least one file.')
|
||||
}
|
||||
@@ -272,8 +281,10 @@ export function Upload() {
|
||||
trimmedName,
|
||||
version,
|
||||
parsedTags.length,
|
||||
acceptedLicenseTerms,
|
||||
files,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
totalBytes,
|
||||
requiredFileLabel,
|
||||
slugCollision,
|
||||
@@ -309,6 +320,10 @@ export function Upload() {
|
||||
setError(slugCollision.message)
|
||||
return
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
setError('Accept the MIT-0 license terms to publish this skill.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
if (totalBytes > maxBytes) {
|
||||
setError('Total size exceeds 50MB per version.')
|
||||
@@ -353,6 +368,7 @@ export function Upload() {
|
||||
displayName: trimmedName,
|
||||
version,
|
||||
changelog: trimmedChangelog,
|
||||
acceptLicenseTerms: isSoulMode ? undefined : acceptedLicenseTerms,
|
||||
tags: parsedTags,
|
||||
files: uploaded,
|
||||
})
|
||||
@@ -519,6 +535,31 @@ export function Upload() {
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<h2 className="upload-panel-title">License</h2>
|
||||
<div className="upload-license-card">
|
||||
<div className="upload-license-pill">
|
||||
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME}
|
||||
</div>
|
||||
<p className="upload-license-copy">
|
||||
All skills published on ClawHub are licensed under {PLATFORM_SKILL_LICENSE}.{' '}
|
||||
{PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</p>
|
||||
<label className="upload-license-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedLicenseTerms}
|
||||
onChange={(event) => setAcceptedLicenseTerms(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
I have the rights to this skill and agree to publish it under{' '}
|
||||
{PLATFORM_SKILL_LICENSE}.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<label className="form-label" htmlFor="changelog">
|
||||
Changelog
|
||||
</label>
|
||||
|
||||
@@ -710,6 +710,55 @@ code {
|
||||
inset 0 1px 0 rgba(255, 214, 198, 0.42);
|
||||
}
|
||||
|
||||
.upload-license-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 118, 84, 0.2);
|
||||
background: rgba(255, 249, 245, 0.72);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .upload-license-card {
|
||||
border-color: rgba(255, 131, 95, 0.24);
|
||||
background: rgba(20, 36, 47, 0.76);
|
||||
}
|
||||
|
||||
.upload-license-pill {
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 107, 74, 0.16);
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .upload-license-pill {
|
||||
background: rgba(232, 106, 71, 0.24);
|
||||
color: #ffd0bf;
|
||||
}
|
||||
|
||||
.upload-license-copy {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.upload-license-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.upload-license-check input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.upload-submit-btn:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
|
||||
Reference in New Issue
Block a user