feat: add nix plugin bundles

- include nix plugin metadata, config requirements, and CLI help
- add config examples and format bundle code blocks
- refresh bundle UI styling and layout
This commit is contained in:
Josh Palmer 2026-01-05 11:52:32 +01:00 committed by Peter Steinberger
parent b34c7261bd
commit 0eb3047ca6
18 changed files with 1083 additions and 102 deletions

View File

@ -93,6 +93,52 @@ This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for y
- `JWT_PRIVATE_KEY` / `JWKS`: Convex Auth keys.
- `OPENAI_API_KEY`: embeddings for search + indexing.
## Nix plugins (nixmode skills)
ClawdHub can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
Nix package bundle to install. A nix plugin is different from a regular skill pack: it bundles the
skill pack, the CLI binary, and its config flags/requirements together.
Add this to `SKILL.md`:
```yaml
---
name: peekaboo
description: Capture and automate macOS UI with the Peekaboo CLI.
metadata: {"clawdbot":{"nix":{"plugin":"github:clawdbot/nix-steipete-tools?dir=tools/peekaboo","systems":["aarch64-darwin"]}}}
---
```
Install via nix-clawdbot:
```nix
programs.clawdbot.plugins = [
{ source = "github:clawdbot/nix-steipete-tools?dir=tools/peekaboo"; }
];
```
You can also declare config requirements + an example snippet:
```yaml
---
name: padel
description: Check padel court availability and manage bookings via Playtomic.
metadata: {"clawdbot":{"config":{"requiredEnv":["PADEL_AUTH_FILE"],"stateDirs":[".config/padel"],"example":"config = { env = { PADEL_AUTH_FILE = \\\"/run/agenix/padel-auth\\\"; }; };"}}}
---
```
To show CLI help (recommended for nix plugins), include the `cli --help` output:
```yaml
---
name: padel
description: Check padel court availability and manage bookings via Playtomic.
metadata: {"clawdbot":{"cliHelp":"padel --help\\nUsage: padel [command]\\n"}}
---
```
`metadata.clawdbot` is preferred, but `metadata.clawdis` is accepted as an alias for compatibility.
## Scripts
```bash

View File

@ -12,7 +12,9 @@
"!**/convex/_generated",
"!**/src/routeTree.gen.ts",
"!**/.tanstack",
"!**/public"
"!**/public",
"!**/.devenv",
"!**/.devenv"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },

View File

@ -11,6 +11,7 @@
import type * as auth from "../auth.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
import type * as devSeed from "../devSeed.js";
import type * as downloads from "../downloads.js";
import type * as githubBackups from "../githubBackups.js";
import type * as githubBackupsNode from "../githubBackupsNode.js";
@ -60,6 +61,7 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
comments: typeof comments;
crons: typeof crons;
devSeed: typeof devSeed;
downloads: typeof downloads;
githubBackups: typeof githubBackups;
githubBackupsNode: typeof githubBackupsNode;

429
convex/devSeed.ts Normal file
View File

@ -0,0 +1,429 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import { internalAction, internalMutation } from './_generated/server'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
type SeedSkillSpec = {
slug: string
displayName: string
summary: string
version: string
metadata: Record<string, unknown>
rawSkillMd: string
}
const SEED_SKILLS: SeedSkillSpec[] = [
{
slug: 'padel',
displayName: 'Padel',
summary: 'Check padel court availability and manage bookings via Playtomic.',
version: '0.1.0',
metadata: {
clawdbot: {
nix: {
plugin: 'github:joshp123/padel-cli',
systems: ['aarch64-darwin', 'x86_64-linux'],
},
config: {
requiredEnv: ['PADEL_AUTH_FILE'],
stateDirs: ['.config/padel'],
example:
'config = { env = { PADEL_AUTH_FILE = "/run/agenix/padel-auth"; }; stateDirs = [ ".config/padel" ]; };',
},
cliHelp: `Padel CLI for availability
Usage:
padel [command]
Available Commands:
auth Manage authentication
availability Show availability for a club on a date
book Book a court
bookings Manage bookings history
search Search for available courts
venues Manage saved venues
Flags:
-h, --help help for padel
--json Output JSON
Use "padel [command] --help" for more information about a command.
`,
},
},
rawSkillMd: `---
name: padel
description: Check padel court availability and manage bookings via the padel CLI.
---
# Padel Booking Skill
## CLI
\`\`\`bash
padel # On PATH (clawdbot plugin bundle)
\`\`\`
## Venues
Use the configured venue list in order of preference. If no venues are configured, ask for a venue name or location.
## Commands
### Check next booking
\`\`\`bash
padel bookings list 2>&1 | head -3
\`\`\`
### Search availability
\`\`\`bash
padel search --venues VENUE1,VENUE2 --date YYYY-MM-DD --time 09:00-12:00
\`\`\`
## Response guidelines
- Keep responses concise.
- Use 🎾 emoji.
- End with a call to action.
## Authorization
Only the authorized booker can confirm bookings. If the requester is not authorized, ask the authorized user to confirm.
`,
},
{
slug: 'gohome',
displayName: 'GoHome',
summary: 'Operate GoHome via gRPC discovery, metrics, and Grafana dashboards.',
version: '0.1.0',
metadata: {
clawdbot: {
nix: {
plugin: 'github:joshp123/gohome',
systems: ['x86_64-linux', 'aarch64-linux'],
},
config: {
requiredEnv: ['GOHOME_GRPC_ADDR', 'GOHOME_HTTP_BASE'],
example:
'config = { env = { GOHOME_GRPC_ADDR = "gohome:9000"; GOHOME_HTTP_BASE = "http://gohome:8080"; }; };',
},
cliHelp: `GoHome CLI
Usage:
gohome-cli [command]
Available Commands:
services List registered services
plugins Inspect loaded plugins
methods List RPC methods
call Call an RPC method
roborock Manage roborock devices
tado Manage tado zones
Flags:
--grpc-addr string gRPC endpoint (host:port)
-h, --help help for gohome-cli
`,
},
},
rawSkillMd: `---
name: gohome
description: Use when Clawdbot needs to test or operate GoHome via gRPC discovery, metrics, and Grafana.
---
# GoHome Skill
## Quick start
\`\`\`bash
export GOHOME_HTTP_BASE="http://gohome:8080"
export GOHOME_GRPC_ADDR="gohome:9000"
\`\`\`
## CLI
\`\`\`bash
gohome-cli services
\`\`\`
## Discovery flow (read-only)
1) List plugins.
2) Describe a plugin.
3) List RPC methods.
4) Call a read-only RPC.
## Metrics validation
\`\`\`bash
curl -s "\${GOHOME_HTTP_BASE}/gohome/metrics" | rg -n "gohome_"
\`\`\`
## Stateful actions
Only call write RPCs after explicit user approval.
`,
},
{
slug: 'xuezh',
displayName: 'Xuezh',
summary: 'Teach Mandarin with the xuezh engine for review, speaking, and audits.',
version: '0.1.0',
metadata: {
clawdbot: {
nix: {
plugin: 'github:joshp123/xuezh',
systems: ['aarch64-darwin', 'x86_64-linux'],
},
config: {
requiredEnv: ['XUEZH_AZURE_SPEECH_KEY_FILE', 'XUEZH_AZURE_SPEECH_REGION'],
stateDirs: ['.config/xuezh'],
example:
'config = { env = { XUEZH_AZURE_SPEECH_KEY_FILE = "/run/agenix/xuezh-azure-speech-key"; XUEZH_AZURE_SPEECH_REGION = "westeurope"; }; stateDirs = [ ".config/xuezh" ]; };',
},
cliHelp: `xuezh - Chinese learning engine
Usage:
xuezh [command]
Available Commands:
snapshot Fetch learner state snapshot
review Review due items
audio Process speech audio
items Manage learning items
events Log learning events
Flags:
-h, --help help for xuezh
--json Output JSON
`,
},
},
rawSkillMd: `---
name: xuezh
description: Teach Mandarin using the xuezh engine for review, speaking, and audits.
---
# Xuezh Skill
## Contract
Use the xuezh CLI exactly as specified. If a command is missing, ask for implementation instead of guessing.
## Default loop
1) Call \`xuezh snapshot\`.
2) Pick a tiny plan (1-2 bullets).
3) Run a short activity.
4) Log outcomes.
## CLI examples
\`\`\`bash
xuezh snapshot --profile default
xuezh review next --limit 10
xuezh audio process-voice --file ./utterance.wav
\`\`\`
`,
},
]
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
const frontmatterEnd = rawSkillMd.indexOf('\n---', 3)
if (frontmatterEnd === -1) return rawSkillMd
return `${rawSkillMd.slice(0, frontmatterEnd)}\nmetadata: ${JSON.stringify(
metadata,
)}${rawSkillMd.slice(frontmatterEnd)}`
}
export const seedNixSkills = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const results = []
for (const spec of SEED_SKILLS) {
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
const result = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})
results.push({ slug: spec.slug, ...result })
}
return { ok: true, results }
},
})
export const seedPadelSkill = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
if (!spec) throw new Error('padel seed spec missing')
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})
},
})
export const seedSkillMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
storageId: v.id('_storage'),
metadata: v.any(),
frontmatter: v.any(),
clawdis: v.any(),
skillMd: v.string(),
slug: v.string(),
displayName: v.string(),
summary: v.optional(v.string()),
version: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (existing && !args.reset) {
return { ok: true, skipped: true, skillId: existing._id }
}
if (existing && args.reset) {
const versions = await ctx.db
.query('skillVersions')
.withIndex('by_skill', (q) => q.eq('skillId', existing._id))
.collect()
for (const version of versions) {
await ctx.db.delete(version._id)
}
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', existing._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.delete(embedding._id)
}
await ctx.db.delete(existing._id)
}
const now = Date.now()
const existingUsers = await ctx.db
.query('users')
.withIndex('handle', (q) => q.eq('handle', 'local'))
.collect()
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert('users', {
handle: 'local',
displayName: 'Local Dev',
role: 'admin',
createdAt: now,
updatedAt: now,
}))
const skillId = await ctx.db.insert('skills', {
slug: args.slug,
displayName: args.displayName,
summary: args.summary,
ownerUserId: userId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 0,
comments: 0,
},
createdAt: now,
updatedAt: now,
})
const versionId = await ctx.db.insert('skillVersions', {
skillId,
version: args.version,
changelog: 'Seeded local version for screenshots.',
files: [
{
path: 'SKILL.md',
size: args.skillMd.length,
storageId: args.storageId,
sha256: 'seeded',
contentType: 'text/markdown',
},
],
parsed: {
frontmatter: args.frontmatter,
metadata: args.metadata,
clawdis: args.clawdis,
},
createdBy: userId,
createdAt: now,
softDeletedAt: undefined,
})
const embeddingId = await ctx.db.insert('skillEmbeddings', {
skillId,
versionId,
ownerId: userId,
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0),
isLatest: true,
isApproved: true,
visibility: 'latest-approved',
updatedAt: now,
})
await ctx.db.patch(skillId, {
latestVersionId: versionId,
tags: { latest: versionId },
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
updatedAt: now,
})
return { ok: true, skillId, versionId, embeddingId }
},
})

View File

@ -107,6 +107,35 @@ describe('skills utils', () => {
expect(clawdis?.requires?.anyBins).toEqual(['rg', 'fd'])
})
it('parses clawdbot metadata with nix plugin pointer', () => {
const frontmatter = parseFrontmatter(
`---\nmetadata: {"clawdbot":{"nix":{"plugin":"github:clawdbot/nix-steipete-tools?dir=tools/peekaboo","systems":["aarch64-darwin"]}}}\n---\nBody`,
)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.nix?.plugin).toBe('github:clawdbot/nix-steipete-tools?dir=tools/peekaboo')
expect(clawdis?.nix?.systems).toEqual(['aarch64-darwin'])
})
it('parses clawdbot config requirements with example', () => {
const frontmatter = parseFrontmatter(
`---\nmetadata: {"clawdbot":{"config":{"requiredEnv":["PADEL_AUTH_FILE"],"stateDirs":[".config/padel"],"example":"config = { env = { PADEL_AUTH_FILE = \\"/run/agenix/padel-auth\\"; }; };"}}}\n---\nBody`,
)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.config?.requiredEnv).toEqual(['PADEL_AUTH_FILE'])
expect(clawdis?.config?.stateDirs).toEqual(['.config/padel'])
expect(clawdis?.config?.example).toBe(
'config = { env = { PADEL_AUTH_FILE = "/run/agenix/padel-auth"; }; };',
)
})
it('parses cli help output', () => {
const frontmatter = parseFrontmatter(
`---\nmetadata: {"clawdbot":{"cliHelp":"padel --help\\nUsage: padel [command]\\n"}}\n---\nBody`,
)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.cliHelp).toBe('padel --help\nUsage: padel [command]')
})
it('sanitizes file paths', () => {
expect(sanitizePath('good/file.md')).toBe('good/file.md')
expect(sanitizePath('../bad/file.md')).toBeNull()

View File

@ -1,7 +1,9 @@
import {
type ClawdbotConfigSpec,
type ClawdisSkillMetadata,
ClawdisSkillMetadataSchema,
isTextContentType,
type NixPluginSpec,
parseArk,
type SkillInstallSpec,
TEXT_FILE_EXTENSION_SET,
@ -59,11 +61,19 @@ export function getFrontmatterMetadata(frontmatter: ParsedSkillFrontmatter) {
export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
const metadata = getFrontmatterMetadata(frontmatter)
const clawdisFromMetadata =
const metadataRecord =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? (metadata as Record<string, unknown>).clawdis
? (metadata as Record<string, unknown>)
: undefined
const clawdisRaw = clawdisFromMetadata ?? frontmatter.clawdis
const clawdbotMeta = metadataRecord?.clawdbot
const clawdisMeta = metadataRecord?.clawdis
const metadataSource =
clawdbotMeta && typeof clawdbotMeta === 'object' && !Array.isArray(clawdbotMeta)
? (clawdbotMeta as Record<string, unknown>)
: clawdisMeta && typeof clawdisMeta === 'object' && !Array.isArray(clawdisMeta)
? (clawdisMeta as Record<string, unknown>)
: undefined
const clawdisRaw = metadataSource ?? frontmatter.clawdis
if (!clawdisRaw || typeof clawdisRaw !== 'object' || Array.isArray(clawdisRaw)) return undefined
try {
@ -84,6 +94,7 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
if (typeof clawdisObj.homepage === 'string') metadata.homepage = clawdisObj.homepage
if (typeof clawdisObj.skillKey === 'string') metadata.skillKey = clawdisObj.skillKey
if (typeof clawdisObj.primaryEnv === 'string') metadata.primaryEnv = clawdisObj.primaryEnv
if (typeof clawdisObj.cliHelp === 'string') metadata.cliHelp = clawdisObj.cliHelp
if (osRaw.length > 0) metadata.os = osRaw
if (requiresRaw) {
@ -101,6 +112,10 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
}
if (install.length > 0) metadata.install = install
const nix = parseNixPluginSpec(clawdisObj.nix)
if (nix) metadata.nix = nix
const config = parseClawdbotConfigSpec(clawdisObj.config)
if (config) metadata.config = config
return parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata')
} catch {
@ -223,6 +238,31 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
return spec
}
function parseNixPluginSpec(input: unknown): NixPluginSpec | undefined {
if (!input || typeof input !== 'object') return undefined
const raw = input as Record<string, unknown>
if (typeof raw.plugin !== 'string') return undefined
const plugin = raw.plugin.trim()
if (!plugin) return undefined
const systems = normalizeStringList(raw.systems)
const spec: NixPluginSpec = { plugin }
if (systems.length > 0) spec.systems = systems
return spec
}
function parseClawdbotConfigSpec(input: unknown): ClawdbotConfigSpec | undefined {
if (!input || typeof input !== 'object') return undefined
const raw = input as Record<string, unknown>
const requiredEnv = normalizeStringList(raw.requiredEnv)
const stateDirs = normalizeStringList(raw.stateDirs)
const example = typeof raw.example === 'string' ? raw.example.trim() : ''
const spec: ClawdbotConfigSpec = {}
if (requiredEnv.length > 0) spec.requiredEnv = requiredEnv
if (stateDirs.length > 0) spec.stateDirs = stateDirs
if (example) spec.example = example
return Object.keys(spec).length > 0 ? spec : undefined
}
function toHex(bytes: Uint8Array) {
let out = ''
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')

View File

@ -113,6 +113,45 @@ export const list = query({
},
})
export const listWithLatest = query({
args: {
batch: v.optional(v.string()),
ownerUserId: v.optional(v.id('users')),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = args.limit ?? 24
let entries: Doc<'skills'>[] = []
if (args.batch) {
entries = await ctx.db
.query('skills')
.withIndex('by_batch', (q) => q.eq('batch', args.batch))
.order('desc')
.take(limit * 5)
} else if (args.ownerUserId) {
entries = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.order('desc')
.take(limit * 5)
} else {
entries = await ctx.db
.query('skills')
.order('desc')
.take(limit * 5)
}
const filtered = entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
const items = await Promise.all(
filtered.map(async (skill) => ({
skill,
latestVersion: skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null,
})),
)
return items
},
})
export const listPublicPage = query({
args: {
cursor: v.optional(v.string()),

View File

@ -61,7 +61,10 @@ read_when:
From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- `name`, `description`, `homepage`, `website`, `url`, `emoji`
- `metadata.clawdis`: `always`, `skillKey`, `primaryEnv`, `emoji`, `homepage`, `os`,
`requires` (`bins`, `anyBins`, `env`, `config`), `install[]`
`requires` (`bins`, `anyBins`, `env`, `config`), `install[]`, `nix` (`plugin`, `systems`),
`config` (`requiredEnv`, `stateDirs`, `example`), `cliHelp` (string; `cli --help` output)
- `metadata.clawdbot`: alias of `metadata.clawdis` (preferred for nix-clawdbot plugin pointers)
- Nix plugins are different from regular skills; they bundle the skill pack, the CLI binary, and config flags/requirements together.
- `metadata` in frontmatter is YAML (object) preferred; legacy JSON-string accepted.

View File

@ -80,7 +80,9 @@ describe('cmdPublish', () => {
})
if (!publishCall) throw new Error('Missing publish call')
const publishForm = (publishCall[1] as { form?: FormData }).form as FormData
const payload = JSON.parse(String(publishForm.get('payload')))
const payloadEntry = publishForm.get('payload')
if (typeof payloadEntry !== 'string') throw new Error('Missing publish payload')
const payload = JSON.parse(payloadEntry)
expect(payload.slug).toBe('my-skill')
expect(payload.displayName).toBe('My Skill')
expect(payload.version).toBe('1.0.0')

View File

@ -6,7 +6,6 @@ import { getFallbackSkillRoots } from '../scanSkills.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive } from '../ui.js'
import { cmdPublish } from './publish.js'
import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
import {
buildScanRoots,
checkRegistrySyncState,
@ -27,6 +26,7 @@ import {
scanRootsWithLabels,
selectToUpload,
} from './syncHelpers.js'
import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
const allowPrompt = isInteractive() && inputAllowed !== false

View File

@ -17,7 +17,7 @@ import { hashSkillZip } from '../../skills.js'
import { getRegistry } from '../registry.js'
import { findSkillFolders, type SkillFolder } from '../scanSkills.js'
import type { GlobalOpts } from '../types.js'
import { fail, formatError, isInteractive } from '../ui.js'
import { fail, formatError } from '../ui.js'
import type { Candidate, LocalSkill } from './syncTypes.js'
export async function reportTelemetryIfEnabled(params: {
@ -74,7 +74,11 @@ export function normalizeConcurrency(value: number | undefined) {
return Math.min(32, Math.max(1, rounded))
}
export async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>) {
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
) {
const results = Array.from({ length: items.length }) as R[]
let nextIndex = 0
const workerCount = Math.min(Math.max(1, limit), items.length || 1)
@ -373,7 +377,10 @@ export function dedupeSkillsBySlug(skills: SkillFolder[]) {
return { skills: unique, duplicates }
}
export function formatActionableStatus(candidate: Candidate, bump: 'patch' | 'minor' | 'major'): string {
export function formatActionableStatus(
candidate: Candidate,
bump: 'patch' | 'minor' | 'major',
): string {
if (candidate.status === 'new') return 'NEW'
const latest = candidate.latestVersion
const next = latest ? semver.inc(latest, bump) : null
@ -381,7 +388,10 @@ export function formatActionableStatus(candidate: Candidate, bump: 'patch' | 'mi
return 'UPDATE'
}
export function formatActionableLine(candidate: Candidate, bump: 'patch' | 'minor' | 'major'): string {
export function formatActionableLine(
candidate: Candidate,
bump: 'patch' | 'minor' | 'major',
): string {
return `${candidate.slug} ${formatActionableStatus(candidate, bump)} (${candidate.fileCount} files)`
}

View File

@ -232,6 +232,17 @@ export declare const SkillInstallSpecSchema: import("arktype/internal/variants/o
module?: string | undefined;
}, {}>;
export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred];
export declare const NixPluginSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
plugin: string;
systems?: string[] | undefined;
}, {}>;
export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred];
export declare const ClawdbotConfigSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
requiredEnv?: string[] | undefined;
stateDirs?: string[] | undefined;
example?: string | undefined;
}, {}>;
export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred];
export declare const ClawdisRequiresSchema: import("arktype/internal/variants/object.ts").ObjectType<{
bins?: string[] | undefined;
anyBins?: string[] | undefined;
@ -246,6 +257,7 @@ export declare const ClawdisSkillMetadataSchema: import("arktype/internal/varian
emoji?: string | undefined;
homepage?: string | undefined;
os?: string[] | undefined;
cliHelp?: string | undefined;
requires?: {
bins?: string[] | undefined;
anyBins?: string[] | undefined;
@ -262,5 +274,14 @@ export declare const ClawdisSkillMetadataSchema: import("arktype/internal/varian
package?: string | undefined;
module?: string | undefined;
}[] | undefined;
nix?: {
plugin: string;
systems?: string[] | undefined;
} | undefined;
config?: {
requiredEnv?: string[] | undefined;
stateDirs?: string[] | undefined;
example?: string | undefined;
} | undefined;
}, {}>;
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred];

View File

@ -202,6 +202,15 @@ export const SkillInstallSpecSchema = type({
package: 'string?',
module: 'string?',
});
export const NixPluginSpecSchema = type({
plugin: 'string',
systems: 'string[]?',
});
export const ClawdbotConfigSpecSchema = type({
requiredEnv: 'string[]?',
stateDirs: 'string[]?',
example: 'string?',
});
export const ClawdisRequiresSchema = type({
bins: 'string[]?',
anyBins: 'string[]?',
@ -215,7 +224,10 @@ export const ClawdisSkillMetadataSchema = type({
emoji: 'string?',
homepage: 'string?',
os: 'string[]?',
cliHelp: 'string?',
requires: ClawdisRequiresSchema.optional(),
install: SkillInstallSpecSchema.array().optional(),
nix: NixPluginSpecSchema.optional(),
config: ClawdbotConfigSpecSchema.optional(),
});
//# sourceMappingURL=schemas.js.map
//# sourceMappingURL=schemas.js.map

View File

@ -238,6 +238,19 @@ export const SkillInstallSpecSchema = type({
})
export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred]
export const NixPluginSpecSchema = type({
plugin: 'string',
systems: 'string[]?',
})
export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred]
export const ClawdbotConfigSpecSchema = type({
requiredEnv: 'string[]?',
stateDirs: 'string[]?',
example: 'string?',
})
export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred]
export const ClawdisRequiresSchema = type({
bins: 'string[]?',
anyBins: 'string[]?',
@ -253,7 +266,10 @@ export const ClawdisSkillMetadataSchema = type({
emoji: 'string?',
homepage: 'string?',
os: 'string[]?',
cliHelp: 'string?',
requires: ClawdisRequiresSchema.optional(),
install: SkillInstallSpecSchema.array().optional(),
nix: NixPluginSpecSchema.optional(),
config: ClawdbotConfigSpecSchema.optional(),
})
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred]

View File

@ -5,14 +5,20 @@ import type { Doc } from '../../convex/_generated/dataModel'
type SkillCardProps = {
skill: Doc<'skills'>
badge?: string
chip?: string
summaryFallback: string
meta: ReactNode
}
export function SkillCard({ skill, badge, summaryFallback, meta }: SkillCardProps) {
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
return (
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
{badge ? <div className="tag">{badge}</div> : null}
{badge || chip ? (
<div className="skill-card-tags">
{badge ? <div className="tag">{badge}</div> : null}
{chip ? <div className="tag tag-accent tag-compact">{chip}</div> : null}
</div>
) : null}
<h3 className="skill-card-title">{skill.displayName}</h3>
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>

View File

@ -103,6 +103,9 @@ export function SkillDetailPage({
const nixSystems = clawdis?.nix?.systems ?? []
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null
const configRequirements = clawdis?.config
const configExample = configRequirements?.example
? formatConfigSnippet(configRequirements.example)
: null
const cliHelp = clawdis?.cliHelp
const hasRuntimeRequirements = Boolean(
clawdis?.emoji ||
@ -273,15 +276,6 @@ export function SkillDetailPage({
<span>CLI</span>
<span>Config</span>
</div>
{nixSnippet ? (
<div className="bundle-section">
<div className="bundle-section-title">Install via Nix</div>
<div style={{ color: 'var(--ink-soft)', fontSize: '0.85rem' }}>
{nixSystems.length ? `Systems: ${nixSystems.join(', ')}` : 'nix-clawdbot'}
</div>
<pre className="hero-install-code">{nixSnippet}</pre>
</div>
) : null}
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
@ -299,9 +293,6 @@ export function SkillDetailPage({
</div>
) : null}
</div>
{configRequirements.example ? (
<pre className="hero-install-code">{configRequirements.example}</pre>
) : null}
</div>
) : null}
{cliHelp ? (
@ -441,6 +432,32 @@ export function SkillDetailPage({
</div>
) : null}
</div>
{nixSnippet ? (
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Install via Nix
</h2>
<p className="section-subtitle" style={{ margin: 0 }}>
{nixSystems.length ? `Systems: ${nixSystems.join(', ')}` : 'nix-clawdbot'}
</p>
<pre className="hero-install-code" style={{ marginTop: 12 }}>
{nixSnippet}
</pre>
</div>
) : null}
{configExample ? (
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Config example
</h2>
<p className="section-subtitle" style={{ margin: 0 }}>
Starter config for this plugin bundle.
</p>
<pre className="hero-install-code" style={{ marginTop: 12 }}>
{configExample}
</pre>
</div>
) : null}
<div className="card tab-card">
<div className="tab-header">
<button
@ -621,6 +638,84 @@ function buildSkillHref(ownerHandle: string | null, slug: string) {
return `/skills/${slug}`
}
function formatConfigSnippet(raw: string) {
const trimmed = raw.trim()
if (!trimmed || raw.includes('\n')) return raw
try {
const parsed = JSON.parse(raw)
return JSON.stringify(parsed, null, 2)
} catch {
// fall through
}
let out = ''
let indent = 0
let inString = false
let isEscaped = false
const newline = () => {
out = out.replace(/[ \t]+$/u, '')
out += `\n${' '.repeat(indent * 2)}`
}
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i]
if (inString) {
out += ch
if (isEscaped) {
isEscaped = false
} else if (ch === '\\') {
isEscaped = true
} else if (ch === '"') {
inString = false
}
continue
}
if (ch === '"') {
inString = true
out += ch
continue
}
if (ch === '{' || ch === '[') {
out += ch
indent += 1
newline()
continue
}
if (ch === '}' || ch === ']') {
indent = Math.max(0, indent - 1)
newline()
out += ch
continue
}
if (ch === ';' || ch === ',') {
out += ch
newline()
continue
}
if (ch === '\n' || ch === '\r' || ch === '\t') {
continue
}
if (ch === ' ') {
if (out.endsWith(' ') || out.endsWith('\n')) {
continue
}
out += ' '
continue
}
out += ch
}
return out.trim()
}
function stripFrontmatter(content: string) {
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!normalized.startsWith('---')) return content
@ -681,5 +776,6 @@ function formatBytes(bytes: number) {
}
function formatNixInstallSnippet(plugin: string) {
return `programs.clawdbot.plugins = [\n { source = "${plugin}"; }\n];`
const snippet = `programs.clawdbot.plugins = [ { source = "${plugin}"; } ];`
return formatConfigSnippet(snippet)
}

View File

@ -42,8 +42,10 @@ function SkillsIndex() {
const highlightedOnly = search.highlighted ?? false
const [query, setQuery] = useState(search.q ?? '')
const skills = useQuery(api.skills.list, { limit: 500 }) as Doc<'skills'>[] | undefined
const isLoadingSkills = skills === undefined
const items = useQuery(api.skills.listWithLatest, { limit: 500 }) as
| Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
| undefined
const isLoadingSkills = items === undefined
useEffect(() => {
setQuery(search.q ?? '')
@ -51,16 +53,17 @@ function SkillsIndex() {
const filtered = useMemo(() => {
const value = query.trim().toLowerCase()
const all = (skills ?? []).filter((skill) =>
highlightedOnly ? skill.batch === 'highlighted' : true,
const all = (items ?? []).filter((entry) =>
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
)
if (!value) return all
return all.filter((skill) => {
return all.filter((entry) => {
const skill = entry.skill
if (skill.slug.toLowerCase().includes(value)) return true
if (skill.displayName.toLowerCase().includes(value)) return true
return (skill.summary ?? '').toLowerCase().includes(value)
})
}, [highlightedOnly, query, skills])
}, [highlightedOnly, query, items])
const sorted = useMemo(() => {
const multiplier = dir === 'asc' ? 1 : -1
@ -68,28 +71,31 @@ function SkillsIndex() {
results.sort((a, b) => {
switch (sort) {
case 'downloads':
return (a.stats.downloads - b.stats.downloads) * multiplier
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier
case 'installs':
return ((a.stats.installsAllTime ?? 0) - (b.stats.installsAllTime ?? 0)) * multiplier
case 'stars':
return (a.stats.stars - b.stats.stars) * multiplier
case 'updated':
return (a.updatedAt - b.updatedAt) * multiplier
case 'name':
return (
(a.displayName.localeCompare(b.displayName) || a.slug.localeCompare(b.slug)) *
((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) *
multiplier
)
case 'stars':
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier
case 'updated':
return (a.skill.updatedAt - b.skill.updatedAt) * multiplier
case 'name':
return (
a.skill.displayName.localeCompare(b.skill.displayName) ||
a.skill.slug.localeCompare(b.skill.slug)
) * multiplier
default:
return (a.createdAt - b.createdAt) * multiplier
return (a.skill.createdAt - b.skill.createdAt) * multiplier
}
})
return results
}, [dir, filtered, sort])
const showing = sorted.length
const total = skills?.filter((skill) =>
highlightedOnly ? skill.batch === 'highlighted' : true,
const total = items?.filter((entry) =>
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
).length
return (
@ -207,46 +213,67 @@ function SkillsIndex() {
<div className="card">No skills match that filter.</div>
) : view === 'cards' ? (
<div className="grid">
{sorted.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))}
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
return (
<SkillCard
key={skill._id}
skill={skill}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
)
})}
</div>
) : (
<div className="skills-list">
{sorted.map((skill) => (
<Link
key={skill._id}
className="skills-row"
to="/skills/$slug"
params={{ slug: skill.slug }}
>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
<span className="skills-row-slug">/{skill.slug}</span>
{skill.batch === 'highlighted' ? <span className="tag">Highlighted</span> : null}
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
return (
<Link
key={skill._id}
className="skills-row"
to="/skills/$slug"
params={{ slug: skill.slug }}
>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
<span className="skills-row-slug">/{skill.slug}</span>
{skill.batch === 'highlighted' ? (
<span className="tag">Highlighted</span>
) : null}
{isPlugin ? (
<span className="tag tag-accent tag-compact">Plugin bundle (nix)</span>
) : null}
</div>
<div className="skills-row-summary">
{skill.summary ?? 'No summary provided.'}
</div>
{isPlugin ? (
<div className="skills-row-meta">
Bundle includes SKILL.md, CLI, and config.
</div>
) : null}
</div>
<div className="skills-row-summary">{skill.summary ?? 'No summary provided.'}</div>
</div>
<div className="skills-row-metrics">
<span> {skill.stats.downloads}</span>
<span> {skill.stats.installsAllTime ?? 0}</span>
<span> {skill.stats.stars}</span>
<span>{skill.stats.versions} v</span>
</div>
</Link>
))}
<div className="skills-row-metrics">
<span> {skill.stats.downloads}</span>
<span> {skill.stats.installsAllTime ?? 0}</span>
<span> {skill.stats.stars}</span>
<span>{skill.stats.versions} v</span>
</div>
</Link>
)
})}
</div>
)}
</main>

View File

@ -458,7 +458,7 @@ code {
.upload-fields {
display: grid;
gap: 18px;
gap: 14px;
}
.upload-field {
@ -758,7 +758,7 @@ code {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 22px;
}
@ -855,8 +855,8 @@ code {
.skills-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
padding: 16px 18px;
gap: 14px;
padding: 14px 18px;
text-decoration: none;
color: inherit;
border-bottom: 1px solid var(--line);
@ -960,6 +960,22 @@ code {
min-height: 176px;
}
.skill-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-compact {
padding: 3px 10px;
font-size: 0.72rem;
}
.skills-row-meta {
font-size: 0.82rem;
color: var(--ink-soft);
}
.skill-card-title {
font-family: var(--font-display);
font-size: 1.2rem;
@ -1051,19 +1067,32 @@ code {
.hero-install-code {
border: 1px solid rgba(255, 107, 74, 0.2);
background: rgba(255, 107, 74, 0.08);
border-radius: 14px;
padding: 10px 12px;
font-size: 0.95rem;
border-left: 3px solid var(--accent-deep);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 247, 0.9));
border-radius: 12px;
padding: 12px 14px;
font-size: 0.9rem;
line-height: 1.55;
color: var(--ink);
font-family: var(--font-mono);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
white-space: nowrap;
margin: 0;
text-align: left;
font-variant-ligatures: none;
font-feature-settings:
"liga" 0,
"calt" 0;
white-space: pre;
tab-size: 2;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
[data-theme="dark"] .hero-install-code {
border-color: rgba(232, 106, 71, 0.32);
background: rgba(232, 106, 71, 0.14);
border-color: rgba(232, 106, 71, 0.35);
background: linear-gradient(180deg, rgba(26, 20, 18, 0.9), rgba(20, 16, 14, 0.85));
color: #f5e9e3;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.tag {
@ -1222,7 +1251,7 @@ code {
}
.skill-hero {
gap: 18px;
gap: 14px;
}
.skill-hero-header {
@ -1233,6 +1262,12 @@ code {
gap: 24px;
}
@media (max-width: 960px) {
.skill-hero-top.has-plugin {
grid-template-columns: 1fr;
}
}
.skill-hero-title {
display: grid;
gap: 10px;
@ -1240,6 +1275,160 @@ code {
flex: 1 1 360px;
}
.skill-hero-title-row {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.skill-hero-note {
font-size: 0.9rem;
color: var(--ink);
max-width: 560px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(255, 107, 74, 0.08);
border: 1px solid rgba(255, 107, 74, 0.18);
}
[data-theme="dark"] .skill-hero-note {
background: rgba(232, 106, 71, 0.16);
border-color: rgba(232, 106, 71, 0.3);
}
.tag-accent {
background: rgba(255, 107, 74, 0.16);
color: var(--accent-deep);
}
.skill-hero-top {
display: grid;
gap: 16px;
}
.skill-hero-top.has-plugin {
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
align-items: start;
}
.skill-hero-content {
display: grid;
gap: 16px;
}
.bundle-card {
border: 1px solid rgba(255, 107, 74, 0.35);
border-radius: 18px;
padding: 18px;
background: linear-gradient(135deg, rgba(255, 107, 74, 0.1), rgba(255, 255, 255, 0.7));
box-shadow: 0 18px 30px rgba(37, 31, 26, 0.08);
}
[data-theme="dark"] .bundle-card {
background: linear-gradient(135deg, rgba(232, 106, 71, 0.2), rgba(24, 19, 16, 0.7));
border-color: rgba(232, 106, 71, 0.5);
box-shadow: 0 18px 30px rgba(0, 0, 0, 0.35);
}
[data-theme="dark"] .tag-accent {
background: rgba(232, 106, 71, 0.24);
color: #ffd0bf;
}
.bundle-header {
display: grid;
gap: 6px;
}
.bundle-title {
font-family: var(--font-display);
font-size: 1.05rem;
letter-spacing: -0.015em;
}
.bundle-subtitle {
font-size: 0.88rem;
color: var(--ink-soft);
}
.bundle-includes {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bundle-includes span {
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 107, 74, 0.16);
color: var(--accent-deep);
font-size: 0.76rem;
font-weight: 650;
letter-spacing: 0.01em;
}
[data-theme="dark"] .bundle-includes span {
background: rgba(232, 106, 71, 0.22);
color: #ffd0bf;
}
.bundle-section {
display: grid;
gap: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 107, 74, 0.12);
}
.bundle-card .hero-install-code {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
text-align: left;
font-variant-ligatures: none;
font-feature-settings:
"liga" 0,
"calt" 0;
}
[data-theme="dark"] .bundle-section {
background: rgba(30, 24, 20, 0.6);
border-color: rgba(232, 106, 71, 0.2);
}
.bundle-section-title {
font-size: 0.9rem;
font-weight: 650;
}
.bundle-meta {
display: grid;
gap: 6px;
}
.bundle-details summary {
cursor: pointer;
font-weight: 650;
list-style: none;
}
.bundle-details summary::-webkit-details-marker {
display: none;
}
.bundle-details summary::before {
content: "▸";
display: inline-block;
margin-right: 8px;
color: var(--accent-deep);
}
.bundle-details[open] summary::before {
content: "▾";
}
.skill-hero-cta {
display: flex;
flex-direction: column;
@ -1498,7 +1687,7 @@ code {
}
.tab-card {
gap: 18px;
gap: 14px;
}
.tab-header {
@ -1926,7 +2115,7 @@ code {
.settings-profile {
display: flex;
align-items: center;
gap: 18px;
gap: 14px;
background: linear-gradient(135deg, var(--surface), var(--surface-muted));
}
@ -2175,13 +2364,20 @@ code {
}
.markdown pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
background: rgba(255, 107, 74, 0.06);
border: 1px solid rgba(255, 107, 74, 0.12);
border-radius: 14px;
white-space: pre;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 250, 247, 0.88));
border: 1px solid rgba(255, 107, 74, 0.18);
border-left: 3px solid var(--accent-deep);
border-radius: 12px;
padding: 14px 16px;
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.55;
tab-size: 2;
color: var(--ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.markdown pre code {
@ -2189,11 +2385,16 @@ code {
padding: 0;
border-radius: 0;
white-space: inherit;
font-family: inherit;
color: inherit;
}
[data-theme="dark"] .markdown pre {
background: rgba(18, 14, 12, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(26, 20, 18, 0.9), rgba(20, 16, 14, 0.85));
border: 1px solid rgba(232, 106, 71, 0.28);
border-left-color: rgba(232, 106, 71, 0.8);
color: #f5e9e3;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .markdown code {