community: add Phase 6 marketplace integration and contribution guides

ClawdHub Web UI Integration:
- Add ClawdHub API client (search, details, install, updates)
- Add Gateway RPC handlers with auth scopes
- Add Marketplace tab with browse/installed views
- Add state management and Lit HTML components

Documentation:
- Add feature maturity matrix classifying 100+ features
- Add skills contribution guide (SKILL.md format, gating, publishing)
- Add plugins contribution guide (manifest, API, distribution)
- Update CONTRIBUTING.md with links to new guides
- Add Contributing section to docs navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ronitchidara 2026-01-27 20:20:26 +05:30
parent 7ff1dc3bba
commit 8e00cbf27d
17 changed files with 2812 additions and 1 deletions

View File

@ -41,6 +41,14 @@ Please include in your PR:
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
## Contribution Guides
Detailed guides for extending Clawdbot:
- **[Skills Guide](https://docs.clawd.bot/contributing/skills)** - Create and publish skills to ClawdHub
- **[Plugins Guide](https://docs.clawd.bot/contributing/plugins)** - Build plugins for channels, tools, and more
- **[Feature Maturity](https://docs.clawd.bot/reference/feature-maturity)** - Understand feature stability levels
## Current Focus & Roadmap 🗺
We are currently prioritizing:

View File

@ -0,0 +1,639 @@
---
title: Contributing Plugins
description: How to create, test, and distribute Clawdbot plugins
---
# Contributing Plugins
This guide walks you through creating plugins that extend Clawdbot with new channels, tools, commands, and services.
## What is a Plugin?
A plugin is a TypeScript module that extends Clawdbot at runtime. Plugins can:
- Register new messaging channels (like Matrix, Nostr, MS Teams)
- Add agent tools the AI can invoke
- Register Gateway RPC methods
- Add CLI commands
- Run background services
- Ship bundled skills
- Register auto-reply commands
Plugins run **in-process** with the Gateway, so treat them as trusted code.
## Quick Start
Create a minimal plugin in 5 minutes:
```bash
# Create plugin directory
mkdir -p ~/.clawdbot/extensions/hello-world
# Create the manifest
cat > ~/.clawdbot/extensions/hello-world/clawdbot.plugin.json << 'EOF'
{
"id": "hello-world",
"name": "Hello World",
"description": "A simple example plugin",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"greeting": {
"type": "string",
"default": "Hello"
}
}
}
}
EOF
# Create the plugin code
cat > ~/.clawdbot/extensions/hello-world/index.ts << 'EOF'
export default function register(api) {
const config = api.pluginConfig ?? {};
const greeting = config.greeting ?? "Hello";
// Register a Gateway RPC method
api.registerGatewayMethod("hello.greet", ({ params, respond }) => {
const name = params?.name ?? "World";
respond(true, { message: `${greeting}, ${name}!` });
});
// Register an auto-reply command
api.registerCommand({
name: "hello",
description: "Say hello",
handler: () => ({ text: `${greeting} from the plugin!` }),
});
api.logger.info("[hello-world] Plugin loaded");
}
EOF
```
Restart the Gateway to load your plugin.
## Plugin Structure
### Directory Layout
```
my-plugin/
├── clawdbot.plugin.json # Required: manifest
├── index.ts # Required: entry point
├── package.json # Optional: for npm distribution
├── src/ # Optional: source files
│ ├── cli.ts
│ ├── tools.ts
│ └── types.ts
├── skills/ # Optional: bundled skills
│ └── my-skill/
│ └── SKILL.md
└── README.md
```
### Plugin Manifest (clawdbot.plugin.json)
Every plugin **must** have a manifest:
```json
{
"id": "my-plugin",
"name": "My Plugin",
"description": "Does amazing things",
"version": "1.0.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": { "type": "string" },
"enabled": { "type": "boolean" }
}
},
"uiHints": {
"apiKey": { "label": "API Key", "sensitive": true },
"enabled": { "label": "Enable Feature" }
}
}
```
#### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique plugin identifier |
| `configSchema` | object | JSON Schema for config validation |
#### Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Display name |
| `description` | string | Short summary |
| `version` | string | Semantic version |
| `kind` | string | Plugin category (e.g., `"memory"`) |
| `channels` | string[] | Channel IDs this plugin registers |
| `providers` | string[] | Provider IDs this plugin registers |
| `skills` | string[] | Skill directories to load |
| `uiHints` | object | UI labels and hints |
### Entry Point (index.ts)
Plugins export either a function or an object:
```typescript
// Function style (simple)
export default function register(api) {
// Registration code
}
// Object style (with metadata)
export default {
id: "my-plugin",
name: "My Plugin",
configSchema: { /* ... */ },
register(api) {
// Registration code
},
};
```
## Plugin API
The `api` object provides access to Clawdbot internals:
### Core Properties
```typescript
api.config // Full Clawdbot configuration
api.pluginConfig // This plugin's config (plugins.entries.<id>.config)
api.logger // Scoped logger instance
api.runtime // Runtime helpers (TTS, etc.)
```
### Registration Methods
#### Gateway RPC Methods
```typescript
api.registerGatewayMethod("myplugin.action", async ({ params, respond }) => {
// params: request parameters
// respond(ok: boolean, payload?: any): send response
try {
const result = await doSomething(params);
respond(true, { data: result });
} catch (err) {
respond(false, { error: err.message });
}
});
```
#### Agent Tools
```typescript
import { Type } from "@sinclair/typebox";
api.registerTool({
name: "my_tool",
description: "Does something useful",
inputSchema: Type.Object({
query: Type.String({ description: "Search query" }),
limit: Type.Optional(Type.Number({ description: "Max results" })),
}),
handler: async ({ params, context }) => {
const results = await search(params.query, params.limit);
return { results };
},
});
```
#### CLI Commands
```typescript
api.registerCli(({ program }) => {
program
.command("myplugin")
.description("My plugin command")
.option("-v, --verbose", "Verbose output")
.action((options) => {
console.log("Running with options:", options);
});
}, { commands: ["myplugin"] });
```
#### Auto-Reply Commands
Commands that respond without invoking the AI:
```typescript
api.registerCommand({
name: "mystatus",
description: "Show plugin status",
acceptsArgs: false,
requireAuth: true,
handler: (ctx) => ({
text: `Plugin active on ${ctx.channel}`,
}),
});
```
Handler context:
- `ctx.senderId` - Sender ID
- `ctx.channel` - Channel name
- `ctx.isAuthorizedSender` - Auth status
- `ctx.args` - Command arguments (if `acceptsArgs: true`)
- `ctx.config` - Clawdbot config
#### Background Services
```typescript
api.registerService({
id: "my-background-service",
start: async () => {
api.logger.info("Service starting");
// Initialize background work
},
stop: async () => {
api.logger.info("Service stopping");
// Cleanup
},
});
```
#### Messaging Channels
```typescript
api.registerChannel({
plugin: {
id: "mychannel",
meta: {
id: "mychannel",
label: "My Channel",
selectionLabel: "My Channel (API)",
docsPath: "/channels/mychannel",
blurb: "Custom messaging channel",
aliases: ["mc"],
},
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: (cfg) =>
Object.keys(cfg.channels?.mychannel?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
cfg.channels?.mychannel?.accounts?.[accountId ?? "default"],
},
outbound: {
deliveryMode: "direct",
sendText: async ({ text, target }) => {
await deliverMessage(text, target);
return { ok: true };
},
},
},
});
```
#### Model Providers
```typescript
api.registerProvider({
id: "acme",
label: "AcmeAI",
auth: [
{
id: "oauth",
label: "OAuth Login",
kind: "oauth",
run: async (ctx) => {
// OAuth flow
return {
profiles: [{ profileId: "acme:default", credential: { ... } }],
defaultModel: "acme/model-1",
};
},
},
],
});
```
## Configuration
### User Configuration
Users configure your plugin in `~/.clawdbot/clawdbot.json`:
```json5
{
plugins: {
entries: {
"my-plugin": {
enabled: true,
config: {
apiKey: "sk-xxx",
endpoint: "https://api.example.com"
}
}
}
}
}
```
### Config Schema
Define validation rules in the manifest:
```json
{
"configSchema": {
"type": "object",
"additionalProperties": false,
"required": ["apiKey"],
"properties": {
"apiKey": {
"type": "string",
"minLength": 1
},
"endpoint": {
"type": "string",
"format": "uri"
},
"timeout": {
"type": "integer",
"minimum": 1000,
"default": 30000
}
}
}
}
```
### UI Hints
Help the Control UI render better forms:
```json
{
"uiHints": {
"apiKey": {
"label": "API Key",
"placeholder": "sk-...",
"sensitive": true
},
"endpoint": {
"label": "API Endpoint",
"help": "Custom endpoint URL",
"advanced": true
},
"timeout": {
"label": "Timeout (ms)",
"advanced": true
}
}
}
```
UI hint fields:
- `label` - Display label
- `placeholder` - Input placeholder
- `help` - Help text
- `sensitive` - Mark as password field
- `advanced` - Hide in basic view
## Bundling Skills
Plugins can ship skills:
```json
{
"id": "my-plugin",
"skills": ["./skills/my-skill"]
}
```
Skills are loaded when the plugin is enabled and follow standard skill precedence.
## Plugin Hooks
Plugins can register event hooks:
```typescript
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
export default function register(api) {
registerPluginHooksFromDir(api, "./hooks");
}
```
Hook directories follow the standard hook structure (`HOOK.md` + `handler.ts`).
## Testing
### Local Testing
1. Create your plugin in `~/.clawdbot/extensions/`
2. Restart the Gateway
3. Check it loaded: `clawdbot plugins list`
4. Test your features
### Development Mode
Link your plugin for live development:
```bash
clawdbot plugins install -l ./my-plugin
```
The `-l` flag creates a symlink instead of copying.
### Unit Tests
Ship tests with your plugin:
```typescript
// src/my-plugin.test.ts
import { describe, it, expect } from "vitest";
import { myFunction } from "./my-function.js";
describe("myFunction", () => {
it("should process input correctly", () => {
expect(myFunction("test")).toBe("expected");
});
});
```
Run with Vitest or your preferred test runner.
## Distribution
### npm Publishing
Package your plugin for npm:
```json
// package.json
{
"name": "@yourname/clawdbot-my-plugin",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist", "clawdbot.plugin.json"],
"clawdbot": {
"extensions": ["./dist/index.js"]
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
```
Users install with:
```bash
clawdbot plugins install @yourname/clawdbot-my-plugin
```
### Local Distribution
Distribute as a tarball or zip:
```bash
# Create archive
tar -czf my-plugin.tar.gz my-plugin/
# Install
clawdbot plugins install ./my-plugin.tar.gz
```
## Best Practices
### Naming
- Plugin ID: kebab-case (`my-plugin`)
- npm package: `@scope/clawdbot-*` or `clawdbot-*`
- RPC methods: `pluginid.action`
- Tools: `snake_case`
### Error Handling
```typescript
api.registerGatewayMethod("myplugin.action", async ({ params, respond }) => {
try {
// Validate required params
if (!params?.id) {
respond(false, { error: "id is required" });
return;
}
const result = await doWork(params.id);
respond(true, { data: result });
} catch (err) {
api.logger.error("[myplugin] Action failed:", err);
respond(false, {
error: err instanceof Error ? err.message : "Unknown error"
});
}
});
```
### Logging
Use the scoped logger:
```typescript
api.logger.debug("[myplugin] Debug info");
api.logger.info("[myplugin] Starting");
api.logger.warn("[myplugin] Warning message");
api.logger.error("[myplugin] Error:", error);
```
### Configuration Defaults
Handle missing config gracefully:
```typescript
const config = api.pluginConfig ?? {};
const timeout = config.timeout ?? 30000;
const endpoint = config.endpoint ?? "https://api.example.com";
```
### Deprecation Warnings
```typescript
if (config.oldOption !== undefined) {
api.logger.warn(
"[myplugin] 'oldOption' is deprecated; use 'newOption' instead"
);
}
```
### Cleanup
Always clean up resources:
```typescript
let interval: NodeJS.Timeout | null = null;
api.registerService({
id: "my-service",
start: () => {
interval = setInterval(checkStatus, 60000);
},
stop: () => {
if (interval) {
clearInterval(interval);
interval = null;
}
},
});
```
## Example Plugins
Browse official plugins for patterns:
| Plugin | Description | Key Patterns |
|--------|-------------|--------------|
| [voice-call](https://github.com/clawdbot/clawdbot/tree/main/extensions/voice-call) | Phone calls | RPC methods, tools, CLI, config schema |
| [matrix](https://github.com/clawdbot/clawdbot/tree/main/extensions/matrix) | Matrix channel | Channel registration, E2EE |
| [memory-lancedb](https://github.com/clawdbot/clawdbot/tree/main/extensions/memory-lancedb) | Vector memory | Plugin slots, background service |
| [lobster](https://github.com/clawdbot/clawdbot/tree/main/extensions/lobster) | Natural CLI | Tool registration |
## Troubleshooting
### Plugin not loading
1. Check `clawdbot plugins list`
2. Verify manifest exists: `clawdbot.plugin.json`
3. Check for syntax errors in manifest JSON
4. Look for errors in logs: `clawdbot logs -f`
5. Run doctor: `clawdbot doctor`
### Config validation errors
1. Verify JSON Schema in manifest
2. Check `additionalProperties: false` blocks unknown keys
3. Test config against schema with a JSON validator
### RPC method not found
1. Verify method name matches registration
2. Check plugin is enabled in config
3. Restart Gateway after changes
### Tool not appearing
1. Verify tool registration code runs
2. Check tool name doesn't conflict
3. Verify schema is valid (no `anyOf`/`oneOf`)
## Resources
- [Plugin reference](/plugin) - Full API documentation
- [Plugin manifest](/plugins/manifest) - Manifest schema
- [Plugin agent tools](/plugins/agent-tools) - Tool authoring guide
- [Voice Call example](/plugins/voice-call) - Complete plugin example
- [Feature maturity](/reference/feature-maturity) - Plugin system stability

422
docs/contributing/skills.md Normal file
View File

@ -0,0 +1,422 @@
---
title: Contributing Skills
description: How to create, test, and publish Clawdbot skills
---
# Contributing Skills
This guide walks you through creating skills for Clawdbot, from your first `SKILL.md` to publishing on ClawdHub.
## What is a Skill?
A skill is a directory containing a `SKILL.md` file that teaches Clawdbot how to use a tool, service, or workflow. Skills are injected into the system prompt and guide the AI agent on how to accomplish specific tasks.
Skills follow the [AgentSkills](https://agentskills.io) specification with Clawdbot-specific extensions.
## Quick Start
Create your first skill in under 5 minutes:
```bash
# Create skill directory
mkdir -p ~/.clawdbot/skills/my-calculator
# Create SKILL.md
cat > ~/.clawdbot/skills/my-calculator/SKILL.md << 'EOF'
---
name: my-calculator
description: Perform calculations using bc (basic calculator)
metadata: {"clawdbot":{"requires":{"bins":["bc"]}}}
---
# Calculator
Use `bc` for arithmetic calculations.
## Examples
Simple math:
```bash
echo "2 + 2" | bc
```
With decimals (scale=2):
```bash
echo "scale=2; 10 / 3" | bc
```
## Notes
- Always use `echo "expression" | bc` pattern
- Set `scale=N` for decimal precision
EOF
```
Start a new Clawdbot session and the skill will be available.
## SKILL.md Format
### Required Fields
Every `SKILL.md` must have YAML frontmatter with at least:
```yaml
---
name: skill-name
description: One-line description of what this skill does
---
```
### Full Frontmatter Reference
```yaml
---
name: my-skill # Unique identifier (kebab-case)
description: Short description # Shown in skill lists
homepage: https://example.com # Link to tool/service docs
user-invocable: true # Expose as /my-skill command (default: true)
disable-model-invocation: false # Exclude from AI prompt (default: false)
command-dispatch: tool # Optional: bypass AI, call tool directly
command-tool: exec # Tool to invoke when command-dispatch is set
command-arg-mode: raw # How to pass args (default: raw)
metadata: {"clawdbot":{...}} # Clawdbot-specific configuration (see below)
---
```
### Metadata Object
The `metadata` field must be a **single-line JSON object** (parser limitation):
```yaml
metadata: {"clawdbot":{"emoji":"🔧","requires":{"bins":["jq"]},"primaryEnv":"MY_API_KEY"}}
```
#### Metadata Fields
| Field | Type | Description |
|-------|------|-------------|
| `emoji` | string | Display emoji for UI |
| `homepage` | string | URL for documentation link |
| `always` | boolean | Skip all gating, always load |
| `os` | string[] | Platform filter: `["darwin"]`, `["linux"]`, `["win32"]` |
| `requires.bins` | string[] | All binaries must exist on PATH |
| `requires.anyBins` | string[] | At least one binary must exist |
| `requires.env` | string[] | Environment variables (or config equivalents) |
| `requires.config` | string[] | Config paths that must be truthy |
| `primaryEnv` | string | Main env var for `skills.entries.<name>.apiKey` |
| `install` | object[] | Installation instructions for UI |
| `skillKey` | string | Override config key (default: skill name) |
### Gating Requirements
Skills are filtered at load time based on `metadata.clawdbot.requires`:
```yaml
# Require specific binaries
metadata: {"clawdbot":{"requires":{"bins":["gh","jq"]}}}
# Require at least one of these binaries
metadata: {"clawdbot":{"requires":{"anyBins":["vim","nvim"]}}}
# Require environment variable (or config equivalent)
metadata: {"clawdbot":{"requires":{"env":["GITHUB_TOKEN"]}}}
# Require config value to be set
metadata: {"clawdbot":{"requires":{"config":["browser.enabled"]}}}
# Platform-specific (macOS only)
metadata: {"clawdbot":{"os":["darwin"]}}
# Combine requirements
metadata: {"clawdbot":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"os":["darwin","linux"]}}
```
### Install Specs
Help users install dependencies via the macOS Skills UI:
```yaml
# Homebrew
metadata: {"clawdbot":{"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"}]}}
# npm (global)
metadata: {"clawdbot":{"install":[{"id":"npm","kind":"node","package":"my-cli","bins":["my-cli"],"label":"Install my-cli (npm)"}]}}
# Go
metadata: {"clawdbot":{"install":[{"id":"go","kind":"go","package":"github.com/user/tool@latest","bins":["tool"],"label":"Install tool (go)"}]}}
# uv (Python)
metadata: {"clawdbot":{"install":[{"id":"uv","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
# Download (tarball/zip)
metadata: {"clawdbot":{"install":[{"id":"download","kind":"download","url":"https://example.com/tool.tar.gz","archive":"tar.gz","bins":["tool"],"label":"Download tool"}]}}
# Platform-specific
metadata: {"clawdbot":{"install":[{"id":"brew-mac","kind":"brew","formula":"tool","bins":["tool"],"os":["darwin"]},{"id":"apt-linux","kind":"download","url":"https://example.com/tool-linux","os":["linux"]}]}}
```
## Writing Good Instructions
The body of `SKILL.md` (after frontmatter) is injected into the system prompt. Write clear, actionable instructions.
### Do
- Use code blocks with specific commands
- Include common use cases and examples
- Document required environment variables
- Explain output formats the agent should expect
- Use `{baseDir}` to reference the skill directory
### Don't
- Write lengthy explanations (tokens cost money)
- Include installation instructions (use `install` metadata)
- Duplicate information already in tool --help
- Add promotional content
### Example Structure
```markdown
# Tool Name
Brief description of what this tool does.
## Commands
Primary command:
```bash
tool command --flag "argument"
```
With options:
```bash
tool command --verbose --output json
```
## Environment
- `TOOL_API_KEY`: Required API key
- `TOOL_REGION`: Optional region (default: us-east-1)
## Notes
- Important behavior to know
- Common gotchas
```
### Using {baseDir}
Reference files within your skill directory:
```markdown
Run the bundled script:
```bash
uv run {baseDir}/scripts/generate.py --prompt "hello"
```
```
Clawdbot replaces `{baseDir}` with the actual skill path at runtime.
## Supporting Files
Skills can include additional files:
```
my-skill/
├── SKILL.md # Required
├── scripts/ # Helper scripts
│ └── generate.py
├── templates/ # Template files
│ └── config.yaml
└── examples/ # Example usage
└── demo.sh
```
Reference these with `{baseDir}`:
```markdown
Use the template:
```bash
cp {baseDir}/templates/config.yaml ./my-config.yaml
```
```
## Testing Your Skill
### Local Testing
1. Create the skill in `~/.clawdbot/skills/` or `<workspace>/skills/`
2. Verify it loads: `clawdbot skills list`
3. Check gating: `clawdbot skills info <name>`
4. Start a session and test the skill
### Checking Eligibility
```bash
# List all skills with status
clawdbot skills list
# Detailed skill info
clawdbot skills info my-skill
# Check why a skill isn't loading
clawdbot doctor
```
### Token Impact
Skills add to your prompt token count. Estimate:
- Base overhead: ~50 tokens (when any skills load)
- Per skill: ~25 tokens + your instruction length
Keep instructions concise. Every character counts.
## Configuration
Users can configure your skill in `~/.clawdbot/clawdbot.json`:
```json5
{
skills: {
entries: {
"my-skill": {
enabled: true,
apiKey: "sk-xxx", // Maps to primaryEnv
env: {
MY_API_KEY: "sk-xxx", // Additional env vars
MY_REGION: "us-east-1"
},
config: {
endpoint: "https://api.example.com" // Custom config
}
}
}
}
}
```
Access custom config via environment or document the config path for users.
## Publishing to ClawdHub
### Prerequisites
1. Install the ClawdHub CLI: `npm i -g clawdhub`
2. Log in: `clawdhub login`
### First Publish
```bash
# Navigate to your skill
cd ~/.clawdbot/skills/my-skill
# Publish
clawdhub publish . \
--slug my-skill \
--name "My Skill" \
--version 1.0.0 \
--changelog "Initial release"
```
### Updating
```bash
# Bump version and publish
clawdhub publish . \
--slug my-skill \
--version 1.1.0 \
--changelog "Added new feature"
```
### Sync Workflow
For managing multiple skills:
```bash
# Scan and publish all new/updated skills
clawdhub sync --all
# Preview what would be published
clawdhub sync --dry-run
# Specify version bump type
clawdhub sync --all --bump minor
```
### Versioning
ClawdHub uses semantic versioning:
- **Patch** (1.0.1): Bug fixes, documentation updates
- **Minor** (1.1.0): New features, backward compatible
- **Major** (2.0.0): Breaking changes to usage or requirements
## Best Practices
### Naming
- Use kebab-case: `my-skill-name`
- Be descriptive but concise
- Avoid generic names: prefer `github-pr-review` over `github`
### Dependencies
- Prefer tools available via Homebrew, npm, or Go
- Document all required binaries in `requires.bins`
- Provide install specs for the macOS UI
- Test on a clean system
### Security
- Never hardcode secrets in `SKILL.md`
- Use `requires.env` for API keys
- Document environment variables clearly
- Consider sandbox compatibility
### Documentation
- Write for the AI agent, not humans
- Use imperative commands: "Run...", "Use...", "Execute..."
- Include example output when helpful
- Keep instructions under 1KB when possible
## Example Skills
Browse the bundled skills for patterns:
| Skill | Description | Key Patterns |
|-------|-------------|--------------|
| [nano-banana-pro](https://github.com/clawdbot/clawdbot/tree/main/skills/nano-banana-pro) | Image generation | Python script, uv, API key |
| [github](https://github.com/clawdbot/clawdbot/tree/main/skills/github) | GitHub operations | gh CLI, OAuth |
| [peekaboo](https://github.com/clawdbot/clawdbot/tree/main/skills/peekaboo) | macOS screenshots | Platform-specific, node binary |
| [gemini](https://github.com/clawdbot/clawdbot/tree/main/skills/gemini) | Gemini CLI | Homebrew install |
## Troubleshooting
### Skill not loading
1. Check `clawdbot skills list` for status
2. Run `clawdbot doctor` for diagnostics
3. Verify binary requirements: `which <binary>`
4. Check env vars: `echo $MY_VAR`
5. Validate SKILL.md syntax (single-line metadata JSON)
### Gating issues
```bash
# Debug skill eligibility
clawdbot skills info my-skill --verbose
```
### Publish errors
- Ensure you're logged in: `clawdhub whoami`
- Check slug uniqueness on clawdhub.com
- Verify SKILL.md is valid YAML frontmatter
## Resources
- [Skills reference](/tools/skills) - Full configuration docs
- [ClawdHub guide](/tools/clawdhub) - Registry CLI reference
- [Sandboxing](/gateway/sandboxing) - Running skills in containers
- [Feature maturity](/reference/feature-maturity) - Skills system stability
- [clawdhub.com](https://clawdhub.com) - Browse existing skills

View File

@ -1137,6 +1137,7 @@
"pages": [
"testing",
"scripts",
"reference/feature-maturity",
"reference/session-management-compaction",
"reference/rpc",
"reference/device-models",
@ -1152,6 +1153,13 @@
"reference/templates/TOOLS",
"reference/templates/USER"
]
},
{
"group": "Contributing",
"pages": [
"contributing/skills",
"contributing/plugins"
]
}
]
}

View File

@ -0,0 +1,243 @@
---
title: Feature Maturity
description: Classification of Clawdbot features by stability and production-readiness
---
# Feature Maturity Matrix
This document classifies Clawdbot features by their maturity level, helping you understand what to expect in terms of stability, API guarantees, and support.
## Maturity Levels
| Level | Description | API Stability | Support |
|-------|-------------|---------------|---------|
| **Core** | Battle-tested, production-ready. Breaking changes only in major versions. | Stable | Full |
| **Stable** | Feature-complete with documented API contracts. Minor refinements possible. | Stable | Full |
| **Beta** | Feature-complete but may have edge cases. API contracts may evolve. | Mostly stable | Community |
| **Experimental** | Active development. API likely to change. Use with caution. | Unstable | Limited |
---
## Messaging Channels
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| WhatsApp (Web) | Core | 2024.1 | Production-ready via whatsapp-web.js |
| Telegram | Core | 2024.1 | Full Bot API support, topics, streaming |
| Discord | Core | 2024.1 | Slash commands, threads, voice support |
| Slack | Core | 2024.1 | Socket Mode, app mentions, channels |
| Signal | Stable | 2024.3 | Requires signal-cli daemon |
| iMessage | Stable | 2024.6 | macOS only, requires Full Disk Access |
| BlueBubbles | Stable | 2024.8 | Alternative iMessage via BlueBubbles server |
| Matrix | Beta | 2025.1 | E2EE support, room federation |
| Google Chat | Beta | 2025.1 | Workspace integration, spaces |
| Mattermost | Beta | 2024.10 | Self-hosted team collaboration |
| LINE | Beta | 2026.1 | Messaging API with rich replies |
| MS Teams | Beta | 2025.1 | Bot Framework integration |
| Zalo | Experimental | 2025.6 | Official API (business accounts) |
| Zalo User | Experimental | 2025.6 | User-mode via zca-js |
| Nostr | Experimental | 2025.8 | Decentralized social protocol |
| Twitch | Experimental | 2025.10 | Chat integration |
| Tlon (Urbit) | Experimental | 2025.12 | Urbit-native messaging |
| Nextcloud Talk | Experimental | 2026.1 | Self-hosted video/chat |
---
## Model Providers
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Anthropic (Claude) | Core | 2024.1 | Primary provider, full tool support |
| OpenAI (GPT) | Core | 2024.1 | Full API support including vision |
| Amazon Bedrock | Stable | 2024.6 | Multi-model, IAM auth |
| Google Gemini | Stable | 2025.1 | Native API support |
| OpenRouter | Stable | 2024.8 | Multi-provider routing |
| Ollama | Stable | 2025.3 | Local models, auto-discovery |
| Venice | Stable | 2025.6 | Privacy-focused provider |
| Moonshot (Kimi) | Beta | 2024.10 | Chinese language optimization |
| Minimax | Beta | 2025.1 | Chinese language models |
| GLM (Zhipu) | Beta | 2025.3 | Chinese language models |
| Zai | Experimental | 2025.8 | Research models |
| OpenCode | Experimental | 2025.6 | Code-focused provider |
| Vercel AI Gateway | Experimental | 2026.1 | Edge deployment |
---
## Gateway & Infrastructure
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Gateway Server | Core | 2024.1 | Central coordination service |
| WebSocket Protocol | Core | 2024.1 | Real-time bidirectional comms |
| Token Authentication | Core | 2024.1 | Bearer token auth |
| Password Authentication | Core | 2025.1 | Human-friendly auth |
| Tailscale Integration | Stable | 2024.6 | Zero-config networking |
| Health Monitoring | Stable | 2024.3 | `/health` endpoint, doctor checks |
| Background Process | Stable | 2024.6 | Daemon mode, launchd/systemd |
| Rate Limiting | Stable | 2026.1 | Request throttling |
| Bonjour Discovery | Beta | 2025.1 | Local network auto-discovery |
| Remote Gateway | Beta | 2024.10 | Connect to remote gateways |
| Multiple Gateways | Experimental | 2025.8 | Multi-gateway coordination |
---
## Agent System
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Agent Loop | Core | 2024.1 | Tool-calling conversation loop |
| Session Management | Core | 2024.1 | Per-conversation state |
| Context Compaction | Core | 2024.3 | Automatic context summarization |
| System Prompts | Core | 2024.1 | Configurable agent personality |
| Multi-Agent | Stable | 2024.6 | Multiple concurrent agents |
| Model Failover | Stable | 2024.8 | Automatic provider switching |
| Extended Thinking | Stable | 2025.1 | Reasoning traces, Claude thinking |
| Heartbeat | Stable | 2024.6 | Periodic agent check-ins |
| Presence | Beta | 2025.1 | Node capability awareness |
| Sub-Agents | Beta | 2025.3 | Spawned child agents |
---
## Tools & Skills
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Shell Execution | Core | 2024.1 | Bash/PowerShell commands |
| Exec Approvals | Core | 2024.6 | Interactive command approval |
| Web Search | Stable | 2024.3 | Brave Search integration |
| Web Fetch | Stable | 2024.3 | URL content retrieval |
| Browser Control | Stable | 2024.6 | Playwright automation |
| Chrome Extension | Beta | 2025.1 | Browser relay for automation |
| Skills System | Stable | 2024.8 | SKILL.md-based extensibility |
| ClawdHub | Stable | 2025.1 | Skill marketplace integration |
| Slash Commands | Stable | 2024.3 | `/approve`, `/help`, etc. |
| Elevated Commands | Stable | 2024.6 | Sudo/admin execution |
| Subagent Tools | Beta | 2025.3 | Agent spawning from tools |
| Lobster | Beta | 2025.6 | Natural language CLI |
| LLM Task | Beta | 2025.8 | Delegated model tasks |
---
## Memory & Context
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Session Files | Core | 2024.1 | JSONL conversation logs |
| Agent Workspace | Core | 2024.3 | Per-agent file storage |
| OpenAI Embeddings | Stable | 2024.6 | text-embedding-3 models |
| Gemini Embeddings | Stable | 2025.1 | text-embedding-004 |
| SQLite Vector Store | Stable | 2024.8 | sqlite-vec for local search |
| Hybrid Search | Beta | 2025.3 | Combined vector + keyword |
| LanceDB Store | Experimental | 2025.8 | Alternative vector backend |
| Memory Sync | Experimental | 2025.6 | Cross-session memory sharing |
---
## Automation
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Cron Jobs | Stable | 2024.6 | Scheduled agent runs |
| Webhooks | Stable | 2024.6 | HTTP trigger endpoints |
| Hooks | Stable | 2024.8 | Pre/post message processing |
| Gmail Pub/Sub | Beta | 2025.1 | Email trigger via Google |
| Auth Monitoring | Beta | 2025.6 | Session re-authentication |
| Poll | Experimental | 2025.8 | Periodic URL polling |
---
## Nodes & Media
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Image Understanding | Stable | 2024.3 | Vision model integration |
| Audio Transcription | Stable | 2024.6 | Whisper integration |
| TTS (Text-to-Speech) | Stable | 2025.1 | Multiple providers, Edge fallback |
| Camera Capture | Beta | 2024.8 | Node camera access |
| Location Services | Beta | 2025.1 | GPS/location awareness |
| Voice Wake | Beta | 2025.3 | macOS voice activation |
| Voice Call | Experimental | 2025.6 | Twilio phone integration |
| Talk Mode | Experimental | 2025.8 | Real-time voice conversation |
---
## Platforms
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| macOS App | Stable | 2024.6 | Menu bar companion app |
| iOS App | Beta | 2025.1 | Mobile companion |
| Android App | Beta | 2025.3 | Mobile companion |
| Linux (CLI) | Core | 2024.1 | Full CLI support |
| Windows (CLI) | Stable | 2024.3 | Full CLI support |
| Docker | Stable | 2024.3 | Container deployment |
| Fly.io | Stable | 2024.6 | Edge deployment |
| Railway | Stable | 2025.1 | One-click deployment |
| Render | Stable | 2026.1 | Managed deployment |
| Northflank | Beta | 2026.1 | Kubernetes deployment |
| Raspberry Pi | Beta | 2025.8 | ARM deployment |
| GCP | Beta | 2026.1 | Google Cloud deployment |
---
## Web Interfaces
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Gateway Dashboard | Stable | 2024.6 | Web-based control panel |
| WebChat | Stable | 2024.6 | Browser-based chat UI |
| TUI | Stable | 2024.8 | Terminal user interface |
| Control UI | Stable | 2025.1 | Administrative interface |
| Marketplace UI | Beta | 2026.1 | ClawdHub skill browser |
---
## Security
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Token Auth | Core | 2024.1 | Bearer token authentication |
| Password Auth | Core | 2025.1 | Human-friendly passwords |
| Exec Sandboxing | Stable | 2024.8 | Command isolation |
| Tool Policies | Stable | 2024.10 | Per-tool permissions |
| Secrets Manager | Stable | 2026.1 | Keychain/Credential Manager |
| Prompt Injection Defense | Beta | 2026.1 | Input sanitization |
| Command Blocklist | Beta | 2026.1 | Dangerous command blocking |
| Rate Limiting | Stable | 2026.1 | Request throttling |
| Formal Verification | Experimental | 2025.10 | Mathematical security proofs |
---
## Plugins & Extensions
| Feature | Maturity | Since | Notes |
|---------|----------|-------|-------|
| Plugin SDK | Stable | 2024.8 | Channel/tool extensions |
| Plugin Manifest | Stable | 2024.8 | `clawdbot.plugin.json` spec |
| Hot Reload | Beta | 2025.1 | Development mode reloading |
| Plugin HTTP Registry | Beta | 2026.1 | Dynamic route registration |
---
## Upgrade Guidance
### Moving from Experimental to Beta
Features at experimental level may have breaking changes between minor versions. Pin your Clawdbot version if you depend on experimental features.
### Moving from Beta to Stable
Beta features are generally safe for production but may require migration steps. Check the [changelog](/reference/RELEASING) when upgrading.
### Core Features
Core features maintain backward compatibility within major versions. Migration guides are provided for any breaking changes.
---
## Requesting Feature Promotion
If you're using a Beta or Experimental feature in production and would like it prioritized for stabilization, please:
1. Open a [GitHub issue](https://github.com/clawdbot/clawdbot/issues) describing your use case
2. Include any edge cases or bugs you've encountered
3. Suggest any API improvements that would make it more stable
Community feedback directly influences feature prioritization.

206
src/clawdhub/client.ts Normal file
View File

@ -0,0 +1,206 @@
/**
* ClawdHub API client for the public skills registry.
* Handles search, details, install, and update operations.
*/
const DEFAULT_REGISTRY_URL = "https://clawdhub.com";
export type ClawdHubSearchResult = {
slug: string;
name: string;
description: string;
emoji?: string;
author?: string;
version: string;
downloads: number;
stars: number;
updatedAt: string;
tags: string[];
};
export type ClawdHubSearchResponse = {
results: ClawdHubSearchResult[];
total: number;
query: string;
};
export type ClawdHubSkillVersion = {
version: string;
changelog?: string;
publishedAt: string;
tags: string[];
downloads: number;
};
export type ClawdHubSkillDetails = {
slug: string;
name: string;
description: string;
emoji?: string;
readme?: string;
author?: string;
homepage?: string;
repository?: string;
license?: string;
currentVersion: string;
versions: ClawdHubSkillVersion[];
downloads: number;
stars: number;
createdAt: string;
updatedAt: string;
tags: string[];
};
export type ClawdHubInstalledSkill = {
slug: string;
version: string;
installedAt: string;
path: string;
};
export type ClawdHubLockFile = {
version: number;
skills: Record<string, ClawdHubInstalledSkill>;
};
export type ClawdHubInstallResult = {
ok: boolean;
slug: string;
version: string;
path: string;
message?: string;
};
export type ClawdHubUpdateCheck = {
slug: string;
currentVersion: string;
latestVersion: string;
hasUpdate: boolean;
};
export type ClawdHubClientOptions = {
registryUrl?: string;
timeout?: number;
};
function getRegistryUrl(): string {
return process.env.CLAWDHUB_REGISTRY || DEFAULT_REGISTRY_URL;
}
async function fetchJson<T>(url: string, options: RequestInit = {}, timeoutMs = 30000): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": "clawdbot-gateway",
...options.headers,
},
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Search for skills in ClawdHub using vector search.
*/
export async function searchSkills(
query: string,
options: ClawdHubClientOptions = {},
): Promise<ClawdHubSearchResponse> {
const registryUrl = options.registryUrl || getRegistryUrl();
const params = new URLSearchParams({ q: query });
const url = `${registryUrl}/api/skills/search?${params}`;
return fetchJson<ClawdHubSearchResponse>(url, {}, options.timeout);
}
/**
* Get detailed information about a skill including all versions.
*/
export async function getSkillDetails(
slug: string,
options: ClawdHubClientOptions = {},
): Promise<ClawdHubSkillDetails> {
const registryUrl = options.registryUrl || getRegistryUrl();
const url = `${registryUrl}/api/skills/${encodeURIComponent(slug)}`;
return fetchJson<ClawdHubSkillDetails>(url, {}, options.timeout);
}
/**
* Download a skill zip from ClawdHub.
*/
export async function downloadSkillZip(
slug: string,
version: string,
options: ClawdHubClientOptions = {},
): Promise<ArrayBuffer> {
const registryUrl = options.registryUrl || getRegistryUrl();
const url = `${registryUrl}/api/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/download`;
const controller = new AbortController();
const timeoutMs = options.timeout || 60000;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "clawdbot-gateway",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.arrayBuffer();
} finally {
clearTimeout(timeoutId);
}
}
/**
* Check for updates across multiple installed skills.
*/
export async function checkUpdates(
installed: ClawdHubInstalledSkill[],
options: ClawdHubClientOptions = {},
): Promise<ClawdHubUpdateCheck[]> {
const results: ClawdHubUpdateCheck[] = [];
for (const skill of installed) {
try {
const details = await getSkillDetails(skill.slug, options);
results.push({
slug: skill.slug,
currentVersion: skill.version,
latestVersion: details.currentVersion,
hasUpdate: details.currentVersion !== skill.version,
});
} catch {
// Skip skills that can't be fetched
results.push({
slug: skill.slug,
currentVersion: skill.version,
latestVersion: skill.version,
hasUpdate: false,
});
}
}
return results;
}

View File

@ -189,6 +189,30 @@ import {
WizardStatusResultSchema,
type WizardStep,
WizardStepSchema,
type ClawdHubSearchParams,
ClawdHubSearchParamsSchema,
type ClawdHubSearchResponse,
ClawdHubSearchResponseSchema,
type ClawdHubDetailsParams,
ClawdHubDetailsParamsSchema,
type ClawdHubDetailsResponse,
ClawdHubDetailsResponseSchema,
type ClawdHubInstallParams,
ClawdHubInstallParamsSchema,
type ClawdHubInstallResponse,
ClawdHubInstallResponseSchema,
type ClawdHubInstalledParams,
ClawdHubInstalledParamsSchema,
type ClawdHubInstalledResponse,
ClawdHubInstalledResponseSchema,
type ClawdHubInstalledSkill,
ClawdHubInstalledSkillSchema,
type ClawdHubCheckUpdatesParams,
ClawdHubCheckUpdatesParamsSchema,
type ClawdHubCheckUpdatesResponse,
ClawdHubCheckUpdatesResponseSchema,
type ClawdHubUpdateCheck,
ClawdHubUpdateCheckSchema,
} from "./schema.js";
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
@ -269,6 +293,21 @@ export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBins
export const validateSkillsInstallParams =
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema);
export const validateClawdHubSearchParams = ajv.compile<ClawdHubSearchParams>(
ClawdHubSearchParamsSchema,
);
export const validateClawdHubDetailsParams = ajv.compile<ClawdHubDetailsParams>(
ClawdHubDetailsParamsSchema,
);
export const validateClawdHubInstallParams = ajv.compile<ClawdHubInstallParams>(
ClawdHubInstallParamsSchema,
);
export const validateClawdHubInstalledParams = ajv.compile<ClawdHubInstalledParams>(
ClawdHubInstalledParamsSchema,
);
export const validateClawdHubCheckUpdatesParams = ajv.compile<ClawdHubCheckUpdatesParams>(
ClawdHubCheckUpdatesParamsSchema,
);
export const validateCronListParams = ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(CronStatusParamsSchema);
export const validateCronAddParams = ajv.compile<CronAddParams>(CronAddParamsSchema);
@ -412,6 +451,18 @@ export {
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
SkillsUpdateParamsSchema,
ClawdHubSearchParamsSchema,
ClawdHubSearchResponseSchema,
ClawdHubDetailsParamsSchema,
ClawdHubDetailsResponseSchema,
ClawdHubInstallParamsSchema,
ClawdHubInstallResponseSchema,
ClawdHubInstalledParamsSchema,
ClawdHubInstalledResponseSchema,
ClawdHubInstalledSkillSchema,
ClawdHubCheckUpdatesParamsSchema,
ClawdHubCheckUpdatesResponseSchema,
ClawdHubUpdateCheckSchema,
CronJobSchema,
CronListParamsSchema,
CronStatusParamsSchema,
@ -517,4 +568,16 @@ export type {
PollParams,
UpdateRunParams,
ChatInjectParams,
ClawdHubSearchParams,
ClawdHubSearchResponse,
ClawdHubDetailsParams,
ClawdHubDetailsResponse,
ClawdHubInstallParams,
ClawdHubInstallResponse,
ClawdHubInstalledParams,
ClawdHubInstalledResponse,
ClawdHubInstalledSkill,
ClawdHubCheckUpdatesParams,
ClawdHubCheckUpdatesResponse,
ClawdHubUpdateCheck,
};

View File

@ -1,6 +1,7 @@
export * from "./schema/agent.js";
export * from "./schema/agents-models-skills.js";
export * from "./schema/channels.js";
export * from "./schema/clawdhub.js";
export * from "./schema/config.js";
export * from "./schema/cron.js";
export * from "./schema/error-codes.js";

View File

@ -0,0 +1,247 @@
import { Type } from "@sinclair/typebox";
import { NonEmptyString } from "./primitives.js";
// ─── Search ───────────────────────────────────────────────────────────────────
export const ClawdHubSearchParamsSchema = Type.Object(
{
query: NonEmptyString,
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
},
{ additionalProperties: false },
);
export const ClawdHubSearchResultSchema = Type.Object(
{
slug: NonEmptyString,
name: NonEmptyString,
description: Type.String(),
emoji: Type.Optional(Type.String()),
author: Type.Optional(Type.String()),
version: NonEmptyString,
downloads: Type.Integer({ minimum: 0 }),
stars: Type.Integer({ minimum: 0 }),
updatedAt: Type.String(),
tags: Type.Array(Type.String()),
},
{ additionalProperties: false },
);
export const ClawdHubSearchResponseSchema = Type.Object(
{
results: Type.Array(ClawdHubSearchResultSchema),
total: Type.Integer({ minimum: 0 }),
query: Type.String(),
},
{ additionalProperties: false },
);
// ─── Details ──────────────────────────────────────────────────────────────────
export const ClawdHubDetailsParamsSchema = Type.Object(
{
slug: NonEmptyString,
},
{ additionalProperties: false },
);
export const ClawdHubVersionSchema = Type.Object(
{
version: NonEmptyString,
changelog: Type.Optional(Type.String()),
publishedAt: Type.String(),
tags: Type.Array(Type.String()),
downloads: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const ClawdHubDetailsResponseSchema = Type.Object(
{
slug: NonEmptyString,
name: NonEmptyString,
description: Type.String(),
emoji: Type.Optional(Type.String()),
readme: Type.Optional(Type.String()),
author: Type.Optional(Type.String()),
homepage: Type.Optional(Type.String()),
repository: Type.Optional(Type.String()),
license: Type.Optional(Type.String()),
currentVersion: NonEmptyString,
versions: Type.Array(ClawdHubVersionSchema),
downloads: Type.Integer({ minimum: 0 }),
stars: Type.Integer({ minimum: 0 }),
createdAt: Type.String(),
updatedAt: Type.String(),
tags: Type.Array(Type.String()),
},
{ additionalProperties: false },
);
// ─── Install ──────────────────────────────────────────────────────────────────
export const ClawdHubInstallParamsSchema = Type.Object(
{
slug: NonEmptyString,
version: Type.Optional(NonEmptyString),
force: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const ClawdHubInstallResponseSchema = Type.Object(
{
ok: Type.Boolean(),
slug: NonEmptyString,
version: NonEmptyString,
path: Type.String(),
message: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
// ─── Installed ────────────────────────────────────────────────────────────────
export const ClawdHubInstalledParamsSchema = Type.Object({}, { additionalProperties: false });
export const ClawdHubInstalledSkillSchema = Type.Object(
{
slug: NonEmptyString,
version: NonEmptyString,
installedAt: Type.String(),
path: Type.String(),
name: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const ClawdHubInstalledResponseSchema = Type.Object(
{
skills: Type.Array(ClawdHubInstalledSkillSchema),
},
{ additionalProperties: false },
);
// ─── Check Updates ────────────────────────────────────────────────────────────
export const ClawdHubCheckUpdatesParamsSchema = Type.Object({}, { additionalProperties: false });
export const ClawdHubUpdateCheckSchema = Type.Object(
{
slug: NonEmptyString,
currentVersion: NonEmptyString,
latestVersion: NonEmptyString,
hasUpdate: Type.Boolean(),
},
{ additionalProperties: false },
);
export const ClawdHubCheckUpdatesResponseSchema = Type.Object(
{
updates: Type.Array(ClawdHubUpdateCheckSchema),
},
{ additionalProperties: false },
);
// ─── Type Exports ─────────────────────────────────────────────────────────────
export type ClawdHubSearchParams = {
query: string;
limit?: number;
};
export type ClawdHubSearchResult = {
slug: string;
name: string;
description: string;
emoji?: string;
author?: string;
version: string;
downloads: number;
stars: number;
updatedAt: string;
tags: string[];
};
export type ClawdHubSearchResponse = {
results: ClawdHubSearchResult[];
total: number;
query: string;
};
export type ClawdHubDetailsParams = {
slug: string;
};
export type ClawdHubVersion = {
version: string;
changelog?: string;
publishedAt: string;
tags: string[];
downloads: number;
};
export type ClawdHubDetailsResponse = {
slug: string;
name: string;
description: string;
emoji?: string;
readme?: string;
author?: string;
homepage?: string;
repository?: string;
license?: string;
currentVersion: string;
versions: ClawdHubVersion[];
downloads: number;
stars: number;
createdAt: string;
updatedAt: string;
tags: string[];
};
export type ClawdHubInstallParams = {
slug: string;
version?: string;
force?: boolean;
};
export type ClawdHubInstallResponse = {
ok: boolean;
slug: string;
version: string;
path: string;
message?: string;
};
export type ClawdHubInstalledParams = Record<string, never>;
export type ClawdHubInstalledSkill = {
slug: string;
version: string;
installedAt: string;
path: string;
name?: string;
description?: string;
emoji?: string;
};
export type ClawdHubInstalledResponse = {
skills: ClawdHubInstalledSkill[];
};
export type ClawdHubCheckUpdatesParams = Record<string, never>;
export type ClawdHubUpdateCheck = {
slug: string;
currentVersion: string;
latestVersion: string;
hasUpdate: boolean;
};
export type ClawdHubCheckUpdatesResponse = {
updates: ClawdHubUpdateCheck[];
};

View File

@ -3,6 +3,7 @@ import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { browserHandlers } from "./server-methods/browser.js";
import { channelsHandlers } from "./server-methods/channels.js";
import { clawdhubHandlers } from "./server-methods/clawdhub.js";
import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
@ -72,6 +73,10 @@ const READ_METHODS = new Set([
"node.list",
"node.describe",
"chat.history",
"clawdhub.search",
"clawdhub.details",
"clawdhub.installed",
"clawdhub.checkUpdates",
]);
const WRITE_METHODS = new Set([
"send",
@ -131,6 +136,7 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
method === "channels.logout" ||
method === "skills.install" ||
method === "skills.update" ||
method === "clawdhub.install" ||
method === "cron.add" ||
method === "cron.update" ||
method === "cron.remove" ||
@ -152,6 +158,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...healthHandlers,
...channelsHandlers,
...chatHandlers,
...clawdhubHandlers,
...cronHandlers,
...deviceHandlers,
...execApprovalsHandlers,

View File

@ -0,0 +1,323 @@
/**
* Gateway RPC handlers for ClawdHub marketplace integration.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import JSZip from "jszip";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
downloadSkillZip,
getSkillDetails,
searchSkills,
checkUpdates as checkClawdHubUpdates,
type ClawdHubInstalledSkill as ClientInstalledSkill,
} from "../../clawdhub/client.js";
import { loadConfig } from "../../config/config.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateClawdHubCheckUpdatesParams,
validateClawdHubDetailsParams,
validateClawdHubInstallParams,
validateClawdHubInstalledParams,
validateClawdHubSearchParams,
type ClawdHubInstallParams,
type ClawdHubInstalledSkill,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
type ClawdHubLockFile = {
version: number;
skills: Record<string, ClawdHubInstalledSkill>;
};
function getLockFilePath(workspaceDir: string): string {
return join(workspaceDir, ".clawdhub", "lock.json");
}
function readLockFile(workspaceDir: string): ClawdHubLockFile {
const lockPath = getLockFilePath(workspaceDir);
if (!existsSync(lockPath)) {
return { version: 1, skills: {} };
}
try {
const raw = readFileSync(lockPath, "utf-8");
return JSON.parse(raw) as ClawdHubLockFile;
} catch {
return { version: 1, skills: {} };
}
}
function writeLockFile(workspaceDir: string, lock: ClawdHubLockFile): void {
const lockPath = getLockFilePath(workspaceDir);
const lockDir = join(workspaceDir, ".clawdhub");
if (!existsSync(lockDir)) {
mkdirSync(lockDir, { recursive: true });
}
writeFileSync(lockPath, JSON.stringify(lock, null, 2));
}
async function extractZipToSkillsDir(
zipBuffer: ArrayBuffer,
skillsDir: string,
slug: string,
): Promise<string> {
const targetDir = join(skillsDir, slug);
// Create the target directory
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}
// Convert ArrayBuffer to Buffer and load with JSZip
const buffer = Buffer.from(zipBuffer);
const zip = await JSZip.loadAsync(buffer);
const entries = Object.values(zip.files);
for (const entry of entries) {
// Skip __MACOSX entries
if (entry.name.startsWith("__MACOSX")) {
continue;
}
const entryPath = entry.name.replaceAll("\\", "/");
// Handle nested directories - strip the first directory component if it exists
const parts = entryPath.split("/").filter(Boolean);
let relativePath: string;
if (parts.length > 1) {
relativePath = parts.slice(1).join("/");
} else {
relativePath = parts.join("/");
}
if (!relativePath) continue;
// Skip directory entries (they end with /)
if (entry.dir || entryPath.endsWith("/")) {
const dirPath = join(targetDir, relativePath);
if (!dirPath.startsWith(targetDir)) {
throw new Error(`zip entry escapes destination: ${entry.name}`);
}
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
continue;
}
const outPath = join(targetDir, relativePath);
if (!outPath.startsWith(targetDir)) {
throw new Error(`zip entry escapes destination: ${entry.name}`);
}
const outDir = dirname(outPath);
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
const content = await entry.async("nodebuffer");
writeFileSync(outPath, content);
}
return targetDir;
}
export const clawdhubHandlers: GatewayRequestHandlers = {
"clawdhub.search": async ({ params, respond }) => {
if (!validateClawdHubSearchParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid clawdhub.search params: ${formatValidationErrors(validateClawdHubSearchParams.errors)}`,
),
);
return;
}
const p = params as { query: string; limit?: number };
try {
const result = await searchSkills(p.query);
// Apply limit if specified
if (p.limit && result.results.length > p.limit) {
result.results = result.results.slice(0, p.limit);
}
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, err instanceof Error ? err.message : "Search failed"),
);
}
},
"clawdhub.details": async ({ params, respond }) => {
if (!validateClawdHubDetailsParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid clawdhub.details params: ${formatValidationErrors(validateClawdHubDetailsParams.errors)}`,
),
);
return;
}
const p = params as { slug: string };
try {
const details = await getSkillDetails(p.slug);
respond(true, details, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
err instanceof Error ? err.message : "Failed to fetch skill details",
),
);
}
},
"clawdhub.install": async ({ params, respond }) => {
if (!validateClawdHubInstallParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid clawdhub.install params: ${formatValidationErrors(validateClawdHubInstallParams.errors)}`,
),
);
return;
}
const p = params as ClawdHubInstallParams;
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const skillsDir = join(workspaceDir, "skills");
try {
// Get skill details to resolve version
const details = await getSkillDetails(p.slug);
const version = p.version || details.currentVersion;
// Check if already installed (unless force is true)
const lock = readLockFile(workspaceDir);
if (!p.force && lock.skills[p.slug]) {
const installed = lock.skills[p.slug];
if (installed.version === version) {
respond(true, {
ok: true,
slug: p.slug,
version,
path: installed.path,
message: `${p.slug}@${version} is already installed`,
});
return;
}
}
// Download and extract
const zipBuffer = await downloadSkillZip(p.slug, version);
const installPath = await extractZipToSkillsDir(zipBuffer, skillsDir, p.slug);
// Update lock file
lock.skills[p.slug] = {
slug: p.slug,
version,
installedAt: new Date().toISOString(),
path: installPath,
name: details.name,
description: details.description,
emoji: details.emoji,
};
writeLockFile(workspaceDir, lock);
respond(true, {
ok: true,
slug: p.slug,
version,
path: installPath,
message: `Installed ${details.name || p.slug}@${version}`,
});
} catch (err) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
err instanceof Error ? err.message : "Installation failed",
),
);
}
},
"clawdhub.installed": ({ params, respond }) => {
if (!validateClawdHubInstalledParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid clawdhub.installed params: ${formatValidationErrors(validateClawdHubInstalledParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const lock = readLockFile(workspaceDir);
const skills: ClawdHubInstalledSkill[] = Object.values(lock.skills);
respond(true, { skills }, undefined);
},
"clawdhub.checkUpdates": async ({ params, respond }) => {
if (!validateClawdHubCheckUpdatesParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid clawdhub.checkUpdates params: ${formatValidationErrors(validateClawdHubCheckUpdatesParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const lock = readLockFile(workspaceDir);
const installedSkills: ClientInstalledSkill[] = Object.values(lock.skills).map((s) => ({
slug: s.slug,
version: s.version,
installedAt: s.installedAt,
path: s.path,
}));
try {
const updates = await checkClawdHubUpdates(installedSkills);
respond(true, { updates }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
err instanceof Error ? err.message : "Update check failed",
),
);
}
},
};

View File

@ -50,6 +50,7 @@ import {
rotateDeviceToken,
} from "./controllers/devices";
import { renderSkills } from "./views/skills";
import { renderMarketplace } from "./views/marketplace";
import { renderChatControls, renderSkillsQuickToggle, renderTab, renderThemeToggle } from "./app-render.helpers";
import { loadChannels } from "./controllers/channels";
import { loadPresence } from "./controllers/presence";
@ -62,6 +63,13 @@ import {
updateSkillEnabled,
type SkillMessage,
} from "./controllers/skills";
import {
checkMarketplaceUpdates,
installMarketplaceSkill,
loadMarketplaceInstalled,
searchMarketplace,
setMarketplaceTab,
} from "./controllers/marketplace";
import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat";
import {
@ -351,6 +359,27 @@ export function renderApp(state: AppViewState) {
})
: nothing}
${state.tab === "marketplace"
? renderMarketplace({
loading: state.marketplaceLoading,
searching: state.marketplaceSearching,
query: state.marketplaceQuery,
results: state.marketplaceResults,
installed: state.marketplaceInstalled,
updates: state.marketplaceUpdates,
error: state.marketplaceError,
busySlug: state.marketplaceBusySlug,
messages: state.marketplaceMessages,
tab: state.marketplaceTab,
onQueryChange: (query) => (state.marketplaceQuery = query),
onSearch: (query) => searchMarketplace(state, query),
onInstall: (slug, version) => installMarketplaceSkill(state, slug, version),
onCheckUpdates: () => checkMarketplaceUpdates(state),
onRefresh: () => loadMarketplaceInstalled(state),
onTabChange: (tab) => setMarketplaceTab(state, tab),
})
: nothing}
${state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,

View File

@ -22,6 +22,12 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
MarketplaceInstalledSkill,
MarketplaceMessageMap,
MarketplaceSearchResult,
MarketplaceUpdateCheck,
} from "./controllers/marketplace";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
@ -128,6 +134,16 @@ export type AppViewState = {
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
marketplaceLoading: boolean;
marketplaceSearching: boolean;
marketplaceQuery: string;
marketplaceResults: MarketplaceSearchResult[];
marketplaceInstalled: MarketplaceInstalledSkill[];
marketplaceUpdates: MarketplaceUpdateCheck[];
marketplaceError: string | null;
marketplaceBusySlug: string | null;
marketplaceMessages: MarketplaceMessageMap;
marketplaceTab: "browse" | "installed";
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;

View File

@ -231,6 +231,17 @@ export class MoltbotApp extends LitElement {
@state() skillsBusyKey: string | null = null;
@state() skillMessages: Record<string, SkillMessage> = {};
@state() marketplaceLoading = false;
@state() marketplaceSearching = false;
@state() marketplaceQuery = "";
@state() marketplaceResults: import("./controllers/marketplace").MarketplaceSearchResult[] = [];
@state() marketplaceInstalled: import("./controllers/marketplace").MarketplaceInstalledSkill[] = [];
@state() marketplaceUpdates: import("./controllers/marketplace").MarketplaceUpdateCheck[] = [];
@state() marketplaceError: string | null = null;
@state() marketplaceBusySlug: string | null = null;
@state() marketplaceMessages: import("./controllers/marketplace").MarketplaceMessageMap = {};
@state() marketplaceTab: "browse" | "installed" = "browse";
@state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null;
@state() debugHealth: HealthSnapshot | null = null;

View File

@ -0,0 +1,188 @@
import type { GatewayBrowserClient } from "../gateway";
export type MarketplaceSearchResult = {
slug: string;
name: string;
description: string;
emoji?: string;
author?: string;
version: string;
downloads: number;
stars: number;
updatedAt: string;
tags: string[];
};
export type MarketplaceInstalledSkill = {
slug: string;
version: string;
installedAt: string;
path: string;
name?: string;
description?: string;
emoji?: string;
};
export type MarketplaceUpdateCheck = {
slug: string;
currentVersion: string;
latestVersion: string;
hasUpdate: boolean;
};
export type MarketplaceMessage = {
kind: "success" | "error";
message: string;
};
export type MarketplaceMessageMap = Record<string, MarketplaceMessage>;
export type MarketplaceState = {
client: GatewayBrowserClient | null;
connected: boolean;
marketplaceLoading: boolean;
marketplaceSearching: boolean;
marketplaceQuery: string;
marketplaceResults: MarketplaceSearchResult[];
marketplaceInstalled: MarketplaceInstalledSkill[];
marketplaceUpdates: MarketplaceUpdateCheck[];
marketplaceError: string | null;
marketplaceBusySlug: string | null;
marketplaceMessages: MarketplaceMessageMap;
marketplaceTab: "browse" | "installed";
};
function getErrorMessage(err: unknown) {
if (err instanceof Error) return err.message;
return String(err);
}
function setMarketplaceMessage(
state: MarketplaceState,
slug: string,
message?: MarketplaceMessage,
) {
if (!slug.trim()) return;
const next = { ...state.marketplaceMessages };
if (message) next[slug] = message;
else delete next[slug];
state.marketplaceMessages = next;
}
export async function loadMarketplaceInstalled(state: MarketplaceState) {
if (!state.client || !state.connected) return;
if (state.marketplaceLoading) return;
state.marketplaceLoading = true;
state.marketplaceError = null;
try {
const res = (await state.client.request("clawdhub.installed", {})) as {
skills: MarketplaceInstalledSkill[];
} | undefined;
if (res) {
state.marketplaceInstalled = res.skills;
}
} catch (err) {
state.marketplaceError = getErrorMessage(err);
} finally {
state.marketplaceLoading = false;
}
}
export async function searchMarketplace(state: MarketplaceState, query: string) {
if (!state.client || !state.connected) return;
if (state.marketplaceSearching) return;
state.marketplaceSearching = true;
state.marketplaceQuery = query;
state.marketplaceError = null;
try {
const res = (await state.client.request("clawdhub.search", { query })) as {
results: MarketplaceSearchResult[];
total: number;
} | undefined;
if (res) {
state.marketplaceResults = res.results;
}
} catch (err) {
state.marketplaceError = getErrorMessage(err);
state.marketplaceResults = [];
} finally {
state.marketplaceSearching = false;
}
}
export async function installMarketplaceSkill(
state: MarketplaceState,
slug: string,
version?: string,
) {
if (!state.client || !state.connected) return;
state.marketplaceBusySlug = slug;
state.marketplaceError = null;
try {
const params: { slug: string; version?: string } = { slug };
if (version) params.version = version;
const res = (await state.client.request("clawdhub.install", params)) as {
ok: boolean;
slug: string;
version: string;
path: string;
message?: string;
} | undefined;
if (res?.ok) {
setMarketplaceMessage(state, slug, {
kind: "success",
message: res.message || `Installed ${slug}@${res.version}`,
});
// Refresh installed list
await loadMarketplaceInstalled(state);
} else {
setMarketplaceMessage(state, slug, {
kind: "error",
message: res?.message || "Installation failed",
});
}
} catch (err) {
const message = getErrorMessage(err);
state.marketplaceError = message;
setMarketplaceMessage(state, slug, { kind: "error", message });
} finally {
state.marketplaceBusySlug = null;
}
}
export async function checkMarketplaceUpdates(state: MarketplaceState) {
if (!state.client || !state.connected) return;
if (state.marketplaceLoading) return;
state.marketplaceLoading = true;
state.marketplaceError = null;
try {
const res = (await state.client.request("clawdhub.checkUpdates", {})) as {
updates: MarketplaceUpdateCheck[];
} | undefined;
if (res) {
state.marketplaceUpdates = res.updates;
}
} catch (err) {
state.marketplaceError = getErrorMessage(err);
} finally {
state.marketplaceLoading = false;
}
}
export function clearMarketplaceMessage(state: MarketplaceState, slug: string) {
setMarketplaceMessage(state, slug, undefined);
}
export function setMarketplaceTab(state: MarketplaceState, tab: "browse" | "installed") {
state.marketplaceTab = tab;
}

View File

@ -6,7 +6,7 @@ export const TAB_GROUPS = [
label: "Control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Agent", tabs: ["skills", "marketplace", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
] as const;
@ -17,6 +17,7 @@ export type Tab =
| "sessions"
| "cron"
| "skills"
| "marketplace"
| "nodes"
| "chat"
| "config"
@ -30,6 +31,7 @@ const TAB_PATHS: Record<Tab, string> = {
sessions: "/sessions",
cron: "/cron",
skills: "/skills",
marketplace: "/marketplace",
nodes: "/nodes",
chat: "/chat",
config: "/config",
@ -116,6 +118,8 @@ export function iconForTab(tab: Tab): IconName {
return "loader";
case "skills":
return "zap";
case "marketplace":
return "puzzle";
case "nodes":
return "monitor";
case "config":
@ -143,6 +147,8 @@ export function titleForTab(tab: Tab) {
return "Cron Jobs";
case "skills":
return "Skills";
case "marketplace":
return "Marketplace";
case "nodes":
return "Nodes";
case "chat":
@ -172,6 +178,8 @@ export function subtitleForTab(tab: Tab) {
return "Schedule wakeups and recurring agent runs.";
case "skills":
return "Manage skill availability and API key injection.";
case "marketplace":
return "Browse and install skills from ClawdHub.";
case "nodes":
return "Paired devices, capabilities, and command exposure.";
case "chat":

View File

@ -0,0 +1,392 @@
import { html, nothing } from "lit";
import { clampText } from "../format";
import type {
MarketplaceInstalledSkill,
MarketplaceMessageMap,
MarketplaceSearchResult,
MarketplaceUpdateCheck,
} from "../controllers/marketplace";
export type MarketplaceProps = {
loading: boolean;
searching: boolean;
query: string;
results: MarketplaceSearchResult[];
installed: MarketplaceInstalledSkill[];
updates: MarketplaceUpdateCheck[];
error: string | null;
busySlug: string | null;
messages: MarketplaceMessageMap;
tab: "browse" | "installed";
onQueryChange: (query: string) => void;
onSearch: (query: string) => void;
onInstall: (slug: string, version?: string) => void;
onCheckUpdates: () => void;
onRefresh: () => void;
onTabChange: (tab: "browse" | "installed") => void;
};
function formatNumber(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
} catch {
return dateStr;
}
}
function renderStars(count: number) {
return html`<span class="marketplace-stars" title="${count} stars">★ ${formatNumber(count)}</span>`;
}
function renderDownloads(count: number) {
return html`<span class="marketplace-downloads" title="${count} downloads">↓ ${formatNumber(count)}</span>`;
}
export function renderMarketplace(props: MarketplaceProps) {
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start;">
<div>
<div class="card-title">ClawdHub Marketplace</div>
<div class="card-sub">Browse and install skills from the public registry.</div>
</div>
<div class="row" style="gap: 8px;">
<button
class="btn"
?disabled=${props.loading}
@click=${props.onCheckUpdates}
>
Check Updates
</button>
<button
class="btn"
?disabled=${props.loading}
@click=${props.onRefresh}
>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div>
<div class="marketplace-tabs" style="margin-top: 16px;">
<button
class="marketplace-tab ${props.tab === "browse" ? "marketplace-tab--active" : ""}"
@click=${() => props.onTabChange("browse")}
>
Browse
</button>
<button
class="marketplace-tab ${props.tab === "installed" ? "marketplace-tab--active" : ""}"
@click=${() => props.onTabChange("installed")}
>
Installed (${props.installed.length})
</button>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${props.tab === "browse" ? renderBrowseTab(props) : renderInstalledTab(props)}
</section>
<style>
.marketplace-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border-color, #333);
padding-bottom: 0;
}
.marketplace-tab {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--text-secondary, #888);
font-size: 14px;
}
.marketplace-tab:hover {
color: var(--text-primary, #fff);
}
.marketplace-tab--active {
color: var(--text-primary, #fff);
border-bottom-color: var(--accent-color, #0ea5e9);
}
.marketplace-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
margin-top: 16px;
}
.marketplace-card {
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 16px;
background: var(--bg-secondary, #1a1a1a);
}
.marketplace-card-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.marketplace-emoji {
font-size: 24px;
line-height: 1;
}
.marketplace-card-title {
font-weight: 600;
font-size: 16px;
color: var(--text-primary, #fff);
}
.marketplace-card-author {
font-size: 12px;
color: var(--text-secondary, #888);
}
.marketplace-card-desc {
font-size: 14px;
color: var(--text-secondary, #aaa);
margin-bottom: 12px;
line-height: 1.4;
}
.marketplace-card-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--text-secondary, #888);
margin-bottom: 12px;
}
.marketplace-stars,
.marketplace-downloads {
display: inline-flex;
align-items: center;
gap: 4px;
}
.marketplace-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.marketplace-version {
font-size: 12px;
color: var(--text-secondary, #888);
font-family: var(--font-mono, monospace);
}
.marketplace-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.marketplace-tag {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-tertiary, #252525);
color: var(--text-secondary, #888);
}
.marketplace-update-badge {
display: inline-block;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--warning-bg, #553300);
color: var(--warning-color, #ffaa00);
margin-left: 8px;
}
</style>
`;
}
function renderBrowseTab(props: MarketplaceProps) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
props.onSearch(props.query);
}
};
return html`
<div class="filters" style="margin-top: 16px;">
<label class="field" style="flex: 1;">
<span>Search ClawdHub</span>
<input
type="text"
.value=${props.query}
@input=${(e: Event) => props.onQueryChange((e.target as HTMLInputElement).value)}
@keydown=${handleKeyDown}
placeholder="Search for skills (e.g., calendar, github, notion)"
/>
</label>
<button
class="btn primary"
?disabled=${props.searching || !props.query.trim()}
@click=${() => props.onSearch(props.query)}
>
${props.searching ? "Searching…" : "Search"}
</button>
</div>
${props.results.length === 0 && !props.searching
? html`
<div class="muted" style="margin-top: 24px; text-align: center;">
${props.query
? "No skills found. Try a different search term."
: "Search for skills to get started."}
</div>
`
: html`
<div class="marketplace-grid">
${props.results.map((skill) => renderSkillCard(skill, props))}
</div>
`}
`;
}
function renderInstalledTab(props: MarketplaceProps) {
if (props.installed.length === 0) {
return html`
<div class="muted" style="margin-top: 24px; text-align: center;">
No ClawdHub skills installed yet. Browse and install skills from the marketplace.
</div>
`;
}
return html`
<div class="marketplace-grid">
${props.installed.map((skill) => renderInstalledCard(skill, props))}
</div>
`;
}
function renderSkillCard(skill: MarketplaceSearchResult, props: MarketplaceProps) {
const busy = props.busySlug === skill.slug;
const message = props.messages[skill.slug];
const isInstalled = props.installed.some((s) => s.slug === skill.slug);
return html`
<div class="marketplace-card">
<div class="marketplace-card-header">
${skill.emoji ? html`<span class="marketplace-emoji">${skill.emoji}</span>` : nothing}
<div>
<div class="marketplace-card-title">${skill.name}</div>
${skill.author
? html`<div class="marketplace-card-author">by ${skill.author}</div>`
: nothing}
</div>
</div>
<div class="marketplace-card-desc">${clampText(skill.description, 120)}</div>
${skill.tags.length > 0
? html`
<div class="marketplace-tags">
${skill.tags.slice(0, 4).map((tag) => html`<span class="marketplace-tag">${tag}</span>`)}
</div>
`
: nothing}
<div class="marketplace-card-meta">
${renderStars(skill.stars)}
${renderDownloads(skill.downloads)}
<span>Updated ${formatDate(skill.updatedAt)}</span>
</div>
<div class="marketplace-card-footer">
<span class="marketplace-version">v${skill.version}</span>
<button
class="btn ${isInstalled ? "" : "primary"}"
?disabled=${busy}
@click=${() => props.onInstall(skill.slug, skill.version)}
>
${busy ? "Installing…" : isInstalled ? "Reinstall" : "Install"}
</button>
</div>
${message
? html`
<div
class="muted"
style="margin-top: 8px; font-size: 12px; color: ${message.kind === "error"
? "var(--danger-color, #d14343)"
: "var(--success-color, #0a7f5a)"};"
>
${message.message}
</div>
`
: nothing}
</div>
`;
}
function renderInstalledCard(skill: MarketplaceInstalledSkill, props: MarketplaceProps) {
const busy = props.busySlug === skill.slug;
const message = props.messages[skill.slug];
const updateCheck = props.updates.find((u) => u.slug === skill.slug);
const hasUpdate = updateCheck?.hasUpdate ?? false;
return html`
<div class="marketplace-card">
<div class="marketplace-card-header">
${skill.emoji ? html`<span class="marketplace-emoji">${skill.emoji}</span>` : nothing}
<div>
<div class="marketplace-card-title">
${skill.name || skill.slug}
${hasUpdate
? html`<span class="marketplace-update-badge">Update available</span>`
: nothing}
</div>
</div>
</div>
${skill.description
? html`<div class="marketplace-card-desc">${clampText(skill.description, 120)}</div>`
: nothing}
<div class="marketplace-card-meta">
<span>Installed ${formatDate(skill.installedAt)}</span>
</div>
<div class="marketplace-card-footer">
<span class="marketplace-version">
v${skill.version}
${hasUpdate && updateCheck
? html` → v${updateCheck.latestVersion}`
: nothing}
</span>
${hasUpdate && updateCheck
? html`
<button
class="btn primary"
?disabled=${busy}
@click=${() => props.onInstall(skill.slug, updateCheck.latestVersion)}
>
${busy ? "Updating…" : "Update"}
</button>
`
: html`
<button
class="btn"
?disabled=${busy}
@click=${() => props.onInstall(skill.slug)}
>
${busy ? "Reinstalling…" : "Reinstall"}
</button>
`}
</div>
${message
? html`
<div
class="muted"
style="margin-top: 8px; font-size: 12px; color: ${message.kind === "error"
? "var(--danger-color, #d14343)"
: "var(--success-color, #0a7f5a)"};"
>
${message.message}
</div>
`
: nothing}
</div>
`;
}