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(() =>