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:
parent
7ff1dc3bba
commit
8e00cbf27d
@ -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:
|
||||
|
||||
639
docs/contributing/plugins.md
Normal file
639
docs/contributing/plugins.md
Normal 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
422
docs/contributing/skills.md
Normal 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
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
243
docs/reference/feature-maturity.md
Normal file
243
docs/reference/feature-maturity.md
Normal 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
206
src/clawdhub/client.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
247
src/gateway/protocol/schema/clawdhub.ts
Normal file
247
src/gateway/protocol/schema/clawdhub.ts
Normal 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[];
|
||||
};
|
||||
@ -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,
|
||||
|
||||
323
src/gateway/server-methods/clawdhub.ts
Normal file
323
src/gateway/server-methods/clawdhub.ts
Normal 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",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
188
ui/src/ui/controllers/marketplace.ts
Normal file
188
ui/src/ui/controllers/marketplace.ts
Normal 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;
|
||||
}
|
||||
@ -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":
|
||||
|
||||
392
ui/src/ui/views/marketplace.ts
Normal file
392
ui/src/ui/views/marketplace.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user