Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion convex/httpApiV1/skillsV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ type ListSkillsResult = {
version: string
createdAt: number
changelog: string
parsed?: { clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } } }
parsed?: {
clawdis?: {
os?: string[]
nix?: { plugin?: boolean; systems?: string[] }
capabilities?: string[]
}
}
} | null
}>
nextCursor: string | null
Expand Down Expand Up @@ -205,6 +211,7 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
version: item.latestVersion.version,
createdAt: item.latestVersion.createdAt,
changelog: item.latestVersion.changelog,
capabilities: item.latestVersion.parsed?.clawdis?.capabilities ?? [],
}
: null,
metadata: item.latestVersion?.parsed?.clawdis
Expand Down Expand Up @@ -301,6 +308,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
version: result.latestVersion.version,
createdAt: result.latestVersion.createdAt,
changelog: result.latestVersion.changelog,
capabilities: result.latestVersion.parsed?.clawdis?.capabilities ?? [],
}
: null,
metadata: result.latestVersion?.parsed?.clawdis
Expand Down
118 changes: 118 additions & 0 deletions convex/lib/skillCapabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const SKILL_CAPABILITIES = [
'shell',
'filesystem',
'network',
'browser',
'sessions',
'messaging',
'scheduling',
] as const

export type SkillCapability = (typeof SKILL_CAPABILITIES)[number]

const SKILL_CAPABILITY_SET = new Set<string>(SKILL_CAPABILITIES)

const CAPABILITY_ALIASES: Record<string, SkillCapability> = {
// shell
bash: 'shell',
command: 'shell',
commands: 'shell',
exec: 'shell',
process: 'shell',
shell: 'shell',
terminal: 'shell',
shell_exec: 'shell',

// filesystem
apply_patch: 'filesystem',
edit: 'filesystem',
file: 'filesystem',
files: 'filesystem',
filesystem: 'filesystem',
fs: 'filesystem',
write: 'filesystem',

// network
fetch: 'network',
http: 'network',
mcp: 'network',
network: 'network',
web: 'network',
'web-fetch': 'network',
web_fetch: 'network',
webfetch: 'network',
web_search: 'network',
'network.fetch': 'network',
'network.search': 'network',

// browser
browser: 'browser',
'computer-use': 'browser',
computer_use: 'browser',
gui: 'browser',
screen: 'browser',
ui: 'browser',

// sessions
delegate: 'sessions',
orchestration: 'sessions',
sessions: 'sessions',
sessions_send: 'sessions',
sessions_spawn: 'sessions',
subagent: 'sessions',
subagents: 'sessions',

// messaging
chat: 'messaging',
message: 'messaging',
messages: 'messaging',
messaging: 'messaging',

// scheduling
cron: 'scheduling',
schedule: 'scheduling',
scheduler: 'scheduling',
scheduling: 'scheduling',
timer: 'scheduling',
}

function normalizeCapabilityName(input: string): SkillCapability | null {
const key = input.trim().toLowerCase()
if (!key) return null
if (SKILL_CAPABILITY_SET.has(key)) return key as SkillCapability
const alias = CAPABILITY_ALIASES[key]
if (alias) return alias
const firstSegment = key.split(/[._:-]/)[0]
if (SKILL_CAPABILITY_SET.has(firstSegment)) return firstSegment as SkillCapability
return null
}

function extractCapabilityNames(input: unknown): string[] {
if (!input) return []
if (typeof input === 'string') return [input]
if (Array.isArray(input)) {
return input.flatMap((entry) => {
if (typeof entry === 'string') return [entry]
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
const obj = entry as Record<string, unknown>
const named = [obj.name, obj.type, obj.id, obj.capability].find(
(value) => typeof value === 'string',
)
return typeof named === 'string' ? [named] : []
})
}
if (typeof input === 'object') {
return Object.keys(input as Record<string, unknown>)
}
return []
}

export function normalizeCapabilities(input: unknown): SkillCapability[] {
const rawNames = extractCapabilityNames(input)
const out = new Set<SkillCapability>()
for (const rawName of rawNames) {
const normalized = normalizeCapabilityName(rawName)
if (normalized) out.add(normalized)
}
return Array.from(out)
}
51 changes: 51 additions & 0 deletions convex/lib/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,57 @@ describe('skills utils', () => {
expect(clawdis?.requires?.bins).toEqual(['rg'])
})

it('parses capabilities from clawdis metadata', () => {
const frontmatter = parseFrontmatter(
`---\nmetadata: {"clawdis":{"capabilities":["shell","network","unknown"]}}\n---\nBody`,
)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.capabilities).toEqual(['shell', 'network'])
})

it('normalizes alias capability names and new canonical values', () => {
const frontmatter = parseFrontmatter(
`---\nmetadata: {"clawdis":{"capabilities":["terminal","web_fetch","subagent","cron","message"]}}\n---\nBody`,
)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.capabilities).toEqual([
'shell',
'network',
'sessions',
'scheduling',
'messaging',
])
})

it('accepts object-style capabilities with inline constraints', () => {
const frontmatter = parseFrontmatter(`---
metadata:
clawdis:
capabilities:
shell:
mode: restricted
allow: [git, gh]
network:
web_search: true
web_fetch: true
---`)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.capabilities).toEqual(['shell', 'network'])
})

it('prefers openclaw capabilities when mixed with legacy clawdis metadata', () => {
const frontmatter = parseFrontmatter(`---
metadata:
clawdis:
emoji: "🦞"
openclaw:
capabilities: [terminal, web_fetch]
---`)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.emoji).toBe('🦞')
expect(clawdis?.capabilities).toEqual(['shell', 'network'])
})

it('ignores invalid clawdis metadata', () => {
const frontmatter = parseFrontmatter(`---\nmetadata: not-json\n---\nBody`)
expect(parseClawdisMetadata(frontmatter)).toBeUndefined()
Expand Down
12 changes: 10 additions & 2 deletions convex/lib/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TEXT_FILE_EXTENSION_SET,
} from 'clawhub-schema'
import { parse as parseYaml } from 'yaml'
import { normalizeCapabilities } from './skillCapabilities'

export type ParsedSkillFrontmatter = Record<string, unknown>
export type { ClawdisSkillMetadata, SkillInstallSpec }
Expand Down Expand Up @@ -70,13 +71,17 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
const clawdbotMeta = metadataRecord?.clawdbot
const clawdisMeta = metadataRecord?.clawdis
const openclawMeta = metadataRecord?.openclaw
const openclawObj =
openclawMeta && typeof openclawMeta === 'object' && !Array.isArray(openclawMeta)
? (openclawMeta as Record<string, unknown>)
: undefined
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>)
: openclawMeta && typeof openclawMeta === 'object' && !Array.isArray(openclawMeta)
? (openclawMeta as Record<string, unknown>)
: openclawObj
? openclawObj
: undefined
const clawdisRaw = metadataSource ?? frontmatter.clawdis

Expand Down Expand Up @@ -139,6 +144,9 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
if (typeof clawdisObj.author === 'string') metadata.author = clawdisObj.author
const links = parseSkillLinks(clawdisObj.links)
if (links) metadata.links = links
// Prefer canonical openclaw capability declarations when present, even in mixed legacy manifests.
const capabilities = normalizeCapabilities(openclawObj?.capabilities ?? clawdisObj.capabilities)
if (capabilities.length > 0) metadata.capabilities = capabilities

return parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata')
} catch {
Expand Down
54 changes: 54 additions & 0 deletions docs/skill-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,63 @@ metadata:
| `emoji` | `string` | Display emoji for the skill. |
| `homepage` | `string` | URL to the skill's homepage or docs. |
| `os` | `string[]` | OS restrictions (e.g. `["macos"]`, `["linux"]`). |
| `capabilities` | `string[] \| object \| object[]` | Capability declarations (`shell`, `filesystem`, `network`, `browser`, `sessions`, `messaging`, `scheduling`). |
| `install` | `array` | Install specs for dependencies (see below). |
| `nix` | `object` | Nix plugin spec (see README). |
| `config` | `object` | Clawdbot config spec (see README). |
| `cliHelp` | `string` | Optional CLI help text shown in skill details. |
| `envVars` | `array` | Structured env var declarations (`name`, `required`, `description`). |
| `dependencies` | `array` | Structured dependency declarations (`name`, `type`, optional metadata). |
| `author` | `string` | Optional skill author string. |
| `links` | `object` | Optional links (`homepage`, `repository`, `documentation`, `changelog`). |

### Capabilities shape and normalization

`metadata.openclaw.capabilities` supports flat and 2-layer shapes under the same key.

Flat list:

```yaml
metadata:
openclaw:
capabilities: [shell, network, sessions]
```

2-layer object (constraints as key/value pairs):

```yaml
metadata:
openclaw:
capabilities:
shell:
mode: restricted
allow: [git, gh]
network:
web_search: true
web_fetch: true
```

Array-of-objects is also accepted:

```yaml
metadata:
openclaw:
capabilities:
- type: network.search
constraints:
provider: brave
- name: shell.exec
constraints:
mode: restricted
```

Aliases are normalized to canonicals at parse time:

- `web_fetch`, `web_search`, `webfetch` -> `network`
- `terminal`, `bash`, `exec` -> `shell`
- `subagent`, `sessions_spawn` -> `sessions`
- `message` -> `messaging`
- `cron`, `schedule` -> `scheduling`

### Install specs

Expand Down
8 changes: 8 additions & 0 deletions packages/schema/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export const ApiV1SkillListResponseSchema = type({
version: 'string',
createdAt: 'number',
changelog: 'string',
capabilities: '("shell"|"filesystem"|"network"|"browser"|"sessions"|"messaging"|"scheduling")[]?',
}).optional(),
}).array(),
nextCursor: 'string|null',
Expand All @@ -190,6 +191,7 @@ export const ApiV1SkillResponseSchema = type({
version: 'string',
createdAt: 'number',
changelog: 'string',
capabilities: '("shell"|"filesystem"|"network"|"browser"|"sessions"|"messaging"|"scheduling")[]?',
}).or('null'),
owner: type({
handle: 'string|null',
Expand Down Expand Up @@ -295,6 +297,11 @@ export const ClawdisRequiresSchema = type({
})
export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred]

export const SkillCapabilitySchema = type(
'"shell"|"filesystem"|"network"|"browser"|"sessions"|"messaging"|"scheduling"',
)
export type SkillCapability = (typeof SkillCapabilitySchema)[inferred]

export const EnvVarDeclarationSchema = type({
name: 'string',
required: 'boolean?',
Expand Down Expand Up @@ -335,5 +342,6 @@ export const ClawdisSkillMetadataSchema = type({
dependencies: DependencyDeclarationSchema.array().optional(),
author: 'string?',
links: SkillLinksSchema.optional(),
capabilities: SkillCapabilitySchema.array().optional(),
})
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred]