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:
parent
b34c7261bd
commit
0eb3047ca6
46
README.md
46
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
|
||||
|
||||
@ -12,7 +12,9 @@
|
||||
"!**/convex/_generated",
|
||||
"!**/src/routeTree.gen.ts",
|
||||
"!**/.tanstack",
|
||||
"!**/public"
|
||||
"!**/public",
|
||||
"!**/.devenv",
|
||||
"!**/.devenv"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@ -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
429
convex/devSeed.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)`
|
||||
}
|
||||
|
||||
|
||||
21
packages/schema/dist/schemas.d.ts
vendored
21
packages/schema/dist/schemas.d.ts
vendored
@ -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];
|
||||
|
||||
14
packages/schema/dist/schemas.js
vendored
14
packages/schema/dist/schemas.js
vendored
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
245
src/styles.css
245
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user