From 0eb3047ca607d4ea6c288dda4a4f323fd7b51f0a Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Mon, 5 Jan 2026 11:52:32 +0100 Subject: [PATCH] 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 --- README.md | 46 ++ biome.json | 4 +- convex/_generated/api.d.ts | 2 + convex/devSeed.ts | 429 ++++++++++++++++++ convex/lib/skills.test.ts | 29 ++ convex/lib/skills.ts | 46 +- convex/skills.ts | 39 ++ docs/spec.md | 5 +- .../clawdhub/src/cli/commands/publish.test.ts | 4 +- packages/clawdhub/src/cli/commands/sync.ts | 2 +- .../clawdhub/src/cli/commands/syncHelpers.ts | 18 +- packages/schema/dist/schemas.d.ts | 21 + packages/schema/dist/schemas.js | 14 +- packages/schema/src/schemas.ts | 16 + src/components/SkillCard.tsx | 10 +- src/components/SkillDetailPage.tsx | 122 ++++- src/routes/skills/index.tsx | 133 +++--- src/styles.css | 245 +++++++++- 18 files changed, 1083 insertions(+), 102 deletions(-) create mode 100644 convex/devSeed.ts diff --git a/README.md b/README.md index 6022b861..789d3880 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/biome.json b/biome.json index 2fc44548..ce6e671d 100644 --- a/biome.json +++ b/biome.json @@ -12,7 +12,9 @@ "!**/convex/_generated", "!**/src/routeTree.gen.ts", "!**/.tanstack", - "!**/public" + "!**/public", + "!**/.devenv", + "!**/.devenv" ] }, "assist": { "actions": { "source": { "organizeImports": "on" } } }, diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 2fbda41a..fa0c21f3 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/devSeed.ts b/convex/devSeed.ts new file mode 100644 index 00000000..fe0479ee --- /dev/null +++ b/convex/devSeed.ts @@ -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 + 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) { + 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 } + }, +}) diff --git a/convex/lib/skills.test.ts b/convex/lib/skills.test.ts index 6615c3e3..a8874324 100644 --- a/convex/lib/skills.test.ts +++ b/convex/lib/skills.test.ts @@ -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() diff --git a/convex/lib/skills.ts b/convex/lib/skills.ts index 418a7c9b..5d48e68f 100644 --- a/convex/lib/skills.ts +++ b/convex/lib/skills.ts @@ -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).clawdis + ? (metadata as Record) : 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) + : clawdisMeta && typeof clawdisMeta === 'object' && !Array.isArray(clawdisMeta) + ? (clawdisMeta as Record) + : 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 + 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 + 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') diff --git a/convex/skills.ts b/convex/skills.ts index 17b9bc8c..3af9e834 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -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()), diff --git a/docs/spec.md b/docs/spec.md index a5ea6338..5a5a6bf6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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. diff --git a/packages/clawdhub/src/cli/commands/publish.test.ts b/packages/clawdhub/src/cli/commands/publish.test.ts index 09dbc85a..eabd6bac 100644 --- a/packages/clawdhub/src/cli/commands/publish.test.ts +++ b/packages/clawdhub/src/cli/commands/publish.test.ts @@ -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') diff --git a/packages/clawdhub/src/cli/commands/sync.ts b/packages/clawdhub/src/cli/commands/sync.ts index 7c00929d..77162dbe 100644 --- a/packages/clawdhub/src/cli/commands/sync.ts +++ b/packages/clawdhub/src/cli/commands/sync.ts @@ -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 diff --git a/packages/clawdhub/src/cli/commands/syncHelpers.ts b/packages/clawdhub/src/cli/commands/syncHelpers.ts index 6ac6cb68..d5a69b45 100644 --- a/packages/clawdhub/src/cli/commands/syncHelpers.ts +++ b/packages/clawdhub/src/cli/commands/syncHelpers.ts @@ -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(items: T[], limit: number, fn: (item: T) => Promise) { +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +) { 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)` } diff --git a/packages/schema/dist/schemas.d.ts b/packages/schema/dist/schemas.d.ts index 2201987a..29377762 100644 --- a/packages/schema/dist/schemas.d.ts +++ b/packages/schema/dist/schemas.d.ts @@ -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]; diff --git a/packages/schema/dist/schemas.js b/packages/schema/dist/schemas.js index 1348db6b..3d9294b0 100644 --- a/packages/schema/dist/schemas.js +++ b/packages/schema/dist/schemas.js @@ -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 \ No newline at end of file +//# sourceMappingURL=schemas.js.map diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index 6805b253..fb8b869d 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -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] diff --git a/src/components/SkillCard.tsx b/src/components/SkillCard.tsx index dccf396c..c83e204e 100644 --- a/src/components/SkillCard.tsx +++ b/src/components/SkillCard.tsx @@ -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 ( - {badge ?
{badge}
: null} + {badge || chip ? ( +
+ {badge ?
{badge}
: null} + {chip ?
{chip}
: null} +
+ ) : null}

{skill.displayName}

{skill.summary ?? summaryFallback}

{meta}
diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index ba76abd3..5ff88ec1 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -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({ CLI Config - {nixSnippet ? ( -
-
Install via Nix
-
- {nixSystems.length ? `Systems: ${nixSystems.join(', ')}` : 'nix-clawdbot'} -
-
{nixSnippet}
-
- ) : null} {configRequirements ? (
Config requirements
@@ -299,9 +293,6 @@ export function SkillDetailPage({
) : null} - {configRequirements.example ? ( -
{configRequirements.example}
- ) : null} ) : null} {cliHelp ? ( @@ -441,6 +432,32 @@ export function SkillDetailPage({ ) : null} + {nixSnippet ? ( +
+

+ Install via Nix +

+

+ {nixSystems.length ? `Systems: ${nixSystems.join(', ')}` : 'nix-clawdbot'} +

+
+              {nixSnippet}
+            
+
+ ) : null} + {configExample ? ( +
+

+ Config example +

+

+ Starter config for this plugin bundle. +

+
+              {configExample}
+            
+
+ ) : null}
+ } + /> + ) + })}
) : (
- {sorted.map((skill) => ( - -
-
- {skill.displayName} - /{skill.slug} - {skill.batch === 'highlighted' ? Highlighted : null} + {sorted.map((entry) => { + const skill = entry.skill + const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin) + return ( + +
+
+ {skill.displayName} + /{skill.slug} + {skill.batch === 'highlighted' ? ( + Highlighted + ) : null} + {isPlugin ? ( + Plugin bundle (nix) + ) : null} +
+
+ {skill.summary ?? 'No summary provided.'} +
+ {isPlugin ? ( +
+ Bundle includes SKILL.md, CLI, and config. +
+ ) : null}
-
{skill.summary ?? 'No summary provided.'}
-
-
- ⤓ {skill.stats.downloads} - ⤒ {skill.stats.installsAllTime ?? 0} - ★ {skill.stats.stars} - {skill.stats.versions} v -
- - ))} +
+ ⤓ {skill.stats.downloads} + ⤒ {skill.stats.installsAllTime ?? 0} + ★ {skill.stats.stars} + {skill.stats.versions} v +
+ + ) + })}
)} diff --git a/src/styles.css b/src/styles.css index e9c6f548..2bdaf48c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 {