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.
|
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 🗺
|
## Current Focus & Roadmap 🗺
|
||||||
|
|
||||||
We are currently prioritizing:
|
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": [
|
"pages": [
|
||||||
"testing",
|
"testing",
|
||||||
"scripts",
|
"scripts",
|
||||||
|
"reference/feature-maturity",
|
||||||
"reference/session-management-compaction",
|
"reference/session-management-compaction",
|
||||||
"reference/rpc",
|
"reference/rpc",
|
||||||
"reference/device-models",
|
"reference/device-models",
|
||||||
@ -1152,6 +1153,13 @@
|
|||||||
"reference/templates/TOOLS",
|
"reference/templates/TOOLS",
|
||||||
"reference/templates/USER"
|
"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,
|
WizardStatusResultSchema,
|
||||||
type WizardStep,
|
type WizardStep,
|
||||||
WizardStepSchema,
|
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";
|
} from "./schema.js";
|
||||||
|
|
||||||
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
|
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 =
|
export const validateSkillsInstallParams =
|
||||||
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
|
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
|
||||||
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema);
|
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 validateCronListParams = ajv.compile<CronListParams>(CronListParamsSchema);
|
||||||
export const validateCronStatusParams = ajv.compile<CronStatusParams>(CronStatusParamsSchema);
|
export const validateCronStatusParams = ajv.compile<CronStatusParams>(CronStatusParamsSchema);
|
||||||
export const validateCronAddParams = ajv.compile<CronAddParams>(CronAddParamsSchema);
|
export const validateCronAddParams = ajv.compile<CronAddParams>(CronAddParamsSchema);
|
||||||
@ -412,6 +451,18 @@ export {
|
|||||||
SkillsStatusParamsSchema,
|
SkillsStatusParamsSchema,
|
||||||
SkillsInstallParamsSchema,
|
SkillsInstallParamsSchema,
|
||||||
SkillsUpdateParamsSchema,
|
SkillsUpdateParamsSchema,
|
||||||
|
ClawdHubSearchParamsSchema,
|
||||||
|
ClawdHubSearchResponseSchema,
|
||||||
|
ClawdHubDetailsParamsSchema,
|
||||||
|
ClawdHubDetailsResponseSchema,
|
||||||
|
ClawdHubInstallParamsSchema,
|
||||||
|
ClawdHubInstallResponseSchema,
|
||||||
|
ClawdHubInstalledParamsSchema,
|
||||||
|
ClawdHubInstalledResponseSchema,
|
||||||
|
ClawdHubInstalledSkillSchema,
|
||||||
|
ClawdHubCheckUpdatesParamsSchema,
|
||||||
|
ClawdHubCheckUpdatesResponseSchema,
|
||||||
|
ClawdHubUpdateCheckSchema,
|
||||||
CronJobSchema,
|
CronJobSchema,
|
||||||
CronListParamsSchema,
|
CronListParamsSchema,
|
||||||
CronStatusParamsSchema,
|
CronStatusParamsSchema,
|
||||||
@ -517,4 +568,16 @@ export type {
|
|||||||
PollParams,
|
PollParams,
|
||||||
UpdateRunParams,
|
UpdateRunParams,
|
||||||
ChatInjectParams,
|
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/agent.js";
|
||||||
export * from "./schema/agents-models-skills.js";
|
export * from "./schema/agents-models-skills.js";
|
||||||
export * from "./schema/channels.js";
|
export * from "./schema/channels.js";
|
||||||
|
export * from "./schema/clawdhub.js";
|
||||||
export * from "./schema/config.js";
|
export * from "./schema/config.js";
|
||||||
export * from "./schema/cron.js";
|
export * from "./schema/cron.js";
|
||||||
export * from "./schema/error-codes.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 { agentsHandlers } from "./server-methods/agents.js";
|
||||||
import { browserHandlers } from "./server-methods/browser.js";
|
import { browserHandlers } from "./server-methods/browser.js";
|
||||||
import { channelsHandlers } from "./server-methods/channels.js";
|
import { channelsHandlers } from "./server-methods/channels.js";
|
||||||
|
import { clawdhubHandlers } from "./server-methods/clawdhub.js";
|
||||||
import { chatHandlers } from "./server-methods/chat.js";
|
import { chatHandlers } from "./server-methods/chat.js";
|
||||||
import { configHandlers } from "./server-methods/config.js";
|
import { configHandlers } from "./server-methods/config.js";
|
||||||
import { connectHandlers } from "./server-methods/connect.js";
|
import { connectHandlers } from "./server-methods/connect.js";
|
||||||
@ -72,6 +73,10 @@ const READ_METHODS = new Set([
|
|||||||
"node.list",
|
"node.list",
|
||||||
"node.describe",
|
"node.describe",
|
||||||
"chat.history",
|
"chat.history",
|
||||||
|
"clawdhub.search",
|
||||||
|
"clawdhub.details",
|
||||||
|
"clawdhub.installed",
|
||||||
|
"clawdhub.checkUpdates",
|
||||||
]);
|
]);
|
||||||
const WRITE_METHODS = new Set([
|
const WRITE_METHODS = new Set([
|
||||||
"send",
|
"send",
|
||||||
@ -131,6 +136,7 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
|||||||
method === "channels.logout" ||
|
method === "channels.logout" ||
|
||||||
method === "skills.install" ||
|
method === "skills.install" ||
|
||||||
method === "skills.update" ||
|
method === "skills.update" ||
|
||||||
|
method === "clawdhub.install" ||
|
||||||
method === "cron.add" ||
|
method === "cron.add" ||
|
||||||
method === "cron.update" ||
|
method === "cron.update" ||
|
||||||
method === "cron.remove" ||
|
method === "cron.remove" ||
|
||||||
@ -152,6 +158,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||||||
...healthHandlers,
|
...healthHandlers,
|
||||||
...channelsHandlers,
|
...channelsHandlers,
|
||||||
...chatHandlers,
|
...chatHandlers,
|
||||||
|
...clawdhubHandlers,
|
||||||
...cronHandlers,
|
...cronHandlers,
|
||||||
...deviceHandlers,
|
...deviceHandlers,
|
||||||
...execApprovalsHandlers,
|
...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,
|
rotateDeviceToken,
|
||||||
} from "./controllers/devices";
|
} from "./controllers/devices";
|
||||||
import { renderSkills } from "./views/skills";
|
import { renderSkills } from "./views/skills";
|
||||||
|
import { renderMarketplace } from "./views/marketplace";
|
||||||
import { renderChatControls, renderSkillsQuickToggle, renderTab, renderThemeToggle } from "./app-render.helpers";
|
import { renderChatControls, renderSkillsQuickToggle, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||||
import { loadChannels } from "./controllers/channels";
|
import { loadChannels } from "./controllers/channels";
|
||||||
import { loadPresence } from "./controllers/presence";
|
import { loadPresence } from "./controllers/presence";
|
||||||
@ -62,6 +63,13 @@ import {
|
|||||||
updateSkillEnabled,
|
updateSkillEnabled,
|
||||||
type SkillMessage,
|
type SkillMessage,
|
||||||
} from "./controllers/skills";
|
} from "./controllers/skills";
|
||||||
|
import {
|
||||||
|
checkMarketplaceUpdates,
|
||||||
|
installMarketplaceSkill,
|
||||||
|
loadMarketplaceInstalled,
|
||||||
|
searchMarketplace,
|
||||||
|
setMarketplaceTab,
|
||||||
|
} from "./controllers/marketplace";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
import {
|
import {
|
||||||
@ -351,6 +359,27 @@ export function renderApp(state: AppViewState) {
|
|||||||
})
|
})
|
||||||
: nothing}
|
: 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"
|
${state.tab === "nodes"
|
||||||
? renderNodes({
|
? renderNodes({
|
||||||
loading: state.nodesLoading,
|
loading: state.nodesLoading,
|
||||||
|
|||||||
@ -22,6 +22,12 @@ import type {
|
|||||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
|
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
|
||||||
import type { EventLogEntry } from "./app-events";
|
import type { EventLogEntry } from "./app-events";
|
||||||
import type { SkillMessage } from "./controllers/skills";
|
import type { SkillMessage } from "./controllers/skills";
|
||||||
|
import type {
|
||||||
|
MarketplaceInstalledSkill,
|
||||||
|
MarketplaceMessageMap,
|
||||||
|
MarketplaceSearchResult,
|
||||||
|
MarketplaceUpdateCheck,
|
||||||
|
} from "./controllers/marketplace";
|
||||||
import type {
|
import type {
|
||||||
ExecApprovalsFile,
|
ExecApprovalsFile,
|
||||||
ExecApprovalsSnapshot,
|
ExecApprovalsSnapshot,
|
||||||
@ -128,6 +134,16 @@ export type AppViewState = {
|
|||||||
skillEdits: Record<string, string>;
|
skillEdits: Record<string, string>;
|
||||||
skillMessages: Record<string, SkillMessage>;
|
skillMessages: Record<string, SkillMessage>;
|
||||||
skillsBusyKey: string | null;
|
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;
|
debugLoading: boolean;
|
||||||
debugStatus: StatusSummary | null;
|
debugStatus: StatusSummary | null;
|
||||||
debugHealth: HealthSnapshot | null;
|
debugHealth: HealthSnapshot | null;
|
||||||
|
|||||||
@ -231,6 +231,17 @@ export class MoltbotApp extends LitElement {
|
|||||||
@state() skillsBusyKey: string | null = null;
|
@state() skillsBusyKey: string | null = null;
|
||||||
@state() skillMessages: Record<string, SkillMessage> = {};
|
@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() debugLoading = false;
|
||||||
@state() debugStatus: StatusSummary | null = null;
|
@state() debugStatus: StatusSummary | null = null;
|
||||||
@state() debugHealth: HealthSnapshot | 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",
|
label: "Control",
|
||||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||||
},
|
},
|
||||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
{ label: "Agent", tabs: ["skills", "marketplace", "nodes"] },
|
||||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -17,6 +17,7 @@ export type Tab =
|
|||||||
| "sessions"
|
| "sessions"
|
||||||
| "cron"
|
| "cron"
|
||||||
| "skills"
|
| "skills"
|
||||||
|
| "marketplace"
|
||||||
| "nodes"
|
| "nodes"
|
||||||
| "chat"
|
| "chat"
|
||||||
| "config"
|
| "config"
|
||||||
@ -30,6 +31,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
|||||||
sessions: "/sessions",
|
sessions: "/sessions",
|
||||||
cron: "/cron",
|
cron: "/cron",
|
||||||
skills: "/skills",
|
skills: "/skills",
|
||||||
|
marketplace: "/marketplace",
|
||||||
nodes: "/nodes",
|
nodes: "/nodes",
|
||||||
chat: "/chat",
|
chat: "/chat",
|
||||||
config: "/config",
|
config: "/config",
|
||||||
@ -116,6 +118,8 @@ export function iconForTab(tab: Tab): IconName {
|
|||||||
return "loader";
|
return "loader";
|
||||||
case "skills":
|
case "skills":
|
||||||
return "zap";
|
return "zap";
|
||||||
|
case "marketplace":
|
||||||
|
return "puzzle";
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return "monitor";
|
return "monitor";
|
||||||
case "config":
|
case "config":
|
||||||
@ -143,6 +147,8 @@ export function titleForTab(tab: Tab) {
|
|||||||
return "Cron Jobs";
|
return "Cron Jobs";
|
||||||
case "skills":
|
case "skills":
|
||||||
return "Skills";
|
return "Skills";
|
||||||
|
case "marketplace":
|
||||||
|
return "Marketplace";
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return "Nodes";
|
return "Nodes";
|
||||||
case "chat":
|
case "chat":
|
||||||
@ -172,6 +178,8 @@ export function subtitleForTab(tab: Tab) {
|
|||||||
return "Schedule wakeups and recurring agent runs.";
|
return "Schedule wakeups and recurring agent runs.";
|
||||||
case "skills":
|
case "skills":
|
||||||
return "Manage skill availability and API key injection.";
|
return "Manage skill availability and API key injection.";
|
||||||
|
case "marketplace":
|
||||||
|
return "Browse and install skills from ClawdHub.";
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return "Paired devices, capabilities, and command exposure.";
|
return "Paired devices, capabilities, and command exposure.";
|
||||||
case "chat":
|
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