diff --git a/.gitignore b/.gitignore index 64b9793..43696aa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ temp/ .codex/ AGENTS.md +# Documentation build output +docs/ + # Working documents (not part of published package) PLANS.md CLI_SPEC.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72200a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Bento CLI is a Bun-based command-line tool for interacting with the Bento email marketing platform. It provides: +- **Command-Oriented Interface (COI)**: Deterministic, scriptable commands backed by `@bentonow/bento-node-sdk` +- **Conversational Interface**: LLM-assisted workflows via MCP (Model Context Protocol) + +**Package**: `@bentonow/bento-cli` +**Runtime**: Bun >= 1.x +**Entry**: `bin/bento` + +## Build & Test Commands + +```bash +# Run tests +bun test + +# Run a single test file +bun test src/tests/subscribers.test.ts + +# Run CLI locally +bun run bin/bento +``` + +## Architecture + +``` +src/ +├── cli.ts # Entry point +├── commands/ # Command definitions (auth, subscribers, tags, etc.) +├── core/ +│ ├── config.ts # Profile management, auth storage +│ ├── sdk.ts # Bento Node SDK wrapper +│ ├── mcp.ts # MCP bridge for conversational features +│ ├── safety.ts # Dry-run, confirmation, limits +│ └── output.ts # Table/JSON formatters +├── ask/ +│ ├── planner.ts # Intent classification for `bento ask` +│ └── executor.ts # MCP tool execution +└── tests/ +``` + +**Key Design Principles**: +- Deterministic by default; conversational only when explicitly requested (`bento ask`) +- All bulk-affecting commands support `--dry-run`, `--limit`, `--sample`, `--confirm` +- CLI works standalone without MCP; MCP is optional +- Output formats: human-readable tables (default), `--json` for scripting, `--quiet` for minimal output + +## Config Location + +Profiles stored via `env-paths`: +- macOS: `~/Library/Application Support/bento/config.json` +- Linux: `~/.config/bento/config.json` + +## Dependencies + +- `@bentonow/bento-node-sdk` - Core API operations +- `@bentonow/bento-mcp` - Optional, for conversational `ask` command diff --git a/skill.zip b/skill.zip new file mode 100644 index 0000000..7779693 Binary files /dev/null and b/skill.zip differ diff --git a/skill/SKILL.md b/skill/SKILL.md deleted file mode 100644 index ea9fcfe..0000000 --- a/skill/SKILL.md +++ /dev/null @@ -1,300 +0,0 @@ ---- -name: bento-cli -description: > - Guide for using Bento CLI to manage email marketing operations. - Use when running bento commands, managing subscribers, tags, events, - or automating email marketing workflows. Triggers on: bento CLI usage, - subscriber management, email list operations, CSV imports. ---- - -# Bento CLI - -A command-line interface for [Bento](https://bentonow.com) email marketing. Manage subscribers, tags, events, and broadcasts directly from the terminal. - -## Philosophy: Safety-First Automation - -Email operations affect real people. A bulk operation mistake can unsubscribe thousands or spam your entire list. The Bento CLI is designed with **safety-first automation** in mind. - -**Before running any command, ask**: -- Is this a bulk operation? If yes, use `--dry-run` first -- Will this modify subscriber data? If yes, consider `--limit` for testing -- Is this running in CI/automation? If yes, use `--confirm` to skip interactive prompts -- Do I need the output for scripting? If yes, use `--json` - -**Core principles**: -1. **Preview before execute**: Always `--dry-run` bulk operations first -2. **Progressive rollout**: Use `--limit` to test on small batches -3. **Explicit confirmation**: Bulk operations require `--confirm` in scripts -4. **Scriptable output**: Use `--json` for machine-readable, consistent output - -## Command Reference - -### Authentication - -```bash -# Interactive login (prompts for credentials) -bento auth login - -# Non-interactive login (for CI/scripts) -bento auth login \ - --publishable-key "your-publishable-key" \ - --secret-key "your-secret-key" \ - --site-uuid "your-site-uuid" - -# Check authentication status -bento auth status - -# Log out (removes stored credentials) -bento auth logout -``` - -**Credentials**: Get from [Settings > Teams](https://app.bentonow.com/account/teams) in Bento dashboard. - -### Profile Management - -Manage multiple Bento accounts (production, staging, etc.): - -```bash -bento profile add production # Add named profile -bento profile add staging -bento profile use staging # Switch active profile -bento profile list # List all profiles -bento profile remove staging # Remove a profile -``` - -### Subscribers - -**Search subscribers**: -```bash -bento subscribers search --email user@example.com -bento subscribers search --tag vip -bento subscribers search --field plan=pro -bento subscribers search --tag active --page 2 --per-page 50 -``` - -**Import from CSV** (requires `email` column): -```bash -bento subscribers import contacts.csv # Interactive -bento subscribers import contacts.csv --dry-run # Preview only -bento subscribers import contacts.csv --limit 100 # First 100 rows -bento subscribers import contacts.csv --confirm # Non-interactive -``` - -**Manage tags on subscribers**: -```bash -bento subscribers tag --email user@example.com --add vip -bento subscribers tag --email user@example.com --remove trial -bento subscribers tag --file users.csv --add customer,active --confirm -``` - -**Subscription management**: -```bash -bento subscribers unsubscribe --email user@example.com -bento subscribers unsubscribe --file unsubscribes.csv --confirm -bento subscribers subscribe --email user@example.com # Re-subscribe -``` - -### Tags - -```bash -bento tags list # List all tags -bento tags create "new-feature-announcement" # Create tag -bento tags delete "old-tag" # Delete (API limitation applies) -``` - -### Custom Fields - -```bash -bento fields list # List all fields -bento fields create company_size # Create field -``` - -### Events - -Track custom events to trigger automations: - -```bash -bento events track --email user@example.com --event signed_up - -bento events track \ - --email user@example.com \ - --event purchase \ - --details '{"product": "Pro Plan", "amount": 99}' -``` - -### Broadcasts - -```bash -bento broadcasts list # List all broadcasts - -bento broadcasts create \ - --name "January Newsletter" \ - --subject "What's new this month" \ - --content "

Hello!

Here's our update...

" \ - --type html \ - --include-tags "newsletter,active" -``` - -### Statistics - -```bash -bento stats site # View site-wide stats -``` - -## Safety Flags - -| Flag | Purpose | -|------|---------| -| `--dry-run` | Preview what would happen without making changes | -| `--limit ` | Only process first N items | -| `--sample ` | Show N sample items in preview | -| `--confirm` | Skip interactive confirmation (required for scripts) | - -## Output Modes - -| Flag | Output Type | -|------|-------------| -| *(none)* | Human-readable tables with colors | -| `--json` | Machine-readable JSON for scripting | -| `--quiet` | Minimal output (errors only) | - -**JSON response format**: -```json -{ - "success": true, - "error": null, - "data": { ... }, - "meta": { "count": 10, "total": 100 } -} -``` - -## CSV Format - -**For imports** - requires `email` column, recognizes special columns: - -| Column | Description | -|--------|-------------| -| `email` | Required. Subscriber email | -| `name` | Optional. Display name | -| `tags` | Optional. Tags to add (comma/semicolon separated) | -| `remove_tags` | Optional. Tags to remove | -| *(other)* | Custom fields | - -```csv -email,name,tags,first_name,plan -alice@example.com,Alice Smith,"customer,active",Alice,pro -bob@example.com,Bob Jones,newsletter,Bob,starter -``` - -**For tag/unsubscribe operations** - simple email list or plain text: -```csv -email -alice@example.com -bob@example.com -``` - -Or one email per line (no header). - -## Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | Success | -| 1 | General error | -| 2 | Invalid arguments or usage | -| 6 | CSV parsing error | - -## Environment Variables - -| Variable | Purpose | -|----------|---------| -| `BENTO_CONFIG_PATH` | Override config file location | -| `BENTO_API_BASE_URL` | Override API endpoint (testing) | -| `BENTO_AUTO_CONFIRM` | Set `true` to skip all confirmations | -| `DEBUG` | Set `bento` for verbose SDK logging | - -## Anti-Patterns to Avoid - -**Running bulk operations without preview**: -```bash -# BAD: Direct import without testing -bento subscribers import huge-list.csv --confirm - -# GOOD: Preview first, then limit test, then full import -bento subscribers import huge-list.csv --dry-run -bento subscribers import huge-list.csv --limit 10 --confirm -bento subscribers import huge-list.csv --confirm -``` - -**Forgetting --confirm in scripts**: -```bash -# BAD: Will hang waiting for input in CI -bento subscribers tag --file users.csv --add vip - -# GOOD: Explicit confirmation for automation -bento subscribers tag --file users.csv --add vip --confirm -``` - -**Not using --json for scripting**: -```bash -# BAD: Parsing human-readable tables -bento subscribers search --tag vip | grep email - -# GOOD: Structured JSON output -bento subscribers search --tag vip --json | jq '.data[].email' -``` - -**Running destructive operations without understanding scope**: -```bash -# BAD: Unsubscribe operation on unclear file -bento subscribers unsubscribe --file list.csv --confirm - -# GOOD: Preview the file contents first -head -20 list.csv -wc -l list.csv -bento subscribers unsubscribe --file list.csv --dry-run -``` - -## Common Workflows - -### CI/CD: Sync subscribers from database -```bash -psql -c "COPY (SELECT email, name FROM users WHERE active) TO STDOUT CSV HEADER" \ - > /tmp/active-users.csv - -bento subscribers import /tmp/active-users.csv --confirm --json -``` - -### Scripting: Batch tag operations -```bash -bento subscribers search --tag inactive --json \ - | jq -r '.data[].email' \ - | while read email; do - bento subscribers tag --email "$email" --add "needs-reengagement" --confirm - done -``` - -### Testing: Safe import validation -```bash -# 1. Validate CSV format -bento subscribers import contacts.csv --dry-run - -# 2. Test with small batch -bento subscribers import contacts.csv --limit 5 --confirm - -# 3. Full import -bento subscribers import contacts.csv --confirm -``` - -## Running Tests - -```bash -bun test # Run all tests -bun test src/tests/commands/tags.test.ts # Run specific test file -``` - -## Remember - -The Bento CLI gives you direct access to operations that affect real subscribers. The safety flags exist because mistakes are costly. Use `--dry-run` and `--limit` liberally. When in doubt, preview first. - -For complex automation, prefer `--json` output and proper error handling over parsing human-readable tables. diff --git a/skill/bento-cli/SKILL.md b/skill/bento-cli/SKILL.md new file mode 100644 index 0000000..a19f9cf --- /dev/null +++ b/skill/bento-cli/SKILL.md @@ -0,0 +1,447 @@ +--- +name: bento-cli +description: > + Complete reference for the Bento CLI (bentonow.com). Use when running + bento commands, managing subscribers, tags, events, broadcasts, sequences, + templates, forms, workflows, transactional emails, or automating email + marketing workflows. Covers all commands, safety flags, output modes, + CSV formats, and CI/CD integration. +--- + +# Bento CLI + +A command-line interface for [Bento](https://bentonow.com) email marketing. Manage subscribers, tags, events, broadcasts, sequences, templates, and more from the terminal. + +## Installation & Usage + +```bash +# Recommended: Run with npx (always uses latest version) +npx @bentonow/bento-cli --help + +# Or install globally +npm install -g @bentonow/bento-cli +bento --help +``` + +## Philosophy: Safety-First Automation + +Email operations affect real people. The Bento CLI is designed with **safety-first automation** in mind. + +**Before running any command, ask**: +- Is this a bulk operation? Use `--dry-run` first +- Will this modify subscriber data? Use `--limit` for testing +- Is this running in CI/automation? Use `--confirm` to skip interactive prompts +- Do I need the output for scripting? Use `--json` + +## Command Reference + +### Authentication + +```bash +bento auth login # Interactive login +bento auth login --profile staging \ # Non-interactive with named profile + --publishable-key "pk_..." \ + --secret-key "sk_..." \ + --site-uuid "site_..." +bento auth status # Check auth status +bento auth logout # Clear credentials +``` + +**Credentials**: Get from [Settings > Teams](https://app.bentonow.com/account/teams). + +### Profile Management + +Manage multiple Bento accounts (production, staging, etc.): + +```bash +bento profile add production # Add named profile (interactive) +bento profile add staging \ # Add non-interactively + --publishable-key "pk_..." \ + --secret-key "sk_..." \ + --site-uuid "site_..." +bento profile use staging # Switch active profile +bento profile list # List all profiles +bento profile remove staging # Remove a profile +bento profile remove staging --yes # Skip confirmation +``` + +### Subscribers + +**Search** (requires `--email` or `--uuid`): +```bash +bento subscribers search --email user@example.com +bento subscribers search --uuid abc-123 +bento subscribers search --email user@example.com --tag vip +bento subscribers search --email user@example.com --field plan=pro +``` + +**Import from CSV** (requires `email` column, bulk operation): +```bash +bento subscribers import contacts.csv --dry-run # Preview +bento subscribers import contacts.csv --limit 10 # Test batch +bento subscribers import contacts.csv --confirm # Full import +``` + +**Upsert** (create or update a subscriber): +```bash +bento subscribers upsert --email user@example.com \ + --fields '{"first_name": "Jane", "plan": "pro"}' \ + --tags "customer,active" \ + --remove-tags "trial" +``` + +**Tag management** (bulk operation): +```bash +bento subscribers tag --email user@example.com --add vip +bento subscribers tag --email user@example.com --remove trial +bento subscribers tag --file users.csv --add customer,active --confirm +``` + +**Field management**: +```bash +# Set a field (does NOT trigger automations) +bento subscribers field set --email user@example.com --key plan --value pro + +# Update multiple fields (TRIGGERS automations) +bento subscribers field update --email user@example.com \ + --fields '{"plan": "pro", "company": "Acme"}' + +# Remove a field +bento subscribers field remove --email user@example.com --key trial_started +``` + +**Change email**: +```bash +bento subscribers change-email --old old@example.com --new new@example.com +``` + +**Subscription management** (bulk operations): +```bash +bento subscribers unsubscribe --email user@example.com +bento subscribers unsubscribe --file list.csv --confirm +bento subscribers unsubscribe --email user@example.com --trigger-automations +bento subscribers subscribe --email user@example.com # Re-subscribe +bento subscribers subscribe --file resubscribes.csv --confirm +``` + +### Tags + +```bash +bento tags list # List all tags +bento tags list news # Search by name +bento tags create "new-feature" # Create tag +bento tags delete "old-tag" # Delete tag +bento tags delete "old-tag" --confirm # Skip confirmation +``` + +### Custom Fields + +```bash +bento fields list # List all fields +bento fields list company # Search by key or name +bento fields create company_size # Create field +``` + +### Events + +```bash +# Track a single event +bento events track --email user@example.com --event signed_up + +# Track with details +bento events track --email user@example.com \ + --event purchase \ + --details '{"product": "Pro Plan", "amount": 99}' + +# Import events from JSON file (up to 1000) +bento events import events.json + +# Track a purchase (TRIGGERS automations) +bento events purchase \ + --email user@example.com \ + --amount 9900 \ + --currency USD \ + --key "order_123" \ + --cart '{"items": [{"name": "Widget", "price": 9900, "quantity": 1}]}' +``` + +**Events import JSON format**: +```json +[ + {"email": "user@example.com", "type": "signed_up"}, + {"email": "user@example.com", "type": "purchase", "details": {"amount": 99}} +] +``` + +### Broadcasts + +```bash +bento broadcasts list # List all +bento broadcasts list --page 1 --per-page 10 # Paginate + +bento broadcasts create \ + --name "January Newsletter" \ + --subject "What's new this month" \ + --content "

Hello!

Here's our update...

" \ + --type html \ + --from-name "Team" \ + --from-email "team@example.com" \ + --include-tags "newsletter,active" \ + --exclude-tags "unsubscribed" \ + --batch-size 500 +``` + +Content types: `plain`, `html`, `markdown` (default: `html`). + +### Sequences + +```bash +bento sequences list # List all sequences + +# Create an email in a sequence +bento sequences create-email \ + --sequence-id sequence_abc123 \ + --subject "Welcome to Bento" \ + --html "

Hi there

" \ + --delay-interval days \ + --delay-count 7 + +# Create from an HTML file +bento sequences create-email \ + --sequence-id sequence_abc123 \ + --subject "Day 2 follow-up" \ + --html-file ./emails/day-2.html + +# Update an existing sequence email +bento sequences update-email \ + --template-id 12345 \ + --subject "Updated Welcome" \ + --html "

Updated body

" +``` + +Options for `create-email`: `--inbox-snippet`, `--delay-interval` (minutes/hours/days/months), `--delay-count`, `--editor-choice`, `--cc`, `--bcc`, `--to` (all support Liquid). + +### Transactional Emails + +```bash +# Send a single email +bento emails send \ + --to user@example.com \ + --from team@example.com \ + --subject "Your receipt" \ + --html-body "

Thanks for your purchase!

" \ + --personalizations '{"name": "Jane"}' + +# Send a batch (up to 100) +bento emails send-batch --file emails.json +``` + +### Templates + +```bash +bento templates get 12345 # Get template by ID +bento templates update 12345 \ + --subject "New subject" \ + --html "

Updated

" +``` + +### Workflows + +```bash +bento workflows list # List all workflows +``` + +### Forms + +```bash +bento forms responses form_abc123 # Get form responses +``` + +### Statistics + +```bash +bento stats site # Site-wide statistics +bento stats segment segment_123 # Segment statistics +bento stats report report_456 # Report statistics +``` + +### Dashboard + +```bash +bento dashboard # Open in browser +bento dashboard --profile staging # Open for specific profile +``` + +### Skills + +```bash +bento skills list # List bundled skills +bento skills install # Install to all detected agents +bento skills install --agent claude-code # Install to specific agent +bento skills install --skill bento --force # Install specific skill, overwrite +``` + +Supported agents: `claude-code`, `cursor`, `windsurf`, `codex`, `copilot`, `gemini`, `roo`. + +### Experimental + +These features may change without notice. + +```bash +bento experimental validate-email user@example.com \ + --ip "1.2.3.4" --name "Jane" --user-agent "Mozilla/5.0" +bento experimental guess-gender "Jesse" +bento experimental geolocate "8.8.8.8" +bento experimental blacklist --domain example.com +bento experimental blacklist --ip "1.2.3.4" +bento experimental moderate "some text to check" +``` + +## Safety Flags + +Available on bulk operations (`subscribers import`, `tag`, `subscribe`, `unsubscribe`): + +| Flag | Purpose | +|---|---| +| `--dry-run` | Preview what would happen without making changes | +| `--limit ` | Only process first N items | +| `--sample ` | Show N sample items in preview | +| `--confirm` | Skip interactive confirmation (required for scripts) | + +## Output Modes + +| Flag | Output Type | +|---|---| +| *(none)* | Human-readable tables with colors | +| `--json` | Machine-readable JSON for scripting | +| `--quiet` | Minimal output (errors only) | + +**JSON response format**: +```json +{ + "success": true, + "error": null, + "data": { ... }, + "meta": { "count": 10, "total": 100 } +} +``` + +## CSV Format + +**For imports** — requires `email` column: + +| Column | Description | +|---|---| +| `email` | Required. Subscriber email | +| `name` | Optional. Display name | +| `tags` | Optional. Tags to add (comma/semicolon separated) | +| `remove_tags` | Optional. Tags to remove | +| *(other)* | Custom fields | + +```csv +email,name,tags,first_name,plan +alice@example.com,Alice Smith,"customer,active",Alice,pro +bob@example.com,Bob Jones,newsletter,Bob,starter +``` + +**For tag/subscribe/unsubscribe** — simple email list: +``` +email +alice@example.com +bob@example.com +``` + +Or one email per line (no header). + +## Exit Codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | General error | +| 2 | Invalid arguments or usage | +| 6 | CSV parsing error | + +## Environment Variables + +| Variable | Purpose | +|---|---| +| `BENTO_CONFIG_PATH` | Override config file location | +| `BENTO_API_BASE_URL` | Override API endpoint (testing) | +| `BENTO_AUTO_CONFIRM` | Set `true` to skip all confirmations | +| `DEBUG` | Set `bento` for verbose SDK logging | + +## Anti-Patterns to Avoid + +**Running bulk operations without preview**: +```bash +# BAD: Direct import without testing +bento subscribers import huge-list.csv --confirm + +# GOOD: Preview first, then limit test, then full import +bento subscribers import huge-list.csv --dry-run +bento subscribers import huge-list.csv --limit 10 --confirm +bento subscribers import huge-list.csv --confirm +``` + +**Forgetting --confirm in scripts**: +```bash +# BAD: Will hang waiting for input in CI +bento subscribers tag --file users.csv --add vip + +# GOOD: Explicit confirmation for automation +bento subscribers tag --file users.csv --add vip --confirm +``` + +**Not using --json for scripting**: +```bash +# BAD: Parsing human-readable tables +bento subscribers search --email user@example.com | grep email + +# GOOD: Structured JSON output +bento subscribers search --email user@example.com --json | jq '.data[].email' +``` + +**Confusing field set vs field update**: +```bash +# field set — does NOT trigger automations (silent write) +bento subscribers field set --email user@example.com --key plan --value pro + +# field update — TRIGGERS automations (use when you want workflows to fire) +bento subscribers field update --email user@example.com --fields '{"plan": "pro"}' +``` + +## Common Workflows + +### CI/CD: Sync subscribers from database +```bash +#!/bin/bash +psql -c "COPY (SELECT email, name FROM users WHERE active) TO STDOUT CSV HEADER" \ + > /tmp/active-users.csv + +npx @bentonow/bento-cli subscribers import /tmp/active-users.csv --confirm --json +``` + +### GitHub Actions +```yaml +- name: Sync subscribers to Bento + run: | + npx @bentonow/bento-cli auth login \ + --publishable-key "${{ secrets.BENTO_PUB_KEY }}" \ + --secret-key "${{ secrets.BENTO_SECRET_KEY }}" \ + --site-uuid "${{ secrets.BENTO_SITE_UUID }}" + npx @bentonow/bento-cli subscribers import users.csv --confirm --json +``` + +### Safe import validation +```bash +bento subscribers import contacts.csv --dry-run # 1. Validate format +bento subscribers import contacts.csv --limit 5 # 2. Test small batch +bento subscribers import contacts.csv --confirm # 3. Full import +``` + +## Remember + +The Bento CLI gives you direct access to operations that affect real subscribers. Use `--dry-run` and `--limit` liberally. When in doubt, preview first. + +For automation, always use `--json` output and `--confirm`. Never parse human-readable table output in scripts. diff --git a/skill/references/command-reference.md b/skill/bento-cli/references/command-reference.md similarity index 76% rename from skill/references/command-reference.md rename to skill/bento-cli/references/command-reference.md index a23ff46..5c3ca4f 100644 --- a/skill/references/command-reference.md +++ b/skill/bento-cli/references/command-reference.md @@ -2,6 +2,17 @@ Complete reference for all Bento CLI commands with options and examples. +## Installation + +```bash +# Recommended: npx (always latest version, no install needed) +npx @bentonow/bento-cli + +# Or install globally +npm install -g @bentonow/bento-cli +bento +``` + ## Global Options These options work with most commands: @@ -106,38 +117,57 @@ bento profile remove staging --- +## dashboard + +Open the Bento dashboard in your default browser. When a profile is active, the CLI opens the dashboard scoped to that site; otherwise it opens the login page. + +```bash +# Active profile +bento dashboard + +# Explicit profile +bento dashboard --profile staging + +# Automation +bento dashboard --json +``` + +**Options**: +| Option | Description | +|--------|-------------| +| `--profile ` | Open the dashboard for a specific profile | + +--- + ## subscribers Subscriber management commands. ### subscribers search -Search for subscribers with various filters. +Look up a single subscriber by email or UUID, optionally filtering by tag or field. ```bash # By email bento subscribers search --email user@example.com -# By tag -bento subscribers search --tag vip -bento subscribers search --tag active +# By UUID +bento subscribers search --uuid abc123-def456 -# By custom field -bento subscribers search --field plan=pro -bento subscribers search --field company=Acme +# With tag filter (client-side: checks if subscriber has the tag) +bento subscribers search --email user@example.com --tag vip -# Pagination -bento subscribers search --tag vip --page 2 --per-page 50 +# With field filter (client-side: checks subscriber field value) +bento subscribers search --email user@example.com --field plan=pro ``` **Options**: | Option | Description | |--------|-------------| -| `--email ` | Filter by email address | -| `--tag ` | Filter by tag name | -| `--field ` | Filter by custom field | -| `--page ` | Page number (default: 1) | -| `--per-page ` | Results per page (default: 25) | +| `--email ` | Look up subscriber by email (required unless --uuid) | +| `--uuid ` | Look up subscriber by UUID (required unless --email) | +| `--tag ` | Only show if subscriber has this tag | +| `--field ` | Only show if subscriber field matches (repeatable) | ### subscribers import @@ -245,13 +275,19 @@ Tag management commands. ### tags list -List all tags in the account. +List all tags in the account. Optionally filter by name with fuzzy search. ```bash bento tags list +bento tags list news # Fuzzy search by tag name bento tags list --json ``` +**Arguments**: +| Argument | Description | +|----------|-------------| +| `[search]` | Optional. Filter tags by name (fuzzy match) | + ### tags create Create a new tag. @@ -280,13 +316,19 @@ Custom field management commands. ### fields list -List all custom fields. +List all custom fields. Optionally filter by key or name with fuzzy search. ```bash bento fields list +bento fields list company # Fuzzy search by field key or name bento fields list --json ``` +**Arguments**: +| Argument | Description | +|----------|-------------| +| `[search]` | Optional. Filter fields by key or name (fuzzy match) | + ### fields create Create a new custom field. @@ -338,13 +380,20 @@ Broadcast management commands. ### broadcasts list -List all broadcasts. +List broadcasts. By default fetches all broadcasts. Use `--page` and `--per-page` to paginate. ```bash bento broadcasts list +bento broadcasts list --page 2 --per-page 10 bento broadcasts list --json ``` +**Options**: +| Option | Description | +|--------|-------------| +| `--page ` | Page number (enables pagination) | +| `--per-page ` | Results per page (default: 25, implies pagination) | + ### broadcasts create Create a new broadcast draft. diff --git a/skill/liquid/SKILL.md b/skill/liquid/SKILL.md new file mode 100644 index 0000000..9329123 --- /dev/null +++ b/skill/liquid/SKILL.md @@ -0,0 +1,374 @@ +--- +name: bento-liquid +description: > + Liquid template reference for Bento email marketing (bentonow.com). + Use when writing or editing email templates, broadcasts, sequences, + or flows in Bento. Covers visitor variables, conditional content, + links, buttons, images, greetings, abandoned cart emails, coupon + generation, external data, and time/money formatting. +--- + +# Bento Liquid Templates + +Bento uses Liquid as its email templating language. `{{ }}` outputs values, `{% %}` runs logic. Templates render server-side at send time — each recipient gets personalized content. + +## Complete Email Example + +```liquid +{% greeting %} + +Welcome to {{ visitor.fields.company_name | default: "our community" }}! + +{% if visitor.tags contains 'customer' %} + As a valued customer, here's 20% off your next order: + {% shopify_coupon THANKYOU :: 20 :: percentage %} +{% elsif visitor.tags contains 'trial' %} + Ready to upgrade? Here's 10% off: + {% shopify_coupon UPGRADE :: 10 :: percentage %} +{% else %} + Start your free trial today. +{% endif %} + +{% if visitor.fields.last_purchase %} + Since you bought {{ visitor.fields.last_purchase }}, you might also like: +{% endif %} + +{% btn https://shop.example.com :: Browse the store :: #8B5CF6 %} + +{{ visitor.first_name | default: "Friend" }}, we're glad you're here. + +{% link {{ visitor.unsubscribe_url }} :: Unsubscribe %} +``` + +## Visitor Variables + +Every subscriber (called a "visitor" in Liquid) exposes these fields: + +| Variable | Output | +|---|---| +| `{{ visitor.email }}` | Subscriber email | +| `{{ visitor.first_name }}` | First name | +| `{{ visitor.last_name }}` | Last name | +| `{{ visitor.city }}` | City | +| `{{ visitor.country }}` | Country | +| `{{ visitor.eu }}` | `true` / `false` | +| `{{ visitor.ip }}` | IP address | +| `{{ visitor.gender }}` | Gender | +| `{{ visitor.age }}` | Numeric age | +| `{{ visitor.age_range }}` | Age range | +| `{{ visitor.group }}` | A/B test group (e.g. `control`) | +| `{{ visitor.tags }}` | All subscriber tags | +| `{{ visitor.confirmation_url }}` | Bento confirmation URL | +| `{{ visitor.unsubscribe_url }}` | Unsubscribe URL | +| `{{ visitor.navigation_url }}` | Navigation URL | +| `{{ visitor.checkout_url }}` | Abandoned cart URL (flows only) | + +**Custom fields** use `visitor.fields`: +```liquid +{{ visitor.fields.plan_name }} +{{ visitor.fields.company_name }} +{{ visitor.fields.order_count }} +``` + +## Personalization with Fallbacks + +Always provide fallbacks — subscribers may have incomplete data. + +```liquid +{{ visitor.first_name | default: "there" }} +{{ visitor.first_name | default: visitor.last_name | default: "Friend" }} +``` + +## Conditionals + +### if / elsif / else + +Check tags, fields, city, or any visitor attribute. Content inside can include any Liquid tag. + +```liquid +{% if visitor.tags contains 'customer' %} + Thank you for being a customer! +{% elsif visitor.tags contains 'trial' %} + Your trial is active — ready to upgrade? +{% else %} + Welcome! Start your free trial today. +{% endif %} +``` + +### unless + +Inverse of `if` — runs the block when the condition is false. + +```liquid +{% unless visitor.tags contains 'unsubscribed' %} + Here's what you missed this week. +{% endunless %} +``` + +### for Loops + +Iterate over lists. Useful for tags, custom list fields, or building dynamic content. + +```liquid +{% for tag in visitor.tags %} + {{ tag }} +{% endfor %} +``` + +With a limit: +```liquid +{% for tag in visitor.tags limit: 3 %} + {{ tag }}{% unless forloop.last %}, {% endunless %} +{% endfor %} +``` + +### Comparison Operators + +```liquid +{% if visitor.fields.order_count > 10 %} + You've ordered {{ visitor.fields.order_count }} times! +{% endif %} + +{% if visitor.city == 'Sydney' %} + Free shipping to Sydney this week! +{% endif %} + +{% if visitor.tags contains 'vip' %} + Exclusive VIP offer inside. +{% endif %} +``` + +## Content Tags + +Bento-specific tags. Arguments are separated by `::`. + +### Links + +```liquid +{% link https://example.com :: Click Here! %} +``` + +> Do NOT wrap in `` tags. Bento generates the anchor HTML. Using `` inside a link tag breaks it. + +### Buttons + +Styled CTA button. Optional hex color as third argument. + +```liquid +{% btn https://example.com :: Get Started :: #8B5CF6 %} +{% btn https://example.com :: Learn More %} +``` + +### Images + +Arguments after `::`: CSS classes, ID, alt text, width (px), height (px) — comma-separated. + +```liquid +{% img https://example.com/photo.png :: hero-image, , Product photo, 600, 400 %} +``` + +### Audio + +Embeds a player with automatic fallback link for unsupported email clients. + +```liquid +{% audio https://example.com/podcast.mp3 :: audio %} +``` + +### Greeting + +```liquid +{% greeting %} +``` +Output: `Hi Jesse,` + +### Formatted Name + +```liquid +{% formatted_name %} +``` +Output: `John Doe` + +### Gravatar + +Renders the subscriber's Gravatar with a fallback avatar. + +```liquid +{% gravatar %} +``` + +## Filters + +### String + +| Filter | Example | Output | +|---|---|---| +| `default` | `{{ name | default: "Friend" }}` | Fallback for nil/empty | +| `append` | `{{ "hello" | append: " world" }}` | `hello world` | +| `prepend` | `{{ "world" | prepend: "hello " }}` | `hello world` | +| `capitalize` | `{{ "hello" | capitalize }}` | `Hello` | +| `upcase` | `{{ "hello" | upcase }}` | `HELLO` | +| `downcase` | `{{ "HELLO" | downcase }}` | `hello` | +| `remove` | `{{ "hello world" | remove: "world" }}` | `hello ` | +| `replace` | `{{ "hello" | replace: "hello", "hi" }}` | `hi` | +| `truncate` | `{{ "long text here" | truncate: 8 }}` | `long ...` | +| `newline_to_br` | `{{ text | newline_to_br }}` | `\n` to `
` | +| `pluralize` | `{{ 3 | pluralize: "item", "items" }}` | `items` | + +### Math + +| Filter | Example | Output | +|---|---|---| +| `plus` | `{{ 10 | plus: 3 }}` | `13` | +| `minus` | `{{ 10 | minus: 3 }}` | `7` | +| `divided_by` | `{{ 10 | divided_by: 3 }}` | `3` (floors for integers) | +| `round` | `{{ 3.14 | round }}` | `3` | + +### Bento Filters + +| Filter | Example | Output | +|---|---|---| +| `money` | `{{ 900 | money }}` | `$900.00 USD` (subscriber's currency) | +| `md5` | `{{ visitor.email | md5 }}` | MD5 hash | +| `sha1` | `{{ visitor.email | sha1 }}` | SHA1 hash | +| `lottery` | `{{ "A\|B\|C" | split: "\|" | lottery }}` | Random pick | + +### Time Filters + +Apply to any date field. Useful for batched sends where timestamps would go stale. + +| Filter | Example | Output | +|---|---|---| +| `days_until` | `{{ visitor.fields.renewal | days_until }}` | `14` | +| `days_since` | `{{ visitor.fields.signup | days_since }}` | `87` | +| `weeks_until` | `{{ visitor.fields.renewal | weeks_until }}` | `2` | +| `months_since` | `{{ visitor.fields.signup | months_since }}` | `3` | +| `end_of_year` | `{{ visitor.fields.date | end_of_year }}` | End of year timestamp | +| `in_time_zone` | `{{ visitor.fields.date | in_time_zone: "America/Chicago" }}` | Timezone-adjusted | +| `to_i` | `{{ visitor.fields.date | to_i }}` | Unix timestamp | +| `time_ago_in_words` | `{{ visitor.fields.signup | time_ago_in_words }}` | `about 2 months` | + +## Context-Specific Tags + +These only work in their specific context. They produce no output elsewhere. + +### Broadcast-Only + +| Variable | Output | +|---|---| +| `{{ broadcast.subject }}` | Subject line | +| `{{ broadcast.name }}` | Broadcast name | +| `{{ broadcast.created_at }}` | Creation timestamp (pipe through time filters to reformat) | + +### Sequence-Only + +**Cancel the sequence** — gives subscribers an alternative to unsubscribing: +```liquid +{% sequence_cancel No more emails like this! %} +``` + +**Skip to next email** — optionally redirect to a URL (must match sender domain): +```liquid +{% sequence_fast_forward Get the next email now. %} +{% sequence_fast_forward Skip ahead :: https://example.com/next %} +``` + +### Flow-Only (Ecommerce) + +**Render last cart** — for abandoned cart emails: +```liquid +{% render_cart %} +``` + +**Render products** — like render_cart but extends beyond the most recent cart: +```liquid +{% render_products %} +``` + +**Checkout URL** — personalized link back to their cart: +```liquid +{% btn {{ visitor.checkout_url }} :: Complete your purchase :: #22C55E %} +``` + +## Coupon Generation + +### Shopify Coupons + +Arguments: coupon name `::` discount amount `::` type (`fixed_amount` or `percentage`). Returns the code string. + +```liquid +Use code {% shopify_coupon WELCOME :: 10 :: percentage %} for 10% off! +``` + +### Stripe Coupons + +Arguments: name `::` discount % `::` validity in months. Returns the code string. + +```liquid +Use code {% stripe_coupon SAVE20 :: 20 :: 3 %} — valid for 3 months! +``` + +**Stripe billing portal** — generates a user-specific portal URL: +```liquid +{% link {% stripe_billing_portal https://example.com %} :: Manage your subscription %} +``` + +## External Data + +Pull public data into templates. Requests must be GET. + +```liquid +{% fetch https://example.com/feed.json :: fetch_json %} +{% for item in data %} + {{ item.title }} +{% endfor %} +``` + +| Type | Use Case | +|---|---| +| `fetch_json` | JSON API responses | +| `rss` | RSS/Atom feeds | +| `youtube` | YouTube channel data | +| `fetch_html` | HTML scraping (supports parent/child selectors) | + +## Environment Checks + +| Variable | Output | +|---|---| +| `{{ env.development? }}` | `true` / `false` | +| `{{ env.production? }}` | `true` / `false` | +| `{{ env.staging? }}` | `true` / `false` | + +## Gmail Promo Annotations + +Add promotional badges in Gmail. Subject to Gmail's rendering discretion. + +```liquid +{% promo 20% Off :: Use code SAVE20 %} +``` + +## Common Mistakes + +**HTML inside link/button tags:** +```liquid +❌ {% link
Click %} +✅ {% link https://example.com :: Click %} +``` + +**Missing fallbacks on optional fields:** +```liquid +❌ Hello {{ visitor.first_name }}, +✅ Hello {{ visitor.first_name | default: "there" }}, +``` + +**Using context-specific tags in the wrong place:** +```liquid +❌ {% render_cart %} ← silent no-op in a broadcast +✅ {% render_cart %} ← works inside a flow +``` + +**Forgetting the `::` delimiter:** +```liquid +❌ {% btn https://example.com Click here %} +✅ {% btn https://example.com :: Click here %} +``` diff --git a/src/cli.ts b/src/cli.ts index 72440a7..0cacff1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,15 +4,22 @@ import chalk from "chalk"; import { registerAuthCommands } from "./commands/auth"; import { registerBroadcastsCommands } from "./commands/broadcasts"; import { registerDashboardCommand } from "./commands/dashboard"; +import { registerEmailsCommands } from "./commands/emails"; import { registerEventsCommands } from "./commands/events"; +import { registerExperimentalCommands } from "./commands/experimental"; import { registerFieldsCommands } from "./commands/fields"; +import { registerFormsCommands } from "./commands/forms"; import { registerProfileCommands } from "./commands/profile"; import { registerSequencesCommands } from "./commands/sequences"; +import { registerSkillsCommands } from "./commands/skills"; import { registerStatsCommands } from "./commands/stats"; import { registerSubscribersCommands } from "./commands/subscribers"; import { registerTagsCommands } from "./commands/tags"; +import { registerTemplatesCommands } from "./commands/templates"; +import { registerWorkflowsCommands } from "./commands/workflows"; import { output } from "./core/output"; import { config } from "./core/config"; +import pkg from "../package.json"; const program = new Command(); @@ -21,7 +28,7 @@ program .description( "Bento CLI - Command-oriented interface for Bento email marketing" ) - .version("0.1.1") + .version(pkg.version) .option("--json", "Output machine-readable JSON") .option("--quiet", "Suppress non-essential output (errors still print)") .hook("preAction", (thisCommand) => { @@ -53,6 +60,12 @@ registerEventsCommands(program); registerBroadcastsCommands(program); registerSequencesCommands(program); registerStatsCommands(program); +registerEmailsCommands(program); +registerWorkflowsCommands(program); +registerTemplatesCommands(program); +registerFormsCommands(program); +registerExperimentalCommands(program); +registerSkillsCommands(program); registerDashboardCommand(program); // Future commands to be implemented: @@ -95,7 +108,7 @@ function configureOutputMode(options: { json?: boolean; quiet?: boolean }): void } async function showWelcomeScreen(): Promise { - const version = "0.1.1"; + const version = pkg.version; // Bento brand color (purple) const brand = chalk.hex("#8B5CF6"); diff --git a/src/commands/emails.ts b/src/commands/emails.ts new file mode 100644 index 0000000..753def4 --- /dev/null +++ b/src/commands/emails.ts @@ -0,0 +1,143 @@ +/** + * Transactional email commands + * + * Commands: + * - bento emails send --to --from --subject --html-body [--personalizations ] + * - bento emails send-batch --file + */ + +import { readFile } from "node:fs/promises"; +import { Command } from "commander"; +import { bento, CLIError } from "../core/sdk"; +import { output } from "../core/output"; +import type { TransactionalEmail } from "../types/sdk"; + +interface SendOptions { + to: string; + from: string; + subject: string; + htmlBody: string; + personalizations?: string; +} + +interface SendBatchOptions { + file: string; +} + +export function registerEmailsCommands(program: Command): void { + const emails = program + .command("emails") + .description("Send transactional emails"); + + emails + .command("send") + .description("Send a single transactional email") + .requiredOption("--to ", "Recipient email address") + .requiredOption("--from ", "Sender email address") + .requiredOption("--subject ", "Email subject") + .requiredOption("--html-body ", "Email HTML body") + .option("--personalizations ", "Personalizations as JSON for liquid tags") + .action(async (opts: SendOptions) => { + try { + let personalizations: Record | undefined; + if (opts.personalizations) { + try { + personalizations = JSON.parse(opts.personalizations); + } catch { + output.error("Invalid JSON in --personalizations. Ensure valid JSON format."); + process.exit(1); + } + } + + const email: TransactionalEmail = { + to: opts.to, + from: opts.from, + subject: opts.subject, + html_body: opts.htmlBody, + transactional: true, + personalizations, + }; + + output.startSpinner("Sending email..."); + + const count = await bento.sendTransactionalEmails([email]); + + output.stopSpinner("Email sent"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: { queued: count }, + meta: { count }, + }); + } else if (!output.isQuiet()) { + output.success(`Sent transactional email to ${opts.to}`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + emails + .command("send-batch") + .description("Send a batch of transactional emails (up to 100)") + .requiredOption("-f, --file ", "JSON file with array of email objects") + .action(async (opts: SendBatchOptions) => { + try { + let emailData: TransactionalEmail[]; + try { + const raw = await readFile(opts.file, "utf-8"); + emailData = JSON.parse(raw); + } catch { + output.error(`Failed to read or parse ${opts.file}. Ensure it is valid JSON.`); + process.exit(1); + } + + if (!Array.isArray(emailData) || emailData.length === 0) { + output.error("File must contain a non-empty array of email objects."); + process.exit(1); + } + + if (emailData.length > 100) { + output.error("Batch limit is 100 emails. Please split into smaller batches."); + process.exit(1); + } + + const emails: TransactionalEmail[] = emailData.map((e) => ({ + ...e, + transactional: true, + })); + + output.startSpinner(`Sending ${emails.length} email(s)...`); + + const count = await bento.sendTransactionalEmails(emails); + + output.stopSpinner("Batch sent"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: { queued: count }, + meta: { count }, + }); + } else if (!output.isQuiet()) { + output.success(`Queued ${count} transactional email(s) for delivery.`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); +} + +function handleError(error: unknown): void { + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} diff --git a/src/commands/events.ts b/src/commands/events.ts index 70c16f8..f6800da 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -3,8 +3,11 @@ * * Commands: * - bento events track --email --event [--details ] - Track a custom event + * - bento events import - Import events from a JSON file + * - bento events purchase --email --amount --currency --key [--cart ] */ +import { readFile } from "node:fs/promises"; import { Command } from "commander"; import { bento, CLIError } from "../core/sdk"; import { output } from "../core/output"; @@ -15,6 +18,14 @@ interface TrackOptions { details?: string; } +interface PurchaseOptions { + email: string; + amount: string; + currency: string; + key: string; + cart?: string; +} + export function registerEventsCommands(program: Command): void { const events = program .command("events") @@ -74,14 +85,135 @@ export function registerEventsCommands(program: Command): void { } } catch (error) { output.failSpinner(); - if (error instanceof CLIError) { - output.error(error.message); - } else if (error instanceof Error) { - output.error(error.message); - } else { - output.error("An unexpected error occurred."); + handleError(error); + } + }); + + events + .command("import") + .description("Import events from a JSON file (up to 1000)") + .argument("", "JSON file with array of event objects ({email, type, details?, date?})") + .action(async (file: string) => { + try { + let eventData: Array<{ + email: string; + type: string; + details?: Record; + date?: string; + }>; + + try { + const raw = await readFile(file, "utf-8"); + eventData = JSON.parse(raw); + } catch { + output.error(`Failed to read or parse ${file}. Ensure it is valid JSON.`); + process.exit(1); + } + + if (!Array.isArray(eventData) || eventData.length === 0) { + output.error("File must contain a non-empty array of event objects."); + process.exit(1); + } + + output.startSpinner(`Importing ${eventData.length} event(s)...`); + + const events = eventData.map((e) => ({ + email: e.email, + type: e.type, + details: e.details, + date: e.date ? new Date(e.date) : undefined, + })); + + const count = await bento.importEvents(events); + + output.stopSpinner("Events imported"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: { imported: count }, + meta: { count }, + }); + } else if (!output.isQuiet()) { + output.success(`Imported ${count} event(s).`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + events + .command("purchase") + .description("Track a purchase event for a subscriber (TRIGGERS automations)") + .requiredOption("-e, --email ", "Subscriber email address") + .requiredOption("--amount ", "Purchase amount (in cents)") + .requiredOption("--currency ", "Currency code (e.g., USD)") + .requiredOption("--key ", "Unique purchase key/ID") + .option("--cart ", "Cart details as JSON") + .action(async (opts: PurchaseOptions) => { + try { + const amount = Number(opts.amount); + if (Number.isNaN(amount)) { + output.error("--amount must be a number."); + process.exit(1); + } + + let cart: { abandoned_checkout_url?: string; items?: unknown[] } | undefined; + if (opts.cart) { + try { + cart = JSON.parse(opts.cart); + } catch { + output.error("Invalid JSON in --cart. Ensure valid JSON format."); + process.exit(1); + } + } + + output.startSpinner("Tracking purchase..."); + + const success = await bento.trackPurchase(opts.email, { + unique: { key: opts.key }, + value: { currency: opts.currency, amount }, + cart, + }); + + if (!success) { + output.failSpinner("Failed to track purchase"); + process.exit(1); } - process.exit(1); + + output.stopSpinner("Purchase tracked"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: { + email: opts.email, + amount, + currency: opts.currency, + key: opts.key, + }, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success( + `Tracked purchase of ${amount} ${opts.currency} for ${opts.email}` + ); + } + } catch (error) { + output.failSpinner(); + handleError(error); } }); } + +function handleError(error: unknown): void { + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} diff --git a/src/commands/experimental.ts b/src/commands/experimental.ts new file mode 100644 index 0000000..bdacf40 --- /dev/null +++ b/src/commands/experimental.ts @@ -0,0 +1,232 @@ +/** + * Experimental commands + * + * Commands: + * - bento experimental validate-email [--ip ] [--name ] [--user-agent ] + * - bento experimental guess-gender + * - bento experimental geolocate + * - bento experimental blacklist --domain | --ip + * - bento experimental moderate + */ + +import { Command } from "commander"; +import { bento, CLIError } from "../core/sdk"; +import { output } from "../core/output"; + +interface ValidateEmailOptions { + ip?: string; + name?: string; + userAgent?: string; +} + +interface BlacklistOptions { + domain?: string; + ip?: string; +} + +export function registerExperimentalCommands(program: Command): void { + const experimental = program + .command("experimental") + .description("Experimental features (may change without notice)"); + + experimental + .command("validate-email") + .description("Validate an email address") + .argument("", "Email address to validate") + .option("--ip ", "IP address of the user") + .option("--name ", "Name of the user") + .option("--user-agent ", "User agent string") + .action(async (email: string, opts: ValidateEmailOptions) => { + try { + output.startSpinner("Validating email..."); + + const valid = await bento.validateEmail(email, opts.ip, opts.name, opts.userAgent); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: { email, valid }, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + if (valid) { + output.success(`${email} is valid.`); + } else { + output.warn(`${email} could not be validated.`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + experimental + .command("guess-gender") + .description("Guess gender from a name using US Census data") + .argument("", "Name to analyze") + .action(async (name: string) => { + try { + output.startSpinner("Guessing gender..."); + + const result = await bento.guessGender(name); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + output.object({ + Name: name, + Gender: result.gender ?? "Unknown", + Confidence: result.confidence !== null ? `${(result.confidence * 100).toFixed(1)}%` : "N/A", + }); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + experimental + .command("geolocate") + .description("Geolocate an IP address") + .argument("", "IP address to geolocate") + .action(async (ip: string) => { + try { + output.startSpinner("Geolocating..."); + + const result = await bento.geolocate(ip); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + if (result && typeof result === "object") { + output.object(result as Record); + } else { + output.info("No location data returned."); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + experimental + .command("blacklist") + .description("Check if a domain or IP is blacklisted") + .option("-d, --domain ", "Domain to check") + .option("--ip ", "IP address to check") + .action(async (opts: BlacklistOptions) => { + try { + if (!opts.domain && !opts.ip) { + output.error("Provide --domain or --ip to check."); + process.exit(1); + } + + output.startSpinner("Checking blacklist..."); + + const result = await bento.checkBlacklist(opts.domain, opts.ip); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + output.object({ + Query: result.query, + Description: result.description, + Results: JSON.stringify(result.results), + }); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + experimental + .command("moderate") + .description("Perform content moderation on text") + .argument("", "Text content to moderate") + .action(async (content: string) => { + try { + output.startSpinner("Moderating content..."); + + const result = await bento.getContentModeration(content); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + if (result.flagged) { + output.warn("Content was FLAGGED for moderation."); + } else { + output.success("Content passed moderation."); + } + + output.newline(); + output.object({ + Flagged: String(result.flagged), + ...Object.fromEntries( + Object.entries(result.categories).map(([k, v]) => [`Category: ${k}`, String(v)]) + ), + }); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); +} + +function handleError(error: unknown): void { + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} diff --git a/src/commands/forms.ts b/src/commands/forms.ts new file mode 100644 index 0000000..e23ada1 --- /dev/null +++ b/src/commands/forms.ts @@ -0,0 +1,74 @@ +/** + * Form commands + * + * Commands: + * - bento forms responses - Get form responses + */ + +import { Command } from "commander"; +import { bento, CLIError } from "../core/sdk"; +import { output } from "../core/output"; + +export function registerFormsCommands(program: Command): void { + const forms = program + .command("forms") + .description("Manage forms and responses"); + + forms + .command("responses") + .description("Get responses for a form") + .argument("", "Form identifier") + .action(async (formId: string) => { + try { + output.startSpinner("Fetching form responses..."); + + const result = await bento.getFormResponses(formId); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: result?.length ?? 0 }, + }); + return; + } + + if (output.isQuiet()) return; + + if (!result || result.length === 0) { + output.info("No responses found for this form."); + return; + } + + output.table( + result.map((r) => ({ + id: r.id, + uuid: r.attributes?.uuid ?? "N/A", + type: r.attributes?.data?.type ?? "N/A", + date: r.attributes?.data?.date ?? "N/A", + ip: r.attributes?.data?.ip ?? "N/A", + })), + { + columns: [ + { key: "id", header: "ID" }, + { key: "uuid", header: "UUID" }, + { key: "type", header: "Type" }, + { key: "date", header: "Date" }, + { key: "ip", header: "IP" }, + ], + } + ); + } catch (error) { + output.failSpinner(); + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); + } + }); +} diff --git a/src/commands/sequences.ts b/src/commands/sequences.ts index 5f818ff..d9af46d 100644 --- a/src/commands/sequences.ts +++ b/src/commands/sequences.ts @@ -4,6 +4,7 @@ * Commands: * - bento sequences list - List all sequences * - bento sequences create-email - Create an email template in a sequence + * - bento sequences update-email - Update an existing sequence email template */ import { readFile } from "node:fs/promises"; @@ -216,6 +217,7 @@ async function resolveHtmlInput(html?: string, htmlFile?: string): Promise boolean; +} + +type InstallMethod = "canonical" | "symlink" | "copy" | "failed"; + +interface InstallResult { + agent: string; + skill: string; + method: InstallMethod; + error?: string; +} + +const CANONICAL_DIR = resolve(homedir(), ".agents", "skills"); + +const AGENTS: AgentConfig[] = [ + { + name: "claude-code", + label: "Claude Code", + globalSkillsDir: resolve( + process.env.CLAUDE_CONFIG_DIR || resolve(homedir(), ".claude"), + "skills" + ), + detect: () => + existsSync( + process.env.CLAUDE_CONFIG_DIR || resolve(homedir(), ".claude") + ), + }, + { + name: "cursor", + label: "Cursor", + globalSkillsDir: resolve(homedir(), ".cursor", "skills"), + detect: () => existsSync(resolve(homedir(), ".cursor")), + }, + { + name: "windsurf", + label: "Windsurf", + globalSkillsDir: resolve(homedir(), ".codeium", "windsurf", "skills"), + detect: () => existsSync(resolve(homedir(), ".codeium", "windsurf")), + }, + { + name: "codex", + label: "Codex", + globalSkillsDir: resolve( + process.env.CODEX_HOME || resolve(homedir(), ".codex"), + "skills" + ), + detect: () => + existsSync( + process.env.CODEX_HOME || resolve(homedir(), ".codex") + ), + }, + { + name: "copilot", + label: "GitHub Copilot", + globalSkillsDir: resolve(homedir(), ".copilot", "skills"), + detect: () => existsSync(resolve(homedir(), ".copilot")), + }, + { + name: "gemini", + label: "Gemini CLI", + globalSkillsDir: resolve(homedir(), ".gemini", "skills"), + detect: () => existsSync(resolve(homedir(), ".gemini")), + }, + { + name: "roo", + label: "Roo Code", + globalSkillsDir: resolve(homedir(), ".roo", "skills"), + detect: () => existsSync(resolve(homedir(), ".roo")), + }, +]; + +export function registerSkillsCommands(program: Command): void { + const skills = program.command("skills").description("Manage AI agent skills"); + + skills + .command("list") + .description("List available skills bundled with Bento CLI") + .action(() => { + try { + const sourceDir = getSkillsSourceDir(); + const skillNames = discoverSkills(sourceDir); + + if (skillNames.length === 0) { + if (output.isJson()) { + output.json({ success: true, error: null, data: [], meta: { count: 0 } }); + } else { + output.info("No skills found."); + } + return; + } + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: skillNames.map((name) => ({ name, source: resolve(sourceDir, name) })), + meta: { count: skillNames.length }, + }); + return; + } + + output.table( + skillNames.map((name) => ({ name })), + { + columns: [{ key: "name", header: "SKILL" }], + meta: { total: skillNames.length }, + } + ); + } catch (error) { + handleError(error); + } + }); + + skills + .command("install [skill-name]") + .description("Install skills to AI agent harnesses") + .option("--agent ", "Install for a specific agent only") + .option("--skill ", "Install a specific skill only") + .option("--force", "Overwrite existing installations") + .action((skillName: string | undefined, options: { agent?: string; skill?: string; force?: boolean }) => { + // Support both positional arg and --skill flag + if (skillName && !options.skill) { + options.skill = skillName; + } + + try { + const sourceDir = getSkillsSourceDir(); + let skillNames = discoverSkills(sourceDir); + + if (skillNames.length === 0) { + output.error("No skills found in package."); + process.exit(1); + } + + if (options.skill) { + if (!skillNames.includes(options.skill)) { + output.error( + `Unknown skill "${options.skill}". Available: ${skillNames.join(", ")}` + ); + process.exit(1); + } + skillNames = [options.skill]; + } + + // Resolve target agents + let targetAgents: AgentConfig[]; + if (options.agent) { + const match = AGENTS.find((a) => a.name === options.agent); + if (!match) { + output.error( + `Unknown agent "${options.agent}". Available: ${AGENTS.map((a) => a.name).join(", ")}` + ); + process.exit(1); + } + targetAgents = [match]; + } else { + targetAgents = AGENTS.filter((a) => a.detect()); + } + + if (targetAgents.length === 0) { + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: [], + meta: { count: 0, canonical_path: CANONICAL_DIR, skills: skillNames }, + }); + } else { + output.warn("No supported AI agents detected. Use --agent to target one explicitly."); + } + return; + } + + // Step 1: Copy to canonical location + output.startSpinner("Installing skills..."); + mkdirSync(CANONICAL_DIR, { recursive: true }); + + for (const skill of skillNames) { + const src = resolve(sourceDir, skill); + const dest = resolve(CANONICAL_DIR, skill); + + if (existsSync(dest)) { + if (!options.force) { + continue; + } + rmSync(dest, { recursive: true, force: true }); + } + + cpSync(src, dest, { recursive: true }); + } + + // Step 2: Link/copy to each agent + const results: InstallResult[] = []; + + for (const agent of targetAgents) { + mkdirSync(agent.globalSkillsDir, { recursive: true }); + + for (const skill of skillNames) { + const canonical = resolve(CANONICAL_DIR, skill); + const target = resolve(agent.globalSkillsDir, skill); + + // Skip if canonical and target resolve to same path + if (resolve(canonical) === resolve(target)) { + results.push({ agent: agent.label, skill, method: "canonical" }); + continue; + } + + // Check existing + if (existsSync(target) || lstatSync(target, { throwIfNoEntry: false })) { + if (!options.force && isCorrectSymlink(target, canonical)) { + results.push({ agent: agent.label, skill, method: "symlink" }); + continue; + } + rmSync(target, { recursive: true, force: true }); + } + + // Try symlink, fall back to copy + const method = createLink(canonical, target); + results.push({ agent: agent.label, skill, method }); + } + } + + output.stopSpinner("Skills installed"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: results, + meta: { + count: results.length, + skills: skillNames, + canonical_path: CANONICAL_DIR, + }, + }); + return; + } + + output.table(results, { + columns: [ + { key: "agent", header: "AGENT" }, + { key: "skill", header: "SKILL" }, + { key: "method", header: "METHOD" }, + ], + meta: { total: results.length }, + }); + + output.success( + `Installed ${skillNames.length} skill(s) to ${targetAgents.length} agent(s)` + ); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); +} + +function getSkillsSourceDir(): string { + let dir = resolve(__dirname, ".."); + for (let i = 0; i < 5; i++) { + const candidate = resolve(dir, "skill"); + if (existsSync(candidate)) { + const entries = readdirSync(candidate, { withFileTypes: true }); + const hasSkill = entries.some( + (e) => e.isDirectory() && existsSync(resolve(candidate, e.name, "SKILL.md")) + ); + if (hasSkill) return candidate; + } + dir = resolve(dir, ".."); + } + throw new Error("Could not locate the skills directory."); +} + +function discoverSkills(sourceDir: string): string[] { + const entries = readdirSync(sourceDir, { withFileTypes: true }); + return entries + .filter( + (e) => e.isDirectory() && existsSync(resolve(sourceDir, e.name, "SKILL.md")) + ) + .map((e) => e.name) + .sort(); +} + +function isCorrectSymlink(linkPath: string, expectedTarget: string): boolean { + try { + const stat = lstatSync(linkPath); + if (!stat.isSymbolicLink()) return false; + const actual = resolve(dirname(linkPath), readlinkSync(linkPath)); + return actual === resolve(expectedTarget); + } catch { + return false; + } +} + +function createLink(target: string, linkPath: string): InstallMethod { + try { + const relativePath = relative(dirname(linkPath), target); + const symlinkType = platform() === "win32" ? "junction" : undefined; + symlinkSync(relativePath, linkPath, symlinkType); + return "symlink"; + } catch { + try { + cpSync(target, linkPath, { recursive: true }); + return "copy"; + } catch { + return "failed"; + } + } +} + +function handleError(error: unknown): never { + if (error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 370b47c..6dea346 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -3,6 +3,8 @@ * * Commands: * - bento stats site - Show site-wide statistics + * - bento stats segment - Show segment statistics + * - bento stats report - Show report statistics */ import { Command } from "commander"; @@ -71,18 +73,82 @@ export function registerStatsCommands(program: Command): void { output.divider(); } catch (error) { output.failSpinner(); - if (error instanceof CLIError) { - output.error(error.message); - } else if (error instanceof Error) { - output.error(error.message); - } else { - output.error("An unexpected error occurred."); + handleError(error); + } + }); + + stats + .command("segment") + .description("Show statistics for a segment") + .argument("", "Segment ID") + .action(async (segmentId: string) => { + try { + output.startSpinner("Fetching segment stats..."); + + const segmentStats = await bento.getSegmentStats(segmentId); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: segmentStats, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + output.object(segmentStats as unknown as Record); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + stats + .command("report") + .description("Show statistics for a report") + .argument("", "Report ID") + .action(async (reportId: string) => { + try { + output.startSpinner("Fetching report stats..."); + + const reportStats = await bento.getReportStats(reportId); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: reportStats, + meta: { count: 1 }, + }); + return; } - process.exit(1); + + if (output.isQuiet()) return; + + output.object(reportStats as unknown as Record); + } catch (error) { + output.failSpinner(); + handleError(error); } }); } +function handleError(error: unknown): void { + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} + /** * Format a number for display with thousand separators */ diff --git a/src/commands/subscribers/change-email.ts b/src/commands/subscribers/change-email.ts new file mode 100644 index 0000000..7076e24 --- /dev/null +++ b/src/commands/subscribers/change-email.ts @@ -0,0 +1,45 @@ +import type { Command } from "commander"; + +import { output } from "../../core/output"; +import { bento, CLIError } from "../../core/sdk"; + +interface ChangeEmailOptions { + old: string; + new: string; +} + +export function registerChangeEmailCommand(subscribers: Command): void { + subscribers + .command("change-email") + .description("Change a subscriber's email address") + .requiredOption("--old ", "Current email address") + .requiredOption("--new ", "New email address") + .action(async (opts: ChangeEmailOptions) => { + try { + output.startSpinner("Changing email..."); + + const result = await bento.changeEmail(opts.old, opts.new); + + output.stopSpinner("Email changed"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success(`Changed email from ${opts.old} to ${opts.new}`); + } + } catch (error) { + output.failSpinner(); + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); + } + }); +} diff --git a/src/commands/subscribers/command-group.ts b/src/commands/subscribers/command-group.ts index 1ec7c42..b71a543 100644 --- a/src/commands/subscribers/command-group.ts +++ b/src/commands/subscribers/command-group.ts @@ -1,10 +1,13 @@ import type { Command } from "commander"; +import { registerChangeEmailCommand } from "./change-email"; +import { registerFieldCommand } from "./field"; import { registerImportCommand } from "./import"; import { registerSearchCommand } from "./search"; import { registerSubscribeCommand } from "./subscribe"; import { registerTagCommand } from "./tag"; import { registerUnsubscribeCommand } from "./unsubscribe"; +import { registerUpsertCommand } from "./upsert"; export function registerSubscribersCommands(program: Command): void { const subscribers = program.command("subscribers").description("Manage subscribers"); @@ -14,4 +17,7 @@ export function registerSubscribersCommands(program: Command): void { registerTagCommand(subscribers); registerSubscribeCommand(subscribers); registerUnsubscribeCommand(subscribers); + registerFieldCommand(subscribers); + registerChangeEmailCommand(subscribers); + registerUpsertCommand(subscribers); } diff --git a/src/commands/subscribers/field.ts b/src/commands/subscribers/field.ts new file mode 100644 index 0000000..5c3a907 --- /dev/null +++ b/src/commands/subscribers/field.ts @@ -0,0 +1,139 @@ +import type { Command } from "commander"; + +import { output } from "../../core/output"; +import { bento, CLIError } from "../../core/sdk"; + +interface FieldSetOptions { + email: string; + key: string; + value: string; +} + +interface FieldRemoveOptions { + email: string; + key: string; +} + +interface FieldUpdateOptions { + email: string; + fields: string; +} + +export function registerFieldCommand(subscribers: Command): void { + const field = subscribers + .command("field") + .description("Manage subscriber fields"); + + field + .command("set") + .description("Set a field value on a subscriber (does NOT trigger automations)") + .requiredOption("-e, --email ", "Subscriber email address") + .requiredOption("-k, --key ", "Field key") + .requiredOption("-v, --value ", "Field value") + .action(async (opts: FieldSetOptions) => { + try { + output.startSpinner("Setting field..."); + + const result = await bento.addField({ + email: opts.email, + field: { key: opts.key, value: opts.value }, + }); + + output.stopSpinner("Field set"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success(`Set field "${opts.key}" = "${opts.value}" on ${opts.email}`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + field + .command("remove") + .description("Remove a field from a subscriber") + .requiredOption("-e, --email ", "Subscriber email address") + .requiredOption("-k, --key ", "Field key to remove") + .action(async (opts: FieldRemoveOptions) => { + try { + output.startSpinner("Removing field..."); + + const result = await bento.removeField(opts.email, opts.key); + + output.stopSpinner("Field removed"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success(`Removed field "${opts.key}" from ${opts.email}`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + field + .command("update") + .description("Update multiple fields on a subscriber (TRIGGERS automations)") + .requiredOption("-e, --email ", "Subscriber email address") + .requiredOption("--fields ", "Fields as JSON object (e.g., '{\"name\": \"John\"}')") + .action(async (opts: FieldUpdateOptions) => { + try { + let fields: Record; + try { + fields = JSON.parse(opts.fields); + } catch { + output.error("Invalid JSON in --fields. Ensure valid JSON format."); + process.exit(1); + } + + output.startSpinner("Updating fields..."); + + const success = await bento.updateFields(opts.email, fields); + + if (!success) { + output.failSpinner("Failed to update fields"); + process.exit(1); + } + + output.stopSpinner("Fields updated"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: { email: opts.email, fields }, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success(`Updated fields on ${opts.email} (automations triggered)`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); +} + +function handleError(error: unknown): void { + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} diff --git a/src/commands/subscribers/unsubscribe.ts b/src/commands/subscribers/unsubscribe.ts index 45be800..2b1948a 100644 --- a/src/commands/subscribers/unsubscribe.ts +++ b/src/commands/subscribers/unsubscribe.ts @@ -12,6 +12,7 @@ interface UnsubscribeOptions { limit?: string; sample?: string; confirm?: boolean; + triggerAutomations?: boolean; } export function registerUnsubscribeCommand(subscribers: Command): void { @@ -19,7 +20,8 @@ export function registerUnsubscribeCommand(subscribers: Command): void { .command("unsubscribe") .description("Unsubscribe subscribers (stop email delivery)") .option("-e, --email ", "Single email to unsubscribe") - .option("-f, --file ", "CSV or newline list of subscriber emails"); + .option("-f, --file ", "CSV or newline list of subscriber emails") + .option("--trigger-automations", "Use automation-aware unsubscribe (triggers automations)"); Safety.addFlags(command); @@ -44,7 +46,11 @@ export function registerUnsubscribeCommand(subscribers: Command): void { isDangerous: true, execute: async (emails) => { for (const email of emails) { - await bento.unsubscribe(email); + if (opts.triggerAutomations) { + await bento.removeSubscriber(email); + } else { + await bento.unsubscribe(email); + } } emitResult(emails.length); }, diff --git a/src/commands/subscribers/upsert.ts b/src/commands/subscribers/upsert.ts new file mode 100644 index 0000000..7a1a06e --- /dev/null +++ b/src/commands/subscribers/upsert.ts @@ -0,0 +1,64 @@ +import type { Command } from "commander"; + +import { output } from "../../core/output"; +import { bento, CLIError } from "../../core/sdk"; + +interface UpsertOptions { + email: string; + fields?: string; + tags?: string; + removeTags?: string; +} + +export function registerUpsertCommand(subscribers: Command): void { + subscribers + .command("upsert") + .description("Create or update a subscriber with fields and tags") + .requiredOption("-e, --email ", "Subscriber email address") + .option("--fields ", "Fields as JSON object (e.g., '{\"name\": \"John\"}')") + .option("--tags ", "Comma-separated tags to add") + .option("--remove-tags ", "Comma-separated tags to remove") + .action(async (opts: UpsertOptions) => { + try { + let fields: Record | undefined; + if (opts.fields) { + try { + fields = JSON.parse(opts.fields); + } catch { + output.error("Invalid JSON in --fields. Ensure valid JSON format."); + process.exit(1); + } + } + + output.startSpinner("Upserting subscriber..."); + + const result = await bento.upsertSubscriber( + opts.email, + fields, + opts.tags, + opts.removeTags + ); + + output.stopSpinner("Subscriber upserted"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success(`Upserted subscriber ${opts.email}`); + } + } catch (error) { + output.failSpinner(); + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); + } + }); +} diff --git a/src/commands/templates.ts b/src/commands/templates.ts new file mode 100644 index 0000000..65b5449 --- /dev/null +++ b/src/commands/templates.ts @@ -0,0 +1,102 @@ +/** + * Email template commands + * + * Commands: + * - bento templates get - Get an email template + * - bento templates update [--subject ] [--html ] - Update an email template + */ + +import { Command } from "commander"; +import { bento, CLIError } from "../core/sdk"; +import { output } from "../core/output"; + +interface UpdateOptions { + subject?: string; + html?: string; +} + +export function registerTemplatesCommands(program: Command): void { + const templates = program + .command("templates") + .description("Manage email templates"); + + templates + .command("get") + .description("Get an email template by ID") + .argument("", "Email template ID") + .action(async (id: string) => { + try { + output.startSpinner("Fetching template..."); + + const result = await bento.getEmailTemplate(id); + + output.stopSpinner(); + + if (!result) { + output.error(`Template with ID "${id}" not found.`); + process.exit(1); + } + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + return; + } + + if (output.isQuiet()) return; + + output.object(result as unknown as Record); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + templates + .command("update") + .description("Update an email template") + .argument("", "Email template ID") + .option("--subject ", "New email subject") + .option("--html ", "New HTML content") + .action(async (id: string, opts: UpdateOptions) => { + try { + if (!opts.subject && !opts.html) { + output.error("Provide at least --subject or --html to update."); + process.exit(1); + } + + output.startSpinner("Updating template..."); + + const result = await bento.updateEmailTemplate(id, opts.subject, opts.html); + + output.stopSpinner("Template updated"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: 1 }, + }); + } else if (!output.isQuiet()) { + output.success(`Updated template ${id}`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); +} + +function handleError(error: unknown): void { + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); +} diff --git a/src/commands/workflows.ts b/src/commands/workflows.ts new file mode 100644 index 0000000..7e5bf43 --- /dev/null +++ b/src/commands/workflows.ts @@ -0,0 +1,75 @@ +/** + * Workflow commands + * + * Commands: + * - bento workflows list - List all workflows + */ + +import { Command } from "commander"; +import { bento, CLIError } from "../core/sdk"; +import { output } from "../core/output"; + +export function registerWorkflowsCommands(program: Command): void { + const workflows = program + .command("workflows") + .description("Manage workflows"); + + workflows + .command("list") + .description("List all workflows") + .action(async () => { + try { + output.startSpinner("Fetching workflows..."); + + const result = await bento.getWorkflows(); + + output.stopSpinner(); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: result.length }, + }); + return; + } + + if (output.isQuiet()) return; + + if (!result || result.length === 0) { + output.info("No workflows found."); + return; + } + + const hasStatus = result.some((w) => "status" in (w.attributes ?? {})); + + const columns = [ + { key: "id", header: "ID" }, + { key: "name", header: "Name" }, + ...(hasStatus ? [{ key: "status", header: "Status" }] : []), + { key: "created_at", header: "Created" }, + { key: "templates", header: "Templates" }, + ]; + + output.table( + result.map((w) => ({ + id: w.id, + name: w.attributes?.name ?? "N/A", + ...(hasStatus ? { status: (w.attributes as any)?.status ?? "N/A" } : {}), + created_at: w.attributes?.created_at ?? "N/A", + templates: w.attributes?.email_templates?.length ?? 0, + })), + { columns } + ); + } catch (error) { + output.failSpinner(); + if (error instanceof CLIError || error instanceof Error) { + output.error(error.message); + } else { + output.error("An unexpected error occurred."); + } + process.exit(1); + } + }); +} diff --git a/src/core/sdk.ts b/src/core/sdk.ts index d40c79d..22baa33 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -19,16 +19,23 @@ import { import type { BentoProfile } from "../types/config"; import type { AddFieldParams, + BlacklistResponse, Broadcast, + ContentModerationResult, CreateBroadcastInput, + CreateSequenceEmailInput, + EmailTemplate, Field, + FormResponse, GetSubscriberParams, + GuessGenderResponse, ImportResult, ImportSubscribersParams, + PurchaseDetails, + ReportStats, SDKErrorCode, + SegmentStats, Sequence, - EmailTemplate, - CreateSequenceEmailInput, UpdateSequenceEmailInput, SiteStats, Subscriber, @@ -37,6 +44,8 @@ import type { Tag, TagSubscriberParams, TrackEventParams, + TransactionalEmail, + Workflow, } from "../types/sdk"; import { config } from "./config"; @@ -434,6 +443,146 @@ export class BentoClient { ); } + // ============================================================ + // Purchase Operations + // ============================================================ + + /** + * Track a purchase event (TRIGGERS automations) + */ + async trackPurchase(email: string, purchaseDetails: PurchaseDetails): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.trackPurchase({ email, purchaseDetails })); + } + + // ============================================================ + // Advanced Subscriber Operations + // ============================================================ + + /** + * Remove subscriber (automation-aware unsubscribe, TRIGGERS automations) + */ + async removeSubscriber(email: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.removeSubscriber({ email })); + } + + /** + * Upsert subscriber — creates or updates with fields and tags + */ + async upsertSubscriber( + email: string, + fields?: Record, + tags?: string, + removeTags?: string + ): Promise> | null> { + const sdk = await this.getClient(); + return this.handleApiCall(() => + sdk.V1.upsertSubscriber({ email, fields, tags, remove_tags: removeTags }) + ); + } + + // ============================================================ + // Transactional Email Operations + // ============================================================ + + /** + * Send transactional emails (batch, up to 100) + */ + async sendTransactionalEmails(emails: TransactionalEmail[]): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Batch.sendTransactionalEmails({ emails })); + } + + // ============================================================ + // Workflow Operations + // ============================================================ + + /** + * List all workflows (auto-paginates) + */ + async getWorkflows(): Promise { + const perPage = 100; + const maxPages = 200; + const workflows: Workflow[] = []; + const seenIds = new Set(); + + type WorkflowListResponse = { + data?: Workflow[]; + meta?: { total?: number; page?: number }; + }; + + let page = 1; + let total: number | undefined; + + while (page <= maxPages) { + const response = await this.apiGet( + "/fetch/workflows", + { page, per_page: perPage } + ); + + const data = Array.isArray(response) ? response : response.data ?? []; + const meta = Array.isArray(response) ? undefined : response.meta; + + if (meta?.total !== undefined) { + total = meta.total; + } + + if (data.length === 0) break; + + for (const item of data) { + if (seenIds.has(item.id)) continue; + seenIds.add(item.id); + workflows.push(item); + } + + if (total !== undefined && workflows.length >= total) break; + if (data.length < perPage) break; + + page += 1; + } + + return workflows; + } + + // ============================================================ + // Email Template Operations + // ============================================================ + + /** + * Get an email template by ID + */ + async getEmailTemplate(id: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.EmailTemplates.getEmailTemplate({ id })); + } + + /** + * Update an email template + */ + async updateEmailTemplate( + id: string, + subject?: string, + html?: string + ): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => + sdk.V1.EmailTemplates.updateEmailTemplate({ id, subject, html }) + ); + } + + // ============================================================ + // Form Operations + // ============================================================ + + /** + * Get form responses by form identifier + */ + async getFormResponses(formIdentifier: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Forms.getResponses(formIdentifier)); + } + // ============================================================ // Stats Operations // ============================================================ @@ -446,6 +595,74 @@ export class BentoClient { return this.handleApiCall(() => sdk.V1.Stats.getSiteStats()); } + /** + * Get segment statistics + */ + async getSegmentStats(segmentId: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Stats.getSegmentStats(segmentId)); + } + + /** + * Get report statistics + */ + async getReportStats(reportId: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Stats.getReportStats(reportId)); + } + + // ============================================================ + // Experimental Operations + // ============================================================ + + /** + * Validate an email address + */ + async validateEmail( + email: string, + ip?: string, + name?: string, + userAgent?: string + ): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => + sdk.V1.Experimental.validateEmail({ email, ip, name, userAgent }) + ); + } + + /** + * Guess gender from a name + */ + async guessGender(name: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Experimental.guessGender({ name })); + } + + /** + * Geolocate an IP address + */ + async geolocate(ip: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Experimental.geoLocateIP({ ip })); + } + + /** + * Check domain/IP against blacklists + */ + async checkBlacklist(domain?: string, ip?: string): Promise { + const sdk = await this.getClient(); + const params = domain ? { domain } : { ipAddress: ip! }; + return this.handleApiCall(() => sdk.V1.Experimental.getBlacklistStatus(params)); + } + + /** + * Perform content moderation + */ + async getContentModeration(content: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Experimental.getContentModeration(content)); + } + // ============================================================ // Broadcast Operations // ============================================================ @@ -547,11 +764,50 @@ export class BentoClient { // ============================================================ /** - * List all sequences. + * List all sequences (auto-paginates) */ async getSequences(): Promise { - const sdk = await this.getClient(); - return this.handleApiCall(() => sdk.V1.Sequences.getSequences()); + const perPage = 100; + const maxPages = 200; + const sequences: Sequence[] = []; + const seenIds = new Set(); + + type SequenceListResponse = { + data?: Sequence[]; + meta?: { total?: number; page?: number }; + }; + + let page = 1; + let total: number | undefined; + + while (page <= maxPages) { + const response = await this.apiGet( + "/fetch/sequences", + { page, per_page: perPage } + ); + + const data = Array.isArray(response) ? response : response.data ?? []; + const meta = Array.isArray(response) ? undefined : response.meta; + + if (meta?.total !== undefined) { + total = meta.total; + } + + if (data.length === 0) break; + + for (const item of data) { + if (seenIds.has(item.id)) continue; + seenIds.add(item.id); + sequences.push(item); + } + + if (total !== undefined && sequences.length >= total) break; + if (data.length < perPage) break; + + page += 1; + } + + return sequences; } /** diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index b481f0e..727f6c0 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -12,7 +12,7 @@ describe("bento CLI", () => { it("shows version with --version flag", () => { const result = spawnSync(["bun", "run", "src/cli.ts", "--version"]); const stdout = result.stdout.toString(); - expect(stdout).toContain("0.1.1"); + expect(stdout).toContain("0.1.5"); }); it("shows welcome screen when no command is provided", () => { diff --git a/src/tests/commands/skills.test.ts b/src/tests/commands/skills.test.ts new file mode 100644 index 0000000..4525b74 --- /dev/null +++ b/src/tests/commands/skills.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "bun"; +import { existsSync, mkdirSync, readlinkSync, rmSync, lstatSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; + +function runCLI(args: string[], env: Record = {}) { + const result = spawnSync(["bun", "run", "src/cli.ts", ...args], { + env: { ...process.env, ...env }, + }); + + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + exitCode: result.exitCode, + }; +} + +describe("bento skills", () => { + it("shows skills help", () => { + const result = runCLI(["skills", "--help"]); + expect(result.stdout).toContain("Manage AI agent skills"); + expect(result.stdout).toContain("list"); + expect(result.stdout).toContain("install"); + }); +}); + +describe("bento skills list", () => { + it("lists available skills", () => { + const result = runCLI(["skills", "list"]); + expect(result.stdout).toContain("bento-cli"); + expect(result.exitCode).toBe(0); + }); + + it("lists skills in JSON mode", () => { + const result = runCLI(["skills", "list", "--json"]); + const parsed = JSON.parse(result.stdout); + expect(parsed.success).toBe(true); + expect(parsed.data.length).toBeGreaterThanOrEqual(1); + expect(parsed.data[0].name).toBe("bento-cli"); + expect(result.exitCode).toBe(0); + }); +}); + +describe("bento skills install", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "bento-skills-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("shows install help", () => { + const result = runCLI(["skills", "install", "--help"]); + expect(result.stdout).toContain("Install skills to AI agent harnesses"); + expect(result.stdout).toContain("--agent"); + expect(result.stdout).toContain("--skill"); + expect(result.stdout).toContain("--force"); + }); + + it("rejects unknown --agent", () => { + const result = runCLI(["skills", "install", "--agent", "nonexistent"]); + expect(result.stderr).toContain('Unknown agent "nonexistent"'); + expect(result.exitCode).toBe(1); + }); + + it("rejects unknown --skill", () => { + const result = runCLI(["skills", "install", "--skill", "nonexistent"]); + expect(result.stderr).toContain('Unknown skill "nonexistent"'); + expect(result.exitCode).toBe(1); + }); + + it("warns when no agents detected", () => { + const result = runCLI(["skills", "install"], { + HOME: tempDir, + CLAUDE_CONFIG_DIR: join(tempDir, "no-such-dir"), + CODEX_HOME: join(tempDir, "no-such-codex"), + }); + expect(result.stderr).toContain("No supported AI agents detected"); + }); + + it("installs skill to a detected agent via symlink", () => { + mkdirSync(join(tempDir, ".claude"), { recursive: true }); + + const result = runCLI(["skills", "install", "--agent", "claude-code", "--force"], { + HOME: tempDir, + CLAUDE_CONFIG_DIR: join(tempDir, ".claude"), + }); + + expect(result.exitCode).toBe(0); + + const canonicalSkill = join(tempDir, ".agents", "skills", "bento-cli", "SKILL.md"); + expect(existsSync(canonicalSkill)).toBe(true); + + const agentSkill = join(tempDir, ".claude", "skills", "bento-cli"); + expect(existsSync(agentSkill)).toBe(true); + + const stat = lstatSync(agentSkill); + if (stat.isSymbolicLink()) { + const linkTarget = resolve(dirname(agentSkill), readlinkSync(agentSkill)); + expect(linkTarget).toBe(resolve(tempDir, ".agents", "skills", "bento-cli")); + } + }); + + it("outputs JSON on install with --json", () => { + mkdirSync(join(tempDir, ".claude"), { recursive: true }); + + const result = runCLI( + ["skills", "install", "--agent", "claude-code", "--force", "--json"], + { + HOME: tempDir, + CLAUDE_CONFIG_DIR: join(tempDir, ".claude"), + } + ); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.success).toBe(true); + expect(parsed.data.length).toBeGreaterThanOrEqual(1); + expect(parsed.data[0].agent).toBe("Claude Code"); + expect(parsed.data[0].skill).toBe("bento-cli"); + expect(["symlink", "copy"]).toContain(parsed.data[0].method); + }); +}); diff --git a/src/types/sdk.ts b/src/types/sdk.ts index 692f140..b481403 100644 --- a/src/types/sdk.ts +++ b/src/types/sdk.ts @@ -39,13 +39,46 @@ export type { CreateBroadcastInput, } from "@bentonow/bento-node-sdk/src/sdk/broadcasts/types"; -// Sequence and email template types +// Sequence types export type { Sequence, SequenceAttributes, + SequenceEmailTemplate, } from "@bentonow/bento-node-sdk/src/sdk/sequences/types"; + +// Email template types export type { EmailTemplate } from "@bentonow/bento-node-sdk/src/sdk/email-templates/types"; +// Workflow types +export type { + Workflow, + WorkflowAttributes, +} from "@bentonow/bento-node-sdk/src/sdk/workflows/types"; + +// Form types +export type { + FormResponse, + FormResponseAttributes, +} from "@bentonow/bento-node-sdk/src/sdk/forms/types"; + +// Experimental types +export type { + GuessGenderResponse, + BlacklistResponse, + ContentModerationResult, +} from "@bentonow/bento-node-sdk/src/sdk/experimental/types"; + +// Batch/transactional types +export type { TransactionalEmail } from "@bentonow/bento-node-sdk/src/sdk/batch/types"; + +// Purchase types +export type { + PurchaseDetails, + PurchaseCart, + PurchaseItem, +} from "@bentonow/bento-node-sdk/src/sdk/batch/events"; + + // Base entity type export type { BaseEntity } from "@bentonow/bento-node-sdk/src/sdk/types"; diff --git a/test-plan.md b/test-plan.md new file mode 100644 index 0000000..738e453 --- /dev/null +++ b/test-plan.md @@ -0,0 +1,1565 @@ +# Bento CLI — Complete Test Plan + +> Goal: Run every command with every meaningful option combination. Each entry includes the exact command, expected output shape, and expected error behavior so an automated agent can evaluate pass/fail. + +--- + +## Prerequisites + +```bash +# Set env vars for test credentials (replace with real or test values) +export BENTO_TEST_EMAIL="test@example.com" +export BENTO_TEST_TAG="test-tag" +export BENTO_TEST_FIELD="company_name" +``` + +--- + +## 0. Global Flags & Edge Cases + +### 0.1 Version +```bash +bun run bin/bento --version +``` +- **Expected**: Prints version string (e.g. `1.0.0`), exit code 0 +- **Validate**: Output matches semver pattern `\d+\.\d+\.\d+` + +### 0.2 Help +```bash +bun run bin/bento --help +``` +- **Expected**: Lists all top-level commands (`auth`, `profile`, `subscribers`, `tags`, `fields`, `events`, `broadcasts`, `sequences`, `stats`, `emails`, `workflows`, `templates`, `forms`, `experimental`, `dashboard`), exit code 0 + +### 0.3 --json and --quiet are mutually exclusive +```bash +bun run bin/bento tags list --json --quiet +``` +- **Expected**: Error message about mutually exclusive flags, exit code ≠ 0 + +### 0.4 Unknown command +```bash +bun run bin/bento nonexistent +``` +- **Expected**: Error or help output, exit code ≠ 0 + +--- + +## 1. Auth + +### 1.1 `auth login` — non-interactive with flags +```bash +bun run bin/bento auth login \ + --publishable-key "$BENTO_PUB_KEY" \ + --secret-key "$BENTO_SECRET_KEY" \ + --site-uuid "$BENTO_SITE_UUID" +``` +- **Expected (normal)**: Success message containing profile name and site UUID, exit code 0 +- **Expected (--json)**: `{ "success": true, "data": { "profile": "default", "siteUuid": "..." } }` + +### 1.2 `auth login` — non-interactive missing flags +```bash +bun run bin/bento auth login --publishable-key "pk_only" +``` +- **Expected**: Error about missing credentials, exit code 1 + +### 1.3 `auth login` — invalid credentials +```bash +bun run bin/bento auth login \ + --publishable-key "invalid" \ + --secret-key "invalid" \ + --site-uuid "invalid" +``` +- **Expected**: Error about invalid/failed credential validation, exit code 1 + +### 1.4 `auth login` — with named profile +```bash +bun run bin/bento auth login -p staging \ + --publishable-key "$BENTO_PUB_KEY" \ + --secret-key "$BENTO_SECRET_KEY" \ + --site-uuid "$BENTO_SITE_UUID" +``` +- **Expected**: Success message mentioning profile "staging", exit code 0 + +### 1.5 `auth status` — authenticated +```bash +bun run bin/bento auth status +``` +- **Expected (normal)**: Displays Profile, Site UUID, masked keys (`****...`), timestamps +- **Expected (--json)**: `{ "success": true, "data": { "authenticated": true, "profile": "...", "siteUuid": "...", "publishableKey": "****...", "secretKey": "****...", "createdAt": "...", "updatedAt": "..." } }` + +### 1.6 `auth status --json` +```bash +bun run bin/bento auth status --json +``` +- **Expected**: JSON envelope with `authenticated: true`, masked keys, exit code 0 + +### 1.7 `auth status --quiet` +```bash +bun run bin/bento auth status --quiet +``` +- **Expected**: No output, exit code 0 + +### 1.8 `auth status` — not authenticated +```bash +# (run after auth logout) +bun run bin/bento auth status +``` +- **Expected**: Message indicating not authenticated or no active profile + +### 1.9 `auth logout` +```bash +bun run bin/bento auth logout +``` +- **Expected (normal)**: Success message about clearing credentials, exit code 0 +- **Expected (--json)**: `{ "success": true }` + +### 1.10 `auth logout` — already logged out +```bash +bun run bin/bento auth logout +``` +- **Expected**: Warning or success (idempotent), exit code 0 + +--- + +## 2. Profile + +### 2.1 `profile add` +```bash +bun run bin/bento profile add testprofile \ + --publishable-key "$BENTO_PUB_KEY" \ + --secret-key "$BENTO_SECRET_KEY" \ + --site-uuid "$BENTO_SITE_UUID" +``` +- **Expected**: Success message with profile name "testprofile", exit code 0 + +### 2.2 `profile add` — duplicate name +```bash +bun run bin/bento profile add testprofile \ + --publishable-key "$BENTO_PUB_KEY" \ + --secret-key "$BENTO_SECRET_KEY" \ + --site-uuid "$BENTO_SITE_UUID" +``` +- **Expected**: Error about profile already existing, exit code 1 + +### 2.3 `profile list` +```bash +bun run bin/bento profile list +``` +- **Expected (normal)**: Table with columns: Current (✓), NAME, SITE UUID, CREATED +- **Expected (--json)**: Array of profile objects with `current` boolean + +### 2.4 `profile list --json` +```bash +bun run bin/bento profile list --json +``` +- **Expected**: `{ "success": true, "data": [ { "name": "...", "current": true/false, "siteUuid": "...", "created": "..." } ] }` + +### 2.5 `profile list --quiet` +```bash +bun run bin/bento profile list --quiet +``` +- **Expected**: No output, exit code 0 + +### 2.6 `profile use` +```bash +bun run bin/bento profile use testprofile +``` +- **Expected**: Success message switching to "testprofile", exit code 0 + +### 2.7 `profile use` — nonexistent +```bash +bun run bin/bento profile use doesnotexist +``` +- **Expected**: Error about profile not found, exit code 1 + +### 2.8 `profile remove` — with confirmation flag +```bash +bun run bin/bento profile remove testprofile --yes +``` +- **Expected**: Success message about removing profile, exit code 0 + +### 2.9 `profile remove` — nonexistent +```bash +bun run bin/bento profile remove doesnotexist --yes +``` +- **Expected**: Error about profile not found, exit code 1 + +### 2.10 `profile remove` — non-interactive without --yes +```bash +echo "" | bun run bin/bento profile remove default +``` +- **Expected**: Error about requiring --yes in non-interactive mode, exit code 1 + +--- + +## 3. Subscribers + +### 3.1 `subscribers search --email` +```bash +bun run bin/bento subscribers search --email "$BENTO_TEST_EMAIL" +``` +- **Expected (normal)**: Table with columns: EMAIL, NAME, STATUS, TAGS, FIELDS +- **Expected (--json)**: `{ "success": true, "data": [ { "email": "...", "uuid": "...", "name": "...", "status": "active"|"unsubscribed", "tags": [...], "fields": {...} } ] }` + +### 3.2 `subscribers search --email --json` +```bash +bun run bin/bento subscribers search --email "$BENTO_TEST_EMAIL" --json +``` +- **Expected**: JSON envelope with subscriber data array, exit code 0 + +### 3.3 `subscribers search --email --quiet` +```bash +bun run bin/bento subscribers search --email "$BENTO_TEST_EMAIL" --quiet +``` +- **Expected**: No output, exit code 0 + +### 3.4 `subscribers search --uuid` +```bash +bun run bin/bento subscribers search --uuid "some-uuid" +``` +- **Expected**: Same table/JSON shape as email search + +### 3.5 `subscribers search` — no email or uuid +```bash +bun run bin/bento subscribers search +``` +- **Expected**: Error about requiring --email or --uuid, exit code 2 + +### 3.6 `subscribers search` — with tag filter +```bash +bun run bin/bento subscribers search --email "$BENTO_TEST_EMAIL" --tag "$BENTO_TEST_TAG" +``` +- **Expected**: Subscriber shown only if they have the tag; otherwise "No subscribers found" + +### 3.7 `subscribers search` — with field filter +```bash +bun run bin/bento subscribers search --email "$BENTO_TEST_EMAIL" --field "company=Acme" +``` +- **Expected**: Subscriber shown only if field matches + +### 3.8 `subscribers search` — invalid field format +```bash +bun run bin/bento subscribers search --email "$BENTO_TEST_EMAIL" --field "badformat" +``` +- **Expected**: Error about field format (must be `key=value`), exit code 2 + +### 3.9 `subscribers search` — not found +```bash +bun run bin/bento subscribers search --email "nonexistent@example.com" +``` +- **Expected**: "No subscribers found" message, exit code 0 + +### 3.10 `subscribers import` — dry run +```bash +# Create test CSV first +echo 'email,name +import1@test.com,Test User 1 +import2@test.com,Test User 2' > /tmp/bento-test-import.csv + +bun run bin/bento subscribers import /tmp/bento-test-import.csv --dry-run +``` +- **Expected**: Preview of records to import, "Dry run — no changes made" message, exit code 0 +- **Validate**: No actual API calls made + +### 3.11 `subscribers import --json --dry-run` +```bash +bun run bin/bento subscribers import /tmp/bento-test-import.csv --dry-run --json +``` +- **Expected**: `{ "success": true, "data": { ... }, "meta": { "dryRun": true } }` + +### 3.12 `subscribers import` — with limit +```bash +bun run bin/bento subscribers import /tmp/bento-test-import.csv --dry-run --limit 1 +``` +- **Expected**: Preview showing only 1 record + +### 3.13 `subscribers import` — with sample +```bash +bun run bin/bento subscribers import /tmp/bento-test-import.csv --dry-run --sample 1 +``` +- **Expected**: Preview showing 1 sample record + +### 3.14 `subscribers import` — file not found +```bash +bun run bin/bento subscribers import /tmp/nonexistent.csv --dry-run +``` +- **Expected**: File not found error, exit code 5 + +### 3.15 `subscribers import` — invalid CSV (no email column) +```bash +echo 'name,age +Test,25' > /tmp/bento-bad-import.csv + +bun run bin/bento subscribers import /tmp/bento-bad-import.csv --dry-run +``` +- **Expected**: Validation error about missing email column, exit code 6 + +### 3.16 `subscribers import` — with confirm (actual execution) +```bash +bun run bin/bento subscribers import /tmp/bento-test-import.csv --confirm +``` +- **Expected (normal)**: Success message with imported count +- **Expected (--json)**: `{ "success": true, "data": { "imported": 2 } }` + +### 3.17 `subscribers tag --add` — single email, dry run +```bash +bun run bin/bento subscribers tag --email "$BENTO_TEST_EMAIL" --add "test-tag-1" --dry-run +``` +- **Expected**: Preview of tag addition, no changes made, exit code 0 + +### 3.18 `subscribers tag --add --remove` — combined +```bash +bun run bin/bento subscribers tag --email "$BENTO_TEST_EMAIL" --add "new-tag" --remove "old-tag" --dry-run +``` +- **Expected**: Preview showing both add and remove actions + +### 3.19 `subscribers tag` — no tags specified +```bash +bun run bin/bento subscribers tag --email "$BENTO_TEST_EMAIL" +``` +- **Expected**: Error about requiring --add or --remove, exit code 2 + +### 3.20 `subscribers tag` — no email/file specified +```bash +bun run bin/bento subscribers tag --add "some-tag" +``` +- **Expected**: Error about requiring --email or --file, exit code 2 + +### 3.21 `subscribers tag` — from file +```bash +echo 'tag-user1@test.com +tag-user2@test.com' > /tmp/bento-tag-emails.txt + +bun run bin/bento subscribers tag --file /tmp/bento-tag-emails.txt --add "bulk-tag" --dry-run +``` +- **Expected**: Preview showing 2 subscribers with tag to add + +### 3.22 `subscribers tag --json --confirm` (actual execution) +```bash +bun run bin/bento subscribers tag --email "$BENTO_TEST_EMAIL" --add "cli-test-tag" --confirm --json +``` +- **Expected**: `{ "success": true, "data": { "updated": 1, "added": ["cli-test-tag"], "removed": [] } }` + +### 3.23 `subscribers subscribe` — dry run +```bash +bun run bin/bento subscribers subscribe --email "$BENTO_TEST_EMAIL" --dry-run +``` +- **Expected**: Preview of re-subscribe action, exit code 0 + +### 3.24 `subscribers subscribe` — no email/file +```bash +bun run bin/bento subscribers subscribe +``` +- **Expected**: Error about requiring --email or --file, exit code 2 + +### 3.25 `subscribers subscribe --json --confirm` +```bash +bun run bin/bento subscribers subscribe --email "$BENTO_TEST_EMAIL" --confirm --json +``` +- **Expected**: `{ "success": true, "data": { "updated": 1, "action": "subscribe" } }` + +### 3.26 `subscribers unsubscribe` — dry run +```bash +bun run bin/bento subscribers unsubscribe --email "$BENTO_TEST_EMAIL" --dry-run +``` +- **Expected**: Preview of unsubscribe action, exit code 0 + +### 3.27 `subscribers unsubscribe` — no email/file +```bash +bun run bin/bento subscribers unsubscribe +``` +- **Expected**: Error about requiring --email or --file, exit code 2 + +### 3.28 `subscribers unsubscribe --json --confirm` +```bash +bun run bin/bento subscribers unsubscribe --email "$BENTO_TEST_EMAIL" --confirm --json +``` +- **Expected**: `{ "success": true, "data": { "updated": 1, "action": "unsubscribe" } }` + +### 3.29 `subscribers subscribe` — from file with limit +```bash +bun run bin/bento subscribers subscribe --file /tmp/bento-tag-emails.txt --limit 1 --dry-run +``` +- **Expected**: Preview showing only 1 subscriber (limited) + +### 3.30 `subscribers unsubscribe` — from file with limit +```bash +bun run bin/bento subscribers unsubscribe --file /tmp/bento-tag-emails.txt --limit 1 --dry-run +``` +- **Expected**: Preview showing only 1 subscriber (limited) + +### 3.31 `subscribers unsubscribe --trigger-automations` +```bash +bun run bin/bento subscribers unsubscribe --email "$BENTO_TEST_EMAIL" --trigger-automations --confirm --json +``` +- **Expected**: `{ "success": true, "data": { "updated": 1, "action": "unsubscribe" } }` +- **Note**: Uses `removeSubscriber()` which triggers automations, unlike the default `unsubscribe()` + +### 3.32 `subscribers field set` +```bash +bun run bin/bento subscribers field set --email "$BENTO_TEST_EMAIL" --key "company_name" --value "Acme Corp" +``` +- **Expected (normal)**: Success message confirming field set +- **Expected (--json)**: `{ "success": true, "data": { ... }, "meta": { "count": 1 } }` + +### 3.33 `subscribers field set --json` +```bash +bun run bin/bento subscribers field set --email "$BENTO_TEST_EMAIL" --key "company_name" --value "Acme Corp" --json +``` +- **Expected**: JSON envelope with subscriber data, exit code 0 + +### 3.34 `subscribers field set` — missing required options +```bash +bun run bin/bento subscribers field set --email "$BENTO_TEST_EMAIL" +``` +- **Expected**: Error about required --key and --value, exit code 1 + +### 3.35 `subscribers field remove` +```bash +bun run bin/bento subscribers field remove --email "$BENTO_TEST_EMAIL" --key "company_name" +``` +- **Expected (normal)**: Success message confirming field removal +- **Expected (--json)**: `{ "success": true, "data": { ... }, "meta": { "count": 1 } }` + +### 3.36 `subscribers field remove --json` +```bash +bun run bin/bento subscribers field remove --email "$BENTO_TEST_EMAIL" --key "company_name" --json +``` +- **Expected**: JSON envelope with subscriber data, exit code 0 + +### 3.37 `subscribers field update` — triggers automations +```bash +bun run bin/bento subscribers field update --email "$BENTO_TEST_EMAIL" --fields '{"company_name": "Acme Corp", "role": "Engineer"}' +``` +- **Expected (normal)**: Success message noting automations triggered +- **Expected (--json)**: `{ "success": true, "data": { "email": "...", "fields": { ... } }, "meta": { "count": 1 } }` + +### 3.38 `subscribers field update` — invalid JSON +```bash +bun run bin/bento subscribers field update --email "$BENTO_TEST_EMAIL" --fields "not json" +``` +- **Expected**: Error about invalid JSON, exit code 1 + +### 3.39 `subscribers field update --json` +```bash +bun run bin/bento subscribers field update --email "$BENTO_TEST_EMAIL" --fields '{"name": "Test"}' --json +``` +- **Expected**: JSON envelope with success, exit code 0 + +### 3.40 `subscribers change-email` +```bash +bun run bin/bento subscribers change-email --old "old@example.com" --new "new@example.com" +``` +- **Expected (normal)**: Success message confirming email change +- **Expected (--json)**: `{ "success": true, "data": { ... }, "meta": { "count": 1 } }` + +### 3.41 `subscribers change-email --json` +```bash +bun run bin/bento subscribers change-email --old "old@example.com" --new "new@example.com" --json +``` +- **Expected**: JSON envelope with subscriber data, exit code 0 + +### 3.42 `subscribers change-email` — missing required options +```bash +bun run bin/bento subscribers change-email --old "old@example.com" +``` +- **Expected**: Error about required --new, exit code 1 + +### 3.43 `subscribers upsert` — basic +```bash +bun run bin/bento subscribers upsert --email "$BENTO_TEST_EMAIL" +``` +- **Expected (normal)**: Success message confirming upsert +- **Expected (--json)**: `{ "success": true, "data": { ... }, "meta": { "count": 1 } }` + +### 3.44 `subscribers upsert` — with fields and tags +```bash +bun run bin/bento subscribers upsert --email "$BENTO_TEST_EMAIL" \ + --fields '{"name": "Test User", "company": "Acme"}' \ + --tags "vip,active" \ + --remove-tags "churned" +``` +- **Expected (normal)**: Success message +- **Expected (--json)**: `{ "success": true, "data": { ... }, "meta": { "count": 1 } }` + +### 3.45 `subscribers upsert --json` +```bash +bun run bin/bento subscribers upsert --email "$BENTO_TEST_EMAIL" --json +``` +- **Expected**: JSON envelope with subscriber data, exit code 0 + +### 3.46 `subscribers upsert` — invalid fields JSON +```bash +bun run bin/bento subscribers upsert --email "$BENTO_TEST_EMAIL" --fields "not json" +``` +- **Expected**: Error about invalid JSON, exit code 1 + +### 3.47 `subscribers upsert` — missing email +```bash +bun run bin/bento subscribers upsert +``` +- **Expected**: Error about required --email, exit code 1 + +--- + +## 4. Tags + +### 4.1 `tags list` +```bash +bun run bin/bento tags list +``` +- **Expected (normal)**: Table with columns: NAME, ID, CREATED +- **Expected**: If no tags, info message about creating tags + +### 4.2 `tags list --json` +```bash +bun run bin/bento tags list --json +``` +- **Expected**: `{ "success": true, "data": [ { "name": "...", "id": "...", "createdAt": "..." } ], "meta": { "count": N } }` + +### 4.3 `tags list --quiet` +```bash +bun run bin/bento tags list --quiet +``` +- **Expected**: No output, exit code 0 + +### 4.4 `tags list` — with search filter +```bash +bun run bin/bento tags list "test" +``` +- **Expected**: Only tags containing "test" (case-insensitive), metadata shows `total` count of all tags + +### 4.5 `tags list` — search with no matches +```bash +bun run bin/bento tags list "zzz_nonexistent_zzz" +``` +- **Expected**: Empty result or "no tags found" message + +### 4.6 `tags create` +```bash +bun run bin/bento tags create "cli-integration-test" +``` +- **Expected (normal)**: Success message with tag name +- **Expected (--json)**: `{ "success": true, "data": { "name": "cli-integration-test" } }` + +### 4.7 `tags create --json` +```bash +bun run bin/bento tags create "cli-json-test" --json +``` +- **Expected**: JSON envelope with success and tag data + +### 4.8 `tags create` — empty name +```bash +bun run bin/bento tags create "" +``` +- **Expected**: Error about empty tag name, exit code 1 + +--- + +## 5. Fields + +### 5.1 `fields list` +```bash +bun run bin/bento fields list +``` +- **Expected (normal)**: Table with columns: KEY, NAME, ID, CREATED +- **Expected**: If no fields, info message about creating fields + +### 5.2 `fields list --json` +```bash +bun run bin/bento fields list --json +``` +- **Expected**: `{ "success": true, "data": [ { "key": "...", "name": "...", "id": "...", "createdAt": "..." } ], "meta": { "count": N } }` + +### 5.3 `fields list --quiet` +```bash +bun run bin/bento fields list --quiet +``` +- **Expected**: No output, exit code 0 + +### 5.4 `fields list` — with search filter +```bash +bun run bin/bento fields list "company" +``` +- **Expected**: Only fields matching "company" in key or name + +### 5.5 `fields create` — valid key +```bash +bun run bin/bento fields create "test_field_cli" +``` +- **Expected (normal)**: Success message with field key +- **Expected (--json)**: `{ "success": true, "data": { "key": "test_field_cli" } }` + +### 5.6 `fields create --json` +```bash +bun run bin/bento fields create "test_field_json" --json +``` +- **Expected**: JSON envelope with success + +### 5.7 `fields create` — empty key +```bash +bun run bin/bento fields create "" +``` +- **Expected**: Error about empty key, exit code 1 + +### 5.8 `fields create` — invalid key format (starts with number) +```bash +bun run bin/bento fields create "123invalid" +``` +- **Expected**: Error about invalid key format (must start with letter, alphanumeric + underscore only), exit code 1 + +### 5.9 `fields create` — invalid key with special chars +```bash +bun run bin/bento fields create "has-dashes" +``` +- **Expected**: Error about invalid key format, exit code 1 + +### 5.10 `fields create` — invalid key with spaces +```bash +bun run bin/bento fields create "has spaces" +``` +- **Expected**: Error about invalid key format, exit code 1 + +--- + +## 6. Events + +### 6.1 `events track` — basic +```bash +bun run bin/bento events track --email "$BENTO_TEST_EMAIL" --event "cli_test_event" +``` +- **Expected (normal)**: Success message with event name and email +- **Expected (--json)**: `{ "success": true, "data": { "email": "...", "event": "cli_test_event" } }` + +### 6.2 `events track --json` +```bash +bun run bin/bento events track --email "$BENTO_TEST_EMAIL" --event "cli_test_json" --json +``` +- **Expected**: JSON envelope with success, email, event name + +### 6.3 `events track` — with valid JSON details +```bash +bun run bin/bento events track --email "$BENTO_TEST_EMAIL" --event "purchase" --details '{"product": "widget", "amount": 29.99}' +``` +- **Expected (normal)**: Success message + info about details +- **Expected (--json)**: `{ "success": true, "data": { "email": "...", "event": "purchase", "details": { "product": "widget", "amount": 29.99 } } }` + +### 6.4 `events track` — invalid JSON details +```bash +bun run bin/bento events track --email "$BENTO_TEST_EMAIL" --event "test" --details "not json" +``` +- **Expected**: Error about invalid JSON, exit code 1 + +### 6.5 `events track` — missing email +```bash +bun run bin/bento events track --event "test" +``` +- **Expected**: Error about required --email, exit code 1 + +### 6.6 `events track` — missing event name +```bash +bun run bin/bento events track --email "$BENTO_TEST_EMAIL" +``` +- **Expected**: Error about required --event, exit code 1 + +### 6.7 `events track --quiet` +```bash +bun run bin/bento events track --email "$BENTO_TEST_EMAIL" --event "quiet_test" --quiet +``` +- **Expected**: No output, exit code 0 + +### 6.8 `events import` — from JSON file +```bash +# Create test events file +cat > /tmp/bento-test-events.json << 'EOF' +[ + {"email": "user1@test.com", "type": "page_viewed", "details": {"page": "/pricing"}}, + {"email": "user2@test.com", "type": "button_clicked", "details": {"button": "signup"}} +] +EOF + +bun run bin/bento events import /tmp/bento-test-events.json +``` +- **Expected (normal)**: Success message with imported count +- **Expected (--json)**: `{ "success": true, "data": { "imported": 2 }, "meta": { "count": 2 } }` + +### 6.9 `events import --json` +```bash +bun run bin/bento events import /tmp/bento-test-events.json --json +``` +- **Expected**: JSON envelope with imported count, exit code 0 + +### 6.10 `events import` — file not found +```bash +bun run bin/bento events import /tmp/nonexistent-events.json +``` +- **Expected**: Error about failed to read or parse file, exit code 1 + +### 6.11 `events import` — invalid JSON file +```bash +echo "not json" > /tmp/bento-bad-events.json +bun run bin/bento events import /tmp/bento-bad-events.json +``` +- **Expected**: Error about failed to read or parse file, exit code 1 + +### 6.12 `events import` — empty array +```bash +echo "[]" > /tmp/bento-empty-events.json +bun run bin/bento events import /tmp/bento-empty-events.json +``` +- **Expected**: Error about non-empty array required, exit code 1 + +### 6.13 `events purchase` — basic +```bash +bun run bin/bento events purchase \ + --email "$BENTO_TEST_EMAIL" \ + --amount 2999 \ + --currency USD \ + --key "order_12345" +``` +- **Expected (normal)**: Success message with amount, currency, and email +- **Expected (--json)**: `{ "success": true, "data": { "email": "...", "amount": 2999, "currency": "USD", "key": "order_12345" }, "meta": { "count": 1 } }` + +### 6.14 `events purchase --json` +```bash +bun run bin/bento events purchase \ + --email "$BENTO_TEST_EMAIL" \ + --amount 2999 \ + --currency USD \ + --key "order_12345" \ + --json +``` +- **Expected**: JSON envelope with purchase data, exit code 0 + +### 6.15 `events purchase` — with cart +```bash +bun run bin/bento events purchase \ + --email "$BENTO_TEST_EMAIL" \ + --amount 5998 \ + --currency USD \ + --key "order_12346" \ + --cart '{"items": [{"product_name": "Widget", "quantity": 2, "product_price": 2999}]}' +``` +- **Expected**: Success message, exit code 0 + +### 6.16 `events purchase` — invalid cart JSON +```bash +bun run bin/bento events purchase \ + --email "$BENTO_TEST_EMAIL" \ + --amount 2999 \ + --currency USD \ + --key "order_bad" \ + --cart "not json" +``` +- **Expected**: Error about invalid JSON in --cart, exit code 1 + +### 6.17 `events purchase` — invalid amount +```bash +bun run bin/bento events purchase \ + --email "$BENTO_TEST_EMAIL" \ + --amount "abc" \ + --currency USD \ + --key "order_nan" +``` +- **Expected**: Error about --amount must be a number, exit code 1 + +### 6.18 `events purchase` — missing required options +```bash +bun run bin/bento events purchase --email "$BENTO_TEST_EMAIL" +``` +- **Expected**: Error about required --amount, --currency, --key, exit code 1 + +--- + +## 7. Broadcasts + +### 7.1 `broadcasts list` +```bash +bun run bin/bento broadcasts list +``` +- **Expected (normal)**: Table with columns: NAME, SUBJECT, SENT, OPENS, CLICKS, CREATED +- **Expected**: If no broadcasts, "No broadcasts found" message + +### 7.2 `broadcasts list --json` +```bash +bun run bin/bento broadcasts list --json +``` +- **Expected**: `{ "success": true, "data": [ { "name": "...", "subject": "...", "recipients": N, "opens": N, "clicks": N, "created": "..." } ], "meta": { "count": N } }` + +### 7.3 `broadcasts list --quiet` +```bash +bun run bin/bento broadcasts list --quiet +``` +- **Expected**: No output, exit code 0 + +### 7.4 `broadcasts list` — paginated +```bash +bun run bin/bento broadcasts list --page 1 --per-page 5 +``` +- **Expected**: Table with at most 5 rows +- **Expected (--json)**: meta contains `page`, `pageSize`, `hasMore`, `total` + +### 7.5 `broadcasts list` — per-page only +```bash +bun run bin/bento broadcasts list --per-page 2 +``` +- **Expected**: First page with 2 results + +### 7.6 `broadcasts create` — minimal required +```bash +bun run bin/bento broadcasts create --name "CLI Test Broadcast" --subject "Test Subject" +``` +- **Expected (normal)**: Success message with broadcast name +- **Expected (--json)**: `{ "success": true, "data": { ... } }` + +### 7.7 `broadcasts create --json` +```bash +bun run bin/bento broadcasts create --name "CLI JSON Broadcast" --subject "JSON Subject" --json +``` +- **Expected**: JSON envelope with success and broadcast object + +### 7.8 `broadcasts create` — full options +```bash +bun run bin/bento broadcasts create \ + --name "Full Test Broadcast" \ + --subject "Full Subject" \ + --content "

Hello

" \ + --type html \ + --from-name "Test Sender" \ + --from-email "sender@test.com" \ + --include-tags "vip,active" \ + --exclude-tags "unsubscribed" \ + --batch-size 500 +``` +- **Expected**: Success message, exit code 0 + +### 7.9 `broadcasts create` — missing name +```bash +bun run bin/bento broadcasts create --subject "No Name" +``` +- **Expected**: Error about required --name, exit code 1 + +### 7.10 `broadcasts create` — missing subject +```bash +bun run bin/bento broadcasts create --name "No Subject" +``` +- **Expected**: Error about required --subject, exit code 1 + +### 7.11 `broadcasts create` — invalid type +```bash +bun run bin/bento broadcasts create --name "Bad Type" --subject "Test" --type "invalid" +``` +- **Expected**: Error about invalid type (must be plain/html/markdown), exit code 1 + +### 7.12 `broadcasts create` — markdown type +```bash +bun run bin/bento broadcasts create --name "MD Broadcast" --subject "MD Test" --content "# Hello" --type markdown +``` +- **Expected**: Success, exit code 0 + +### 7.13 `broadcasts create` — plain type +```bash +bun run bin/bento broadcasts create --name "Plain Broadcast" --subject "Plain Test" --content "Hello plain" --type plain +``` +- **Expected**: Success, exit code 0 + +--- + +## 8. Sequences + +### 8.1 `sequences list` +```bash +bun run bin/bento sequences list +``` +- **Expected (normal)**: Table with columns: ID, NAME, EMAILS, CREATED +- **Expected**: If no sequences, "No sequences found" message + +### 8.2 `sequences list --json` +```bash +bun run bin/bento sequences list --json +``` +- **Expected**: `{ "success": true, "data": [ { "id": "...", "name": "...", "emails": N, "created": "..." } ], "meta": { "count": N } }` + +### 8.3 `sequences list --quiet` +```bash +bun run bin/bento sequences list --quiet +``` +- **Expected**: No output, exit code 0 + +### 8.4 `sequences email create` — minimal +```bash +bun run bin/bento sequences email create "" \ + --subject "Welcome Email" \ + --html "

Welcome!

" +``` +- **Expected (normal)**: Success message with subject line and email ID +- **Expected (--json)**: `{ "success": true, "data": { "subject": "Welcome Email", "id": "..." } }` +- **Note**: Replace `` with a real ID from `sequences list` + +### 8.5 `sequences email create --json` +```bash +bun run bin/bento sequences email create "" \ + --subject "JSON Email" \ + --html "

Test

" \ + --json +``` +- **Expected**: JSON envelope with success and email template object + +### 8.6 `sequences email create` — full options +```bash +bun run bin/bento sequences email create "" \ + --subject "Full Email" \ + --html "

Full test

" \ + --delay-interval days \ + --delay-count 3 \ + --snippet "Preview text here" \ + --cc "cc@test.com" \ + --bcc "bcc@test.com" +``` +- **Expected**: Success, exit code 0 + +### 8.7 `sequences email create` — missing subject +```bash +bun run bin/bento sequences email create "" --html "

No subject

" +``` +- **Expected**: Error about required --subject, exit code 1 + +### 8.8 `sequences email create` — missing html +```bash +bun run bin/bento sequences email create "" --subject "No HTML" +``` +- **Expected**: Error about required --html, exit code 1 + +### 8.9 `sequences email create` — invalid delay interval +```bash +bun run bin/bento sequences email create "" \ + --subject "Bad Delay" \ + --html "

Test

" \ + --delay-interval "weeks" +``` +- **Expected**: Error about invalid delay interval (must be minutes/hours/days/months), exit code 2 + +### 8.10 `sequences email create` — delay count without interval +```bash +bun run bin/bento sequences email create "" \ + --subject "Count Only" \ + --html "

Test

" \ + --delay-count 5 +``` +- **Expected**: Error about requiring --delay-interval with --delay-count, exit code 2 + +--- + +## 9. Stats + +### 9.1 `stats site` +```bash +bun run bin/bento stats site +``` +- **Expected (normal)**: Formatted display with: + - Subscriber Metrics: Total Subscribers, Active Subscribers, Unsubscribed + - Broadcast Metrics: Total Broadcasts, Avg. Open Rate, Avg. Click Rate + - Numbers formatted with thousand separators, percentages with 1 decimal + +### 9.2 `stats site --json` +```bash +bun run bin/bento stats site --json +``` +- **Expected**: `{ "success": true, "data": { "total_subscribers": N, "active_subscribers": N, "unsubscribed_count": N, "broadcast_count": N, "average_open_rate": N, "average_click_rate": N } }` + +### 9.3 `stats site --quiet` +```bash +bun run bin/bento stats site --quiet +``` +- **Expected**: No output, exit code 0 + +### 9.4 `stats segment` +```bash +bun run bin/bento stats segment "" +``` +- **Expected (normal)**: Key-value display of segment statistics +- **Expected (--json)**: `{ "success": true, "data": { "segment_id": "...", "subscriber_count": N, ... }, "meta": { "count": 1 } }` +- **Note**: Replace `` with a real segment ID + +### 9.5 `stats segment --json` +```bash +bun run bin/bento stats segment "" --json +``` +- **Expected**: JSON envelope with segment stats, exit code 0 + +### 9.6 `stats segment --quiet` +```bash +bun run bin/bento stats segment "" --quiet +``` +- **Expected**: No output, exit code 0 + +### 9.7 `stats segment` — missing argument +```bash +bun run bin/bento stats segment +``` +- **Expected**: Error about missing segment-id argument, exit code 1 + +### 9.8 `stats report` +```bash +bun run bin/bento stats report "" +``` +- **Expected (normal)**: Key-value display of report statistics (total_sent, opens, clicks, etc.) +- **Expected (--json)**: `{ "success": true, "data": { "report_id": "...", "total_sent": N, "total_opens": N, ... }, "meta": { "count": 1 } }` +- **Note**: Replace `` with a real report ID + +### 9.9 `stats report --json` +```bash +bun run bin/bento stats report "" --json +``` +- **Expected**: JSON envelope with report stats, exit code 0 + +### 9.10 `stats report --quiet` +```bash +bun run bin/bento stats report "" --quiet +``` +- **Expected**: No output, exit code 0 + +### 9.11 `stats report` — missing argument +```bash +bun run bin/bento stats report +``` +- **Expected**: Error about missing report-id argument, exit code 1 + +--- + +## 10. Dashboard + +### 10.1 `dashboard` — authenticated +```bash +bun run bin/bento dashboard +``` +- **Expected**: Opens browser to Bento dashboard URL with site UUID, success message, exit code 0 +- **Note**: Browser open may need to be mocked in automated testing + +### 10.2 `dashboard --json` +```bash +bun run bin/bento dashboard --json +``` +- **Expected**: `{ "success": true, "data": { "url": "https://app.bentonow.com/...", "profile": "...", "siteUuid": "..." } }` + +### 10.3 `dashboard -p ` +```bash +bun run bin/bento dashboard -p staging +``` +- **Expected**: Opens dashboard for the "staging" profile's site UUID + +### 10.4 `dashboard -p ` +```bash +bun run bin/bento dashboard -p doesnotexist +``` +- **Expected**: Error about profile not found, exit code 1 + +### 10.5 `dashboard` — not authenticated +```bash +# (after auth logout) +bun run bin/bento dashboard +``` +- **Expected**: Falls back to login page URL or shows auth required message + +--- + +## 11. Emails (Transactional) + +### 11.1 `emails send` — basic +```bash +bun run bin/bento emails send \ + --to "recipient@example.com" \ + --from "sender@example.com" \ + --subject "Test Transactional" \ + --html-body "

Hello

This is a test.

" +``` +- **Expected (normal)**: Success message confirming email sent +- **Expected (--json)**: `{ "success": true, "data": { "queued": 1 }, "meta": { "count": 1 } }` + +### 11.2 `emails send --json` +```bash +bun run bin/bento emails send \ + --to "recipient@example.com" \ + --from "sender@example.com" \ + --subject "JSON Test" \ + --html-body "

Test

" \ + --json +``` +- **Expected**: JSON envelope with queued count, exit code 0 + +### 11.3 `emails send` — with personalizations +```bash +bun run bin/bento emails send \ + --to "recipient@example.com" \ + --from "sender@example.com" \ + --subject "Personalized Email" \ + --html-body "

Hello {{ name }}!

" \ + --personalizations '{"name": "John", "discount": 20}' +``` +- **Expected**: Success message, exit code 0 + +### 11.4 `emails send` — invalid personalizations JSON +```bash +bun run bin/bento emails send \ + --to "recipient@example.com" \ + --from "sender@example.com" \ + --subject "Test" \ + --html-body "

Test

" \ + --personalizations "not json" +``` +- **Expected**: Error about invalid JSON in --personalizations, exit code 1 + +### 11.5 `emails send` — missing required options +```bash +bun run bin/bento emails send --to "recipient@example.com" +``` +- **Expected**: Error about required --from, --subject, --html-body, exit code 1 + +### 11.6 `emails send-batch` — from JSON file +```bash +cat > /tmp/bento-test-emails.json << 'EOF' +[ + {"to": "user1@test.com", "from": "sender@test.com", "subject": "Hello 1", "html_body": "

Hi 1

"}, + {"to": "user2@test.com", "from": "sender@test.com", "subject": "Hello 2", "html_body": "

Hi 2

"} +] +EOF + +bun run bin/bento emails send-batch --file /tmp/bento-test-emails.json +``` +- **Expected (normal)**: Success message with queued count +- **Expected (--json)**: `{ "success": true, "data": { "queued": 2 }, "meta": { "count": 2 } }` + +### 11.7 `emails send-batch --json` +```bash +bun run bin/bento emails send-batch --file /tmp/bento-test-emails.json --json +``` +- **Expected**: JSON envelope with queued count, exit code 0 + +### 11.8 `emails send-batch` — file not found +```bash +bun run bin/bento emails send-batch --file /tmp/nonexistent-emails.json +``` +- **Expected**: Error about failed to read or parse file, exit code 1 + +### 11.9 `emails send-batch` — empty array +```bash +echo "[]" > /tmp/bento-empty-emails.json +bun run bin/bento emails send-batch --file /tmp/bento-empty-emails.json +``` +- **Expected**: Error about non-empty array required, exit code 1 + +### 11.10 `emails send-batch` — exceeds 100 limit +```bash +# Create a file with 101 email objects +python3 -c "import json; print(json.dumps([{'to':f'u{i}@t.com','from':'s@t.com','subject':'S','html_body':'

B

'} for i in range(101)]))" > /tmp/bento-too-many-emails.json +bun run bin/bento emails send-batch --file /tmp/bento-too-many-emails.json +``` +- **Expected**: Error about batch limit of 100, exit code 1 + +--- + +## 12. Workflows + +### 12.1 `workflows list` +```bash +bun run bin/bento workflows list +``` +- **Expected (normal)**: Table with columns: ID, Name, Created, Templates +- **Expected**: If no workflows, "No workflows found" message + +### 12.2 `workflows list --json` +```bash +bun run bin/bento workflows list --json +``` +- **Expected**: `{ "success": true, "data": [ { "id": "...", "type": "...", "attributes": { "name": "...", "created_at": "...", "email_templates": [...] } } ], "meta": { "count": N } }` + +### 12.3 `workflows list --quiet` +```bash +bun run bin/bento workflows list --quiet +``` +- **Expected**: No output, exit code 0 + +--- + +## 13. Templates + +### 13.1 `templates get` +```bash +bun run bin/bento templates get "" +``` +- **Expected (normal)**: Key-value display of email template properties +- **Expected (--json)**: `{ "success": true, "data": { "id": "...", ... }, "meta": { "count": 1 } }` +- **Note**: Replace `` with a real template ID + +### 13.2 `templates get --json` +```bash +bun run bin/bento templates get "" --json +``` +- **Expected**: JSON envelope with template data, exit code 0 + +### 13.3 `templates get` — not found +```bash +bun run bin/bento templates get "nonexistent-id" +``` +- **Expected**: Error about template not found, exit code 1 + +### 13.4 `templates get` — missing argument +```bash +bun run bin/bento templates get +``` +- **Expected**: Error about missing id argument, exit code 1 + +### 13.5 `templates update` — subject only +```bash +bun run bin/bento templates update "" --subject "New Subject Line" +``` +- **Expected (normal)**: Success message confirming update +- **Expected (--json)**: `{ "success": true, "data": { ... }, "meta": { "count": 1 } }` + +### 13.6 `templates update` — html only +```bash +bun run bin/bento templates update "" --html "

Updated Content

" +``` +- **Expected**: Success message, exit code 0 + +### 13.7 `templates update` — both subject and html +```bash +bun run bin/bento templates update "" --subject "Updated" --html "

New body

" +``` +- **Expected**: Success message, exit code 0 + +### 13.8 `templates update --json` +```bash +bun run bin/bento templates update "" --subject "JSON Update" --json +``` +- **Expected**: JSON envelope with updated template data, exit code 0 + +### 13.9 `templates update` — no options +```bash +bun run bin/bento templates update "" +``` +- **Expected**: Error about providing at least --subject or --html, exit code 1 + +### 13.10 `templates update` — missing argument +```bash +bun run bin/bento templates update +``` +- **Expected**: Error about missing id argument, exit code 1 + +--- + +## 14. Forms + +### 14.1 `forms responses` +```bash +bun run bin/bento forms responses "" +``` +- **Expected (normal)**: Table with columns: ID, UUID, Type, Date, IP +- **Expected**: If no responses, "No responses found for this form" message +- **Note**: Replace `` with a real form identifier + +### 14.2 `forms responses --json` +```bash +bun run bin/bento forms responses "" --json +``` +- **Expected**: `{ "success": true, "data": [ { "id": "...", "type": "...", "attributes": { "uuid": "...", "data": { ... } } } ], "meta": { "count": N } }` + +### 14.3 `forms responses --quiet` +```bash +bun run bin/bento forms responses "" --quiet +``` +- **Expected**: No output, exit code 0 + +### 14.4 `forms responses` — missing argument +```bash +bun run bin/bento forms responses +``` +- **Expected**: Error about missing form-id argument, exit code 1 + +--- + +## 15. Experimental + +### 15.1 `experimental validate-email` +```bash +bun run bin/bento experimental validate-email "user@example.com" +``` +- **Expected (normal)**: Success/warning message about email validity +- **Expected (--json)**: `{ "success": true, "data": { "email": "user@example.com", "valid": true|false }, "meta": { "count": 1 } }` + +### 15.2 `experimental validate-email --json` +```bash +bun run bin/bento experimental validate-email "user@example.com" --json +``` +- **Expected**: JSON envelope with email and valid boolean, exit code 0 + +### 15.3 `experimental validate-email` — with optional flags +```bash +bun run bin/bento experimental validate-email "user@example.com" \ + --ip "1.2.3.4" \ + --name "John Doe" \ + --user-agent "Mozilla/5.0" +``` +- **Expected**: Success/warning message, exit code 0 + +### 15.4 `experimental validate-email` — missing argument +```bash +bun run bin/bento experimental validate-email +``` +- **Expected**: Error about missing email argument, exit code 1 + +### 15.5 `experimental guess-gender` +```bash +bun run bin/bento experimental guess-gender "Alice" +``` +- **Expected (normal)**: Key-value display: Name, Gender, Confidence (percentage) +- **Expected (--json)**: `{ "success": true, "data": { "confidence": N|null, "gender": "female"|"male"|null }, "meta": { "count": 1 } }` + +### 15.6 `experimental guess-gender --json` +```bash +bun run bin/bento experimental guess-gender "Bob" --json +``` +- **Expected**: JSON envelope with gender and confidence, exit code 0 + +### 15.7 `experimental guess-gender` — missing argument +```bash +bun run bin/bento experimental guess-gender +``` +- **Expected**: Error about missing name argument, exit code 1 + +### 15.8 `experimental geolocate` +```bash +bun run bin/bento experimental geolocate "8.8.8.8" +``` +- **Expected (normal)**: Key-value display of location data (city, country, lat/lng, etc.) +- **Expected (--json)**: `{ "success": true, "data": { "city_name": "...", "country_name": "...", "latitude": N, "longitude": N, ... }, "meta": { "count": 1 } }` + +### 15.9 `experimental geolocate --json` +```bash +bun run bin/bento experimental geolocate "8.8.8.8" --json +``` +- **Expected**: JSON envelope with location data, exit code 0 + +### 15.10 `experimental geolocate` — missing argument +```bash +bun run bin/bento experimental geolocate +``` +- **Expected**: Error about missing ip argument, exit code 1 + +### 15.11 `experimental blacklist` — by domain +```bash +bun run bin/bento experimental blacklist --domain "example.com" +``` +- **Expected (normal)**: Key-value display: Query, Description, Results +- **Expected (--json)**: `{ "success": true, "data": { "description": "...", "query": "example.com", "results": { ... } }, "meta": { "count": 1 } }` + +### 15.12 `experimental blacklist` — by IP +```bash +bun run bin/bento experimental blacklist --ip "1.2.3.4" +``` +- **Expected**: Same shape as domain check, exit code 0 + +### 15.13 `experimental blacklist --json` +```bash +bun run bin/bento experimental blacklist --domain "example.com" --json +``` +- **Expected**: JSON envelope with blacklist results, exit code 0 + +### 15.14 `experimental blacklist` — no domain or IP +```bash +bun run bin/bento experimental blacklist +``` +- **Expected**: Error about providing --domain or --ip, exit code 1 + +### 15.15 `experimental moderate` +```bash +bun run bin/bento experimental moderate "This is a normal marketing email about our product." +``` +- **Expected (normal)**: Success message "Content passed moderation" with category breakdown +- **Expected (--json)**: `{ "success": true, "data": { "flagged": false, "categories": { "hate": false, ... }, "category_scores": { "hate": N, ... } }, "meta": { "count": 1 } }` + +### 15.16 `experimental moderate --json` +```bash +bun run bin/bento experimental moderate "Hello world" --json +``` +- **Expected**: JSON envelope with moderation result, exit code 0 + +### 15.17 `experimental moderate` — missing argument +```bash +bun run bin/bento experimental moderate +``` +- **Expected**: Error about missing content argument, exit code 1 + +--- + +## 16. Unauthenticated Error Handling + +All data commands should fail gracefully when not authenticated. + +### 16.1 `tags list` — no auth +```bash +bun run bin/bento auth logout && bun run bin/bento tags list +``` +- **Expected**: Authentication error, exit code 3 + +### 16.2 `subscribers search` — no auth +```bash +bun run bin/bento subscribers search --email "test@test.com" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.3 `fields list` — no auth +```bash +bun run bin/bento fields list +``` +- **Expected**: Authentication error, exit code 3 + +### 16.4 `broadcasts list` — no auth +```bash +bun run bin/bento broadcasts list +``` +- **Expected**: Authentication error, exit code 3 + +### 16.5 `stats site` — no auth +```bash +bun run bin/bento stats site +``` +- **Expected**: Authentication error, exit code 3 + +### 16.6 `events track` — no auth +```bash +bun run bin/bento events track --email "test@test.com" --event "test" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.7 `sequences list` — no auth +```bash +bun run bin/bento sequences list +``` +- **Expected**: Authentication error, exit code 3 + +### 16.8 `emails send` — no auth +```bash +bun run bin/bento emails send --to "t@t.com" --from "s@t.com" --subject "T" --html-body "

T

" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.9 `workflows list` — no auth +```bash +bun run bin/bento workflows list +``` +- **Expected**: Authentication error, exit code 3 + +### 16.10 `templates get` — no auth +```bash +bun run bin/bento templates get "some-id" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.11 `forms responses` — no auth +```bash +bun run bin/bento forms responses "some-form-id" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.12 `experimental validate-email` — no auth +```bash +bun run bin/bento experimental validate-email "test@test.com" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.13 `subscribers upsert` — no auth +```bash +bun run bin/bento subscribers upsert --email "test@test.com" +``` +- **Expected**: Authentication error, exit code 3 + +### 16.14 `events purchase` — no auth +```bash +bun run bin/bento events purchase --email "t@t.com" --amount 100 --currency USD --key "k1" +``` +- **Expected**: Authentication error, exit code 3 + +--- + +## Exit Code Reference + +| Code | Meaning | How to validate | +|------|---------|-----------------| +| 0 | Success | `$?` equals 0 | +| 1 | General error | Validation failures, auth errors | +| 2 | CLI argument error | Missing/invalid options | +| 3 | Auth required | No active profile or invalid credentials | +| 4 | API error | Rate limit, timeout, not found | +| 5 | File I/O error | File not found, read permission | +| 6 | Data validation error | Bad CSV format, invalid data | + +--- + +## JSON Envelope Shape (all --json responses) + +```json +{ + "success": true | false, + "error": null | "error message string", + "data": "", + "meta": { + "count": 0, + "total": 0, + "page": 1, + "pageSize": 25, + "hasMore": false, + "code": 0, + "hint": "optional hint string" + } +} +``` + +--- + +## Evaluation Criteria + +For each command, the automated agent should check: + +1. **Exit code**: Matches expected value (0 for success, specific code for errors) +2. **Output format**: Normal mode shows human-readable text; `--json` returns valid parseable JSON matching the envelope shape; `--quiet` produces no stdout +3. **Data shape**: JSON responses contain the expected keys and value types +4. **Error messages**: Errors are descriptive, mention the problem, and never show raw stack traces +5. **Idempotency**: Commands that should be idempotent (logout, list) don't fail on repeat runs +6. **Safety flags**: `--dry-run` never modifies data; `--limit` caps operation count; `--confirm` skips prompts + +--- + +## Suggested Test Execution Order + +Run in this order to manage state (auth, profiles, test data): + +1. **0.x** — Global flags (version, help, mutual exclusion) +2. **1.1–1.4** — Auth login (establish credentials) +3. **1.5–1.7** — Auth status (verify login worked) +4. **2.1–2.6** — Profile CRUD (add, list, use) +5. **4.1–4.8** — Tags (list, create, search) +6. **5.1–5.10** — Fields (list, create, validation) +7. **3.1–3.9** — Subscribers search +8. **3.10–3.16** — Subscribers import (dry-run first, then actual) +9. **3.17–3.22** — Subscribers tag +10. **3.23–3.31** — Subscribers subscribe/unsubscribe (use dry-run to avoid side effects) +11. **3.32–3.39** — Subscribers field management (set, remove, update) +12. **3.40–3.42** — Subscribers change-email +13. **3.43–3.47** — Subscribers upsert +14. **6.1–6.7** — Events track +15. **6.8–6.12** — Events import +16. **6.13–6.18** — Events purchase +17. **7.1–7.13** — Broadcasts (list, create) +18. **8.1–8.10** — Sequences (list, email create) +19. **9.1–9.3** — Stats site +20. **9.4–9.11** — Stats segment and report +21. **10.1–10.5** — Dashboard +22. **11.1–11.10** — Emails (transactional send, batch) +23. **12.1–12.3** — Workflows (list) +24. **13.1–13.10** — Templates (get, update) +25. **14.1–14.4** — Forms (responses) +26. **15.1–15.17** — Experimental (validate-email, guess-gender, geolocate, blacklist, moderate) +27. **2.7–2.10** — Profile cleanup (remove, error cases) +28. **1.9–1.10** — Auth logout +29. **16.x** — Unauthenticated error handling (must run last) diff --git a/tests/core/sdk.test.ts b/tests/core/sdk.test.ts index 36fb168..e1e4fdb 100644 --- a/tests/core/sdk.test.ts +++ b/tests/core/sdk.test.ts @@ -49,10 +49,9 @@ describe("BentoClient", () => { // Spy on config module to use our test config const configModule = await import("../../src/core/config"); const originalConfig = configModule.config; - const getCurrentProfileSpy = spyOn( - originalConfig, - "getCurrentProfile" - ).mockImplementation(async () => null); + const getCurrentProfileSpy = spyOn(originalConfig, "getCurrentProfile").mockImplementation( + async () => null + ); try { await expect(testClient.getClient()).rejects.toThrow(CLIError); @@ -228,14 +227,12 @@ describe("BentoClient", () => { }; const configModule = await import("../../src/core/config"); - spyOn(configModule.config, "getCurrentProfile").mockImplementation( - async () => ({ - apiKey: "test-api-key", - siteId: "test-site-id", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - ); + spyOn(configModule.config, "getCurrentProfile").mockImplementation(async () => ({ + apiKey: "test-api-key", + siteId: "test-site-id", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); }); it("translates NotAuthorizedError to AUTH_FAILED", async () => { @@ -412,17 +409,13 @@ describe("SDK wrapper methods", () => { }, Tags: { getTags: mock(() => - Promise.resolve([ - { id: "tag-1", type: "tag", attributes: { name: "test-tag" } }, - ]) + Promise.resolve([{ id: "tag-1", type: "tag", attributes: { name: "test-tag" } }]) ), createTag: mock(() => Promise.resolve([{ id: "tag-1" }])), }, Fields: { getFields: mock(() => - Promise.resolve([ - { id: "field-1", type: "field", attributes: { key: "test_field" } }, - ]) + Promise.resolve([{ id: "field-1", type: "field", attributes: { key: "test_field" } }]) ), createField: mock(() => Promise.resolve([{ id: "field-1" }])), }, @@ -434,6 +427,34 @@ describe("SDK wrapper methods", () => { }) ), }, + Sequences: { + getSequences: mock(() => + Promise.resolve([ + { + id: "sequence-1", + type: "sequences", + attributes: { + name: "Welcome Sequence", + created_at: "2025-01-01T00:00:00Z", + email_templates: [{ id: 1, subject: "Welcome", stats: null }], + }, + }, + ]) + ), + createSequenceEmail: mock(() => + Promise.resolve({ + id: "template-1", + type: "email_templates", + attributes: { + name: "Welcome", + subject: "Welcome", + html: "

Welcome

", + created_at: "2025-01-01T00:00:00Z", + stats: null, + }, + }) + ), + }, Commands: { addTag: mock(() => Promise.resolve({ id: "sub-1" })), removeTag: mock(() => Promise.resolve({ id: "sub-1" })), @@ -457,8 +478,9 @@ describe("SDK wrapper methods", () => { // Override getClient to return mock SDK (client as any).sdk = mockSdk; (client as any).profile = { - apiKey: "test-key", - siteId: "test-site", + publishableKey: "test-pub-key", + secretKey: "test-secret-key", + siteUuid: "test-site-uuid", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -674,6 +696,93 @@ describe("SDK wrapper methods", () => { }); }); + describe("sequence operations", () => { + it("getSequences returns sequence array", async () => { + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + id: "sequence-1", + type: "sequences", + attributes: { name: "Welcome", created_at: "2025-01-01T00:00:00Z", email_templates: [] }, + }, + ], + meta: { total: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + try { + const sequences = await client.getSequences(); + expect(sequences).toHaveLength(1); + expect(sequences[0].id).toBe("sequence-1"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("createSequenceEmail uses SDK method when available", async () => { + const result = await client.createSequenceEmail("sequence_1", { + subject: "Welcome", + html: "

Welcome

", + }); + + expect(result?.id).toBe("template-1"); + expect(mockSdk.V1.Sequences.createSequenceEmail).toHaveBeenCalledWith("sequence_1", { + subject: "Welcome", + html: "

Welcome

", + }); + }); + + it("createSequenceEmail falls back to API POST when SDK method is unavailable", async () => { + mockSdk.V1.Sequences.createSequenceEmail = undefined; + + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: { + id: "template-2", + type: "email_templates", + attributes: { + name: "Fallback", + subject: "Fallback", + html: "

Fallback

", + created_at: "2025-01-01T00:00:00Z", + stats: null, + }, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + try { + const result = await client.createSequenceEmail("sequence_42", { + subject: "Fallback", + html: "

Fallback

", + }); + + expect(result?.id).toBe("template-2"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [requestUrl, init] = fetchSpy.mock.calls[0]; + expect(String(requestUrl)).toContain("/fetch/sequences/sequence_42/emails/templates"); + expect(init?.method).toBe("POST"); + const parsedBody = JSON.parse(init?.body as string); + expect(parsedBody.site_uuid).toBe("test-site-uuid"); + expect(parsedBody.email_template.subject).toBe("Fallback"); + expect(parsedBody.email_template.html).toBe("

Fallback

"); + } finally { + fetchSpy.mockRestore(); + } + }); + }); + describe("error propagation", () => { it("propagates errors through handleApiCall", async () => { mockSdk.V1.Tags.getTags = mock(() =>