From 83cccc3157f3347974ed77425d500f201dedde1c Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 10 Apr 2026 19:24:18 +1000 Subject: [PATCH 1/8] feat: add create-bot CLI for scaffolding Chat SDK projects Introduces `create-bot`, a CLI tool (like create-next-app) that scaffolds new Chat SDK bot projects. It prompts for a project name, description, platform adapters, and a state adapter, then generates a complete Next.js project with webhook routes, environment variables, and bot configuration. Supports interactive and non-interactive usage via flags (--adapter, --description, --pm, --quiet, --yes). Includes full test coverage, README, and documentation integrated into the Chat SDK docs site. Made-with: Cursor --- apps/docs/content/docs/create-bot.mdx | 86 ++++ apps/docs/content/docs/getting-started.mdx | 8 + apps/docs/content/docs/index.mdx | 10 + apps/docs/content/docs/meta.json | 1 + biome.jsonc | 1 + knip.json | 2 +- packages/create-bot/README.md | 129 ++++++ .../.agents/skills/chat-sdk/SKILL.md | 213 +++++++++ packages/create-bot/_template/.env.example | 2 + packages/create-bot/_template/.gitignore | 16 + packages/create-bot/_template/AGENTS.md | 48 ++ packages/create-bot/_template/README.md | 48 ++ packages/create-bot/_template/next.config.ts | 5 + packages/create-bot/_template/package.json | 21 + .../src/app/api/webhooks/[platform]/route.ts | 22 + packages/create-bot/_template/tsconfig.json | 28 ++ packages/create-bot/adapters.json | 386 ++++++++++++++++ packages/create-bot/package.json | 57 +++ packages/create-bot/src/cli.test.ts | 220 +++++++++ packages/create-bot/src/cli.ts | 122 +++++ packages/create-bot/src/index.ts | 3 + packages/create-bot/src/prompts.test.ts | 426 ++++++++++++++++++ packages/create-bot/src/prompts.ts | 189 ++++++++ packages/create-bot/src/scaffold.test.ts | 357 +++++++++++++++ packages/create-bot/src/scaffold.ts | 139 ++++++ packages/create-bot/src/templates.test.ts | 118 +++++ packages/create-bot/src/templates.ts | 37 ++ packages/create-bot/src/types.ts | 41 ++ packages/create-bot/tsconfig.json | 12 + packages/create-bot/tsup.config.ts | 11 + packages/create-bot/vitest.config.ts | 14 + pnpm-lock.yaml | 166 +++++++ 32 files changed, 2937 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/docs/create-bot.mdx create mode 100644 packages/create-bot/README.md create mode 100644 packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md create mode 100644 packages/create-bot/_template/.env.example create mode 100644 packages/create-bot/_template/.gitignore create mode 100644 packages/create-bot/_template/AGENTS.md create mode 100644 packages/create-bot/_template/README.md create mode 100644 packages/create-bot/_template/next.config.ts create mode 100644 packages/create-bot/_template/package.json create mode 100644 packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts create mode 100644 packages/create-bot/_template/tsconfig.json create mode 100644 packages/create-bot/adapters.json create mode 100644 packages/create-bot/package.json create mode 100644 packages/create-bot/src/cli.test.ts create mode 100644 packages/create-bot/src/cli.ts create mode 100644 packages/create-bot/src/index.ts create mode 100644 packages/create-bot/src/prompts.test.ts create mode 100644 packages/create-bot/src/prompts.ts create mode 100644 packages/create-bot/src/scaffold.test.ts create mode 100644 packages/create-bot/src/scaffold.ts create mode 100644 packages/create-bot/src/templates.test.ts create mode 100644 packages/create-bot/src/templates.ts create mode 100644 packages/create-bot/src/types.ts create mode 100644 packages/create-bot/tsconfig.json create mode 100644 packages/create-bot/tsup.config.ts create mode 100644 packages/create-bot/vitest.config.ts diff --git a/apps/docs/content/docs/create-bot.mdx b/apps/docs/content/docs/create-bot.mdx new file mode 100644 index 00000000..5818089f --- /dev/null +++ b/apps/docs/content/docs/create-bot.mdx @@ -0,0 +1,86 @@ +--- +title: create-bot +description: Scaffold a new Chat SDK bot project with a single command. +--- + +`create-bot` is the fastest way to start a new Chat SDK project. It scaffolds a Next.js app with your chosen platform adapters, state adapter, environment variables, and webhook route — ready to run. + +## Quick start + +```bash tab="npm" +npx create-bot my-bot +``` + +```bash tab="pnpm" +pnpm create bot my-bot +``` + +```bash tab="yarn" +yarn create bot my-bot +``` + +```bash tab="bun" +bunx create-bot my-bot +``` + +The CLI walks you through selecting platform adapters (Slack, Teams, Discord, etc.) and a state adapter (Redis, PostgreSQL, or in-memory), then installs dependencies automatically. + +## What you get + +``` +my-bot/ + src/ + lib/bot.ts # Bot config with your adapters and handlers + app/api/webhooks/[platform]/route.ts # Dynamic webhook route for all platforms + .env.example # Pre-populated environment variables + next.config.ts # Next.js config with serverExternalPackages + package.json # Dependencies for your selected adapters + tsconfig.json # TypeScript config with Chat SDK JSX runtime +``` + +The generated `bot.ts` includes starter handlers for `onNewMention` and `onSubscribedMessage` so you can start testing immediately. + +## Non-interactive usage + +Every prompt can be skipped with flags, making `create-bot` fully scriptable for CI, automation, and AI agents: + +```bash +npx create-bot my-bot -d 'My bot' --adapter slack teams redis -yq +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-d, --description ` | Project description | +| `--adapter ` | Platform or state adapters to include (skips interactive prompt) | +| `--pm ` | Package manager to use (`npm`, `yarn`, `pnpm`, `bun`) | +| `-y, --yes` | Skip confirmation prompts (accept defaults) | +| `-q, --quiet` | Suppress non-essential output | +| `--no-color` | Disable color output (respects `NO_COLOR`) | + +### Available adapter values + +**Messaging Platforms:** `slack`, `teams`, `gchat`, `discord`, `telegram`, `whatsapp`, `matrix`, `imessage`, `zernio` + +**Developer Tools:** `github`, `linear`, `resend`, `liveblocks` + +**State:** `memory`, `redis`, `ioredis`, `pg` + +## After scaffolding + +1. Copy the example environment file and fill in your platform credentials: + +```bash +cp .env.example .env.local +``` + +2. Start the dev server: + +```bash +npm run dev +``` + +3. Expose your local server (e.g. with [ngrok](https://ngrok.com)) and configure your platform's webhook URL to `https:///api/webhooks/`. + +See the [platform adapter docs](/docs/adapters) for setup instructions specific to each platform. diff --git a/apps/docs/content/docs/getting-started.mdx b/apps/docs/content/docs/getting-started.mdx index 3270e92d..ecbec472 100644 --- a/apps/docs/content/docs/getting-started.mdx +++ b/apps/docs/content/docs/getting-started.mdx @@ -3,6 +3,14 @@ title: Getting Started description: Pick a guide to start building with Chat SDK. --- +## Scaffold a new project + +The fastest way to get started is with `create-bot`. It scaffolds a complete Next.js bot project with your chosen adapters, environment variables, and webhook route. + + + + + ## Usage Learn the core patterns for handling incoming events and posting messages back to your users. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index b81253c1..8f3ec7cc 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -18,6 +18,16 @@ Building a chat bot that works across multiple platforms typically means maintai - **AI streaming** with first-class support for streaming LLM responses - **Serverless-ready** with distributed state via Redis and message deduplication +## Quick start + +The fastest way to start a new project is with `create-bot`: + +```bash +npx create-bot my-bot +``` + +This scaffolds a Next.js app with your chosen adapters, environment variables, and a webhook route — ready to run. See the [create-bot docs](/docs/create-bot) for all options. + ## How it works Chat SDK has three core concepts: diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 34ab76b5..11c025f2 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "getting-started", + "create-bot", "---Usage---", "usage", "threads-messages-channels", diff --git a/biome.jsonc b/biome.jsonc index 68a031a9..38238816 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -29,6 +29,7 @@ "files": { "includes": [ "**/*", + "!packages/create-bot/_template", "!apps/docs/app/\\[lang\\]/docs", "!apps/docs/app/\\[lang\\]/llms.mdx", "!apps/docs/app/\\[lang\\]/llms.txt", diff --git a/knip.json b/knip.json index eab64222..6cd44642 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "ignoreBinaries": ["vercel"], "ignoreDependencies": ["@biomejs/biome"], - "ignore": ["apps/docs/**/*"], + "ignore": ["apps/docs/**/*", "packages/create-bot/_template/**/*"], "rules": { "duplicates": "off", "types": "off" diff --git a/packages/create-bot/README.md b/packages/create-bot/README.md new file mode 100644 index 00000000..0dceabdf --- /dev/null +++ b/packages/create-bot/README.md @@ -0,0 +1,129 @@ +# create-bot + +Scaffold a new [Chat SDK](https://chat-sdk.dev) bot project. + +Chat SDK is a unified TypeScript SDK by Vercel for building chat bots across Slack, Teams, Google Chat, Discord, WhatsApp, and more. + +## Quick Start + +```bash +npx create-bot my-bot +``` + +Or with your preferred package manager: + +```bash +pnpm create bot my-bot +yarn create bot my-bot +bunx create-bot my-bot +``` + +The CLI walks you through selecting platform adapters, a state adapter, and installs dependencies for you. + +## Usage + +``` +Usage: create-bot [options] [name] + +Arguments: + name name of the project + +Options: + -d, --description project description + --adapter platform or state adapters to include (skips interactive prompt) + --pm package manager to use (npm, yarn, pnpm, bun) + -y, --yes skip confirmation prompts (accept defaults) + -q, --quiet suppress non-essential output + --no-color disable color output (respects NO_COLOR) + -h, --help display help for command +``` + +## Examples + +Interactive mode (prompts for everything): + +```bash +npx create-bot +``` + +Provide a name and let the CLI prompt for the rest: + +```bash +npx create-bot my-bot +``` + +Skip adapter prompts by passing them directly: + +```bash +npx create-bot my-bot --adapter slack teams redis +``` + +Fully non-interactive: + +```bash +npx create-bot my-bot -d 'My awesome bot' --adapter slack redis -y +``` + +Silent non-interactive (for CI/scripts): + +```bash +npx create-bot my-bot --adapter slack pg -yq --pm pnpm +``` + +## Available Adapters + +### Messaging Platforms + +| Adapter | Flag value | Package | +| --- | --- | --- | +| Slack | `slack` | `@chat-adapter/slack` | +| Microsoft Teams | `teams` | `@chat-adapter/teams` | +| Google Chat | `gchat` | `@chat-adapter/gchat` | +| Discord | `discord` | `@chat-adapter/discord` | +| Telegram | `telegram` | `@chat-adapter/telegram` | +| WhatsApp | `whatsapp` | `@chat-adapter/whatsapp` | +| Beeper Matrix | `matrix` | `@beeper/chat-adapter-matrix` | +| Photon iMessage | `imessage` | `chat-adapter-imessage` | +| Zernio | `zernio` | `@zernio/chat-sdk-adapter` | + +### Developer Tools + +| Adapter | Flag value | Package | +| --- | --- | --- | +| GitHub | `github` | `@chat-adapter/github` | +| Linear | `linear` | `@chat-adapter/linear` | +| Resend | `resend` | `@resend/chat-sdk-adapter` | +| Liveblocks | `liveblocks` | `@liveblocks/chat-sdk-adapter` | + +### State + +| Adapter | Flag value | Package | Notes | +| --- | --- | --- | --- | +| In-Memory | `memory` | `@chat-adapter/state-memory` | Development only | +| Redis | `redis` | `@chat-adapter/state-redis` | node-redis driver | +| ioredis | `ioredis` | `@chat-adapter/state-ioredis` | ioredis driver | +| PostgreSQL | `pg` | `@chat-adapter/state-pg` | | + +## What You Get + +The scaffolded project is a Next.js app with: + +- **`src/lib/bot.ts`** — Bot configuration with your selected adapters +- **`src/app/api/webhooks/[platform]/route.ts`** — Dynamic webhook route +- **`.env.example`** — Pre-populated with the environment variables for your adapters +- **`next.config.ts`** — Configured with any required `serverExternalPackages` + +## After Scaffolding + +```bash +cd my-bot +cp .env.example .env.local +# Fill in your credentials in .env.local +npm run dev +``` + +See the [Chat SDK docs](https://chat-sdk.dev/docs) for platform setup guides and API reference. + +## License + +MIT diff --git a/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md new file mode 100644 index 00000000..349d9b9c --- /dev/null +++ b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md @@ -0,0 +1,213 @@ +--- +name: chat-sdk +description: > + Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to + (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, + (2) Use Chat SDK to handle mentions, direct messages, subscribed threads, reactions, slash + commands, cards, modals, files, or AI streaming, + (3) Set up webhook routes or multi-adapter bots, + (4) Send rich cards or streamed AI responses to chat platforms, + (5) Build or maintain a custom adapter or state adapter. + Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "google chat bot", "discord bot", + "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", + "state adapter", "build adapter", and building bots that work across multiple chat platforms. +--- + +# Chat SDK + +Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write bot logic once, deploy everywhere. + +## Start with published sources + +When Chat SDK is installed in a user project, inspect the published files that ship in `node_modules`: + +``` +node_modules/chat/docs/ # bundled docs +node_modules/chat/dist/index.d.ts # core API types +node_modules/chat/dist/jsx-runtime.d.ts # JSX runtime types +node_modules/chat/docs/contributing/ # adapter-authoring docs +node_modules/chat/docs/guides/ # framework/platform guides +``` + +If one of the paths below does not exist, that package is not installed in the project yet. + +Read these before writing code: +- `node_modules/chat/docs/getting-started.mdx` — install and setup +- `node_modules/chat/docs/usage.mdx` — `Chat` config and lifecycle +- `node_modules/chat/docs/handling-events.mdx` — event routing and handlers +- `node_modules/chat/docs/threads-messages-channels.mdx` — thread/channel/message model +- `node_modules/chat/docs/posting-messages.mdx` — post, edit, delete, schedule +- `node_modules/chat/docs/streaming.mdx` — AI SDK integration and streaming semantics +- `node_modules/chat/docs/cards.mdx` — JSX cards +- `node_modules/chat/docs/actions.mdx` — button/select interactions +- `node_modules/chat/docs/modals.mdx` — modal submit/close flows +- `node_modules/chat/docs/slash-commands.mdx` — slash command routing +- `node_modules/chat/docs/direct-messages.mdx` — DM behavior and `openDM()` +- `node_modules/chat/docs/files.mdx` — attachments/uploads +- `node_modules/chat/docs/state.mdx` — persistence, locking, dedupe +- `node_modules/chat/docs/adapters.mdx` — cross-platform feature matrix +- `node_modules/chat/docs/api/chat.mdx` — exact `Chat` API +- `node_modules/chat/docs/api/thread.mdx` — exact `Thread` API +- `node_modules/chat/docs/api/message.mdx` — exact `Message` API +- `node_modules/chat/docs/api/modals.mdx` — modal element and event details + +For the specific adapter or state package you are using, inspect that installed package's `dist/index.d.ts` export surface in `node_modules`. + +## Quick start + +```typescript +import { Chat } from "chat"; +import { createSlackAdapter } from "@chat-adapter/slack"; +import { createRedisState } from "@chat-adapter/state-redis"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + slack: createSlackAdapter(), + }, + state: createRedisState(), + dedupeTtlMs: 600_000, +}); + +bot.onNewMention(async (thread) => { + await thread.subscribe(); + await thread.post("Hello! I'm listening to this thread."); +}); + +bot.onSubscribedMessage(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +## Core concepts + +- **Chat** — main entry point; coordinates adapters, routing, locks, and state +- **Adapters** — platform-specific integrations for Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp +- **State adapters** — persistence for subscriptions, locks, dedupe, and thread state +- **Thread** — conversation context with `post()`, `stream()`, `subscribe()`, `setState()`, `startTyping()` +- **Message** — normalized content with `text`, `formatted`, attachments, author info, and platform `raw` +- **Channel** — container for threads and top-level posts + +## Event handlers + +| Handler | Trigger | +|---------|---------| +| `onNewMention` | Bot @-mentioned in an unsubscribed thread | +| `onDirectMessage` | New DM in an unsubscribed DM thread | +| `onSubscribedMessage` | Any message in a subscribed thread | +| `onNewMessage(regex)` | Regex match in an unsubscribed thread | +| `onReaction(emojis?)` | Emoji added or removed | +| `onAction(actionIds?)` | Button clicks and select/radio interactions | +| `onModalSubmit(callbackId?)` | Modal form submitted | +| `onModalClose(callbackId?)` | Modal dismissed/cancelled | +| `onSlashCommand(commands?)` | Slash command invocation | +| `onAssistantThreadStarted` | Slack assistant thread opened | +| `onAssistantContextChanged` | Slack assistant context changed | +| `onAppHomeOpened` | Slack App Home opened | +| `onMemberJoinedChannel` | Slack member joined channel event | + +Read `node_modules/chat/docs/handling-events.mdx`, `node_modules/chat/docs/actions.mdx`, `node_modules/chat/docs/modals.mdx`, and `node_modules/chat/docs/slash-commands.mdx` before wiring handlers. `onDirectMessage` behavior is documented in `node_modules/chat/docs/direct-messages.mdx`. + +## Streaming + +Pass any `AsyncIterable` to `thread.post()` or `thread.stream()`. For AI SDK, prefer `result.fullStream` over `result.textStream` when available so step boundaries are preserved. + +```typescript +import { ToolLoopAgent } from "ai"; + +const agent = new ToolLoopAgent({ model: "anthropic/claude-4.5-sonnet" }); + +bot.onNewMention(async (thread, message) => { + const result = await agent.stream({ prompt: message.text }); + await thread.post(result.fullStream); +}); +``` + +Key details: +- `streamingUpdateIntervalMs` controls post+edit fallback cadence +- `fallbackStreamingPlaceholderText` defaults to `"..."`; set `null` to disable +- Structured `StreamChunk` support is Slack-only; other adapters ignore non-text chunks + +## Cards and modals (JSX) + +Set `jsxImportSource: "chat"` in `tsconfig.json`. + +Card components: +- `Card`, `CardText`, `Section`, `Fields`, `Field`, `Button`, `CardLink`, `LinkButton`, `Actions`, `Select`, `SelectOption`, `RadioSelect`, `Table`, `Image`, `Divider` + +Modal components: +- `Modal`, `TextInput`, `Select`, `SelectOption`, `RadioSelect` + +```tsx +await thread.post( + + Your order has been received. + + + + + +); +``` + +## Adapter inventory + +### Official platform adapters + +| Platform | Package | Factory | +|---------|---------|---------| +| Slack | `@chat-adapter/slack` | `createSlackAdapter` | +| Microsoft Teams | `@chat-adapter/teams` | `createTeamsAdapter` | +| Google Chat | `@chat-adapter/gchat` | `createGoogleChatAdapter` | +| Discord | `@chat-adapter/discord` | `createDiscordAdapter` | +| GitHub | `@chat-adapter/github` | `createGitHubAdapter` | +| Linear | `@chat-adapter/linear` | `createLinearAdapter` | +| Telegram | `@chat-adapter/telegram` | `createTelegramAdapter` | +| WhatsApp Business Cloud | `@chat-adapter/whatsapp` | `createWhatsAppAdapter` | + +### Official state adapters + +| State backend | Package | Factory | +|--------------|---------|---------| +| Redis | `@chat-adapter/state-redis` | `createRedisState` | +| ioredis | `@chat-adapter/state-ioredis` | `createIoRedisState` | +| PostgreSQL | `@chat-adapter/state-pg` | `createPostgresState` | +| Memory | `@chat-adapter/state-memory` | `createMemoryState` | + +### Community adapters + +- `chat-state-cloudflare-do` +- `@beeper/chat-adapter-matrix` +- `chat-adapter-imessage` +- `@bitbasti/chat-adapter-webex` +- `@resend/chat-sdk-adapter` +- `@zernio/chat-sdk-adapter` +- `chat-adapter-baileys` +- `@liveblocks/chat-sdk-adapter` +- `chat-adapter-sendblue` +- `chat-adapter-zalo` + +### Coming-soon platform entries + +- Instagram +- Signal +- X +- Messenger + +## Building a custom adapter + +Read these published docs first: +- `node_modules/chat/docs/contributing/building.mdx` +- `node_modules/chat/docs/contributing/testing.mdx` +- `node_modules/chat/docs/contributing/publishing.mdx` + +Also inspect: +- `node_modules/chat/dist/index.d.ts` — `Adapter` and related interfaces +- `node_modules/@chat-adapter/shared/dist/index.d.ts` — shared errors and utilities +- Installed official adapter `dist/index.d.ts` files — reference implementations for config and APIs + +A custom adapter needs request verification, webhook parsing, message/thread/channel operations, ID encoding/decoding, and a format converter. Use `BaseFormatConverter` from `chat` and shared utilities from `@chat-adapter/shared`. + +## Webhook setup + +Each registered adapter exposes `bot.webhooks.`. Wire those directly to your HTTP framework routes. See `node_modules/chat/docs/guides/slack-nextjs.mdx` and `node_modules/chat/docs/guides/discord-nuxt.mdx` for framework-specific route patterns. diff --git a/packages/create-bot/_template/.env.example b/packages/create-bot/_template/.env.example new file mode 100644 index 00000000..502574b0 --- /dev/null +++ b/packages/create-bot/_template/.env.example @@ -0,0 +1,2 @@ +# Bot Configuration +BOT_USERNAME=my-bot diff --git a/packages/create-bot/_template/.gitignore b/packages/create-bot/_template/.gitignore new file mode 100644 index 00000000..b41fed88 --- /dev/null +++ b/packages/create-bot/_template/.gitignore @@ -0,0 +1,16 @@ +# dependencies +node_modules/ + +# next.js +.next/ +out/ + +# env +.env +.env.local +.env.*.local + +# misc +.DS_Store +*.tsbuildinfo +next-env.d.ts diff --git a/packages/create-bot/_template/AGENTS.md b/packages/create-bot/_template/AGENTS.md new file mode 100644 index 00000000..afd31b83 --- /dev/null +++ b/packages/create-bot/_template/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +This is a chat bot built with [Chat SDK](https://chat-sdk.dev), a unified TypeScript SDK by Vercel for building bots across Slack, Teams, Google Chat, Discord, WhatsApp, and more. + +## Commands + +```bash +npm run dev # Start the dev server +npm run build # Production build +npm run start # Start production server +``` + +## Project structure + +``` +src/ + lib/bot.ts # Bot config — adapters, state, handlers + app/api/webhooks/[platform]/route.ts # Webhook route (all platforms) +.env.example # Required environment variables +next.config.ts # Next.js config (serverExternalPackages if needed) +``` + +## How it works + +1. Each chat platform sends webhooks to `/api/webhooks/{platform}` (e.g. `/api/webhooks/slack`). +2. The route handler in `route.ts` delegates to the bot's webhook handler for that platform. +3. The bot is configured in `src/lib/bot.ts` with platform adapters, a state adapter, and message handlers. + +## Key concepts + +- **Adapters** connect the bot to chat platforms. Each adapter handles webhook verification, message parsing, and platform-specific formatting. +- **State adapter** provides persistence for subscriptions and distributed locking (e.g. Redis, PostgreSQL). In-memory state is for development only. +- **Handlers** respond to events: + - `onNewMention` — bot is @mentioned in a new thread + - `onSubscribedMessage` — new message in a thread the bot is subscribed to + - `onNewMessage` — messages matching a pattern (e.g. regex, keyword) + - `onReaction` — reaction added to a message + - `onSlashCommand` — slash command invoked (Slack, Discord) +- **Thread** represents a conversation. Use `thread.post()` to send messages, `thread.subscribe()` to listen for follow-ups. +- **Cards** are rich messages built with JSX (using `jsxImportSource: "chat"` in tsconfig). Import components from `chat/cards`. + +## Environment variables + +Use your 'chat-sdk' skill and other applicable skills while working on this project. + +## Docs + +Full documentation: https://chat-sdk.dev/docs diff --git a/packages/create-bot/_template/README.md b/packages/create-bot/_template/README.md new file mode 100644 index 00000000..2fc6c4f1 --- /dev/null +++ b/packages/create-bot/_template/README.md @@ -0,0 +1,48 @@ +# Chat SDK Bot + +A chat bot built with [Chat SDK](https://chat-sdk.dev), a unified TypeScript SDK by Vercel for building bots across Slack, Teams, Google Chat, Discord, WhatsApp, and more. + +## Getting Started + +1. Copy the example environment file and fill in your credentials: + +```bash +cp .env.example .env.local +``` + +2. Start the dev server: + +```bash +npm run dev +``` + +3. Expose your local server to the internet (e.g. with [ngrok](https://ngrok.com)) and configure your platform's webhook URL to point to: + +``` +https:///api/webhooks/ +``` + +Replace `` with `slack`, `teams`, `gchat`, `discord`, etc. + +## Project Structure + +``` +src/ + lib/bot.ts Bot configuration and handlers + app/api/webhooks/[platform]/route.ts Webhook endpoint (all platforms) +.env.example Required environment variables +``` + +## Scripts + +| Command | Description | +| --- | --- | +| `npm run dev` | Start the development server | +| `npm run build` | Create a production build | +| `npm run start` | Start the production server | + +## Learn More + +- [Chat SDK Documentation](https://chat-sdk.dev/docs) +- [Adapter Setup Guides](https://chat-sdk.dev/docs/adapters) +- [GitHub Repository](https://github.com/vercel/chat) diff --git a/packages/create-bot/_template/next.config.ts b/packages/create-bot/_template/next.config.ts new file mode 100644 index 00000000..cb651cdc --- /dev/null +++ b/packages/create-bot/_template/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/packages/create-bot/_template/package.json b/packages/create-bot/_template/package.json new file mode 100644 index 00000000..148a4444 --- /dev/null +++ b/packages/create-bot/_template/package.json @@ -0,0 +1,21 @@ +{ + "name": "chat-sdk-bot", + "description": "Chatbot powered by Chat SDK", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "chat": "latest", + "next": "^16.2.3", + "react": "^19.2.5" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "typescript": "^6.0.2" + } +} diff --git a/packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts b/packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts new file mode 100644 index 00000000..d900a6e2 --- /dev/null +++ b/packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts @@ -0,0 +1,22 @@ +import { after } from "next/server"; +import { bot } from "@/lib/bot"; + +type Platform = keyof typeof bot.webhooks; + +export async function POST( + request: Request, + context: RouteContext<"/api/webhooks/[platform]"> +) { + + const { platform } = await context.params; + + const handler = bot.webhooks[platform as Platform]; + + if (!handler) { + return new Response(`Unknown platform: ${platform}`, { status: 404 }); + } + + return handler(request, { + waitUntil: (task) => after(() => task), + }); +} diff --git a/packages/create-bot/_template/tsconfig.json b/packages/create-bot/_template/tsconfig.json new file mode 100644 index 00000000..9ea68feb --- /dev/null +++ b/packages/create-bot/_template/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "jsxImportSource": "chat", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/create-bot/adapters.json b/packages/create-bot/adapters.json new file mode 100644 index 00000000..ad50c3d0 --- /dev/null +++ b/packages/create-bot/adapters.json @@ -0,0 +1,386 @@ +{ + "platformAdapters": [ + { + "name": "Slack", + "value": "slack", + "package": "@chat-adapter/slack", + "factoryFn": "createSlackAdapter", + "typeName": "SlackAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "SLACK_SIGNING_SECRET", + "description": "Slack app signing secret", + "required": true + }, + { + "name": "SLACK_BOT_TOKEN", + "description": "Slack bot OAuth token (xoxb-...)", + "required": true + } + ], + "serverExternalPackages": [] + }, + { + "name": "Microsoft Teams", + "value": "teams", + "package": "@chat-adapter/teams", + "factoryFn": "createTeamsAdapter", + "typeName": "TeamsAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "TEAMS_APP_ID", + "description": "Teams app (client) ID", + "required": true + }, + { + "name": "TEAMS_APP_PASSWORD", + "description": "Teams app password (client secret)", + "required": true + }, + { + "name": "TEAMS_APP_TENANT_ID", + "description": "Azure AD tenant ID (for single-tenant apps)", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Google Chat", + "value": "gchat", + "package": "@chat-adapter/gchat", + "factoryFn": "createGoogleChatAdapter", + "typeName": "GoogleChatAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "GOOGLE_CHAT_CREDENTIALS", + "description": "Service account JSON string", + "required": true + }, + { + "name": "GOOGLE_CHAT_USE_ADC", + "description": "Use Application Default Credentials (set to \"true\")", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Discord", + "value": "discord", + "package": "@chat-adapter/discord", + "factoryFn": "createDiscordAdapter", + "typeName": "DiscordAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "DISCORD_BOT_TOKEN", + "description": "Discord bot token", + "required": true + }, + { + "name": "DISCORD_PUBLIC_KEY", + "description": "Discord application public key", + "required": true + }, + { + "name": "DISCORD_APPLICATION_ID", + "description": "Discord application ID", + "required": true + } + ], + "serverExternalPackages": [ + "discord.js", + "@discordjs/ws", + "@discordjs/voice", + "zlib-sync", + "bufferutil", + "utf-8-validate" + ] + }, + { + "name": "Telegram", + "value": "telegram", + "package": "@chat-adapter/telegram", + "factoryFn": "createTelegramAdapter", + "typeName": "TelegramAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "TELEGRAM_BOT_TOKEN", + "description": "Telegram bot token from @BotFather", + "required": true + }, + { + "name": "TELEGRAM_WEBHOOK_SECRET_TOKEN", + "description": "Secret token for webhook verification", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "WhatsApp", + "value": "whatsapp", + "package": "@chat-adapter/whatsapp", + "factoryFn": "createWhatsAppAdapter", + "typeName": "WhatsAppAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "WHATSAPP_ACCESS_TOKEN", + "description": "WhatsApp Cloud API access token", + "required": true + }, + { + "name": "WHATSAPP_PHONE_NUMBER_ID", + "description": "WhatsApp phone number ID", + "required": true + }, + { + "name": "WHATSAPP_APP_SECRET", + "description": "WhatsApp app secret for webhook verification", + "required": false + }, + { + "name": "WHATSAPP_VERIFY_TOKEN", + "description": "Webhook verification token", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "GitHub", + "value": "github", + "package": "@chat-adapter/github", + "factoryFn": "createGitHubAdapter", + "typeName": "GitHubAdapter", + "category": "Developer Tools", + "envVars": [ + { + "name": "GITHUB_WEBHOOK_SECRET", + "description": "GitHub webhook secret", + "required": true + }, + { + "name": "GITHUB_TOKEN", + "description": "GitHub personal access token (simple auth)", + "required": false + }, + { + "name": "GITHUB_APP_ID", + "description": "GitHub App ID (app auth)", + "required": false + }, + { + "name": "GITHUB_PRIVATE_KEY", + "description": "GitHub App private key (app auth)", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Linear", + "value": "linear", + "package": "@chat-adapter/linear", + "factoryFn": "createLinearAdapter", + "typeName": "LinearAdapter", + "category": "Developer Tools", + "envVars": [ + { + "name": "LINEAR_WEBHOOK_SECRET", + "description": "Linear webhook signing secret", + "required": true + }, + { + "name": "LINEAR_API_KEY", + "description": "Linear API key", + "required": false + }, + { + "name": "LINEAR_ACCESS_TOKEN", + "description": "Linear OAuth access token", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Beeper Matrix", + "value": "matrix", + "package": "@beeper/chat-adapter-matrix", + "factoryFn": "createMatrixAdapter", + "typeName": "MatrixAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "MATRIX_BASE_URL", + "description": "Matrix homeserver URL", + "required": true + }, + { + "name": "MATRIX_ACCESS_TOKEN", + "description": "Matrix access token", + "required": true + }, + { + "name": "MATRIX_USER_ID", + "description": "Matrix user ID", + "required": true + }, + { + "name": "MATRIX_RECOVERY_KEY", + "description": "Matrix recovery key", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Photon iMessage", + "value": "imessage", + "package": "chat-adapter-imessage", + "factoryFn": "createiMessageAdapter", + "typeName": "iMessageAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "IMESSAGE_SERVER_URL", + "description": "Photon iMessage server URL", + "required": true + }, + { + "name": "IMESSAGE_API_KEY", + "description": "Photon iMessage API key", + "required": true + }, + { + "name": "IMESSAGE_LOCAL", + "description": "Use local on-device iMessage integration", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Resend", + "value": "resend", + "package": "@resend/chat-sdk-adapter", + "factoryFn": "createResendAdapter", + "typeName": "ResendAdapter", + "category": "Developer Tools", + "envVars": [ + { + "name": "RESEND_API_KEY", + "description": "Resend API key", + "required": true + }, + { + "name": "RESEND_WEBHOOK_SECRET", + "description": "Resend webhook signing secret", + "required": true + } + ], + "serverExternalPackages": [] + }, + { + "name": "Zernio", + "value": "zernio", + "package": "@zernio/chat-sdk-adapter", + "factoryFn": "createZernioAdapter", + "typeName": "ZernioAdapter", + "category": "Messaging Platforms", + "envVars": [ + { + "name": "ZERNIO_API_KEY", + "description": "Zernio API key", + "required": true + }, + { + "name": "ZERNIO_WEBHOOK_SECRET", + "description": "Zernio webhook signing secret", + "required": false + } + ], + "serverExternalPackages": [] + }, + { + "name": "Liveblocks", + "value": "liveblocks", + "package": "@liveblocks/chat-sdk-adapter", + "factoryFn": "createLiveblocksAdapter", + "typeName": "LiveblocksAdapter", + "category": "Developer Tools", + "envVars": [ + { + "name": "LIVEBLOCKS_SECRET_KEY", + "description": "Liveblocks secret key", + "required": true + }, + { + "name": "LIVEBLOCKS_WEBHOOK_SECRET", + "description": "Liveblocks webhook signing secret", + "required": true + } + ], + "serverExternalPackages": [] + } + ], + "stateAdapters": [ + { + "name": "In-Memory", + "value": "memory", + "package": "@chat-adapter/state-memory", + "factoryFn": "createMemoryState", + "hint": "development only", + "envVars": [] + }, + { + "name": "Redis", + "value": "redis", + "package": "@chat-adapter/state-redis", + "factoryFn": "createRedisState", + "hint": "production — node-redis driver", + "envVars": [ + { + "name": "REDIS_URL", + "description": "Redis connection URL", + "required": true + } + ] + }, + { + "name": "ioredis", + "value": "ioredis", + "package": "@chat-adapter/state-ioredis", + "factoryFn": "createIoRedisState", + "hint": "production — ioredis driver", + "envVars": [ + { + "name": "REDIS_URL", + "description": "Redis connection URL", + "required": true + } + ] + }, + { + "name": "PostgreSQL", + "value": "pg", + "package": "@chat-adapter/state-pg", + "factoryFn": "createPostgresState", + "hint": "production", + "envVars": [ + { + "name": "POSTGRES_URL", + "description": "PostgreSQL connection URL (or DATABASE_URL)", + "required": true + } + ] + } + ] +} diff --git a/packages/create-bot/package.json b/packages/create-bot/package.json new file mode 100644 index 00000000..7848e7a5 --- /dev/null +++ b/packages/create-bot/package.json @@ -0,0 +1,57 @@ +{ + "name": "create-bot", + "description": "Create a new Chat SDK bot project", + "keywords": [ + "vercel", + "chat-sdk", + "bot", + "scaffold", + "create", + "cli" + ], + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "bin": { + "create-bot": "./dist/index.js" + }, + "files": [ + "dist", + "_template", + "README.md" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clack/prompts": "^0.10.0", + "commander": "^13.0.0", + "execa": "^9.5.2", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "@vitest/coverage-v8": "^4.0.18", + "tsup": "^8.3.5", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/create-bot" + }, + "homepage": "https://chat-sdk.dev", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/packages/create-bot/src/cli.test.ts b/packages/create-bot/src/cli.test.ts new file mode 100644 index 00000000..b45beed1 --- /dev/null +++ b/packages/create-bot/src/cli.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProjectConfig } from "./types.js"; + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + note: vi.fn(), + outro: vi.fn(), +})); + +vi.mock("./prompts.js", () => ({ + runPrompts: vi.fn(), +})); + +vi.mock("./scaffold.js", () => ({ + scaffold: vi.fn().mockResolvedValue(undefined), +})); + +import { intro, note, outro } from "@clack/prompts"; +import { buildAdapterList, createProgram } from "./cli.js"; +import { runPrompts } from "./prompts.js"; +import { scaffold } from "./scaffold.js"; + +let exitSpy: ReturnType; +let consoleSpy: ReturnType; + +const fakeConfig: ProjectConfig = { + name: "my-bot", + description: "A bot", + platformAdapters: [], + stateAdapter: { + name: "In-Memory", + value: "memory", + package: "@chat-adapter/state-memory", + factoryFn: "createMemoryState", + hint: "development only", + envVars: [], + }, + shouldInstall: false, + packageManager: "npm", +}; + +beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); +}); + +describe("buildAdapterList", () => { + it("groups platform adapters by category", () => { + const result = buildAdapterList(); + expect(result).toContain("Messaging Platforms:"); + expect(result).toContain("slack"); + expect(result).toContain("Developer Tools:"); + expect(result).toContain("github"); + }); + + it("includes state adapters", () => { + const result = buildAdapterList(); + expect(result).toContain("State:"); + expect(result).toContain("memory"); + expect(result).toContain("redis"); + }); +}); + +describe("createProgram", () => { + it("returns a Commander program", () => { + const prog = createProgram(); + expect(prog.name()).toBe("create-bot"); + }); + + describe("--help", () => { + it("includes SDK description", () => { + const prog = createProgram(); + const help = prog.helpInformation(); + expect(help).toContain("Chat SDK is a unified TypeScript SDK by Vercel"); + }); + + it("lists available adapters", () => { + const prog = createProgram(); + let helpText = ""; + prog.configureOutput({ + writeOut: (str) => { + helpText += str; + }, + }); + prog.outputHelp(); + expect(helpText).toContain("Available adapters:"); + expect(helpText).toContain("Messaging Platforms:"); + expect(helpText).toContain("State:"); + }); + + it("shows examples", () => { + const prog = createProgram(); + let helpText = ""; + prog.configureOutput({ + writeOut: (str) => { + helpText += str; + }, + }); + prog.outputHelp(); + expect(helpText).toContain("Examples:"); + expect(helpText).toContain("$ create-bot my-bot"); + }); + }); + + describe("action — successful flow", () => { + it("calls runPrompts and scaffold", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig); + const prog = createProgram(); + await prog.parseAsync(["node", "create-bot", "my-bot", "-yq"]); + + expect(runPrompts).toHaveBeenCalled(); + expect(scaffold).toHaveBeenCalledWith(fakeConfig, true, true); + }); + + it("shows intro, note, and outro when not quiet", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig); + const prog = createProgram(); + await prog.parseAsync(["node", "create-bot", "my-bot", "-y"]); + + expect(intro).toHaveBeenCalled(); + expect(note).toHaveBeenCalled(); + expect(outro).toHaveBeenCalledWith(expect.stringContaining("Done!")); + }); + + it("hides intro, note, and outro when quiet", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig); + const prog = createProgram(); + await prog.parseAsync(["node", "create-bot", "my-bot", "-yq"]); + + expect(intro).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalled(); + expect(outro).not.toHaveBeenCalled(); + }); + + it("defaults yes and quiet to false when not passed", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig); + const prog = createProgram(); + await prog.parseAsync(["node", "create-bot", "my-bot"]); + + expect(runPrompts).toHaveBeenCalledWith( + expect.anything(), + "my-bot", + undefined, + undefined, + undefined, + false, + false + ); + }); + + it("passes flags to runPrompts", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig); + const prog = createProgram(); + await prog.parseAsync([ + "node", + "create-bot", + "my-bot", + "-d", + "desc", + "--adapter", + "slack", + "redis", + "--pm", + "pnpm", + "-yq", + ]); + + expect(runPrompts).toHaveBeenCalledWith( + expect.anything(), + "my-bot", + "desc", + ["slack", "redis"], + "pnpm", + true, + true + ); + }); + }); + + describe("action — cancelled flow", () => { + it("calls process.exit when prompts return null", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(null); + const prog = createProgram(); + + await expect( + prog.parseAsync(["node", "create-bot", "my-bot", "-yq"]) + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(scaffold).not.toHaveBeenCalled(); + }); + + it("shows cancelled outro when not quiet", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(null); + const prog = createProgram(); + + await expect( + prog.parseAsync(["node", "create-bot", "my-bot", "-y"]) + ).rejects.toThrow("process.exit"); + expect(outro).toHaveBeenCalledWith(expect.stringContaining("Cancelled")); + }); + + it("skips cancelled outro when quiet", async () => { + vi.mocked(runPrompts).mockResolvedValueOnce(null); + const prog = createProgram(); + + await expect( + prog.parseAsync(["node", "create-bot", "my-bot", "-yq"]) + ).rejects.toThrow("process.exit"); + expect(outro).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/create-bot/src/cli.ts b/packages/create-bot/src/cli.ts new file mode 100644 index 00000000..dc95c9c6 --- /dev/null +++ b/packages/create-bot/src/cli.ts @@ -0,0 +1,122 @@ +import { intro, note, outro } from "@clack/prompts"; +import { Command } from "commander"; +import pc from "picocolors"; +import adapters from "../adapters.json" with { type: "json" }; +import { runPrompts } from "./prompts.js"; +import { scaffold } from "./scaffold.js"; +import type { AdaptersConfig, PackageManager } from "./types.js"; + +const config = adapters as AdaptersConfig; + +export function buildAdapterList(): string { + const categories = new Map(); + for (const a of config.platformAdapters) { + if (!categories.has(a.category)) { + categories.set(a.category, []); + } + categories.get(a.category)?.push(a.value); + } + + const lines: string[] = []; + for (const [category, values] of categories) { + lines.push(` ${category}: ${values.join(", ")}`); + } + lines.push(` State: ${config.stateAdapters.map((a) => a.value).join(", ")}`); + return lines.join("\n"); +} + +export function createProgram() { + const prog = new Command(); + + prog + .name("create-bot") + .description( + [ + "Scaffold a new Chat SDK bot project.", + "", + "Chat SDK is a unified TypeScript SDK by Vercel for building chat bots", + "across Slack, Teams, Google Chat, Discord, WhatsApp, and more.", + "Docs: https://chat-sdk.dev/docs", + ].join("\n") + ) + .argument("[name]", "name of the project") + .option("-d, --description ", "project description") + .option( + "--adapter ", + "platform or state adapters to include (skips interactive prompt)" + ) + .option("--pm ", "package manager to use (npm, yarn, pnpm, bun)") + .option("-y, --yes", "skip confirmation prompts (accept defaults)") + .option("-q, --quiet", "suppress non-essential output") + .option("--no-color", "disable color output (respects NO_COLOR)") + .addHelpText( + "after", + [ + "", + "Available adapters:", + buildAdapterList(), + "", + "Examples:", + " $ create-bot my-bot", + " $ create-bot my-bot -d 'My awesome bot' --adapter slack teams redis", + " $ create-bot --adapter discord telegram pg", + "", + ].join("\n") + ) + .action( + async ( + name?: string, + opts?: { + description?: string; + adapter?: string[]; + pm?: string; + yes?: boolean; + quiet?: boolean; + } + ) => { + const quiet = opts?.quiet ?? false; + const yes = opts?.yes ?? false; + const pm = opts?.pm as PackageManager | undefined; + + if (!quiet) { + console.log(); + intro(pc.bgCyan(pc.black(" create-bot "))); + } + + const result = await runPrompts( + config, + name, + opts?.description, + opts?.adapter, + pm, + yes, + quiet + ); + if (!result) { + if (!quiet) { + outro(pc.gray("Cancelled.")); + } + process.exit(0); + } + + await scaffold(result, yes, quiet); + + if (!quiet) { + note( + [ + `cd ${result.name}`, + "cp .env.example .env.local", + `${result.packageManager} run dev`, + ].join("\n"), + "Next steps" + ); + + outro( + `${pc.green("Done!")} Visit ${pc.cyan("https://chat-sdk.dev/docs")} for the docs.` + ); + } + } + ); + + return prog; +} diff --git a/packages/create-bot/src/index.ts b/packages/create-bot/src/index.ts new file mode 100644 index 00000000..ba346009 --- /dev/null +++ b/packages/create-bot/src/index.ts @@ -0,0 +1,3 @@ +import { createProgram } from "./cli.js"; + +createProgram().parse(); diff --git a/packages/create-bot/src/prompts.test.ts b/packages/create-bot/src/prompts.test.ts new file mode 100644 index 00000000..8c6ae208 --- /dev/null +++ b/packages/create-bot/src/prompts.test.ts @@ -0,0 +1,426 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdaptersConfig, PlatformAdapter, StateAdapter } from "./types.js"; + +const CANCEL = Symbol("cancel"); + +vi.mock("@clack/prompts", () => ({ + text: vi.fn(), + confirm: vi.fn(), + select: vi.fn(), + groupMultiselect: vi.fn(), + isCancel: vi.fn((value: unknown) => value === CANCEL), + log: { info: vi.fn(), warn: vi.fn() }, +})); + +import { + confirm, + groupMultiselect, + isCancel, + log, + select, + text, +} from "@clack/prompts"; +import { runPrompts } from "./prompts.js"; + +const slackAdapter: PlatformAdapter = { + name: "Slack", + value: "slack", + package: "@chat-adapter/slack", + factoryFn: "createSlackAdapter", + typeName: "SlackAdapter", + category: "Messaging Platforms", + envVars: [], + serverExternalPackages: [], +}; + +const teamsAdapter: PlatformAdapter = { + name: "Microsoft Teams", + value: "teams", + package: "@chat-adapter/teams", + factoryFn: "createTeamsAdapter", + typeName: "TeamsAdapter", + category: "Messaging Platforms", + envVars: [], + serverExternalPackages: [], +}; + +const githubAdapter: PlatformAdapter = { + name: "GitHub", + value: "github", + package: "@chat-adapter/github", + factoryFn: "createGitHubAdapter", + typeName: "GitHubAdapter", + category: "Developer Tools", + envVars: [], + serverExternalPackages: [], +}; + +const memoryState: StateAdapter = { + name: "In-Memory", + value: "memory", + package: "@chat-adapter/state-memory", + factoryFn: "createMemoryState", + hint: "development only", + envVars: [], +}; + +const redisState: StateAdapter = { + name: "Redis", + value: "redis", + package: "@chat-adapter/state-redis", + factoryFn: "createRedisState", + hint: "production", + envVars: [ + { name: "REDIS_URL", description: "Redis connection URL", required: true }, + ], +}; + +const adapters: AdaptersConfig = { + platformAdapters: [slackAdapter, teamsAdapter, githubAdapter], + stateAdapters: [memoryState, redisState], +}; + +function mockFullFlow( + overrides: { + name?: string; + description?: string; + platforms?: string[]; + state?: string; + install?: boolean; + } = {} +) { + vi.mocked(text) + .mockResolvedValueOnce(overrides.name ?? "my-bot") + .mockResolvedValueOnce(overrides.description ?? "A bot"); + vi.mocked(groupMultiselect).mockResolvedValueOnce( + overrides.platforms ?? ["slack"] + ); + vi.mocked(select).mockResolvedValueOnce(overrides.state ?? "memory"); + vi.mocked(confirm).mockResolvedValueOnce(overrides.install ?? true); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(isCancel).mockImplementation((value: unknown) => value === CANCEL); + process.env.npm_config_user_agent = ""; +}); + +afterEach(() => { + process.env.npm_config_user_agent = ""; +}); + +describe("runPrompts", () => { + describe("interactive flow", () => { + it("returns full config from prompts", async () => { + mockFullFlow(); + const result = await runPrompts(adapters); + + expect(result).toEqual({ + name: "my-bot", + description: "A bot", + platformAdapters: [slackAdapter], + stateAdapter: memoryState, + shouldInstall: true, + packageManager: "npm", + }); + }); + + it("returns null when name is cancelled", async () => { + vi.mocked(text).mockResolvedValueOnce(CANCEL as never); + expect(await runPrompts(adapters)).toBeNull(); + }); + + it("returns null when description is cancelled", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce(CANCEL as never); + expect(await runPrompts(adapters)).toBeNull(); + }); + + it("returns null when platform selection is cancelled", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(CANCEL as never); + expect(await runPrompts(adapters)).toBeNull(); + }); + + it("returns null when state selection is cancelled", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(select).mockResolvedValueOnce(CANCEL as never); + expect(await runPrompts(adapters)).toBeNull(); + }); + + it("returns null when install confirm is cancelled", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(select).mockResolvedValueOnce("memory"); + vi.mocked(confirm).mockResolvedValueOnce(CANCEL as never); + expect(await runPrompts(adapters)).toBeNull(); + }); + + it("throws when selected state adapter is not found", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(select).mockResolvedValueOnce("nonexistent"); + await expect(runPrompts(adapters)).rejects.toThrow( + "Unknown state adapter: nonexistent" + ); + }); + + it("groups platform options by category", async () => { + mockFullFlow(); + await runPrompts(adapters); + + const call = vi.mocked(groupMultiselect).mock.calls[0]?.[0]; + expect(call?.options).toEqual({ + "Messaging Platforms": [ + { label: "Slack", value: "slack" }, + { label: "Microsoft Teams", value: "teams" }, + ], + "Developer Tools": [{ label: "GitHub", value: "github" }], + }); + }); + }); + + describe("flag overrides", () => { + it("skips name prompt when initial name provided", async () => { + vi.mocked(text).mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(select).mockResolvedValueOnce("memory"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + const result = await runPrompts(adapters, "flagged-name"); + expect(result?.name).toBe("flagged-name"); + expect(vi.mocked(text)).toHaveBeenCalledTimes(1); + }); + + it("skips description prompt when initial description provided", async () => { + mockFullFlow(); + const result = await runPrompts(adapters, undefined, "flagged desc"); + expect(result?.description).toBe("flagged desc"); + expect(vi.mocked(text)).toHaveBeenCalledTimes(1); + }); + + it("uses empty string when description prompt returns empty", async () => { + vi.mocked(text).mockResolvedValueOnce("my-bot").mockResolvedValueOnce(""); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(select).mockResolvedValueOnce("memory"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + const result = await runPrompts(adapters); + expect(result?.name).toBe("my-bot"); + expect(result?.description).toBe(""); + }); + + it("skips platform prompt when adapters flagged", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(select).mockResolvedValueOnce("memory"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + const result = await runPrompts(adapters, undefined, undefined, [ + "slack", + ]); + expect(result?.platformAdapters).toEqual([slackAdapter]); + expect(groupMultiselect).not.toHaveBeenCalled(); + }); + + it("skips state prompt when state adapter flagged", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + const result = await runPrompts(adapters, undefined, undefined, [ + "slack", + "redis", + ]); + expect(result?.stateAdapter).toEqual(redisState); + expect(select).not.toHaveBeenCalled(); + }); + + it("skips install prompt when --yes", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(select).mockResolvedValueOnce("memory"); + + const result = await runPrompts( + adapters, + undefined, + undefined, + undefined, + undefined, + true + ); + expect(result?.shouldInstall).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("warns on unknown adapter flags", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(select).mockResolvedValueOnce("memory"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + await runPrompts(adapters, undefined, undefined, ["slack", "bogus"]); + expect(log.warn).toHaveBeenCalledWith("Unknown adapter(s): bogus"); + }); + + it("warns on multiple state adapters and uses last", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]); + vi.mocked(confirm).mockResolvedValueOnce(true); + + const result = await runPrompts(adapters, undefined, undefined, [ + "memory", + "redis", + ]); + expect(log.warn).toHaveBeenCalledWith( + 'Multiple state adapters passed; using "redis"' + ); + expect(result?.stateAdapter).toEqual(redisState); + }); + + it("logs selected adapters when not quiet", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + await runPrompts( + adapters, + undefined, + undefined, + ["slack", "redis"], + undefined, + false, + false + ); + expect(log.info).toHaveBeenCalledWith("Platform adapters: Slack"); + expect(log.info).toHaveBeenCalledWith("State adapter: Redis"); + }); + + it("suppresses info logs in quiet mode", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + await runPrompts( + adapters, + undefined, + undefined, + ["slack", "redis"], + undefined, + false, + true + ); + expect(log.info).not.toHaveBeenCalled(); + }); + + it("still prompts state when only platforms flagged", async () => { + vi.mocked(text) + .mockResolvedValueOnce("my-bot") + .mockResolvedValueOnce("desc"); + vi.mocked(select).mockResolvedValueOnce("redis"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + const result = await runPrompts(adapters, undefined, undefined, [ + "slack", + ]); + expect(select).toHaveBeenCalled(); + expect(result?.stateAdapter).toEqual(redisState); + }); + }); + + describe("name validation", () => { + it("validates empty name", async () => { + mockFullFlow(); + await runPrompts(adapters); + + const textCall = vi.mocked(text).mock.calls[0]?.[0] as { + validate?: (value: string) => string | undefined; + }; + expect(textCall.validate?.("")).toBe("Project name is required"); + expect(textCall.validate?.(" ")).toBe("Project name is required"); + }); + + it("validates invalid package name", async () => { + mockFullFlow(); + await runPrompts(adapters); + + const textCall = vi.mocked(text).mock.calls[0]?.[0] as { + validate?: (value: string) => string | undefined; + }; + expect(textCall.validate?.("bad name!")).toBe("Invalid package name"); + }); + + it("accepts valid package names", async () => { + mockFullFlow(); + await runPrompts(adapters); + + const textCall = vi.mocked(text).mock.calls[0]?.[0] as { + validate?: (value: string) => string | undefined; + }; + expect(textCall.validate?.("my-bot")).toBeUndefined(); + expect(textCall.validate?.("my_bot.v2")).toBeUndefined(); + expect(textCall.validate?.("@scope-name")).toBeUndefined(); + }); + }); + + describe("package manager detection", () => { + it("detects pnpm", async () => { + process.env.npm_config_user_agent = "pnpm/9.0.0"; + mockFullFlow(); + const result = await runPrompts(adapters); + expect(result?.packageManager).toBe("pnpm"); + }); + + it("detects yarn", async () => { + process.env.npm_config_user_agent = "yarn/4.0.0"; + mockFullFlow(); + const result = await runPrompts(adapters); + expect(result?.packageManager).toBe("yarn"); + }); + + it("detects bun", async () => { + process.env.npm_config_user_agent = "bun/1.0.0"; + mockFullFlow(); + const result = await runPrompts(adapters); + expect(result?.packageManager).toBe("bun"); + }); + + it("defaults to npm", async () => { + mockFullFlow(); + const result = await runPrompts(adapters); + expect(result?.packageManager).toBe("npm"); + }); + + it("uses --pm override", async () => { + process.env.npm_config_user_agent = "pnpm/9.0.0"; + mockFullFlow(); + const result = await runPrompts( + adapters, + undefined, + undefined, + undefined, + "bun" + ); + expect(result?.packageManager).toBe("bun"); + }); + }); +}); diff --git a/packages/create-bot/src/prompts.ts b/packages/create-bot/src/prompts.ts new file mode 100644 index 00000000..36e899bc --- /dev/null +++ b/packages/create-bot/src/prompts.ts @@ -0,0 +1,189 @@ +import { + confirm, + groupMultiselect, + isCancel, + log, + select, + text, +} from "@clack/prompts"; +import type { + AdaptersConfig, + PackageManager, + PlatformAdapter, + ProjectConfig, + StateAdapter, +} from "./types.js"; + +const VALID_PKG_NAME = /^[a-z0-9@._-]+$/i; + +function resolveAdapterFlags(adapters: AdaptersConfig, values: string[]) { + const platforms: PlatformAdapter[] = []; + let state: StateAdapter | undefined; + const unknown: string[] = []; + + for (const v of values) { + const platform = adapters.platformAdapters.find((a) => a.value === v); + if (platform) { + platforms.push(platform); + continue; + } + const s = adapters.stateAdapters.find((a) => a.value === v); + if (s) { + if (state) { + log.warn(`Multiple state adapters passed; using "${s.value}"`); + } + state = s; + continue; + } + unknown.push(v); + } + + if (unknown.length > 0) { + log.warn(`Unknown adapter(s): ${unknown.join(", ")}`); + } + + return { platforms, state }; +} + +export async function runPrompts( + adapters: AdaptersConfig, + initialName?: string, + initialDescription?: string, + initialAdapters?: string[], + initialPm?: PackageManager, + yes = false, + quiet = false +): Promise { + const name = + initialName ?? + (await text({ + message: "Project name:", + placeholder: "my-bot", + validate: (value) => { + if (!value.trim()) { + return "Project name is required"; + } + if (!VALID_PKG_NAME.test(value)) { + return "Invalid package name"; + } + }, + })); + if (isCancel(name)) { + return null; + } + + let description: string; + + if (initialDescription != null) { + description = initialDescription; + } else { + const result = await text({ + message: "Description:", + placeholder: "A Chat SDK bot", + defaultValue: "", + }); + if (isCancel(result)) { + return null; + } + description = result as string; + } + + const flagged = initialAdapters?.length + ? resolveAdapterFlags(adapters, initialAdapters) + : undefined; + + let selectedPlatforms: PlatformAdapter[]; + + if (flagged?.platforms.length) { + selectedPlatforms = flagged.platforms; + if (!quiet) { + log.info( + `Platform adapters: ${selectedPlatforms.map((a) => a.name).join(", ")}` + ); + } + } else { + const categories = new Map(); + for (const a of adapters.platformAdapters) { + if (!categories.has(a.category)) { + categories.set(a.category, []); + } + categories.get(a.category)?.push({ label: a.name, value: a.value }); + } + + const platformValues = await groupMultiselect({ + message: "Select platform adapters:", + options: Object.fromEntries(categories), + required: false, + }); + if (isCancel(platformValues)) { + return null; + } + + selectedPlatforms = adapters.platformAdapters.filter((a) => + (platformValues as string[]).includes(a.value) + ); + } + + let selectedState: StateAdapter; + + if (flagged?.state) { + selectedState = flagged.state; + if (!quiet) { + log.info(`State adapter: ${selectedState.name}`); + } + } else { + const stateValue = await select({ + message: "Select state adapter:", + options: adapters.stateAdapters.map((a) => ({ + label: a.name, + value: a.value, + hint: a.hint, + })), + }); + if (isCancel(stateValue)) { + return null; + } + + const found = adapters.stateAdapters.find((a) => a.value === stateValue); + if (!found) { + throw new Error(`Unknown state adapter: ${String(stateValue)}`); + } + selectedState = found; + } + + let shouldInstall = true; + + if (!yes) { + const result = await confirm({ + message: "Install dependencies?", + initialValue: true, + }); + if (isCancel(result)) { + return null; + } + shouldInstall = result; + } + + return { + name: name as string, + description: description || "", + platformAdapters: selectedPlatforms, + stateAdapter: selectedState, + shouldInstall, + packageManager: initialPm ?? detectPackageManager(), + }; +} + +function detectPackageManager(): "npm" | "yarn" | "pnpm" | "bun" { + const agent = process.env.npm_config_user_agent || ""; + if (agent.startsWith("pnpm")) { + return "pnpm"; + } + if (agent.startsWith("yarn")) { + return "yarn"; + } + if (agent.startsWith("bun")) { + return "bun"; + } + return "npm"; +} diff --git a/packages/create-bot/src/scaffold.test.ts b/packages/create-bot/src/scaffold.test.ts new file mode 100644 index 00000000..e16ec5e0 --- /dev/null +++ b/packages/create-bot/src/scaffold.test.ts @@ -0,0 +1,357 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PlatformAdapter, ProjectConfig, StateAdapter } from "./types.js"; + +const CANCEL = Symbol("cancel"); + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn((value: unknown) => value === CANCEL), + log: { warning: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), +})); + +vi.mock("execa", () => ({ + execa: vi.fn().mockResolvedValue(undefined), +})); + +import { confirm, log, spinner } from "@clack/prompts"; +import { execa } from "execa"; +import { scaffold } from "./scaffold.js"; + +const slackAdapter: PlatformAdapter = { + name: "Slack", + value: "slack", + package: "@chat-adapter/slack", + factoryFn: "createSlackAdapter", + typeName: "SlackAdapter", + category: "Messaging Platforms", + envVars: [ + { + name: "SLACK_SIGNING_SECRET", + description: "Slack app signing secret", + required: true, + }, + { + name: "SLACK_BOT_TOKEN", + description: "Slack bot OAuth token", + required: false, + }, + ], + serverExternalPackages: [], +}; + +const discordAdapter: PlatformAdapter = { + name: "Discord", + value: "discord", + package: "@chat-adapter/discord", + factoryFn: "createDiscordAdapter", + typeName: "DiscordAdapter", + category: "Messaging Platforms", + envVars: [ + { + name: "DISCORD_BOT_TOKEN", + description: "Discord bot token", + required: true, + }, + ], + serverExternalPackages: ["discord.js", "@discordjs/ws"], +}; + +const memoryState: StateAdapter = { + name: "In-Memory", + value: "memory", + package: "@chat-adapter/state-memory", + factoryFn: "createMemoryState", + hint: "development only", + envVars: [], +}; + +const redisState: StateAdapter = { + name: "Redis", + value: "redis", + package: "@chat-adapter/state-redis", + factoryFn: "createRedisState", + hint: "production", + envVars: [ + { name: "REDIS_URL", description: "Redis connection URL", required: true }, + ], +}; + +let tmpDir: string; +let cwdSpy: ReturnType; +let exitSpy: ReturnType; + +function makeConfig(overrides: Partial = {}): ProjectConfig { + return { + name: "test-project", + description: "", + platformAdapters: [slackAdapter], + stateAdapter: memoryState, + shouldInstall: false, + packageManager: "npm", + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-bot-test-")); + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); +}); + +afterEach(() => { + cwdSpy.mockRestore(); + exitSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function projectDir() { + return path.join(tmpDir, "test-project"); +} + +describe("scaffold", () => { + describe("file creation", () => { + it("copies template files to project directory", async () => { + await scaffold(makeConfig(), true, true); + expect(fs.existsSync(path.join(projectDir(), "package.json"))).toBe(true); + expect(fs.existsSync(path.join(projectDir(), "tsconfig.json"))).toBe( + true + ); + expect(fs.existsSync(path.join(projectDir(), "next.config.ts"))).toBe( + true + ); + expect(fs.existsSync(path.join(projectDir(), ".env.example"))).toBe(true); + expect(fs.existsSync(path.join(projectDir(), ".gitignore"))).toBe(true); + }); + + it("generates bot.ts with adapter config", async () => { + await scaffold(makeConfig(), true, true); + const botFile = fs.readFileSync( + path.join(projectDir(), "src", "lib", "bot.ts"), + "utf-8" + ); + expect(botFile).toContain("createSlackAdapter"); + expect(botFile).toContain("createMemoryState"); + }); + }); + + describe("post-processing", () => { + it("replaces bot name in .env.example", async () => { + await scaffold(makeConfig({ name: "test-project" }), true, true); + const env = fs.readFileSync( + path.join(projectDir(), ".env.example"), + "utf-8" + ); + expect(env).toContain("BOT_USERNAME=test-project"); + }); + + it("appends platform adapter env vars to .env.example", async () => { + await scaffold(makeConfig(), true, true); + const env = fs.readFileSync( + path.join(projectDir(), ".env.example"), + "utf-8" + ); + expect(env).toContain("# Slack"); + expect(env).toContain("SLACK_SIGNING_SECRET="); + expect(env).toContain("# Slack bot OAuth token (optional)"); + expect(env).toContain("SLACK_BOT_TOKEN="); + }); + + it("appends state adapter env vars when present", async () => { + await scaffold(makeConfig({ stateAdapter: redisState }), true, true); + const env = fs.readFileSync( + path.join(projectDir(), ".env.example"), + "utf-8" + ); + expect(env).toContain("# Redis State"); + expect(env).toContain("REDIS_URL="); + }); + + it("skips state env section when state has no env vars", async () => { + await scaffold(makeConfig({ stateAdapter: memoryState }), true, true); + const env = fs.readFileSync( + path.join(projectDir(), ".env.example"), + "utf-8" + ); + expect(env).not.toContain("# In-Memory State"); + }); + + it("adds serverExternalPackages to next.config.ts", async () => { + await scaffold( + makeConfig({ platformAdapters: [discordAdapter] }), + true, + true + ); + const nextConfig = fs.readFileSync( + path.join(projectDir(), "next.config.ts"), + "utf-8" + ); + expect(nextConfig).toContain("serverExternalPackages"); + expect(nextConfig).toContain('"discord.js"'); + expect(nextConfig).toContain('"@discordjs/ws"'); + }); + + it("leaves next.config.ts unchanged when no external packages", async () => { + await scaffold(makeConfig(), true, true); + const nextConfig = fs.readFileSync( + path.join(projectDir(), "next.config.ts"), + "utf-8" + ); + expect(nextConfig).not.toContain("serverExternalPackages"); + }); + }); + + describe("package.json population", () => { + it("calls npm pkg set with name and adapter deps", async () => { + await scaffold(makeConfig(), true, true); + expect(execa).toHaveBeenCalledWith( + "npm", + expect.arrayContaining([ + "pkg", + "set", + "name=test-project", + "dependencies.@chat-adapter/slack=latest", + "dependencies.@chat-adapter/state-memory=latest", + ]), + expect.objectContaining({ cwd: projectDir() }) + ); + }); + + it("includes description when provided", async () => { + await scaffold(makeConfig({ description: "My bot" }), true, true); + expect(execa).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["description=My bot"]), + expect.anything() + ); + }); + + it("omits description when empty", async () => { + await scaffold(makeConfig({ description: "" }), true, true); + const args = vi.mocked(execa).mock.calls[0]?.[1] as string[]; + expect(args.some((a) => a.startsWith("description="))).toBe(false); + }); + }); + + describe("directory overwrite", () => { + it("prompts when directory exists, is not empty, and --yes is false", async () => { + const dir = projectDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "file.txt"), "existing"); + vi.mocked(confirm).mockResolvedValueOnce(true); + + await scaffold(makeConfig(), false, true); + expect(confirm).toHaveBeenCalled(); + }); + + it("exits when overwrite is declined", async () => { + const dir = projectDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "file.txt"), "existing"); + vi.mocked(confirm).mockResolvedValueOnce(false); + + await expect(scaffold(makeConfig(), false, true)).rejects.toThrow( + "process.exit" + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("exits when overwrite is cancelled", async () => { + const dir = projectDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "file.txt"), "existing"); + vi.mocked(confirm).mockResolvedValueOnce(CANCEL as never); + + await expect(scaffold(makeConfig(), false, true)).rejects.toThrow( + "process.exit" + ); + }); + + it("skips prompt when --yes", async () => { + const dir = projectDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "file.txt"), "existing"); + + await scaffold(makeConfig(), true, true); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("skips prompt when directory does not exist", async () => { + await scaffold(makeConfig(), false, true); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("skips prompt when directory is empty", async () => { + fs.mkdirSync(projectDir(), { recursive: true }); + await scaffold(makeConfig(), false, true); + expect(confirm).not.toHaveBeenCalled(); + }); + }); + + describe("spinners", () => { + it("shows spinners when not quiet", async () => { + await scaffold(makeConfig(), true, false); + expect(spinner).toHaveBeenCalled(); + }); + + it("hides spinners when quiet", async () => { + await scaffold(makeConfig(), true, true); + expect(spinner).not.toHaveBeenCalled(); + }); + }); + + describe("dependency installation", () => { + it("installs dependencies when shouldInstall is true", async () => { + await scaffold(makeConfig({ shouldInstall: true }), true, true); + expect(execa).toHaveBeenCalledWith( + "npm", + ["install"], + expect.objectContaining({ cwd: projectDir(), stdio: "pipe" }) + ); + }); + + it("uses configured package manager for install", async () => { + await scaffold( + makeConfig({ shouldInstall: true, packageManager: "pnpm" }), + true, + true + ); + expect(execa).toHaveBeenCalledWith( + "pnpm", + ["install"], + expect.anything() + ); + }); + + it("skips install when shouldInstall is false", async () => { + await scaffold(makeConfig({ shouldInstall: false }), true, true); + const calls = vi.mocked(execa).mock.calls; + const installCalls = calls.filter( + (c) => c[1] && (c[1] as string[]).includes("install") + ); + expect(installCalls).toHaveLength(0); + }); + + it("handles install failure gracefully", async () => { + vi.mocked(execa) + .mockResolvedValueOnce(undefined as never) + .mockRejectedValueOnce(new Error("install failed")); + + await scaffold(makeConfig({ shouldInstall: true }), true, false); + expect(log.warning).toHaveBeenCalledWith( + 'Run "npm install" manually in the project directory.' + ); + }); + + it("shows install spinner when not quiet", async () => { + await scaffold(makeConfig({ shouldInstall: true }), true, false); + expect(spinner).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/create-bot/src/scaffold.ts b/packages/create-bot/src/scaffold.ts new file mode 100644 index 00000000..c869b04d --- /dev/null +++ b/packages/create-bot/src/scaffold.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { confirm, isCancel, log, spinner } from "@clack/prompts"; +import { execa } from "execa"; +import { botTs } from "./templates.js"; +import type { ProjectConfig } from "./types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function copyDir(src: string, dest: string) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function readFile(projectDir: string, filePath: string): string { + return fs.readFileSync(path.join(projectDir, filePath), "utf-8"); +} + +function writeFile(projectDir: string, filePath: string, content: string) { + const fullPath = path.join(projectDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); +} + +function postProcessEnvExample(projectDir: string, config: ProjectConfig) { + let content = readFile(projectDir, ".env.example").replace( + "BOT_USERNAME=my-bot", + `BOT_USERNAME=${config.name}` + ); + + for (const adapter of config.platformAdapters) { + content += `\n# ${adapter.name}\n`; + for (const env of adapter.envVars) { + const suffix = env.required ? "" : " (optional)"; + content += `# ${env.description}${suffix}\n`; + content += `${env.name}=\n`; + } + } + + if (config.stateAdapter.envVars.length > 0) { + content += `\n# ${config.stateAdapter.name} State\n`; + for (const env of config.stateAdapter.envVars) { + content += `# ${env.description}\n`; + content += `${env.name}=\n`; + } + } + + writeFile(projectDir, ".env.example", content); +} + +function postProcessNextConfig(projectDir: string, config: ProjectConfig) { + const externalPkgs = config.platformAdapters.flatMap( + (a) => a.serverExternalPackages + ); + if (externalPkgs.length === 0) { + return; + } + + const pkgList = externalPkgs.map((p) => ` "${p}",`).join("\n"); + const content = readFile(projectDir, "next.config.ts").replace( + "const nextConfig: NextConfig = {};", + `const nextConfig: NextConfig = {\n serverExternalPackages: [\n${pkgList}\n ],\n};` + ); + writeFile(projectDir, "next.config.ts", content); +} + +export async function scaffold( + config: ProjectConfig, + yes = false, + quiet = false +) { + const projectDir = path.resolve(process.cwd(), config.name); + + if ( + fs.existsSync(projectDir) && + fs.readdirSync(projectDir).length > 0 && + !yes + ) { + const shouldContinue = await confirm({ + message: `Directory "${config.name}" already exists and is not empty. Continue?`, + initialValue: false, + }); + if (!shouldContinue || isCancel(shouldContinue)) { + process.exit(0); + } + } + + const s = quiet ? null : spinner(); + s?.start("Creating project files"); + + const templateDir = path.resolve(__dirname, "..", "_template"); + copyDir(templateDir, projectDir); + + postProcessEnvExample(projectDir, config); + postProcessNextConfig(projectDir, config); + + const pkgArgs = [`name=${config.name}`]; + if (config.description) { + pkgArgs.push(`description=${config.description}`); + } + for (const adapter of config.platformAdapters) { + pkgArgs.push(`dependencies.${adapter.package}=latest`); + } + pkgArgs.push(`dependencies.${config.stateAdapter.package}=latest`); + await execa("npm", ["pkg", "set", ...pkgArgs], { cwd: projectDir }); + + writeFile(projectDir, "src/lib/bot.ts", botTs(config)); + + s?.stop("Project files created!"); + + if (config.shouldInstall) { + const installSpinner = quiet ? null : spinner(); + installSpinner?.start( + `Installing dependencies with ${config.packageManager}` + ); + + try { + await execa(config.packageManager, ["install"], { + cwd: projectDir, + stdio: "pipe", + }); + installSpinner?.stop("Dependencies installed."); + } catch { + installSpinner?.stop("Failed to install dependencies."); + log.warning( + `Run "${config.packageManager} install" manually in the project directory.` + ); + } + } +} diff --git a/packages/create-bot/src/templates.test.ts b/packages/create-bot/src/templates.test.ts new file mode 100644 index 00000000..ba79a9a6 --- /dev/null +++ b/packages/create-bot/src/templates.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { botTs } from "./templates.js"; +import type { PlatformAdapter, ProjectConfig, StateAdapter } from "./types.js"; + +const slackAdapter: PlatformAdapter = { + name: "Slack", + value: "slack", + package: "@chat-adapter/slack", + factoryFn: "createSlackAdapter", + typeName: "SlackAdapter", + category: "Messaging Platforms", + envVars: [], + serverExternalPackages: [], +}; + +const teamsAdapter: PlatformAdapter = { + name: "Microsoft Teams", + value: "teams", + package: "@chat-adapter/teams", + factoryFn: "createTeamsAdapter", + typeName: "TeamsAdapter", + category: "Messaging Platforms", + envVars: [], + serverExternalPackages: [], +}; + +const memoryState: StateAdapter = { + name: "In-Memory", + value: "memory", + package: "@chat-adapter/state-memory", + factoryFn: "createMemoryState", + hint: "development only", + envVars: [], +}; + +const redisState: StateAdapter = { + name: "Redis", + value: "redis", + package: "@chat-adapter/state-redis", + factoryFn: "createRedisState", + hint: "production", + envVars: [ + { name: "REDIS_URL", description: "Redis connection URL", required: true }, + ], +}; + +function makeConfig(overrides: Partial = {}): ProjectConfig { + return { + name: "test-bot", + description: "A test bot", + platformAdapters: [slackAdapter], + stateAdapter: memoryState, + shouldInstall: false, + packageManager: "npm", + ...overrides, + }; +} + +describe("botTs", () => { + it("generates imports for each platform adapter", () => { + const result = botTs( + makeConfig({ platformAdapters: [slackAdapter, teamsAdapter] }) + ); + expect(result).toContain( + 'import { createSlackAdapter } from "@chat-adapter/slack";' + ); + expect(result).toContain( + 'import { createTeamsAdapter } from "@chat-adapter/teams";' + ); + }); + + it("generates import for state adapter", () => { + const result = botTs(makeConfig({ stateAdapter: redisState })); + expect(result).toContain( + 'import { createRedisState } from "@chat-adapter/state-redis";' + ); + }); + + it("always imports Chat from 'chat'", () => { + const result = botTs(makeConfig()); + expect(result).toContain('import { Chat } from "chat";'); + }); + + it("creates adapter entries in the adapters block", () => { + const result = botTs( + makeConfig({ platformAdapters: [slackAdapter, teamsAdapter] }) + ); + expect(result).toContain("slack: createSlackAdapter(),"); + expect(result).toContain("teams: createTeamsAdapter(),"); + }); + + it("uses empty adapters block when no platform adapters", () => { + const result = botTs(makeConfig({ platformAdapters: [] })); + expect(result).toContain("adapters: {},"); + }); + + it("uses bot name from config", () => { + const result = botTs(makeConfig({ name: "my-cool-bot" })); + expect(result).toContain('"my-cool-bot"'); + }); + + it("calls state factory in the state property", () => { + const result = botTs(makeConfig({ stateAdapter: memoryState })); + expect(result).toContain("state: createMemoryState(),"); + }); + + it("includes onNewMention handler", () => { + const result = botTs(makeConfig()); + expect(result).toContain("bot.onNewMention("); + expect(result).toContain("await thread.subscribe();"); + }); + + it("includes onSubscribedMessage handler", () => { + const result = botTs(makeConfig()); + expect(result).toContain("bot.onSubscribedMessage("); + expect(result).toContain("await thread.post("); + }); +}); diff --git a/packages/create-bot/src/templates.ts b/packages/create-bot/src/templates.ts new file mode 100644 index 00000000..224f4539 --- /dev/null +++ b/packages/create-bot/src/templates.ts @@ -0,0 +1,37 @@ +import type { ProjectConfig } from "./types.js"; + +export function botTs(config: ProjectConfig): string { + const imports: string[] = []; + + for (const a of config.platformAdapters) { + imports.push(`import { ${a.factoryFn} } from "${a.package}";`); + } + imports.push( + `import { ${config.stateAdapter.factoryFn} } from "${config.stateAdapter.package}";` + ); + imports.push('import { Chat } from "chat";'); + + const adapterEntries = config.platformAdapters + .map((a) => ` ${a.value}: ${a.factoryFn}(),`) + .join("\n"); + + const adaptersBlock = adapterEntries ? `{\n${adapterEntries}\n }` : "{}"; + + return `${imports.join("\n")} + +export const bot = new Chat({ + userName: process.env.BOT_USERNAME || "${config.name}", + adapters: ${adaptersBlock}, + state: ${config.stateAdapter.factoryFn}(), +}); + +bot.onNewMention(async (thread, message) => { + await thread.subscribe(); + await thread.post(\`Hello, \${message.author.fullName}! I'm listening to this thread.\`); +}); + +bot.onSubscribedMessage(async (thread, message) => { + await thread.post(\`You said: \${message.text}\`); +}); +`; +} diff --git a/packages/create-bot/src/types.ts b/packages/create-bot/src/types.ts new file mode 100644 index 00000000..57236d1f --- /dev/null +++ b/packages/create-bot/src/types.ts @@ -0,0 +1,41 @@ +export interface EnvVar { + description: string; + name: string; + required: boolean; +} + +export interface PlatformAdapter { + category: string; + envVars: EnvVar[]; + factoryFn: string; + name: string; + package: string; + serverExternalPackages: string[]; + typeName: string; + value: string; +} + +export interface StateAdapter { + envVars: EnvVar[]; + factoryFn: string; + hint: string; + name: string; + package: string; + value: string; +} + +export interface AdaptersConfig { + platformAdapters: PlatformAdapter[]; + stateAdapters: StateAdapter[]; +} + +export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; + +export interface ProjectConfig { + description: string; + name: string; + packageManager: PackageManager; + platformAdapters: PlatformAdapter[]; + shouldInstall: boolean; + stateAdapter: StateAdapter; +} diff --git a/packages/create-bot/tsconfig.json b/packages/create-bot/tsconfig.json new file mode 100644 index 00000000..e00f90b7 --- /dev/null +++ b/packages/create-bot/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "_template"] +} diff --git a/packages/create-bot/tsup.config.ts b/packages/create-bot/tsup.config.ts new file mode 100644 index 00000000..959f4f33 --- /dev/null +++ b/packages/create-bot/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + clean: true, + sourcemap: false, + banner: { + js: "#!/usr/bin/env node", + }, +}); diff --git a/packages/create-bot/vitest.config.ts b/packages/create-bot/vitest.config.ts new file mode 100644 index 00000000..c34af693 --- /dev/null +++ b/packages/create-bot/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/index.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52d206dc..e046d204 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,6 +522,37 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/create-bot: + dependencies: + '@clack/prompts': + specifier: ^0.10.0 + version: 0.10.1 + commander: + specifier: ^13.0.0 + version: 13.1.0 + execa: + specifier: ^9.5.2 + version: 9.6.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/integration-tests: dependencies: '@chat-adapter/discord': @@ -867,9 +898,15 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@clack/core@0.4.2': + resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} + '@clack/core@1.0.1': resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} + '@clack/prompts@0.10.1': + resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} + '@clack/prompts@1.0.1': resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} @@ -2510,6 +2547,9 @@ packages: resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@3.22.0': resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} @@ -2537,6 +2577,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@slack/logger@4.0.0': resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} engines: {node: '>= 18', npm: '>= 8.6.0'} @@ -3261,6 +3305,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -3696,6 +3744,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3743,6 +3795,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3919,6 +3975,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -4062,6 +4122,10 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4148,10 +4212,18 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4820,6 +4892,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + npm-to-yarn@3.0.1: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4907,6 +4983,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4928,6 +5008,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5062,6 +5146,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -5471,6 +5559,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -5691,6 +5783,10 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -5957,6 +6053,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + youtube-video-element@1.8.1: resolution: {integrity: sha512-+5UuAGaj+5AnBf39huLVpy/4dLtR0rmJP1TxOHVZ81bac4ZHFpTtQ4Dz2FAn2GPnfXISezvUEaQoAdFW4hH9Xg==} @@ -6244,11 +6344,22 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@clack/core@0.4.2': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@clack/core@1.0.1': dependencies: picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/prompts@0.10.1': + dependencies: + '@clack/core': 0.4.2 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@clack/prompts@1.0.1': dependencies: '@clack/core': 1.0.1 @@ -7803,6 +7914,8 @@ snapshots: '@sapphire/snowflake@3.5.3': {} + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@3.22.0': dependencies: '@shikijs/types': 3.22.0 @@ -7850,6 +7963,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@slack/logger@4.0.0': dependencies: '@types/node': 25.3.2 @@ -8535,6 +8650,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@13.1.0: {} + commander@14.0.3: {} commander@4.1.1: {} @@ -9021,6 +9138,21 @@ snapshots: eventsource-parser@3.0.6: {} + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} express@5.2.1: @@ -9093,6 +9225,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -9305,6 +9441,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -9566,6 +9707,8 @@ snapshots: human-id@4.1.3: {} + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -9637,10 +9780,14 @@ snapshots: is-stream@2.0.1: {} + is-stream@4.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 + is-unicode-supported@2.1.0: {} + is-windows@1.0.2: {} isexe@2.0.0: {} @@ -10557,6 +10704,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + npm-to-yarn@3.0.1: {} nypm@0.6.5: @@ -10662,6 +10814,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -10676,6 +10830,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -10793,6 +10949,10 @@ snapshots: prettier@2.8.8: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -11408,6 +11568,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@4.0.0: {} + strip-json-comments@5.0.3: {} style-to-js@1.1.21: @@ -11593,6 +11755,8 @@ snapshots: undici@6.21.3: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -11830,6 +11994,8 @@ snapshots: yallist@4.0.0: {} + yoctocolors@2.1.2: {} + youtube-video-element@1.8.1: {} zod@4.3.3: {} From 6bb5274dc90dc0b1990aea8be2face6fd17339f8 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 10 Apr 2026 23:53:49 +1000 Subject: [PATCH 2/8] docs: add create-bot to Chat SDK skill and link adapter inventory to website --- .../.agents/skills/chat-sdk/SKILL.md | 118 +++++++++-------- skills/chat/SKILL.md | 119 +++++++++--------- 2 files changed, 115 insertions(+), 122 deletions(-) diff --git a/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md index 349d9b9c..4f2f7bfb 100644 --- a/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md +++ b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md @@ -1,5 +1,6 @@ --- -name: chat-sdk + +## name: chat-sdk description: > Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, @@ -7,16 +8,43 @@ description: > commands, cards, modals, files, or AI streaming, (3) Set up webhook routes or multi-adapter bots, (4) Send rich cards or streamed AI responses to chat platforms, - (5) Build or maintain a custom adapter or state adapter. + (5) Build or maintain a custom adapter or state adapter, + (6) Scaffold a new bot project with create-bot. Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "google chat bot", "discord bot", "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", - "state adapter", "build adapter", and building bots that work across multiple chat platforms. ---- + "state adapter", "build adapter", "create-bot", "scaffold bot", and building bots that work + across multiple chat platforms. # Chat SDK Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write bot logic once, deploy everywhere. +## Scaffold a new project + +The `create-bot` CLI scaffolds a complete Next.js bot project with adapters, environment variables, and a webhook route: + +```bash +npx create-bot my-bot +``` + +It supports non-interactive usage for CI or scripting: + +```bash +npx create-bot my-bot --adapter slack discord --adapter redis -d "My bot" --pm pnpm -y +``` + +Key flags: `--adapter ` (platform or state adapters), `-d ` (description), `--pm ` (npm/yarn/pnpm/bun), `-y` (skip confirmations), `-q` (quiet output). Run `npx create-bot --help` for the full list. + +The scaffolded project structure: + +``` +src/ + lib/bot.ts # Bot config — adapters, state, handlers + app/api/webhooks/[platform]/route.ts # Webhook route (all platforms) +.env.example # Required environment variables +next.config.ts # Next.js config +``` + ## Start with published sources When Chat SDK is installed in a user project, inspect the published files that ship in `node_modules`: @@ -32,6 +60,7 @@ node_modules/chat/docs/guides/ # framework/platform guides If one of the paths below does not exist, that package is not installed in the project yet. Read these before writing code: + - `node_modules/chat/docs/getting-started.mdx` — install and setup - `node_modules/chat/docs/usage.mdx` — `Chat` config and lifecycle - `node_modules/chat/docs/handling-events.mdx` — event routing and handlers @@ -90,21 +119,23 @@ bot.onSubscribedMessage(async (thread, message) => { ## Event handlers -| Handler | Trigger | -|---------|---------| -| `onNewMention` | Bot @-mentioned in an unsubscribed thread | -| `onDirectMessage` | New DM in an unsubscribed DM thread | -| `onSubscribedMessage` | Any message in a subscribed thread | -| `onNewMessage(regex)` | Regex match in an unsubscribed thread | -| `onReaction(emojis?)` | Emoji added or removed | -| `onAction(actionIds?)` | Button clicks and select/radio interactions | -| `onModalSubmit(callbackId?)` | Modal form submitted | -| `onModalClose(callbackId?)` | Modal dismissed/cancelled | -| `onSlashCommand(commands?)` | Slash command invocation | -| `onAssistantThreadStarted` | Slack assistant thread opened | -| `onAssistantContextChanged` | Slack assistant context changed | -| `onAppHomeOpened` | Slack App Home opened | -| `onMemberJoinedChannel` | Slack member joined channel event | + +| Handler | Trigger | +| ---------------------------- | ------------------------------------------- | +| `onNewMention` | Bot @-mentioned in an unsubscribed thread | +| `onDirectMessage` | New DM in an unsubscribed DM thread | +| `onSubscribedMessage` | Any message in a subscribed thread | +| `onNewMessage(regex)` | Regex match in an unsubscribed thread | +| `onReaction(emojis?)` | Emoji added or removed | +| `onAction(actionIds?)` | Button clicks and select/radio interactions | +| `onModalSubmit(callbackId?)` | Modal form submitted | +| `onModalClose(callbackId?)` | Modal dismissed/cancelled | +| `onSlashCommand(commands?)` | Slash command invocation | +| `onAssistantThreadStarted` | Slack assistant thread opened | +| `onAssistantContextChanged` | Slack assistant context changed | +| `onAppHomeOpened` | Slack App Home opened | +| `onMemberJoinedChannel` | Slack member joined channel event | + Read `node_modules/chat/docs/handling-events.mdx`, `node_modules/chat/docs/actions.mdx`, `node_modules/chat/docs/modals.mdx`, and `node_modules/chat/docs/slash-commands.mdx` before wiring handlers. `onDirectMessage` behavior is documented in `node_modules/chat/docs/direct-messages.mdx`. @@ -124,6 +155,7 @@ bot.onNewMention(async (thread, message) => { ``` Key details: + - `streamingUpdateIntervalMs` controls post+edit fallback cadence - `fallbackStreamingPlaceholderText` defaults to `"..."`; set `null` to disable - Structured `StreamChunk` support is Slack-only; other adapters ignore non-text chunks @@ -133,9 +165,11 @@ Key details: Set `jsxImportSource: "chat"` in `tsconfig.json`. Card components: + - `Card`, `CardText`, `Section`, `Fields`, `Field`, `Button`, `CardLink`, `LinkButton`, `Actions`, `Select`, `SelectOption`, `RadioSelect`, `Table`, `Image`, `Divider` Modal components: + - `Modal`, `TextInput`, `Select`, `SelectOption`, `RadioSelect` ```tsx @@ -152,56 +186,18 @@ await thread.post( ## Adapter inventory -### Official platform adapters - -| Platform | Package | Factory | -|---------|---------|---------| -| Slack | `@chat-adapter/slack` | `createSlackAdapter` | -| Microsoft Teams | `@chat-adapter/teams` | `createTeamsAdapter` | -| Google Chat | `@chat-adapter/gchat` | `createGoogleChatAdapter` | -| Discord | `@chat-adapter/discord` | `createDiscordAdapter` | -| GitHub | `@chat-adapter/github` | `createGitHubAdapter` | -| Linear | `@chat-adapter/linear` | `createLinearAdapter` | -| Telegram | `@chat-adapter/telegram` | `createTelegramAdapter` | -| WhatsApp Business Cloud | `@chat-adapter/whatsapp` | `createWhatsAppAdapter` | - -### Official state adapters - -| State backend | Package | Factory | -|--------------|---------|---------| -| Redis | `@chat-adapter/state-redis` | `createRedisState` | -| ioredis | `@chat-adapter/state-ioredis` | `createIoRedisState` | -| PostgreSQL | `@chat-adapter/state-pg` | `createPostgresState` | -| Memory | `@chat-adapter/state-memory` | `createMemoryState` | - -### Community adapters - -- `chat-state-cloudflare-do` -- `@beeper/chat-adapter-matrix` -- `chat-adapter-imessage` -- `@bitbasti/chat-adapter-webex` -- `@resend/chat-sdk-adapter` -- `@zernio/chat-sdk-adapter` -- `chat-adapter-baileys` -- `@liveblocks/chat-sdk-adapter` -- `chat-adapter-sendblue` -- `chat-adapter-zalo` - -### Coming-soon platform entries - -- Instagram -- Signal -- X -- Messenger +For the full list of official, vendor-official, and community adapters (platform and state), see https://chat-sdk.dev/adapters. ## Building a custom adapter Read these published docs first: + - `node_modules/chat/docs/contributing/building.mdx` - `node_modules/chat/docs/contributing/testing.mdx` - `node_modules/chat/docs/contributing/publishing.mdx` Also inspect: + - `node_modules/chat/dist/index.d.ts` — `Adapter` and related interfaces - `node_modules/@chat-adapter/shared/dist/index.d.ts` — shared errors and utilities - Installed official adapter `dist/index.d.ts` files — reference implementations for config and APIs @@ -210,4 +206,4 @@ A custom adapter needs request verification, webhook parsing, message/thread/cha ## Webhook setup -Each registered adapter exposes `bot.webhooks.`. Wire those directly to your HTTP framework routes. See `node_modules/chat/docs/guides/slack-nextjs.mdx` and `node_modules/chat/docs/guides/discord-nuxt.mdx` for framework-specific route patterns. +Each registered adapter exposes `bot.webhooks.`. Wire those directly to your HTTP framework routes. See `node_modules/chat/docs/guides/slack-nextjs.mdx` and `node_modules/chat/docs/guides/discord-nuxt.mdx` for framework-specific route patterns. \ No newline at end of file diff --git a/skills/chat/SKILL.md b/skills/chat/SKILL.md index 349d9b9c..c6348d19 100644 --- a/skills/chat/SKILL.md +++ b/skills/chat/SKILL.md @@ -1,5 +1,7 @@ --- -name: chat-sdk + +## name: chat-sdk + description: > Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, @@ -7,16 +9,43 @@ description: > commands, cards, modals, files, or AI streaming, (3) Set up webhook routes or multi-adapter bots, (4) Send rich cards or streamed AI responses to chat platforms, - (5) Build or maintain a custom adapter or state adapter. + (5) Build or maintain a custom adapter or state adapter, + (6) Scaffold a new bot project with create-bot. Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "google chat bot", "discord bot", "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", - "state adapter", "build adapter", and building bots that work across multiple chat platforms. ---- + "state adapter", "build adapter", "create-bot", "scaffold bot", and building bots that work + across multiple chat platforms. # Chat SDK Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write bot logic once, deploy everywhere. +## Scaffold a new project + +The `create-bot` CLI scaffolds a complete Next.js bot project with adapters, environment variables, and a webhook route: + +```bash +npx create-bot my-bot +``` + +It supports non-interactive usage for CI or scripting: + +```bash +npx create-bot my-bot --adapter slack discord --adapter redis -d "My bot" --pm pnpm -y +``` + +Key flags: `--adapter ` (platform or state adapters), `-d ` (description), `--pm ` (npm/yarn/pnpm/bun), `-y` (skip confirmations), `-q` (quiet output). Run `npx create-bot --help` for the full list. + +The scaffolded project structure: + +``` +src/ + lib/bot.ts # Bot config — adapters, state, handlers + app/api/webhooks/[platform]/route.ts # Webhook route (all platforms) +.env.example # Required environment variables +next.config.ts # Next.js config +``` + ## Start with published sources When Chat SDK is installed in a user project, inspect the published files that ship in `node_modules`: @@ -32,6 +61,7 @@ node_modules/chat/docs/guides/ # framework/platform guides If one of the paths below does not exist, that package is not installed in the project yet. Read these before writing code: + - `node_modules/chat/docs/getting-started.mdx` — install and setup - `node_modules/chat/docs/usage.mdx` — `Chat` config and lifecycle - `node_modules/chat/docs/handling-events.mdx` — event routing and handlers @@ -90,21 +120,23 @@ bot.onSubscribedMessage(async (thread, message) => { ## Event handlers -| Handler | Trigger | -|---------|---------| -| `onNewMention` | Bot @-mentioned in an unsubscribed thread | -| `onDirectMessage` | New DM in an unsubscribed DM thread | -| `onSubscribedMessage` | Any message in a subscribed thread | -| `onNewMessage(regex)` | Regex match in an unsubscribed thread | -| `onReaction(emojis?)` | Emoji added or removed | -| `onAction(actionIds?)` | Button clicks and select/radio interactions | -| `onModalSubmit(callbackId?)` | Modal form submitted | -| `onModalClose(callbackId?)` | Modal dismissed/cancelled | -| `onSlashCommand(commands?)` | Slash command invocation | -| `onAssistantThreadStarted` | Slack assistant thread opened | -| `onAssistantContextChanged` | Slack assistant context changed | -| `onAppHomeOpened` | Slack App Home opened | -| `onMemberJoinedChannel` | Slack member joined channel event | + +| Handler | Trigger | +| ---------------------------- | ------------------------------------------- | +| `onNewMention` | Bot @-mentioned in an unsubscribed thread | +| `onDirectMessage` | New DM in an unsubscribed DM thread | +| `onSubscribedMessage` | Any message in a subscribed thread | +| `onNewMessage(regex)` | Regex match in an unsubscribed thread | +| `onReaction(emojis?)` | Emoji added or removed | +| `onAction(actionIds?)` | Button clicks and select/radio interactions | +| `onModalSubmit(callbackId?)` | Modal form submitted | +| `onModalClose(callbackId?)` | Modal dismissed/cancelled | +| `onSlashCommand(commands?)` | Slash command invocation | +| `onAssistantThreadStarted` | Slack assistant thread opened | +| `onAssistantContextChanged` | Slack assistant context changed | +| `onAppHomeOpened` | Slack App Home opened | +| `onMemberJoinedChannel` | Slack member joined channel event | + Read `node_modules/chat/docs/handling-events.mdx`, `node_modules/chat/docs/actions.mdx`, `node_modules/chat/docs/modals.mdx`, and `node_modules/chat/docs/slash-commands.mdx` before wiring handlers. `onDirectMessage` behavior is documented in `node_modules/chat/docs/direct-messages.mdx`. @@ -124,6 +156,7 @@ bot.onNewMention(async (thread, message) => { ``` Key details: + - `streamingUpdateIntervalMs` controls post+edit fallback cadence - `fallbackStreamingPlaceholderText` defaults to `"..."`; set `null` to disable - Structured `StreamChunk` support is Slack-only; other adapters ignore non-text chunks @@ -133,9 +166,11 @@ Key details: Set `jsxImportSource: "chat"` in `tsconfig.json`. Card components: + - `Card`, `CardText`, `Section`, `Fields`, `Field`, `Button`, `CardLink`, `LinkButton`, `Actions`, `Select`, `SelectOption`, `RadioSelect`, `Table`, `Image`, `Divider` Modal components: + - `Modal`, `TextInput`, `Select`, `SelectOption`, `RadioSelect` ```tsx @@ -152,56 +187,18 @@ await thread.post( ## Adapter inventory -### Official platform adapters - -| Platform | Package | Factory | -|---------|---------|---------| -| Slack | `@chat-adapter/slack` | `createSlackAdapter` | -| Microsoft Teams | `@chat-adapter/teams` | `createTeamsAdapter` | -| Google Chat | `@chat-adapter/gchat` | `createGoogleChatAdapter` | -| Discord | `@chat-adapter/discord` | `createDiscordAdapter` | -| GitHub | `@chat-adapter/github` | `createGitHubAdapter` | -| Linear | `@chat-adapter/linear` | `createLinearAdapter` | -| Telegram | `@chat-adapter/telegram` | `createTelegramAdapter` | -| WhatsApp Business Cloud | `@chat-adapter/whatsapp` | `createWhatsAppAdapter` | - -### Official state adapters - -| State backend | Package | Factory | -|--------------|---------|---------| -| Redis | `@chat-adapter/state-redis` | `createRedisState` | -| ioredis | `@chat-adapter/state-ioredis` | `createIoRedisState` | -| PostgreSQL | `@chat-adapter/state-pg` | `createPostgresState` | -| Memory | `@chat-adapter/state-memory` | `createMemoryState` | - -### Community adapters - -- `chat-state-cloudflare-do` -- `@beeper/chat-adapter-matrix` -- `chat-adapter-imessage` -- `@bitbasti/chat-adapter-webex` -- `@resend/chat-sdk-adapter` -- `@zernio/chat-sdk-adapter` -- `chat-adapter-baileys` -- `@liveblocks/chat-sdk-adapter` -- `chat-adapter-sendblue` -- `chat-adapter-zalo` - -### Coming-soon platform entries - -- Instagram -- Signal -- X -- Messenger +For the full list of official, vendor-official, and community adapters (platform and state), see [https://chat-sdk.dev/adapters](https://chat-sdk.dev/adapters). ## Building a custom adapter Read these published docs first: + - `node_modules/chat/docs/contributing/building.mdx` - `node_modules/chat/docs/contributing/testing.mdx` - `node_modules/chat/docs/contributing/publishing.mdx` Also inspect: + - `node_modules/chat/dist/index.d.ts` — `Adapter` and related interfaces - `node_modules/@chat-adapter/shared/dist/index.d.ts` — shared errors and utilities - Installed official adapter `dist/index.d.ts` files — reference implementations for config and APIs @@ -210,4 +207,4 @@ A custom adapter needs request verification, webhook parsing, message/thread/cha ## Webhook setup -Each registered adapter exposes `bot.webhooks.`. Wire those directly to your HTTP framework routes. See `node_modules/chat/docs/guides/slack-nextjs.mdx` and `node_modules/chat/docs/guides/discord-nuxt.mdx` for framework-specific route patterns. +Each registered adapter exposes `bot.webhooks.`. Wire those directly to your HTTP framework routes. See `node_modules/chat/docs/guides/slack-nextjs.mdx` and `node_modules/chat/docs/guides/discord-nuxt.mdx` for framework-specific route patterns. \ No newline at end of file From e556590664bcec8c33a1df0cfcecf1a7e3560736 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 10 Apr 2026 23:55:40 +1000 Subject: [PATCH 3/8] docs: add create-bot package to CLAUDE.md --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ddcae2b8..3537444d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,11 @@ pnpm --filter chat build pnpm --filter @chat-adapter/slack build pnpm --filter @chat-adapter/gchat build pnpm --filter @chat-adapter/teams build +pnpm --filter create-bot build # Run tests for a specific package pnpm --filter chat test +pnpm --filter create-bot test pnpm --filter @chat-adapter/integration-tests test # Run a single test file @@ -65,6 +67,7 @@ This is a **pnpm monorepo** using **Turborepo** for build orchestration. All pac - **`packages/state-memory`** - In-memory state adapter (for development/testing) - **`packages/state-redis`** - Redis state adapter (for production) - **`packages/adapter-whatsapp`** - WhatsApp adapter using Meta Cloud API +- **`packages/create-bot`** - `create-bot` CLI for scaffolding new Chat SDK bot projects - **`packages/integration-tests`** - Integration tests against real platform APIs - **`examples/nextjs-chat`** - Example Next.js app showing how to use the SDK From 8844bb4230a0fe8e7652e750a52f85a984af40ae Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sat, 11 Apr 2026 00:05:04 +1000 Subject: [PATCH 4/8] docs: add AGENTS.md and ARCHITECTURE.md to create-bot package --- packages/create-bot/AGENTS.md | 19 +++++++ packages/create-bot/ARCHITECTURE.md | 81 +++++++++++++++++++++++++++++ packages/create-bot/CLAUDE.md | 1 + 3 files changed, 101 insertions(+) create mode 100644 packages/create-bot/AGENTS.md create mode 100644 packages/create-bot/ARCHITECTURE.md create mode 100644 packages/create-bot/CLAUDE.md diff --git a/packages/create-bot/AGENTS.md b/packages/create-bot/AGENTS.md new file mode 100644 index 00000000..5e697566 --- /dev/null +++ b/packages/create-bot/AGENTS.md @@ -0,0 +1,19 @@ +# AGENTS.md + +`create-bot` is a CLI tool that scaffolds new [Chat SDK](https://chat-sdk.dev) bot projects. + +## Commands + +```bash +pnpm --filter create-bot build # Build with tsup +pnpm --filter create-bot typecheck # Type-check +pnpm --filter create-bot test # Run tests with coverage +pnpm -w run check # Lint and format (monorepo-wide) +pnpm validate # Full validation (build, typecheck, lint, test) +``` + +## Docs + +- [CLI documentation](../../apps/docs/content/docs/create-bot.mdx) +- [Full Chat SDK docs](../../apps/docs/content/docs) +- [Architecture](./ARCHITECTURE.md) for detailed structure, data flow, and design decisions. diff --git a/packages/create-bot/ARCHITECTURE.md b/packages/create-bot/ARCHITECTURE.md new file mode 100644 index 00000000..0c07c602 --- /dev/null +++ b/packages/create-bot/ARCHITECTURE.md @@ -0,0 +1,81 @@ +# Architecture + +`create-bot` is a CLI that scaffolds new [Chat SDK](https://chat-sdk.dev) bot projects. It is published as the `create-bot` npm package and lives in the Chat SDK monorepo at `packages/create-bot`. + +## Project structure + +``` +packages/create-bot/ +├── src/ +│ ├── index.ts # Entry point — calls createProgram().parse() +│ ├── cli.ts # Commander program definition, flags, help text +│ ├── prompts.ts # Interactive prompts (Clack) and flag resolution +│ ├── scaffold.ts # File copying, post-processing, dependency install +│ ├── templates.ts # Dynamic code generation (bot.ts) +│ └── types.ts # Shared TypeScript interfaces +├── _template/ # Static template files copied into scaffolded projects +│ ├── src/app/api/webhooks/[platform]/route.ts +│ ├── .agents/skills/chat-sdk/SKILL.md +│ ├── AGENTS.md +│ ├── README.md +│ ├── .env.example +│ ├── .gitignore +│ ├── next.config.ts +│ ├── tsconfig.json +│ └── package.json +├── adapters.json # Adapter registry (platforms, state, env vars, categories) +├── AGENTS.md # Agent instructions for this package +├── ARCHITECTURE.md # This document +├── CLAUDE.md # Points to AGENTS.md +├── tsup.config.ts # Bundler config (ESM + shebang) +├── vitest.config.ts # Test config with v8 coverage +└── package.json +``` + +## Data flow + +``` +CLI invocation (npx create-bot my-bot --adapter slack redis) + │ + ▼ +index.ts ──► cli.ts (Commander parses args and flags) + │ + ▼ + prompts.ts (resolves flags or runs interactive Clack prompts) + │ + ├── resolveAdapterFlags() ← matches --adapter values to adapters.json + ├── text / groupMultiselect / select / confirm ← interactive fallback + └── detectPackageManager() ← reads npm_config_user_agent + │ + ▼ + scaffold.ts (creates the project on disk) + │ + ├── copyDir() ← copies _template/ → project directory + ├── postProcessEnvExample ← injects adapter env vars into .env.example + ├── postProcessNextConfig ← adds serverExternalPackages if needed + ├── npm pkg set ← sets name, description, adapter dependencies + ├── templates.botTs() ← generates src/lib/bot.ts with imports + handlers + └── execa(install) ← runs package manager install + │ + ▼ + cli.ts (displays next steps and outro) +``` + +## Key design decisions + +- **Static template + post-processing**: Most files live as-is in `_template/` and are copied verbatim. Only `.env.example`, `next.config.ts`, and `package.json` are post-processed. `src/lib/bot.ts` is the sole fully-generated file because its imports and adapter configuration vary per selection. +- **`adapters.json` as registry**: All adapter metadata (packages, factory functions, env vars, categories, server external packages) is centralized in a single JSON file. The CLI reads it at build time via an import assertion. This avoids hardcoding adapter knowledge across multiple source files. +- **`npm pkg set` for `package.json`**: Instead of generating the full `package.json` from a template function, the CLI copies a base `package.json` from `_template/` and patches it with `npm pkg set`. This keeps the base file readable and avoids JSON serialization edge cases. +- **Clack for interactive UX**: `@clack/prompts` provides the interactive prompt flow with spinners, grouped multi-select, and cancellation handling. All prompts are skippable via flags for non-interactive / CI usage. +- **Commander for arg parsing**: Handles positional args, flags, help text generation, and the `--no-color` convention. + +## Testing + +Tests use Vitest with v8 coverage. Each source module has a co-located `.test.ts` file. The test strategy: + +- **`cli.test.ts`** — Mocks `runPrompts` and `scaffold`; tests Commander program creation, help text output, and action handler logic. +- **`prompts.test.ts`** — Mocks `@clack/prompts`; tests interactive flows, flag resolution, cancellation, validation, and package manager detection. +- **`scaffold.test.ts`** — Uses real temp directories; mocks `execa` and `@clack/prompts`; tests file copying, post-processing, dependency installation, and overwrite prompts. +- **`templates.test.ts`** — Pure function tests for `botTs()` output with various adapter combinations. + +`types.ts` and `index.ts` are excluded from coverage — `types.ts` is definition-only and `index.ts` is a one-line entry point. diff --git a/packages/create-bot/CLAUDE.md b/packages/create-bot/CLAUDE.md new file mode 100644 index 00000000..eef4bd20 --- /dev/null +++ b/packages/create-bot/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file From 90b4ffd4a4290dd33f7df30df010e9d0609062ae Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sat, 11 Apr 2026 00:07:51 +1000 Subject: [PATCH 5/8] test: update chat-skill tests to match adapters link instead of inline list --- .../integration-tests/src/chat-skill.test.ts | 69 +------------------ 1 file changed, 2 insertions(+), 67 deletions(-) diff --git a/packages/integration-tests/src/chat-skill.test.ts b/packages/integration-tests/src/chat-skill.test.ts index 8bb06372..a2c27b79 100644 --- a/packages/integration-tests/src/chat-skill.test.ts +++ b/packages/integration-tests/src/chat-skill.test.ts @@ -3,17 +3,10 @@ import { ADAPTER_CATALOG, CHAT_SKILL, cleanupPackArtifacts, - extractBulletItems, - extractFactoryName, - extractMarkdownTableRows, extractPublishedPaths, - extractSection, getPackedTarballEntries, invariant, - isCommunityEntry, isOfficialCatalogEntry, - isOfficialPlatformEntry, - isOfficialStateEntry, parsePublishedPath, } from "./chat-skill-test-utils"; @@ -87,65 +80,7 @@ describe("skills/chat/SKILL.md", () => { } }); - it("should list all official platform adapters with correct factories", () => { - const section = extractSection(CHAT_SKILL, "Official platform adapters"); - const actualRows = extractMarkdownTableRows(section).map( - ([name, packageName, factory]) => ({ - name, - packageName, - factory, - }) - ); - - const expectedRows = ADAPTER_CATALOG.filter(isOfficialPlatformEntry).map( - (entry) => ({ - name: entry.name, - packageName: entry.packageName, - factory: extractFactoryName(entry.packageName), - }) - ); - - expect(actualRows).toEqual(expectedRows); - }); - - it("should list all official state adapters with correct factories", () => { - const section = extractSection(CHAT_SKILL, "Official state adapters"); - const actualRows = extractMarkdownTableRows(section).map( - ([name, packageName, factory]) => ({ - name, - packageName, - factory, - }) - ); - - const expectedRows = ADAPTER_CATALOG.filter(isOfficialStateEntry).map( - (entry) => ({ - name: entry.name, - packageName: entry.packageName, - factory: extractFactoryName(entry.packageName), - }) - ); - - expect(actualRows).toEqual(expectedRows); - }); - - it("should list all community adapters", () => { - const section = extractSection(CHAT_SKILL, "Community adapters"); - const actualItems = extractBulletItems(section); - const expectedItems = ADAPTER_CATALOG.filter(isCommunityEntry).map( - (entry) => entry.packageName - ); - - expect(actualItems).toEqual(expectedItems); - }); - - it("should list all coming-soon platform entries", () => { - const section = extractSection(CHAT_SKILL, "Coming-soon platform entries"); - const actualItems = extractBulletItems(section); - const expectedItems = ADAPTER_CATALOG.filter( - (entry) => entry.type === "platform" && entry.comingSoon - ).map((entry) => entry.name); - - expect(actualItems).toEqual(expectedItems); + it("should link to the adapters page instead of listing them inline", () => { + expect(CHAT_SKILL).toContain("https://chat-sdk.dev/adapters"); }); }); From 1b9cd088273a7b194ade89181a5c7c90bc4a0d6b Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sat, 11 Apr 2026 00:11:25 +1000 Subject: [PATCH 6/8] refactor: remove unused adapter-listing helpers from chat-skill test utils --- .../src/chat-skill-test-utils.ts | 106 +----------------- 1 file changed, 1 insertion(+), 105 deletions(-) diff --git a/packages/integration-tests/src/chat-skill-test-utils.ts b/packages/integration-tests/src/chat-skill-test-utils.ts index 62e937fa..9b4cf2ad 100644 --- a/packages/integration-tests/src/chat-skill-test-utils.ts +++ b/packages/integration-tests/src/chat-skill-test-utils.ts @@ -7,7 +7,7 @@ import { rmSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { join } from "node:path"; import { ADAPTERS_JSON_PATH, CHAT_SKILL_PATH, @@ -42,32 +42,6 @@ function hasPackageName( return typeof entry.packageName === "string" && entry.packageName.length > 0; } -export function isCommunityEntry( - entry: AdapterCatalogEntry -): entry is AdapterCatalogEntry & { packageName: string } { - return entry.community === true && hasPackageName(entry); -} - -export function isOfficialPlatformEntry( - entry: AdapterCatalogEntry -): entry is AdapterCatalogEntry & { packageName: string } { - return ( - entry.type === "platform" && - !(entry.community || entry.comingSoon) && - hasPackageName(entry) - ); -} - -export function isOfficialStateEntry( - entry: AdapterCatalogEntry -): entry is AdapterCatalogEntry & { packageName: string } { - return ( - entry.type === "state" && - !(entry.community || entry.comingSoon) && - hasPackageName(entry) - ); -} - export function isOfficialCatalogEntry( entry: AdapterCatalogEntry ): entry is AdapterCatalogEntry & { packageName: string } { @@ -122,51 +96,6 @@ export function cleanupPackArtifacts(): void { rmSync(PACK_DEST_ROOT, { recursive: true, force: true }); } -export function extractSection(markdown: string, heading: string): string { - const lines = markdown.split("\n"); - const headingLine = `### ${heading}`; - const startIndex = lines.findIndex((line) => line.trim() === headingLine); - - invariant(startIndex !== -1, `Missing section "${heading}" in SKILL.md`); - - const sectionLines: string[] = []; - for (let i = startIndex + 1; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith("### ") || line.startsWith("## ")) { - break; - } - sectionLines.push(line); - } - - return sectionLines.join("\n").trim(); -} - -export function extractMarkdownTableRows(section: string): string[][] { - const tableLines = section - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("|")); - - if (tableLines.length < 3) { - return []; - } - - return tableLines.slice(2).map((line) => - line - .split("|") - .slice(1, -1) - .map((cell) => cell.trim().replace(/^`|`$/g, "")) - ); -} - -export function extractBulletItems(section: string): string[] { - return section - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("- ")) - .map((line) => line.slice(2).trim().replace(/^`|`$/g, "")); -} - export function extractPublishedPaths(markdown: string): string[] { const paths = new Set(); @@ -268,36 +197,3 @@ export function getPackedTarballEntries(packageName: string): string[] { PACKED_TARBALL_ENTRIES.set(packageName, entries); return entries; } - -export function extractFactoryName(packageName: string): string { - const packageDir = PACKAGE_DIRS_BY_NAME.get(packageName); - invariant(packageDir, `Missing local package for ${packageName}`); - - const sourcePath = join(packageDir, "src/index.ts"); - invariant( - existsSync(sourcePath), - `Expected ${packageName} to have ${relative(REPO_ROOT, sourcePath)}` - ); - - const source = readFileSync(sourcePath, "utf-8"); - const factoryNames = [ - ...new Set( - [...source.matchAll(/export function (create[A-Za-z0-9_]+)\(/g)].map( - (match) => match[1] - ) - ), - ]; - - invariant( - factoryNames.length === 1, - `${packageName} should export exactly one create* factory` - ); - - const [factoryName] = factoryNames; - invariant( - factoryName, - `${packageName} should export exactly one create* factory` - ); - - return factoryName; -} From c8b832157621a8b1c9ffdc38d5da05766859abaf Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sat, 11 Apr 2026 00:14:22 +1000 Subject: [PATCH 7/8] docs: mention create-bot CLI in vendor official adapter qualifications --- apps/docs/content/docs/contributing/building.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/docs/content/docs/contributing/building.mdx b/apps/docs/content/docs/contributing/building.mdx index a731590f..a63ad670 100644 --- a/apps/docs/content/docs/contributing/building.mdx +++ b/apps/docs/content/docs/contributing/building.mdx @@ -36,6 +36,10 @@ Chat SDK ships with Vercel-maintained adapters for Slack, Teams, Google Chat, Di - Documentation of the adapter in primary vendor docs. - Announcement of the adapter in blog post or changelog and social media. + + Vendor official adapters are also included in the [`create-bot`](/docs/create-bot) CLI and co-marketing opportunities may be available. + + ## Project setup This guide uses a hypothetical **Matrix** adapter as a running example. Replace "matrix" with your platform name throughout. From a497a72aa80551e9ff9d4674d75759fd15c9a754 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sat, 11 Apr 2026 00:43:25 +1000 Subject: [PATCH 8/8] fix: restore valid YAML frontmatter in SKILL.md files --- .../create-bot/_template/.agents/skills/chat-sdk/SKILL.md | 4 ++-- skills/chat/SKILL.md | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md index 4f2f7bfb..91f0239c 100644 --- a/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md +++ b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md @@ -1,6 +1,5 @@ --- - -## name: chat-sdk +name: chat-sdk description: > Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, @@ -14,6 +13,7 @@ description: > "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", "state adapter", "build adapter", "create-bot", "scaffold bot", and building bots that work across multiple chat platforms. +--- # Chat SDK diff --git a/skills/chat/SKILL.md b/skills/chat/SKILL.md index c6348d19..9f09f3a9 100644 --- a/skills/chat/SKILL.md +++ b/skills/chat/SKILL.md @@ -1,7 +1,5 @@ --- - -## name: chat-sdk - +name: chat-sdk description: > Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, @@ -15,6 +13,7 @@ description: > "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", "state adapter", "build adapter", "create-bot", "scaffold bot", and building bots that work across multiple chat platforms. +--- # Chat SDK