From 4fd6a7122a995194d5998d4e62b4cca4c7bd9e54 Mon Sep 17 00:00:00 2001 From: ziptied Date: Mon, 16 Mar 2026 13:23:59 +0900 Subject: [PATCH 01/11] feat: add forms responses and stats commands Add new CLI commands for managing forms and retrieving statistics. Implement forms responses command to fetch and display form submission data with support for JSON and table output formats. Extend stats commands with segment and report statistics endpoints. Extract error handling logic into reusable function to reduce code duplication across command handlers. --- src/cli.ts | 17 +- src/commands/emails.ts | 143 ++++++++++ src/commands/events.ts | 146 +++++++++- src/commands/experimental.ts | 232 +++++++++++++++ src/commands/forms.ts | 74 +++++ src/commands/sequences.ts | 207 ++++++++++++++ src/commands/stats.ts | 80 +++++- src/commands/subscribers/change-email.ts | 45 +++ src/commands/subscribers/command-group.ts | 6 + src/commands/subscribers/field.ts | 139 +++++++++ src/commands/subscribers/unsubscribe.ts | 10 +- src/commands/subscribers/upsert.ts | 64 +++++ src/commands/templates.ts | 102 +++++++ src/commands/workflows.ts | 71 +++++ src/core/sdk.ts | 257 +++++++++++++++++ src/tests/commands/sequences.test.ts | 333 ++++++++++++++++++++++ src/types/sdk.ts | 53 ++++ tests/core/sdk.test.ts | 128 +++++++-- 18 files changed, 2069 insertions(+), 38 deletions(-) create mode 100644 src/commands/emails.ts create mode 100644 src/commands/experimental.ts create mode 100644 src/commands/forms.ts create mode 100644 src/commands/sequences.ts create mode 100644 src/commands/subscribers/change-email.ts create mode 100644 src/commands/subscribers/field.ts create mode 100644 src/commands/subscribers/upsert.ts create mode 100644 src/commands/templates.ts create mode 100644 src/commands/workflows.ts create mode 100644 src/tests/commands/sequences.test.ts diff --git a/src/cli.ts b/src/cli.ts index 28fefb9..a794149 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,14 +4,21 @@ 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 { 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(); @@ -20,7 +27,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) => { @@ -50,7 +57,13 @@ registerTagsCommands(program); registerFieldsCommands(program); registerEventsCommands(program); registerBroadcastsCommands(program); +registerSequencesCommands(program); registerStatsCommands(program); +registerEmailsCommands(program); +registerWorkflowsCommands(program); +registerTemplatesCommands(program); +registerFormsCommands(program); +registerExperimentalCommands(program); registerDashboardCommand(program); // Future commands to be implemented: @@ -93,7 +106,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 new file mode 100644 index 0000000..94189ab --- /dev/null +++ b/src/commands/sequences.ts @@ -0,0 +1,207 @@ +/** + * Sequences commands + * + * Commands: + * - bento sequences list - List all sequences + * - bento sequences email create --subject --html + */ + +import type { Command } from "commander"; +import { output } from "../core/output"; +import { CLIError, bento } from "../core/sdk"; +import type { CreateSequenceEmailParameters, Sequence, SequenceDelayInterval } from "../types/sdk"; + +interface EmailCreateOptions { + subject: string; + html: string; + delayInterval?: string; + delayCount?: string; + snippet?: string; + editor?: string; + cc?: string; + bcc?: string; + to?: string; +} + +const VALID_DELAY_INTERVALS: SequenceDelayInterval[] = ["minutes", "hours", "days", "months"]; + +export function registerSequencesCommands(program: Command): void { + const sequences = program.command("sequences").description("Manage email sequences"); + + sequences + .command("list") + .description("List all sequences") + .action(async () => { + try { + output.startSpinner("Fetching sequences..."); + const sequenceList = await bento.getSequences(); + output.stopSpinner(); + + if (!sequenceList || sequenceList.length === 0) { + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: [], + meta: { count: 0 }, + }); + } else { + output.info("No sequences found."); + } + return; + } + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: sequenceList, + meta: { count: sequenceList.length }, + }); + return; + } + + output.table(sequencesToRows(sequenceList), { + columns: sequenceColumns(), + meta: { total: sequenceList.length }, + emptyMessage: "No sequences found.", + }); + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); + + const email = sequences.command("email").description("Manage sequence emails"); + + email + .command("create ") + .description("Create a new email in a sequence") + .requiredOption("-s, --subject ", "Email subject line") + .requiredOption("-H, --html ", "Email HTML content") + .option("--delay-interval ", "Delay unit: minutes, hours, days, or months") + .option("--delay-count ", "Delay amount (requires --delay-interval)") + .option("--snippet ", "Inbox preview snippet") + .option("--editor ", "Editor mode") + .option("--cc ", "CC email address") + .option("--bcc ", "BCC email address") + .option("--to ", "Override recipient email") + .action(async (sequenceId: string, options: EmailCreateOptions) => { + try { + const delayInterval = parseDelayInterval(options.delayInterval); + + if (options.delayCount && !delayInterval) { + output.error("--delay-count requires --delay-interval to be set."); + process.exit(2); + } + + const delayCount = options.delayCount + ? parsePositiveInteger(options.delayCount, "--delay-count") + : undefined; + + const params: CreateSequenceEmailParameters = { + subject: options.subject, + html: options.html, + inbox_snippet: options.snippet, + delay_interval: delayInterval, + delay_interval_count: delayCount, + editor_choice: options.editor, + cc: options.cc, + bcc: options.bcc, + to: options.to, + }; + + output.startSpinner("Creating sequence email..."); + const result = await bento.createSequenceEmail(sequenceId, params); + output.stopSpinner("Sequence email created"); + + if (output.isJson()) { + output.json({ + success: true, + error: null, + data: result, + meta: { count: result ? 1 : 0 }, + }); + return; + } + + if (!output.isQuiet()) { + const idText = result?.id ? ` (ID: ${result.id})` : ""; + output.success(`Created sequence email "${options.subject}"${idText}`); + } + } catch (error) { + output.failSpinner(); + handleError(error); + } + }); +} + +function sequencesToRows(sequences: Sequence[]) { + return sequences.map((sequence) => { + const templates = sequence.attributes.email_templates ?? []; + + return { + id: sequence.id, + name: sequence.attributes.name, + emails: templates.length.toString(), + created: formatDate(sequence.attributes.created_at), + }; + }); +} + +function sequenceColumns() { + return [ + { key: "id" as const, header: "ID" }, + { key: "name" as const, header: "NAME" }, + { key: "emails" as const, header: "EMAILS" }, + { key: "created" as const, header: "CREATED" }, + ]; +} + +function parsePositiveInteger(value: string, flag: string): number { + const numeric = Number.parseInt(value, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + output.error(`${flag} must be a positive integer.`); + process.exit(2); + } + return numeric; +} + +function parseDelayInterval(value?: string): SequenceDelayInterval | undefined { + if (!value) { + return undefined; + } + + if (VALID_DELAY_INTERVALS.includes(value as SequenceDelayInterval)) { + return value as SequenceDelayInterval; + } + + output.error( + `Invalid --delay-interval "${value}". Must be one of: ${VALID_DELAY_INTERVALS.join(", ")}` + ); + process.exit(2); +} + +function formatDate(isoDate: string): string { + try { + const date = new Date(isoDate); + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return isoDate; + } +} + +function handleError(error: unknown): never { + if (error instanceof CLIError) { + output.error(error.message); + } else 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..69ebbf8 --- /dev/null +++ b/src/commands/workflows.ts @@ -0,0 +1,71 @@ +/** + * 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; + } + + output.table( + result.map((w) => ({ + id: w.id, + name: w.attributes?.name ?? "N/A", + created_at: w.attributes?.created_at ?? "N/A", + templates: w.attributes?.email_templates?.length ?? 0, + })), + { + columns: [ + { key: "id", header: "ID" }, + { key: "name", header: "Name" }, + { key: "created_at", header: "Created" }, + { key: "templates", header: "Templates" }, + ], + } + ); + } 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 74f279b..4cc647d 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -19,13 +19,23 @@ import { import type { BentoProfile } from "../types/config"; import type { AddFieldParams, + BlacklistResponse, Broadcast, + ContentModerationResult, CreateBroadcastInput, + CreateSequenceEmailParameters, + EmailTemplate, Field, + FormResponse, GetSubscriberParams, + GuessGenderResponse, ImportResult, ImportSubscribersParams, + PurchaseDetails, + ReportStats, SDKErrorCode, + SegmentStats, + Sequence, SiteStats, Subscriber, SubscriberSearchParams, @@ -33,6 +43,8 @@ import type { Tag, TagSubscriberParams, TrackEventParams, + TransactionalEmail, + Workflow, } from "../types/sdk"; import { config } from "./config"; @@ -428,6 +440,107 @@ 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 + */ + async getWorkflows(): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Workflows.getWorkflows()); + } + + // ============================================================ + // 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 // ============================================================ @@ -440,6 +553,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.geolocate({ ip })); + } + + /** + * Check domain/IP against blacklists + */ + async checkBlacklist(domain?: string, ip?: string): Promise { + const sdk = await this.getClient(); + const params = domain ? { domain } : { ip: ip! }; + return this.handleApiCall(() => sdk.V1.Experimental.checkBlacklist(params)); + } + + /** + * Perform content moderation + */ + async getContentModeration(content: string): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Experimental.getContentModeration(content)); + } + // ============================================================ // Broadcast Operations // ============================================================ @@ -536,6 +717,53 @@ export class BentoClient { return this.handleApiCall(() => sdk.V1.Broadcasts.createBroadcast([input])); } + // ============================================================ + // Sequence Operations + // ============================================================ + + /** + * List all sequences + */ + async getSequences(): Promise { + const sdk = await this.getClient(); + return this.handleApiCall(() => sdk.V1.Sequences.getSequences()); + } + + /** + * Create a sequence email template + */ + async createSequenceEmail( + sequenceId: string, + params: CreateSequenceEmailParameters + ): Promise { + const sdk = await this.getClient(); + const sequenceApi = sdk.V1.Sequences as unknown as { + createSequenceEmail?: ( + id: string, + input: CreateSequenceEmailParameters + ) => Promise; + }; + + if (typeof sequenceApi.createSequenceEmail === "function") { + return this.handleApiCall(() => sequenceApi.createSequenceEmail(sequenceId, params)); + } + + const response = await this.apiPost<{ data?: EmailTemplate } | EmailTemplate>( + `/fetch/sequences/${encodeURIComponent(sequenceId)}/emails/templates`, + params + ); + + if (!response) { + return null; + } + + if ("data" in response) { + return response.data ?? null; + } + + return response; + } + // ============================================================ // Error Handling // ============================================================ @@ -673,6 +901,35 @@ export class BentoClient { } } + private async apiPost( + path: string, + body: Record + ): Promise { + const profile = await this.ensureProfileLoaded(); + const url = new URL(`${this.apiBaseUrl}${path}`); + url.searchParams.set("site_uuid", profile.siteUuid); + + const response = await fetch(url, { + method: "POST", + headers: { + ...this.buildAuthHeaders(profile), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const responseBody = await response.text(); + throw this.createHttpError(response.status, responseBody || response.statusText); + } + + try { + return (await response.json()) as T; + } catch { + throw new CLIError("Invalid JSON response from Bento API.", "API_ERROR", response.status); + } + } + private buildAuthHeaders(profile: BentoProfile): Record { const token = Buffer.from(`${profile.publishableKey}:${profile.secretKey}`).toString("base64"); diff --git a/src/tests/commands/sequences.test.ts b/src/tests/commands/sequences.test.ts new file mode 100644 index 0000000..f006813 --- /dev/null +++ b/src/tests/commands/sequences.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "bun"; +import { Command } from "commander"; + +import { registerSequencesCommands } from "../../commands/sequences"; +import { output } from "../../core/output"; +import { bento } from "../../core/sdk"; +import type { EmailTemplate, Sequence } from "../../types/sdk"; + +function runCLI(args: string[], options: { configPath?: string; input?: string } = {}) { + const env: Record = { + ...process.env, + BENTO_API_KEY: "test-api-key", + BENTO_SITE_ID: "test-site-id", + }; + + if (options.configPath) { + env.BENTO_CONFIG_PATH = options.configPath; + } + + const result = spawnSync(["bun", "run", "src/cli.ts", ...args], { + env, + stdin: options.input ? Buffer.from(options.input) : undefined, + }); + + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + exitCode: result.exitCode, + }; +} + +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerSequencesCommands(program); + return program; +} + +function makeSequence(id: string, overrides: Partial = {}): Sequence { + return { + id, + type: "sequences", + attributes: { + name: `Sequence ${id}`, + created_at: "2025-01-01T00:00:00Z", + email_templates: [{ id: 100, subject: "Welcome", stats: null }], + ...overrides, + }, + } as Sequence; +} + +describe("bento sequences", () => { + it("shows sequences help", () => { + const result = runCLI(["sequences", "--help"]); + expect(result.stdout).toContain("Manage email sequences"); + expect(result.stdout).toContain("list"); + expect(result.stdout).toContain("email"); + }); + + it("shows sequences list help", () => { + const result = runCLI(["sequences", "list", "--help"]); + expect(result.stdout).toContain("List all sequences"); + }); + + it("shows sequences email create help", () => { + const result = runCLI(["sequences", "email", "create", "--help"]); + expect(result.stdout).toContain("Create a new email in a sequence"); + expect(result.stdout).toContain("--subject"); + expect(result.stdout).toContain("--html"); + expect(result.stdout).toContain("--delay-interval"); + expect(result.stdout).toContain("--cc"); + expect(result.stdout).toContain("--bcc"); + }); +}); + +describe("bento sequences list", () => { + let tempDir: string; + let configPath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "bento-test-")); + configPath = join(tempDir, "config.json"); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("requires authentication", async () => { + await writeFile(configPath, JSON.stringify({ version: 1, current: null, profiles: {} })); + + const result = runCLI(["sequences", "list"], { configPath }); + expect(result.stderr).toContain("Not authenticated"); + expect(result.exitCode).toBe(1); + }); + + it("outputs JSON error with --json flag when not authenticated", async () => { + await writeFile(configPath, JSON.stringify({ version: 1, current: null, profiles: {} })); + + const result = runCLI(["sequences", "list", "--json"], { configPath }); + expect(result.stderr).toContain("Not authenticated"); + expect(result.exitCode).toBe(1); + }); +}); + +describe("bento sequences email create", () => { + let tempDir: string; + let configPath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "bento-test-")); + configPath = join(tempDir, "config.json"); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("requires --subject option", () => { + const result = runCLI(["sequences", "email", "create", "sequence_123", "--html", "

Hi

"]); + expect(result.stderr).toContain("required option"); + expect(result.stderr).toContain("--subject"); + expect(result.exitCode).toBe(1); + }); + + it("requires --html option", () => { + const result = runCLI(["sequences", "email", "create", "sequence_123", "--subject", "Welcome"]); + expect(result.stderr).toContain("required option"); + expect(result.stderr).toContain("--html"); + expect(result.exitCode).toBe(1); + }); + + it("requires authentication", async () => { + await writeFile(configPath, JSON.stringify({ version: 1, current: null, profiles: {} })); + + const result = runCLI( + [ + "sequences", + "email", + "create", + "sequence_123", + "--subject", + "Welcome", + "--html", + "

Hi

", + ], + { configPath } + ); + + expect(result.stderr).toContain("Not authenticated"); + expect(result.exitCode).toBe(1); + }); + + it("requires --delay-interval when --delay-count is provided", () => { + const result = runCLI([ + "sequences", + "email", + "create", + "sequence_123", + "--subject", + "Welcome", + "--html", + "

Hi

", + "--delay-count", + "3", + ]); + + expect(result.stderr).toContain("--delay-count requires --delay-interval"); + expect(result.exitCode).toBe(2); + }); + + it("rejects invalid --delay-interval", () => { + const result = runCLI([ + "sequences", + "email", + "create", + "sequence_123", + "--subject", + "Welcome", + "--html", + "

Hi

", + "--delay-interval", + "weeks", + ]); + + expect(result.stderr).toContain("Invalid --delay-interval"); + expect(result.exitCode).toBe(2); + }); + + it("rejects non-positive --delay-count", () => { + const result = runCLI([ + "sequences", + "email", + "create", + "sequence_123", + "--subject", + "Welcome", + "--html", + "

Hi

", + "--delay-interval", + "days", + "--delay-count", + "0", + ]); + + expect(result.stderr).toContain("--delay-count must be a positive integer"); + expect(result.exitCode).toBe(2); + }); +}); + +describe("sequences command rendering", () => { + afterEach(() => { + output.reset(); + }); + + it("renders sequence rows in table mode", async () => { + output.setInteractiveOverride(false); + + const sequenceSpy = spyOn(bento, "getSequences").mockResolvedValue([ + makeSequence("1"), + makeSequence("2", { email_templates: [] }), + ]); + const tableSpy = spyOn(output, "table").mockImplementation(() => {}); + + const program = buildProgram(); + await program.parseAsync(["node", "test", "sequences", "list"]); + + expect(sequenceSpy).toHaveBeenCalledTimes(1); + expect(tableSpy).toHaveBeenCalledTimes(1); + + const rows = tableSpy.mock.calls[0][0] as Array>; + expect(rows).toHaveLength(2); + expect(rows[0].id).toBe("1"); + expect(rows[0].name).toBe("Sequence 1"); + expect(rows[0].emails).toBe("1"); + expect(rows[1].emails).toBe("0"); + + sequenceSpy.mockRestore(); + tableSpy.mockRestore(); + }); + + it("renders sequence list JSON payload", async () => { + output.setInteractiveOverride(false); + output.setMode("json"); + + const sequenceSpy = spyOn(bento, "getSequences").mockResolvedValue([makeSequence("1")]); + const jsonSpy = spyOn(output, "json").mockImplementation(() => {}); + + const program = buildProgram(); + await program.parseAsync(["node", "test", "sequences", "list"]); + + expect(sequenceSpy).toHaveBeenCalledTimes(1); + expect(jsonSpy).toHaveBeenCalledTimes(1); + + const payload = jsonSpy.mock.calls[0][0] as { + data: unknown[]; + meta: { count: number }; + }; + expect(payload.data).toHaveLength(1); + expect(payload.meta.count).toBe(1); + + sequenceSpy.mockRestore(); + jsonSpy.mockRestore(); + }); + + it("passes sequence email create arguments to the SDK wrapper", async () => { + output.setInteractiveOverride(false); + + const createSpy = spyOn(bento, "createSequenceEmail").mockResolvedValue({ + id: "template_123", + type: "email_templates", + attributes: { + name: "Email", + subject: "Welcome", + html: "

Hi

", + created_at: "2025-01-01T00:00:00Z", + stats: null, + }, + } as EmailTemplate); + const successSpy = spyOn(output, "success").mockImplementation(() => {}); + + const program = buildProgram(); + await program.parseAsync([ + "node", + "test", + "sequences", + "email", + "create", + "sequence_123", + "--subject", + "Welcome", + "--html", + "

Hi

", + "--delay-interval", + "days", + "--delay-count", + "3", + "--snippet", + "Preview", + "--editor", + "visual", + "--cc", + "team@example.com", + "--bcc", + "audit@example.com", + "--to", + "person@example.com", + ]); + + expect(createSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledWith("sequence_123", { + subject: "Welcome", + html: "

Hi

", + delay_interval: "days", + delay_interval_count: 3, + inbox_snippet: "Preview", + editor_choice: "visual", + cc: "team@example.com", + bcc: "audit@example.com", + to: "person@example.com", + }); + expect(successSpy).toHaveBeenCalledWith( + expect.stringContaining('Created sequence email "Welcome"') + ); + + createSpy.mockRestore(); + successSpy.mockRestore(); + }); +}); diff --git a/src/types/sdk.ts b/src/types/sdk.ts index 9808b24..ce48a1c 100644 --- a/src/types/sdk.ts +++ b/src/types/sdk.ts @@ -39,9 +39,62 @@ export type { CreateBroadcastInput, } from "@bentonow/bento-node-sdk/src/sdk/broadcasts/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"; +export type SequenceDelayInterval = "minutes" | "hours" | "days" | "months"; + +export interface CreateSequenceEmailParameters { + subject: string; + html: string; + inbox_snippet?: string; + delay_interval?: SequenceDelayInterval; + delay_interval_count?: number; + editor_choice?: string; + cc?: string; + bcc?: string; + to?: string; +} + /** * CLI error codes for SDK operations */ diff --git a/tests/core/sdk.test.ts b/tests/core/sdk.test.ts index 36fb168..5976b1d 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,72 @@ describe("SDK wrapper methods", () => { }); }); + describe("sequence operations", () => { + it("getSequences returns sequence array", async () => { + const sequences = await client.getSequences(); + + expect(sequences).toHaveLength(1); + expect(sequences?.[0].id).toBe("sequence-1"); + expect(mockSdk.V1.Sequences.getSequences).toHaveBeenCalled(); + }); + + 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(String(requestUrl)).toContain("site_uuid=test-site-uuid"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ subject: "Fallback", html: "

Fallback

" })); + } finally { + fetchSpy.mockRestore(); + } + }); + }); + describe("error propagation", () => { it("propagates errors through handleApiCall", async () => { mockSdk.V1.Tags.getTags = mock(() => From c07e0b937935645b9af01a1bf5877056a21a2212 Mon Sep 17 00:00:00 2001 From: ziptied Date: Mon, 16 Mar 2026 16:57:06 +0900 Subject: [PATCH 02/11] docs: add installation guide and improve examples Add Installation & Usage section with npx recommendation and rationale. Update subscriber search examples to require email or uuid parameter for clarity. Add pagination and search examples to tags, fields, and broadcasts commands. Include GitHub Actions example for CI/CD workflows. Add dashboard command reference with browser integration. Improve scripting examples with better comments and npx usage for CI environments. --- skill/SKILL.md | 55 +++++++++++++++--- skill/references/command-reference.md | 83 +++++++++++++++++++++------ 2 files changed, 113 insertions(+), 25 deletions(-) diff --git a/skill/SKILL.md b/skill/SKILL.md index ea9fcfe..d6d3f9e 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -11,6 +11,23 @@ description: > A command-line interface for [Bento](https://bentonow.com) email marketing. Manage subscribers, tags, events, and broadcasts directly 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 +``` + +**Why npx is recommended:** +- Always fetches the latest version automatically +- No installation or updates required +- Ideal for CI/CD pipelines and scripts +- Works immediately without global install permissions + ## 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. @@ -41,6 +58,12 @@ bento auth login \ --secret-key "your-secret-key" \ --site-uuid "your-site-uuid" +# Using npx (no global install needed) +npx @bentonow/bento-cli auth login \ + --publishable-key "your-publishable-key" \ + --secret-key "your-secret-key" \ + --site-uuid "your-site-uuid" + # Check authentication status bento auth status @@ -64,12 +87,11 @@ bento profile remove staging # Remove a profile ### Subscribers -**Search subscribers**: +**Look up a subscriber** (requires `--email` or `--uuid`): ```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 +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): @@ -98,6 +120,7 @@ bento subscribers subscribe --email user@example.com # Re-subscribe ```bash bento tags list # List all tags +bento tags list news # Search tags by name bento tags create "new-feature-announcement" # Create tag bento tags delete "old-tag" # Delete (API limitation applies) ``` @@ -106,6 +129,7 @@ bento tags delete "old-tag" # Delete (API limitation applies) ```bash bento fields list # List all fields +bento fields list company # Search fields by key or name bento fields create company_size # Create field ``` @@ -126,6 +150,7 @@ bento events track \ ```bash bento broadcasts list # List all broadcasts +bento broadcasts list --page 1 --per-page 10 # Paginate results bento broadcasts create \ --name "January Newsletter" \ @@ -238,10 +263,10 @@ 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 +bento subscribers search --email user@example.com | grep email # GOOD: Structured JSON output -bento subscribers search --tag vip --json | jq '.data[].email' +bento subscribers search --email user@example.com --json | jq '.data[].email' ``` **Running destructive operations without understanding scope**: @@ -259,15 +284,29 @@ bento subscribers unsubscribe --file list.csv --dry-run ### CI/CD: Sync subscribers from database ```bash +#!/bin/bash +# Export and sync (using npx for CI environments) 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 +npx @bentonow/bento-cli subscribers import /tmp/active-users.csv --confirm --json +``` + +### GitHub Actions Example +```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 ``` ### Scripting: Batch tag operations ```bash -bento subscribers search --tag inactive --json \ +# Check if a subscriber has the "inactive" tag +bento subscribers search --email user@example.com --tag inactive --json \ | jq -r '.data[].email' \ | while read email; do bento subscribers tag --email "$email" --add "needs-reengagement" --confirm diff --git a/skill/references/command-reference.md b/skill/references/command-reference.md index a23ff46..5c3ca4f 100644 --- a/skill/references/command-reference.md +++ b/skill/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. From 207665876b0e8d59bacf0c88a0947f8e9b38b2ba Mon Sep 17 00:00:00 2001 From: ziptied Date: Tue, 17 Mar 2026 21:53:49 +0900 Subject: [PATCH 03/11] feat: add conditional status column to workflows Add dynamic status column to workflows table output. The status column now only displays when the API response includes status data in workflow attributes. This prevents showing empty columns for API versions that don't support workflow status. --- src/commands/workflows.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/commands/workflows.ts b/src/commands/workflows.ts index 69ebbf8..7e5bf43 100644 --- a/src/commands/workflows.ts +++ b/src/commands/workflows.ts @@ -42,21 +42,25 @@ export function registerWorkflowsCommands(program: Command): void { 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: [ - { key: "id", header: "ID" }, - { key: "name", header: "Name" }, - { key: "created_at", header: "Created" }, - { key: "templates", header: "Templates" }, - ], - } + { columns } ); } catch (error) { output.failSpinner(); From db7a054199dbcb6e038c05a3432f0ce8e6b6ebd3 Mon Sep 17 00:00:00 2001 From: ziptied Date: Fri, 20 Mar 2026 14:42:29 +0900 Subject: [PATCH 04/11] test: add comprehensive skills command tests Add test suite for the skills command covering help output, listing available skills, and installing skills to AI agents. Tests verify both standard and JSON output modes, error handling for unknown agents and skills, agent detection, symlink creation to canonical skill copies, and force installation behavior. Also update .gitignore to exclude documentation build output directory. --- .gitignore | 3 + CLAUDE.md | 61 + skill.zip | Bin 0 -> 7877 bytes skill/SKILL.md | 339 ---- skill/bento-cli/SKILL.md | 447 +++++ .../references/command-reference.md | 0 skill/liquid/SKILL.md | 374 ++++ src/cli.ts | 2 + src/commands/skills.ts | 346 ++++ src/tests/commands/skills.test.ts | 136 ++ test-plan.md | 1565 +++++++++++++++++ 11 files changed, 2934 insertions(+), 339 deletions(-) create mode 100644 CLAUDE.md create mode 100644 skill.zip delete mode 100644 skill/SKILL.md create mode 100644 skill/bento-cli/SKILL.md rename skill/{ => bento-cli}/references/command-reference.md (100%) create mode 100644 skill/liquid/SKILL.md create mode 100644 src/commands/skills.ts create mode 100644 src/tests/commands/skills.test.ts create mode 100644 test-plan.md 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 0000000000000000000000000000000000000000..777969349a16a9e395b8186bec0273ca3506e360 GIT binary patch literal 7877 zcmc(Eby(Ev+Aa+u&CnqdQW8?a&>@I`bSY9p3^8=WP*T#}Aq@&BDIFpp-3*-qL#L#` zfxW)vVy*4kdwqYLb7ro0e(%)%+)uo7J@-6Ha>ytEgqs_wld#UeHvjX0hCqX0Z((L> z$>Hd77XcCFJ0b$Yzdow2hK+#UeCTHO`%?NB1_lDgzhU5AW9aEAh)XJ}YH_gZ{R{3t zp#gE-%|QA@AX9xq{apolja^)gASG|4NK#}*JPJhH`*^QULdb%U!tdV~S9`f9*II<|ESzUrmwQ2tV~@Ta=mgra?g$RjSRIY^WC}~S9+cxD9NE?JTKg<(#5+!p_B$_|S4|Y@ZqoVTNd&<-H3!c(EimpkMw&o_9I{wH?RiSC zI`jI3-|Qc8WZT!iMW$Ml(zIfK(Z0}nVzlIEc>SK+MvVUn1+Zjb^9m%CcIA&J8oHd_ z2@cv28_1fl08y!~P;&Sy;vgkmac(ZfliYlt3eE2Xh4Ch8Mf*Dp6W0+#A^lHLBfp*L z-^zwv@4p7{Cg1-#=cN?aIVTe9K~Pej(^qHWGLs{9v$%3D1S{}4OXmM z6*n0z9lN-eL$!uMDJ#{C*n*ff{`Jb;tIpXaGK+dFFp+z>$aca^lw>i2qt+uEdmDLFfX^j>2CX^YAMUC6TAW`$uykW2n#Nc-y?bvw3eJ7&ZI6>w2he z*R~#aP*j~nKQWE7mKBt*8c9QJF#YkR_Qx*2$(KS`%=&2Qh6Gb(tykVS(oIUKE85Bu zi3sEy4d#vm8(X7xD6*D3ZFwVYQvT0DO_=juk4=n|re?Mv2JJ=%_igwzpO6lb>Iith}Ia#N= zS6FhNXb_xvAr!AKE{0-L zZM46S)gGzx&f4r98?)kg3%VO`M1AsOn%x^Z@u5eD2)vD3#XNfu+Xh#Tx2hJ@`OKN>?C z$Rv(U{Tsu+Q~Jln!*Z6|)s1+~7v zvsfCBS7@Y)3XM=F>#NwhXr>R}-lxY|imnIN6K)P&c2)XcHW(hSo8thTUVLn5;w+64 zDzH^yeb*Ys^aPOb&L)5#XZ{C%tJ~5f!S?zdeHl(__FzpqTp~s3V2w5WJz1R_9JFzE z=2oqsd@a+;Y$!pX^L<{`8+i@dKdF5J-7IeNVKKTarHU4%4?!e zUBn|bL4LEL4477jzGsxN@AIL!mb}%>f;L;?$OH+%0wpr@ptG9(dg>kr7drWhTAG}Y zva(lb7kBVPOjR}5Apl^nRb?CNiSA*n214H?k)l=Rc-}q7wf%(;bg%5NwA>WkO*wNi12JIA31|QqmWoPZ^={B~E(=-3sp;F_5WCx2VGc zwdZu<#i3R$#%ct+rSXi5L8QOQoUTs zOP_>aJQ}VQb3Iad$NglWxk`HxX+@_u)^IWK&U&e3+x#o}SR!@vbqZnJ2PfJ@K&5sX zP+(TO21uvR68)o(uOtCV$yw$%`ci2ki*Gs$#ZC_Iqg}TplHO=*Cyf<{rE0HN4D*ak52}eYnGlFtoBO@r6?Bj5kT zTdcu*8P-vH2}@$pds%UQ;KuT3xhP;Ur!yGdl+xgOC7V5SIa~+%;gPUZIMBSrT-5cb z8opq2VkPLMmmMFXGh8H3coq7FrjV8@sL4LmCBTDg-5HZ#@Ucniu1{5UrR2A4Ru2|2 zuZ1$&;-yq)EMGO%;{z7xQ~JxDW%QY0ZS`+tSkSpT+;9C?txX7{RL{3lhIj+l!RhJ+ zaBitAJ}$6bN76frZMAtJlVGk*+3NN0ef6fa`33IN^?sx#)83MdD0#zZ)4{4^yV=@& z=zgLO-e6|?{*Vr{6#z{Uj?0W~{u+K(pT6m1@qjpob?HV9vIp?+=pTEiFP*}&%#9(C z#2$Dro4&_8r69KBsL zGBEv$hF=gGldX21?Fk26_2O`eC&jTu1yY@josXT|+kuK;Aoq}Zj=rQhgdhqJRb%hc z_T-^;5^ra0;N{LWEy}w_)I@nnk_2w07xNAR=|)MWvqgY1q)tH7CMg23KKjLv66y?+ zz)&{exW7COPQYt7vVaZNRi|x1J|iz*Tc)syb$F&j38&EpcsR2zer4U3rE%4EHqHRy z;g<%F(u3SJ-67PofOH#C{Ufw8c=T>PtX?RVbshF39%3}%~v_7<~IAC zV?WNtS<=+5HljxZNeUgCQVg;#R3g- zV6t&A;;oc=Y9-XczU^FO$Ak>Oe?1(7+i~wh-x0gWn~a%77Iyq-vixsoBR$r{Ko@vz z$YY;?x!Y9<)lTC*>@ zBd~)3VxQzcFN5Np9ZooOwdK>ov|hUBg}&x)dk&_zIAaw23Xfh|*S~5mO7s?3x9m$NOV%R*)wh%A&-KG797_-Z^)qHchb#9{WzSCXDXgfnS!E?d?nn#@2Bho|uQ&q<#1FCuYQ z^Yv2mhhT~_t1_O}Rk9@cJhl8n4fT;{`)lBxUHEh_?(&C@nB)^2#4<(6(h(E-z}u?S=%VevBF1<6gAHT@Fb}Vx70MuiF>2Pyou?CX|WIxHfe4P#lNZ#vR{d(e;1KudAo9igqt7}EIN7ZDAEUYyYS&{PvcWfYi z(k^u}%s9e~5f>sOge0>_>+yY?=1uYWT%G&9W*-^`IY%x+5xnNDj;-TuMEBk3gj(P7 zjJalQbwH8)0-c^+XSbgWKKrNuj50^>wK0Rwva48v77!ICVX8FxLM<0*@ryO-;CorX z)>ez6Yo)$G{U;3NL*}L6PbaXr`U=Y6XKV3mW&o%y{VuAmZQRp^^ehh0v4!;}p-NB3 z(wv%lU(^aTdqes))56x~g+QksCuGE?zKe)I+KboMBFmNWneyABxZIgl6%7?}^_30e zh}6CM2h-XbudbaOjY{|8q@|fG<#JfK25}@Ua)Mtt7FE?D_sVU)MQmY3aYLQ zt2_(A1v_@C`j)0Ltsb2E7CxMO{Wy0en&E|x?f3@ zoxRCdHiF>K7)lF6tlE01lN+YZdWaN z4UK!;&vWRhtZmO3zLl--0G19Mj?SuIKK4RX9<4|6p|pUh9n2}#I+|pZ=-8(mYSCf& zacGKs##9An8=U3*;8muWD#k-_U&%w+cW6&+!6L649JP}TG4q)@nNBMoK_-!$^H#(* zy#Cr0H zN}OD&xsj=_f#u=TK)KiOU9pX0rZezG|Mo~1>a%|R+0}R2N=_WrISAJGtO~~wQS%;AQMN zF2=pmR^@eb$!?3GikIX&*apS>l4?2Ic1%j>((&&1uSJ}7Z0mER(|(E zjZho7gx3ngj+agLO>lLD>Rr4&-)hwfi{SUDt$nt7ac%@q(bDyr z`7;yQ^@>k~@}jyY*ow(E)t^|-gre0YI^`~RWc)JnYwU@7U>!_wM3~1Pki5u%WA}9vq12RD z(MHFHe;u%*X0$?=2x?Jws1)A*EJW+`%4_+u2a#RL*0?V*!)iw8;Vs<;GA z9m;ZN4f-wk8OP6v?lP{-^U}!CJ!5qUrJB1Yq%TPCQn|?aD(%gUe!v%#oH{b`K+hzJR&(F20!th^anH-N z)pv3fACcw?KHderJxo#zZOHeXJfd-1U3=?$<(YOt7{e;0Sf~);B%Hz`-s@m80KF<7 zPBj>g$5!#lLoj~C{Gpva&Ssxi`3a%RB%ibCS)Cc>A~NP2bbZLgoX7Ya@Fb%yka|d@ zw#Z3Uxy$|=D-8YkdA$DnslaJL-}3$rH0@6n0whAQv*^bev@BNz;5&xPboZQ1jH=WZ zGtuiH`aL{vsVT>*xMr!y8YC$h6adiAT;=EECIl8svF2kR%Rx;C`R6`5R5R@d^B(3j zE>#^u;aS}Fjyp(rWoaMU0!X9UQN{ef*zyrj{HS8+rL`_u;c0(x_DDe#quTOa>V?z7n zwgM~mATtb5AJdm+)gILMd3eyO|HWYoMUagy8JuJYO9L(4@ndl#_VGkzn0>e)q8`(p zbt93FN%q}_5?9gsQ=-KzVb2`aDoFfzx$+zqakuXyGPo}-a1U#sv2-@wPpzW2!h$o+ z;ePj&G*a@};FHm?FNB$kR)hAQhZ*Ay zDZz^O+YRoCyk%Iz|5Efer+qYE@#-8bS@OkWt9oz$LeE#7V0A9!3sh7#a9zR@Lp;$i}XCmMm;pGB+w14F}!{~BgY;eBy7-5dN zC~HR0$U^oU&DDF8H$#OM74DhFeC{f}fM=epOtE=ayA;qb2H54exL})@Td>yf#^L(f zt&mmx{xug~^QU-UVw^&wZUa`bfjZ7de4%q?Wd?TGs}N|!#cHt09)%*$mnIW=QI)D( zBbQB`fRFjt=8Q2!a7}I%ZdodlO(&-~l zQsYi+9Q~O2UJ5%NdOoW&^|_M|SZ&AfoxRHmLGOmB<72*b`9wD*_Gi?gJneX@Lrl%z zH!iWMnT2z`9tXWF(4J)+>PVi0^JTg7)wp{)!y)_A(;<_Yfv(~i@^B(vUe(+uS!g@C zcUnAtyvfo7uW;)Hl{_(@^$^Tj7#~%Ju>0*@iQY7Ln=h9|OEC}-1gLK}cz^AYIRCxD z``H=UUk`!)x9!sZ%f1g034r`})3EFD$N${6#BVl#GaExiK)m_uTuJUXtly_%w`Wgy z2)D3&q^`04>x}Fds5f(~+p`(mTc|lcCqEhf{Sf9F^k#x}o9Yed?U@SspFr7>Zc+X3 zL!Q6j-vsTi#e#nee@ndk8vi%gTNU$f!-;Fyn-=;u?HkzJ5vI5e>qCzG3)+8J_$LN# zRLpICgLBJ3j?cukf!}!lt&sUe0&g&HlP3EUrY7DmNdIr(Z(?%$#4`K|9)SNltR2tVhaO!1Tdn - 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. - -## 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 -``` - -**Why npx is recommended:** -- Always fetches the latest version automatically -- No installation or updates required -- Ideal for CI/CD pipelines and scripts -- Works immediately without global install permissions - -## 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" - -# Using npx (no global install needed) -npx @bentonow/bento-cli 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 - -**Look up a subscriber** (requires `--email` or `--uuid`): -```bash -bento subscribers search --email user@example.com -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): -```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 list news # Search tags by name -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 list company # Search fields by key or name -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 list --page 1 --per-page 10 # Paginate results - -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 --email user@example.com | grep email - -# GOOD: Structured JSON output -bento subscribers search --email user@example.com --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 -#!/bin/bash -# Export and sync (using npx for CI environments) -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 Example -```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 -``` - -### Scripting: Batch tag operations -```bash -# Check if a subscriber has the "inactive" tag -bento subscribers search --email user@example.com --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 100% rename from skill/references/command-reference.md rename to skill/bento-cli/references/command-reference.md 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 a794149..0cacff1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ 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"; @@ -64,6 +65,7 @@ registerWorkflowsCommands(program); registerTemplatesCommands(program); registerFormsCommands(program); registerExperimentalCommands(program); +registerSkillsCommands(program); registerDashboardCommand(program); // Future commands to be implemented: diff --git a/src/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..b9ae5df --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,346 @@ +/** + * Skills commands + * + * Commands: + * - bento skills list - List available skills + * - bento skills install - Install skills to AI agent harnesses + */ + +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { homedir, platform } from "node:os"; +import { dirname, relative, resolve } from "node:path"; +import type { Command } from "commander"; +import { output } from "../core/output"; + +interface AgentConfig { + name: string; + label: string; + globalSkillsDir: string; + detect: () => 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) { + // Check if content differs — skip if identical + 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)) { + // Verify it has at least one skill subdirectory with SKILL.md + 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/tests/commands/skills.test.ts b/src/tests/commands/skills.test.ts new file mode 100644 index 0000000..77f23bd --- /dev/null +++ b/src/tests/commands/skills.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "bun"; +import { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync, lstatSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, relative, 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; + let canonicalDir: string; + let agentDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "bento-skills-test-")); + canonicalDir = join(tempDir, ".agents", "skills"); + agentDir = join(tempDir, ".claude", "skills"); + }); + + 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", () => { + // Use a HOME that has no agent config dirs + const result = runCLI(["skills", "install"], { + HOME: tempDir, + CLAUDE_CONFIG_DIR: join(tempDir, "no-such-dir"), + }); + expect(result.stderr).toContain("No supported AI agents detected"); + }); + + it("installs skill to a detected agent via symlink", () => { + // Create fake agent config dir so detection works + 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); + + // Check the canonical copy exists + const canonicalSkill = join(tempDir, ".agents", "skills", "bento-cli", "SKILL.md"); + expect(existsSync(canonicalSkill)).toBe(true); + + // Check the agent symlink exists + const agentSkill = join(tempDir, ".claude", "skills", "bento-cli"); + expect(existsSync(agentSkill)).toBe(true); + + // Verify it's a symlink pointing to canonical + 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/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) From c6dcebcb424215c1dac5ffaa6eb6afc706ba613c Mon Sep 17 00:00:00 2001 From: ziptied Date: Fri, 20 Mar 2026 14:45:36 +0900 Subject: [PATCH 05/11] fix: update bento-node-sdk to latest version Update @bentonow/bento-node-sdk dependency from pinned version1.0.5 to latest to ensure the package always uses the most recent stable release. This allows the project to benefit from bug fixes and new features without manual version updates. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d02a78..d2a53ef 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepublishOnly": "bun test && bun run build" }, "dependencies": { - "@bentonow/bento-node-sdk": "^1.0.5", + "@bentonow/bento-node-sdk": "latest", "@inquirer/prompts": "8.2.0", "chalk": "5.6.2", "commander": "^12.1.0", From 5b65a35b5b6d8247aefd0381cb8435bc8a193859 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 20 Mar 2026 14:47:38 +0900 Subject: [PATCH 06/11] Update src/tests/commands/skills.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/tests/commands/skills.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/commands/skills.test.ts b/src/tests/commands/skills.test.ts index 77f23bd..4449fe7 100644 --- a/src/tests/commands/skills.test.ts +++ b/src/tests/commands/skills.test.ts @@ -108,9 +108,14 @@ describe("bento skills install", () => { // Verify it's a symlink pointing to canonical const stat = lstatSync(agentSkill); + // On non-Windows platforms a symlink is expected; on Windows a junction is used instead if (stat.isSymbolicLink()) { const linkTarget = resolve(dirname(agentSkill), readlinkSync(agentSkill)); expect(linkTarget).toBe(resolve(tempDir, ".agents", "skills", "bento-cli")); + } else { + // Junction or copy on Windows — just verify the target directory is accessible + expect(existsSync(join(agentSkill, "SKILL.md"))).toBe(true); + } } }); From a56f373566a7fbde8fa7d449214dfcd65631c945 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 20 Mar 2026 14:47:48 +0900 Subject: [PATCH 07/11] Update src/tests/commands/skills.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/tests/commands/skills.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tests/commands/skills.test.ts b/src/tests/commands/skills.test.ts index 4449fe7..8015998 100644 --- a/src/tests/commands/skills.test.ts +++ b/src/tests/commands/skills.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test"; import { spawnSync } from "bun"; -import { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync, lstatSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve, relative, dirname } from "node:path"; +import { existsSync, mkdirSync, readlinkSync, rmSync, lstatSync } from "node:fs"; function runCLI(args: string[], env: Record = {}) { const result = spawnSync(["bun", "run", "src/cli.ts", ...args], { From 88b3ef00501f3e33d74123e3aa56b99b26a0a761 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 20 Mar 2026 14:47:55 +0900 Subject: [PATCH 08/11] Update src/tests/commands/skills.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/tests/commands/skills.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/commands/skills.test.ts b/src/tests/commands/skills.test.ts index 8015998..822c7ad 100644 --- a/src/tests/commands/skills.test.ts +++ b/src/tests/commands/skills.test.ts @@ -75,11 +75,10 @@ describe("bento skills install", () => { expect(result.exitCode).toBe(1); }); - it("warns when no agents detected", () => { - // Use a HOME that has no agent config dirs 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"); }); From b953f563b6f30de4399d8c3c0127f7d9865cdb6c Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 20 Mar 2026 14:48:27 +0900 Subject: [PATCH 09/11] Update src/commands/skills.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/commands/skills.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/skills.ts b/src/commands/skills.ts index b9ae5df..8edef56 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -204,9 +204,10 @@ export function registerSkillsCommands(program: Command): void { for (const skill of skillNames) { const src = resolve(sourceDir, skill); const dest = resolve(CANONICAL_DIR, skill); - - if (existsSync(dest)) { if (!options.force) { + // Already installed — skip without overwriting; use --force to refresh + continue; + } // Check if content differs — skip if identical continue; } From 649dec394d07bc9f6487dc75fa2fd7b130b90bcf Mon Sep 17 00:00:00 2001 From: ziptied Date: Fri, 20 Mar 2026 15:01:35 +0900 Subject: [PATCH 10/11] fix: resolve merge conflicts in skills command and tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/skills.ts | 8 +++----- src/tests/commands/skills.test.ts | 17 ++++------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 8edef56..a57b01f 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -149,6 +149,7 @@ export function registerSkillsCommands(program: Command): void { if (skillName && !options.skill) { options.skill = skillName; } + try { const sourceDir = getSkillsSourceDir(); let skillNames = discoverSkills(sourceDir); @@ -204,11 +205,9 @@ export function registerSkillsCommands(program: Command): void { for (const skill of skillNames) { const src = resolve(sourceDir, skill); const dest = resolve(CANONICAL_DIR, skill); + + if (existsSync(dest)) { if (!options.force) { - // Already installed — skip without overwriting; use --force to refresh - continue; - } - // Check if content differs — skip if identical continue; } rmSync(dest, { recursive: true, force: true }); @@ -288,7 +287,6 @@ function getSkillsSourceDir(): string { for (let i = 0; i < 5; i++) { const candidate = resolve(dir, "skill"); if (existsSync(candidate)) { - // Verify it has at least one skill subdirectory with SKILL.md const entries = readdirSync(candidate, { withFileTypes: true }); const hasSkill = entries.some( (e) => e.isDirectory() && existsSync(resolve(candidate, e.name, "SKILL.md")) diff --git a/src/tests/commands/skills.test.ts b/src/tests/commands/skills.test.ts index 822c7ad..4525b74 100644 --- a/src/tests/commands/skills.test.ts +++ b/src/tests/commands/skills.test.ts @@ -1,6 +1,9 @@ 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], { @@ -42,13 +45,9 @@ describe("bento skills list", () => { describe("bento skills install", () => { let tempDir: string; - let canonicalDir: string; - let agentDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "bento-skills-test-")); - canonicalDir = join(tempDir, ".agents", "skills"); - agentDir = join(tempDir, ".claude", "skills"); }); afterEach(async () => { @@ -75,6 +74,7 @@ describe("bento skills install", () => { 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"), @@ -84,7 +84,6 @@ describe("bento skills install", () => { }); it("installs skill to a detected agent via symlink", () => { - // Create fake agent config dir so detection works mkdirSync(join(tempDir, ".claude"), { recursive: true }); const result = runCLI(["skills", "install", "--agent", "claude-code", "--force"], { @@ -94,24 +93,16 @@ describe("bento skills install", () => { expect(result.exitCode).toBe(0); - // Check the canonical copy exists const canonicalSkill = join(tempDir, ".agents", "skills", "bento-cli", "SKILL.md"); expect(existsSync(canonicalSkill)).toBe(true); - // Check the agent symlink exists const agentSkill = join(tempDir, ".claude", "skills", "bento-cli"); expect(existsSync(agentSkill)).toBe(true); - // Verify it's a symlink pointing to canonical const stat = lstatSync(agentSkill); - // On non-Windows platforms a symlink is expected; on Windows a junction is used instead if (stat.isSymbolicLink()) { const linkTarget = resolve(dirname(agentSkill), readlinkSync(agentSkill)); expect(linkTarget).toBe(resolve(tempDir, ".agents", "skills", "bento-cli")); - } else { - // Junction or copy on Windows — just verify the target directory is accessible - expect(existsSync(join(agentSkill, "SKILL.md"))).toBe(true); - } } }); From b5e659d88d84fadd56b2fa9989ff3f186e8d9a54 Mon Sep 17 00:00:00 2001 From: ziptied Date: Fri, 20 Mar 2026 15:07:05 +0900 Subject: [PATCH 11/11] fix: address PR review comments - Paginate workflows and sequences (auto-page loop like broadcasts) - Fix geolocate to use SDK's geoLocateIP() method name - Fix blacklist to use SDK's getBlacklistStatus() with ipAddress param - Fix __dirname for ESM compatibility using import.meta.url Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/skills.ts | 4 ++ src/core/sdk.ts | 96 ++++++++++++++++++++++++++++++++++++++---- tests/core/sdk.test.ts | 27 ++++++++++-- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/commands/skills.ts b/src/commands/skills.ts index a57b01f..0899ca8 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -18,9 +18,13 @@ import { } from "node:fs"; import { homedir, platform } from "node:os"; import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Command } from "commander"; import { output } from "../core/output"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + interface AgentConfig { name: string; label: string; diff --git a/src/core/sdk.ts b/src/core/sdk.ts index e61753c..22baa33 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -499,11 +499,50 @@ export class BentoClient { // ============================================================ /** - * List all workflows + * List all workflows (auto-paginates) */ async getWorkflows(): Promise { - const sdk = await this.getClient(); - return this.handleApiCall(() => sdk.V1.Workflows.getWorkflows()); + 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; } // ============================================================ @@ -604,7 +643,7 @@ export class BentoClient { */ async geolocate(ip: string): Promise { const sdk = await this.getClient(); - return this.handleApiCall(() => sdk.V1.Experimental.geolocate({ ip })); + return this.handleApiCall(() => sdk.V1.Experimental.geoLocateIP({ ip })); } /** @@ -612,8 +651,8 @@ export class BentoClient { */ async checkBlacklist(domain?: string, ip?: string): Promise { const sdk = await this.getClient(); - const params = domain ? { domain } : { ip: ip! }; - return this.handleApiCall(() => sdk.V1.Experimental.checkBlacklist(params)); + const params = domain ? { domain } : { ipAddress: ip! }; + return this.handleApiCall(() => sdk.V1.Experimental.getBlacklistStatus(params)); } /** @@ -725,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/tests/core/sdk.test.ts b/tests/core/sdk.test.ts index d20c1f6..e1e4fdb 100644 --- a/tests/core/sdk.test.ts +++ b/tests/core/sdk.test.ts @@ -698,11 +698,30 @@ describe("SDK wrapper methods", () => { describe("sequence operations", () => { it("getSequences returns sequence array", async () => { - const sequences = await client.getSequences(); + 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" } } + ) + ); - expect(sequences).toHaveLength(1); - expect(sequences?.[0].id).toBe("sequence-1"); - expect(mockSdk.V1.Sequences.getSequences).toHaveBeenCalled(); + 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 () => {