diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 509d5b11a..2b4cc27f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,14 @@ Please include in your PR: AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. +## Contribution Guides + +Detailed guides for extending Clawdbot: + +- **[Skills Guide](https://docs.clawd.bot/contributing/skills)** - Create and publish skills to ClawdHub +- **[Plugins Guide](https://docs.clawd.bot/contributing/plugins)** - Build plugins for channels, tools, and more +- **[Feature Maturity](https://docs.clawd.bot/reference/feature-maturity)** - Understand feature stability levels + ## Current Focus & Roadmap πŸ—Ί We are currently prioritizing: diff --git a/docs/contributing/plugins.md b/docs/contributing/plugins.md new file mode 100644 index 000000000..d45a20a6e --- /dev/null +++ b/docs/contributing/plugins.md @@ -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..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 diff --git a/docs/contributing/skills.md b/docs/contributing/skills.md new file mode 100644 index 000000000..b9c7b8e8a --- /dev/null +++ b/docs/contributing/skills.md @@ -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..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 `/skills/` +2. Verify it loads: `clawdbot skills list` +3. Check gating: `clawdbot skills info ` +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 ` +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 diff --git a/docs/docs.json b/docs/docs.json index 3e26cc720..e674e149e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1137,6 +1137,7 @@ "pages": [ "testing", "scripts", + "reference/feature-maturity", "reference/session-management-compaction", "reference/rpc", "reference/device-models", @@ -1152,6 +1153,13 @@ "reference/templates/TOOLS", "reference/templates/USER" ] + }, + { + "group": "Contributing", + "pages": [ + "contributing/skills", + "contributing/plugins" + ] } ] } diff --git a/docs/reference/feature-maturity.md b/docs/reference/feature-maturity.md new file mode 100644 index 000000000..727ca74ac --- /dev/null +++ b/docs/reference/feature-maturity.md @@ -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. diff --git a/src/clawdhub/client.ts b/src/clawdhub/client.ts new file mode 100644 index 000000000..2f5fe4ac8 --- /dev/null +++ b/src/clawdhub/client.ts @@ -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; +}; + +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(url: string, options: RequestInit = {}, timeoutMs = 30000): Promise { + 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 { + const registryUrl = options.registryUrl || getRegistryUrl(); + const params = new URLSearchParams({ q: query }); + const url = `${registryUrl}/api/skills/search?${params}`; + + return fetchJson(url, {}, options.timeout); +} + +/** + * Get detailed information about a skill including all versions. + */ +export async function getSkillDetails( + slug: string, + options: ClawdHubClientOptions = {}, +): Promise { + const registryUrl = options.registryUrl || getRegistryUrl(); + const url = `${registryUrl}/api/skills/${encodeURIComponent(slug)}`; + + return fetchJson(url, {}, options.timeout); +} + +/** + * Download a skill zip from ClawdHub. + */ +export async function downloadSkillZip( + slug: string, + version: string, + options: ClawdHubClientOptions = {}, +): Promise { + 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 { + 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; +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 6e5a862d1..58cacb60e 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -189,6 +189,30 @@ import { WizardStatusResultSchema, type WizardStep, WizardStepSchema, + type ClawdHubSearchParams, + ClawdHubSearchParamsSchema, + type ClawdHubSearchResponse, + ClawdHubSearchResponseSchema, + type ClawdHubDetailsParams, + ClawdHubDetailsParamsSchema, + type ClawdHubDetailsResponse, + ClawdHubDetailsResponseSchema, + type ClawdHubInstallParams, + ClawdHubInstallParamsSchema, + type ClawdHubInstallResponse, + ClawdHubInstallResponseSchema, + type ClawdHubInstalledParams, + ClawdHubInstalledParamsSchema, + type ClawdHubInstalledResponse, + ClawdHubInstalledResponseSchema, + type ClawdHubInstalledSkill, + ClawdHubInstalledSkillSchema, + type ClawdHubCheckUpdatesParams, + ClawdHubCheckUpdatesParamsSchema, + type ClawdHubCheckUpdatesResponse, + ClawdHubCheckUpdatesResponseSchema, + type ClawdHubUpdateCheck, + ClawdHubUpdateCheckSchema, } from "./schema.js"; const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({ @@ -269,6 +293,21 @@ export const validateSkillsBinsParams = ajv.compile(SkillsBins export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema); export const validateSkillsUpdateParams = ajv.compile(SkillsUpdateParamsSchema); +export const validateClawdHubSearchParams = ajv.compile( + ClawdHubSearchParamsSchema, +); +export const validateClawdHubDetailsParams = ajv.compile( + ClawdHubDetailsParamsSchema, +); +export const validateClawdHubInstallParams = ajv.compile( + ClawdHubInstallParamsSchema, +); +export const validateClawdHubInstalledParams = ajv.compile( + ClawdHubInstalledParamsSchema, +); +export const validateClawdHubCheckUpdatesParams = ajv.compile( + ClawdHubCheckUpdatesParamsSchema, +); export const validateCronListParams = ajv.compile(CronListParamsSchema); export const validateCronStatusParams = ajv.compile(CronStatusParamsSchema); export const validateCronAddParams = ajv.compile(CronAddParamsSchema); @@ -412,6 +451,18 @@ export { SkillsStatusParamsSchema, SkillsInstallParamsSchema, SkillsUpdateParamsSchema, + ClawdHubSearchParamsSchema, + ClawdHubSearchResponseSchema, + ClawdHubDetailsParamsSchema, + ClawdHubDetailsResponseSchema, + ClawdHubInstallParamsSchema, + ClawdHubInstallResponseSchema, + ClawdHubInstalledParamsSchema, + ClawdHubInstalledResponseSchema, + ClawdHubInstalledSkillSchema, + ClawdHubCheckUpdatesParamsSchema, + ClawdHubCheckUpdatesResponseSchema, + ClawdHubUpdateCheckSchema, CronJobSchema, CronListParamsSchema, CronStatusParamsSchema, @@ -517,4 +568,16 @@ export type { PollParams, UpdateRunParams, ChatInjectParams, + ClawdHubSearchParams, + ClawdHubSearchResponse, + ClawdHubDetailsParams, + ClawdHubDetailsResponse, + ClawdHubInstallParams, + ClawdHubInstallResponse, + ClawdHubInstalledParams, + ClawdHubInstalledResponse, + ClawdHubInstalledSkill, + ClawdHubCheckUpdatesParams, + ClawdHubCheckUpdatesResponse, + ClawdHubUpdateCheck, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 614942008..714def506 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -1,6 +1,7 @@ export * from "./schema/agent.js"; export * from "./schema/agents-models-skills.js"; export * from "./schema/channels.js"; +export * from "./schema/clawdhub.js"; export * from "./schema/config.js"; export * from "./schema/cron.js"; export * from "./schema/error-codes.js"; diff --git a/src/gateway/protocol/schema/clawdhub.ts b/src/gateway/protocol/schema/clawdhub.ts new file mode 100644 index 000000000..ea63714b0 --- /dev/null +++ b/src/gateway/protocol/schema/clawdhub.ts @@ -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; + +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; + +export type ClawdHubUpdateCheck = { + slug: string; + currentVersion: string; + latestVersion: string; + hasUpdate: boolean; +}; + +export type ClawdHubCheckUpdatesResponse = { + updates: ClawdHubUpdateCheck[]; +}; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 66492b976..155eafc52 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -3,6 +3,7 @@ import { agentHandlers } from "./server-methods/agent.js"; import { agentsHandlers } from "./server-methods/agents.js"; import { browserHandlers } from "./server-methods/browser.js"; import { channelsHandlers } from "./server-methods/channels.js"; +import { clawdhubHandlers } from "./server-methods/clawdhub.js"; import { chatHandlers } from "./server-methods/chat.js"; import { configHandlers } from "./server-methods/config.js"; import { connectHandlers } from "./server-methods/connect.js"; @@ -72,6 +73,10 @@ const READ_METHODS = new Set([ "node.list", "node.describe", "chat.history", + "clawdhub.search", + "clawdhub.details", + "clawdhub.installed", + "clawdhub.checkUpdates", ]); const WRITE_METHODS = new Set([ "send", @@ -131,6 +136,7 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c method === "channels.logout" || method === "skills.install" || method === "skills.update" || + method === "clawdhub.install" || method === "cron.add" || method === "cron.update" || method === "cron.remove" || @@ -152,6 +158,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...healthHandlers, ...channelsHandlers, ...chatHandlers, + ...clawdhubHandlers, ...cronHandlers, ...deviceHandlers, ...execApprovalsHandlers, diff --git a/src/gateway/server-methods/clawdhub.ts b/src/gateway/server-methods/clawdhub.ts new file mode 100644 index 000000000..f7a3dce70 --- /dev/null +++ b/src/gateway/server-methods/clawdhub.ts @@ -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; +}; + +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 { + 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", + ), + ); + } + }, +}; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1d802bc88..db6565bed 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -50,6 +50,7 @@ import { rotateDeviceToken, } from "./controllers/devices"; import { renderSkills } from "./views/skills"; +import { renderMarketplace } from "./views/marketplace"; import { renderChatControls, renderSkillsQuickToggle, renderTab, renderThemeToggle } from "./app-render.helpers"; import { loadChannels } from "./controllers/channels"; import { loadPresence } from "./controllers/presence"; @@ -62,6 +63,13 @@ import { updateSkillEnabled, type SkillMessage, } from "./controllers/skills"; +import { + checkMarketplaceUpdates, + installMarketplaceSkill, + loadMarketplaceInstalled, + searchMarketplace, + setMarketplaceTab, +} from "./controllers/marketplace"; import { loadNodes } from "./controllers/nodes"; import { loadChatHistory } from "./controllers/chat"; import { @@ -351,6 +359,27 @@ export function renderApp(state: AppViewState) { }) : nothing} + ${state.tab === "marketplace" + ? renderMarketplace({ + loading: state.marketplaceLoading, + searching: state.marketplaceSearching, + query: state.marketplaceQuery, + results: state.marketplaceResults, + installed: state.marketplaceInstalled, + updates: state.marketplaceUpdates, + error: state.marketplaceError, + busySlug: state.marketplaceBusySlug, + messages: state.marketplaceMessages, + tab: state.marketplaceTab, + onQueryChange: (query) => (state.marketplaceQuery = query), + onSearch: (query) => searchMarketplace(state, query), + onInstall: (slug, version) => installMarketplaceSkill(state, slug, version), + onCheckUpdates: () => checkMarketplaceUpdates(state), + onRefresh: () => loadMarketplaceInstalled(state), + onTabChange: (tab) => setMarketplaceTab(state, tab), + }) + : nothing} + ${state.tab === "nodes" ? renderNodes({ loading: state.nodesLoading, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 069465e32..fa808bafe 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -22,6 +22,12 @@ import type { import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import type { SkillMessage } from "./controllers/skills"; +import type { + MarketplaceInstalledSkill, + MarketplaceMessageMap, + MarketplaceSearchResult, + MarketplaceUpdateCheck, +} from "./controllers/marketplace"; import type { ExecApprovalsFile, ExecApprovalsSnapshot, @@ -128,6 +134,16 @@ export type AppViewState = { skillEdits: Record; skillMessages: Record; skillsBusyKey: string | null; + marketplaceLoading: boolean; + marketplaceSearching: boolean; + marketplaceQuery: string; + marketplaceResults: MarketplaceSearchResult[]; + marketplaceInstalled: MarketplaceInstalledSkill[]; + marketplaceUpdates: MarketplaceUpdateCheck[]; + marketplaceError: string | null; + marketplaceBusySlug: string | null; + marketplaceMessages: MarketplaceMessageMap; + marketplaceTab: "browse" | "installed"; debugLoading: boolean; debugStatus: StatusSummary | null; debugHealth: HealthSnapshot | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9dc371339..494e086d0 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -231,6 +231,17 @@ export class MoltbotApp extends LitElement { @state() skillsBusyKey: string | null = null; @state() skillMessages: Record = {}; + @state() marketplaceLoading = false; + @state() marketplaceSearching = false; + @state() marketplaceQuery = ""; + @state() marketplaceResults: import("./controllers/marketplace").MarketplaceSearchResult[] = []; + @state() marketplaceInstalled: import("./controllers/marketplace").MarketplaceInstalledSkill[] = []; + @state() marketplaceUpdates: import("./controllers/marketplace").MarketplaceUpdateCheck[] = []; + @state() marketplaceError: string | null = null; + @state() marketplaceBusySlug: string | null = null; + @state() marketplaceMessages: import("./controllers/marketplace").MarketplaceMessageMap = {}; + @state() marketplaceTab: "browse" | "installed" = "browse"; + @state() debugLoading = false; @state() debugStatus: StatusSummary | null = null; @state() debugHealth: HealthSnapshot | null = null; diff --git a/ui/src/ui/controllers/marketplace.ts b/ui/src/ui/controllers/marketplace.ts new file mode 100644 index 000000000..bd53e084d --- /dev/null +++ b/ui/src/ui/controllers/marketplace.ts @@ -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; + +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; +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 966abec96..df5712ec9 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -6,7 +6,7 @@ export const TAB_GROUPS = [ label: "Control", tabs: ["overview", "channels", "instances", "sessions", "cron"], }, - { label: "Agent", tabs: ["skills", "nodes"] }, + { label: "Agent", tabs: ["skills", "marketplace", "nodes"] }, { label: "Settings", tabs: ["config", "debug", "logs"] }, ] as const; @@ -17,6 +17,7 @@ export type Tab = | "sessions" | "cron" | "skills" + | "marketplace" | "nodes" | "chat" | "config" @@ -30,6 +31,7 @@ const TAB_PATHS: Record = { sessions: "/sessions", cron: "/cron", skills: "/skills", + marketplace: "/marketplace", nodes: "/nodes", chat: "/chat", config: "/config", @@ -116,6 +118,8 @@ export function iconForTab(tab: Tab): IconName { return "loader"; case "skills": return "zap"; + case "marketplace": + return "puzzle"; case "nodes": return "monitor"; case "config": @@ -143,6 +147,8 @@ export function titleForTab(tab: Tab) { return "Cron Jobs"; case "skills": return "Skills"; + case "marketplace": + return "Marketplace"; case "nodes": return "Nodes"; case "chat": @@ -172,6 +178,8 @@ export function subtitleForTab(tab: Tab) { return "Schedule wakeups and recurring agent runs."; case "skills": return "Manage skill availability and API key injection."; + case "marketplace": + return "Browse and install skills from ClawdHub."; case "nodes": return "Paired devices, capabilities, and command exposure."; case "chat": diff --git a/ui/src/ui/views/marketplace.ts b/ui/src/ui/views/marketplace.ts new file mode 100644 index 000000000..a5f16db0a --- /dev/null +++ b/ui/src/ui/views/marketplace.ts @@ -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`β˜… ${formatNumber(count)}`; +} + +function renderDownloads(count: number) { + return html`↓ ${formatNumber(count)}`; +} + +export function renderMarketplace(props: MarketplaceProps) { + return html` +
+
+
+
ClawdHub Marketplace
+
Browse and install skills from the public registry.
+
+
+ + +
+
+ +
+ + +
+ + ${props.error + ? html`
${props.error}
` + : nothing} + + ${props.tab === "browse" ? renderBrowseTab(props) : renderInstalledTab(props)} +
+ + + `; +} + +function renderBrowseTab(props: MarketplaceProps) { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + props.onSearch(props.query); + } + }; + + return html` +
+ + +
+ + ${props.results.length === 0 && !props.searching + ? html` +
+ ${props.query + ? "No skills found. Try a different search term." + : "Search for skills to get started."} +
+ ` + : html` +
+ ${props.results.map((skill) => renderSkillCard(skill, props))} +
+ `} + `; +} + +function renderInstalledTab(props: MarketplaceProps) { + if (props.installed.length === 0) { + return html` +
+ No ClawdHub skills installed yet. Browse and install skills from the marketplace. +
+ `; + } + + return html` +
+ ${props.installed.map((skill) => renderInstalledCard(skill, props))} +
+ `; +} + +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` +
+
+ ${skill.emoji ? html`${skill.emoji}` : nothing} +
+
${skill.name}
+ ${skill.author + ? html`
by ${skill.author}
` + : nothing} +
+
+
${clampText(skill.description, 120)}
+ ${skill.tags.length > 0 + ? html` +
+ ${skill.tags.slice(0, 4).map((tag) => html`${tag}`)} +
+ ` + : nothing} +
+ ${renderStars(skill.stars)} + ${renderDownloads(skill.downloads)} + Updated ${formatDate(skill.updatedAt)} +
+ + ${message + ? html` +
+ ${message.message} +
+ ` + : nothing} +
+ `; +} + +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` +
+
+ ${skill.emoji ? html`${skill.emoji}` : nothing} +
+
+ ${skill.name || skill.slug} + ${hasUpdate + ? html`Update available` + : nothing} +
+
+
+ ${skill.description + ? html`
${clampText(skill.description, 120)}
` + : nothing} +
+ Installed ${formatDate(skill.installedAt)} +
+ + ${message + ? html` +
+ ${message.message} +
+ ` + : nothing} +
+ `; +}