feat: enforce MIT-0 skill licensing

This commit is contained in:
Peter Steinberger
2026-03-07 22:46:28 +00:00
parent deb216e3b4
commit 2687d671a0
38 changed files with 735 additions and 25 deletions
+8
View File
@@ -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`.
+1
View File
@@ -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.
+4
View File
@@ -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
+112
View File
@@ -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())
+4
View File
@@ -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
View File
@@ -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) {
+4 -1
View File
@@ -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) {
+2
View File
@@ -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,
+2
View File
@@ -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',
},
})
})
+2
View File
@@ -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)),
}),
),
},
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+7
View File
@@ -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')
+14 -1
View File
@@ -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 } : {}),
}),
+6
View File
@@ -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'
+40
View File
@@ -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',
+1
View File
@@ -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';
+1
View File
@@ -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';
+1 -1
View File
@@ -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"}
+7
View File
@@ -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];
+7
View File
@@ -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
+1
View File
@@ -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"}
+37
View File
@@ -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";
+38
View File
@@ -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"',
File diff suppressed because one or more lines are too long
+1
View File
@@ -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'
+10
View File
@@ -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]
+41
View File
@@ -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"',
+7
View File
@@ -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 }))
+25
View File
@@ -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)
+9 -1
View File
@@ -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}
+27 -2
View File
@@ -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 }}>
+41
View File
@@ -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>
+49
View File
@@ -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: