@arcsend/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +69 -0
  2. package/dist/index.js +2105 -0
  3. package/package.json +44 -0
package/dist/index.js ADDED
@@ -0,0 +1,2105 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/api.ts
7
+ import chalk2 from "chalk";
8
+
9
+ // src/config.ts
10
+ import { homedir } from "os";
11
+ import { join } from "path";
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "fs";
13
+ var DEFAULT_URL = "https://send.highsend.io";
14
+ var CONFIG_DIR = join(homedir(), ".arcsend");
15
+ var CONFIG_PATH = join(CONFIG_DIR, "config.txt");
16
+ function loadConfig(keyOverride, urlOverride) {
17
+ let apiKey = keyOverride || process.env.ARCSEND_API_KEY || "";
18
+ let baseUrl = urlOverride || process.env.ARCSEND_URL || "";
19
+ if ((!apiKey || !baseUrl) && existsSync(CONFIG_PATH)) {
20
+ for (const line of readFileSync(CONFIG_PATH, "utf8").split("\n")) {
21
+ const idx = line.indexOf("=");
22
+ if (idx < 0) continue;
23
+ const key = line.slice(0, idx).trim();
24
+ const val = line.slice(idx + 1).trim();
25
+ if (!apiKey && key === "api_key") apiKey = val;
26
+ if (!baseUrl && (key === "instance_url" || key === "url")) baseUrl = val;
27
+ }
28
+ }
29
+ baseUrl = (baseUrl || DEFAULT_URL).replace(/\/+$/, "");
30
+ return { apiKey, baseUrl };
31
+ }
32
+ function saveConfig(apiKey, url) {
33
+ mkdirSync(CONFIG_DIR, { recursive: true });
34
+ writeFileSync(
35
+ CONFIG_PATH,
36
+ `api_key=${apiKey}
37
+ instance_url=${url.replace(/\/+$/, "")}
38
+ `,
39
+ { mode: 384 }
40
+ );
41
+ try {
42
+ chmodSync(CONFIG_PATH, 384);
43
+ } catch {
44
+ }
45
+ }
46
+
47
+ // src/output.ts
48
+ import chalk from "chalk";
49
+ import Table from "cli-table3";
50
+ var DRY = false;
51
+ var QUIET = false;
52
+ function setDryRun(v) {
53
+ DRY = v;
54
+ }
55
+ function setQuiet(v) {
56
+ QUIET = v;
57
+ }
58
+ function fail(msg) {
59
+ if (!QUIET) console.error(chalk.red(`\u2717 ${msg}`));
60
+ process.exit(1);
61
+ }
62
+ function ok(msg) {
63
+ if (DRY || QUIET) return;
64
+ console.log(chalk.green.bold("\u2713") + " " + msg);
65
+ }
66
+ function dim(s) {
67
+ return chalk.dim(s);
68
+ }
69
+ var STATUS_COLS = /* @__PURE__ */ new Set(["status", "lead_status", "health_status", "sentiment_category", "is_active"]);
70
+ function statusColor(v) {
71
+ switch (v) {
72
+ case "active":
73
+ case "connected":
74
+ case "healthy":
75
+ case "interested":
76
+ case "sent":
77
+ case "delivered":
78
+ case "won":
79
+ case "closed_won":
80
+ case "true":
81
+ return chalk.green(v);
82
+ case "paused":
83
+ case "warning":
84
+ case "medium":
85
+ case "not_now":
86
+ case "pending":
87
+ case "syncing":
88
+ case "connecting":
89
+ case "processing":
90
+ case "question":
91
+ return chalk.yellow(v);
92
+ case "draft":
93
+ case "scheduled":
94
+ case "skipped":
95
+ case "false":
96
+ return chalk.dim(v);
97
+ case "completed":
98
+ case "meeting_booked":
99
+ case "follow_up":
100
+ return chalk.blue(v);
101
+ case "error":
102
+ case "failed":
103
+ case "disconnected":
104
+ case "bounced":
105
+ case "bounce":
106
+ case "not_interested":
107
+ case "lost":
108
+ case "closed_lost":
109
+ case "critical":
110
+ case "unsubscribe":
111
+ case "unsubscribed":
112
+ return chalk.red(v);
113
+ case "archived":
114
+ case "out_of_office":
115
+ return chalk.gray(v);
116
+ default:
117
+ return v;
118
+ }
119
+ }
120
+ function getPath(obj, path) {
121
+ return path.split(".").reduce((o, k) => o == null ? o : o[k], obj);
122
+ }
123
+ var MAXLEN = 200;
124
+ function truncate(s) {
125
+ const flat = s.replace(/\s+/g, " ").trim();
126
+ return flat.length > MAXLEN ? flat.slice(0, MAXLEN) + dim(`\u2026 (${flat.length} chars \u2014 use -j for full)`) : flat;
127
+ }
128
+ function fmtCell(v) {
129
+ if (v == null || v === "") return "\u2014";
130
+ if (Array.isArray(v)) {
131
+ if (!v.length) return "\u2014";
132
+ if (v.every((x) => x && typeof x === "object")) return dim(`${v.length} item(s) \u2014 see below or use -j`);
133
+ return v.join(", ");
134
+ }
135
+ if (typeof v === "object") return Object.keys(v).length ? truncate(JSON.stringify(v)) : "\u2014";
136
+ return truncate(String(v));
137
+ }
138
+ function renderArrayTable(arr) {
139
+ const rows = arr.slice(0, 25);
140
+ const cols = Array.from(new Set(rows.flatMap((r) => Object.keys(r))));
141
+ const t = new Table({ head: cols.map((c) => chalk.dim(c)), style: { head: [], border: [] } });
142
+ for (const r of rows) t.push(cols.map((c) => valCell(c, r[c])));
143
+ let s = t.toString().split("\n").map((l) => " " + l).join("\n");
144
+ if (arr.length > 25) s += "\n " + dim(`\u2026 ${arr.length - 25} more`);
145
+ return s;
146
+ }
147
+ function csvEsc(v) {
148
+ const s = fmtCell(v);
149
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
150
+ }
151
+ function unwrap(data) {
152
+ if (data && typeof data === "object" && !Array.isArray(data) && "data" in data && ("object" in data || "pagination" in data)) {
153
+ return { payload: data.data, pagination: data.pagination ?? (typeof data.total === "number" ? { total: data.total } : void 0) };
154
+ }
155
+ if (typeof data?.total === "number" && Array.isArray(data?.data)) {
156
+ return { payload: data.data, pagination: { total: data.total } };
157
+ }
158
+ return { payload: data, pagination: void 0 };
159
+ }
160
+ function out(ctx2, data, columns, headers, noun = "row(s)") {
161
+ if (ctx2.dryRun) return;
162
+ if (ctx2.quiet) return;
163
+ if (ctx2.json) {
164
+ console.log(JSON.stringify(data, null, 2));
165
+ return;
166
+ }
167
+ if (typeof data === "string") {
168
+ console.log(data);
169
+ return;
170
+ }
171
+ if (data == null) {
172
+ console.log(dim(`No ${noun}.`));
173
+ return;
174
+ }
175
+ const { payload, pagination } = unwrap(data);
176
+ if (Array.isArray(payload)) {
177
+ renderList(ctx2, payload, columns, headers, noun, pagination);
178
+ return;
179
+ }
180
+ if (payload && typeof payload === "object") {
181
+ renderDetail(ctx2, payload);
182
+ return;
183
+ }
184
+ console.log(fmtCell(payload));
185
+ }
186
+ function renderDetail(ctx2, obj) {
187
+ const keys = Object.keys(obj);
188
+ if (!keys.length) {
189
+ console.log(dim("(empty)"));
190
+ return;
191
+ }
192
+ if (ctx2.csv) {
193
+ console.log("field,value");
194
+ for (const k of keys) console.log(`${csvEsc(k)},${csvEsc(obj[k])}`);
195
+ return;
196
+ }
197
+ const width = Math.max(...keys.map((k) => k.length));
198
+ const lines = [];
199
+ for (const k of keys) {
200
+ const label = chalk.dim(k.padEnd(width));
201
+ const v = obj[k];
202
+ if (Array.isArray(v) && v.length && v.every((x) => x && typeof x === "object")) {
203
+ lines.push(`${label} ${dim(`(${v.length})`)}`);
204
+ lines.push(renderArrayTable(v));
205
+ } else if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length) {
206
+ lines.push(`${label} `);
207
+ const sub = v;
208
+ const sw = Math.max(...Object.keys(sub).map((s) => s.length));
209
+ for (const sk of Object.keys(sub)) lines.push(` ${chalk.dim(sk.padEnd(sw))} ${valCell(sk, sub[sk])}`);
210
+ } else {
211
+ lines.push(`${label} ${valCell(k, v)}`);
212
+ }
213
+ }
214
+ console.log(lines.join("\n"));
215
+ }
216
+ function valCell(key, v) {
217
+ const s = fmtCell(v);
218
+ if (s !== "\u2014" && (STATUS_COLS.has(key) || key.endsWith("_status"))) return statusColor(s);
219
+ return s;
220
+ }
221
+ function renderList(ctx2, list, columns, headers, noun, pagination) {
222
+ const useFields = ctx2.fields && ctx2.fields.length ? ctx2.fields : void 0;
223
+ let cols = useFields || columns;
224
+ if (!cols || !cols.length) {
225
+ cols = list.length ? Object.keys(list[0]) : [];
226
+ headers = void 0;
227
+ }
228
+ const hdrs = useFields || headers || cols;
229
+ if (ctx2.csv) {
230
+ console.log(hdrs.map(csvEsc).join(","));
231
+ for (const r of list) console.log(cols.map((c) => csvEsc(getPath(r, c))).join(","));
232
+ return;
233
+ }
234
+ if (!list.length) {
235
+ console.log(dim(`No ${noun}.`));
236
+ return;
237
+ }
238
+ const table = new Table({ head: hdrs.map((h) => chalk.dim(h)), style: { head: [], border: [] } });
239
+ for (const r of list) table.push(cols.map((c) => valCell(c, getPath(r, c))));
240
+ console.log(table.toString());
241
+ const total = pagination?.total;
242
+ if (typeof total === "number") {
243
+ const parts = [`${list.length} shown`];
244
+ if (pagination.page && pagination.total_pages) parts.push(`page ${pagination.page}/${pagination.total_pages}`);
245
+ parts.push(`${total} total`);
246
+ console.log(dim(parts.join(" \xB7 ")));
247
+ }
248
+ }
249
+
250
+ // src/api.ts
251
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
252
+ function extractError(data, text, status) {
253
+ let d = data && (data.detail ?? data.error ?? data.message);
254
+ if (d && typeof d === "object") {
255
+ if (Array.isArray(d)) d = d.map((e) => e?.msg ?? e?.message ?? JSON.stringify(e)).join("; ");
256
+ else d = d.message ?? d.msg ?? JSON.stringify(d);
257
+ }
258
+ return typeof d === "string" && d || text || `HTTP ${status}`;
259
+ }
260
+ function buildQuery(params) {
261
+ if (!params) return "";
262
+ const parts = [];
263
+ for (const [k, v] of Object.entries(params)) {
264
+ if (v === void 0 || v === null || v === "") continue;
265
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
266
+ }
267
+ return parts.length ? "?" + parts.join("&") : "";
268
+ }
269
+ var Api = class {
270
+ keyOverride;
271
+ urlOverride;
272
+ apiKey = "";
273
+ baseUrl = "";
274
+ ready = false;
275
+ dryRun;
276
+ constructor(opts = {}) {
277
+ this.keyOverride = opts.apiKey;
278
+ this.urlOverride = opts.url;
279
+ this.dryRun = !!opts.dryRun;
280
+ }
281
+ ensure() {
282
+ if (this.ready) return;
283
+ const { apiKey, baseUrl } = loadConfig(this.keyOverride, this.urlOverride);
284
+ if (!apiKey) {
285
+ fail("No API key. Use --api-key, set ARCSEND_API_KEY, or run `arcsend auth setup --key <key>`.");
286
+ }
287
+ this.apiKey = apiKey;
288
+ this.baseUrl = `${baseUrl}/api/external`;
289
+ this.ready = true;
290
+ }
291
+ get(path, params) {
292
+ return this.request("GET", path, { params });
293
+ }
294
+ post(path, body) {
295
+ return this.request("POST", path, { body });
296
+ }
297
+ put(path, body) {
298
+ return this.request("PUT", path, { body });
299
+ }
300
+ patch(path, body) {
301
+ return this.request("PATCH", path, { body });
302
+ }
303
+ del(path, params) {
304
+ return this.request("DELETE", path, { params });
305
+ }
306
+ async request(method, path, opts = {}) {
307
+ if (this.dryRun && method !== "GET") {
308
+ const b = opts.body !== void 0 ? " " + JSON.stringify(opts.body).slice(0, 300) : "";
309
+ console.log(chalk2.yellow(`[dry-run] would ${method} ${path}${b}`));
310
+ return { dry_run: true, method, path, body: opts.body ?? null };
311
+ }
312
+ this.ensure();
313
+ const url = `${this.baseUrl}${path}${buildQuery(opts.params)}`;
314
+ const idempotent = method === "GET" || method === "DELETE";
315
+ const attempts = 4;
316
+ let lastErr = "";
317
+ for (let attempt = 1; attempt <= attempts; attempt++) {
318
+ let resp;
319
+ try {
320
+ resp = await fetch(url, {
321
+ method,
322
+ headers: {
323
+ Authorization: `Bearer ${this.apiKey}`,
324
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
325
+ },
326
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
327
+ });
328
+ } catch (e) {
329
+ lastErr = e?.message || String(e);
330
+ if (attempt < attempts && idempotent) {
331
+ await sleep(Math.min(2 ** attempt, 8) * 1e3);
332
+ continue;
333
+ }
334
+ fail(`connection error: ${lastErr}`);
335
+ }
336
+ if (resp.status === 429 && attempt < attempts) {
337
+ const ra = Number(resp.headers.get("retry-after")) || Math.min(2 ** attempt, 8);
338
+ await sleep(ra * 1e3);
339
+ continue;
340
+ }
341
+ if (resp.status >= 500 && attempt < attempts && idempotent) {
342
+ await sleep(Math.min(2 ** attempt, 8) * 1e3);
343
+ continue;
344
+ }
345
+ const text = await resp.text();
346
+ let data = null;
347
+ try {
348
+ data = text ? JSON.parse(text) : null;
349
+ } catch {
350
+ data = text;
351
+ }
352
+ if (!resp.ok) {
353
+ let msg = extractError(data, text, resp.status);
354
+ const s = resp.status;
355
+ if (s === 401 || s === 403) msg += " \u2014 run `arcsend auth setup` or check ARCSEND_API_KEY";
356
+ else if (s === 404 && !/not.?found/i.test(msg)) msg = `Not found \u2014 ${msg}`;
357
+ else if (s === 429) msg = `Rate limited \u2014 ${msg}`;
358
+ fail(msg);
359
+ }
360
+ return data;
361
+ }
362
+ return fail(`request failed: ${lastErr}`);
363
+ }
364
+ /** Multipart file upload (for material uploads). */
365
+ async upload(path, filePath, fields = {}) {
366
+ if (this.dryRun) {
367
+ console.log(`[dry-run] would POST ${path} (upload ${filePath})`);
368
+ return { dry_run: true, method: "POST", path };
369
+ }
370
+ this.ensure();
371
+ const { readFileSync: readFileSync3 } = await import("fs");
372
+ const { basename } = await import("path");
373
+ const fd = new FormData();
374
+ fd.append("file", new Blob([readFileSync3(filePath)]), basename(filePath));
375
+ for (const [k, v] of Object.entries(fields)) fd.append(k, v);
376
+ const resp = await fetch(`${this.baseUrl}${path}`, {
377
+ method: "POST",
378
+ headers: { Authorization: `Bearer ${this.apiKey}` },
379
+ body: fd
380
+ });
381
+ const text = await resp.text();
382
+ let data = null;
383
+ try {
384
+ data = text ? JSON.parse(text) : null;
385
+ } catch {
386
+ data = text;
387
+ }
388
+ if (!resp.ok) fail(extractError(data, text, resp.status));
389
+ return data;
390
+ }
391
+ /** Fetch every page (--all) or a single page, matching the API's {data,total,page} shape. */
392
+ async paged(path, params, page, limit, all) {
393
+ if (!all) return this.get(path, { ...params, page, page_size: limit });
394
+ const collected = [];
395
+ let p = 1;
396
+ let total = 0;
397
+ for (; ; ) {
398
+ const res = await this.get(path, { ...params, page: p, page_size: 200 });
399
+ const chunk = Array.isArray(res) ? res : res?.data || [];
400
+ total = res?.pagination?.total ?? res?.total ?? total;
401
+ collected.push(...chunk);
402
+ if (res?.pagination?.has_more === false || chunk.length < 200) break;
403
+ p += 1;
404
+ }
405
+ return { object: "list", data: collected, total: total || collected.length };
406
+ }
407
+ };
408
+
409
+ // src/confirm.ts
410
+ import readline from "readline";
411
+ async function confirmAction(prompt, opts) {
412
+ if (opts.yes || opts.dryRun) return true;
413
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
414
+ const answer = await new Promise((res) => {
415
+ rl.question(`${prompt} [y/N]: `, (a) => {
416
+ rl.close();
417
+ res(a);
418
+ });
419
+ });
420
+ return /^y(es)?$/i.test(answer.trim());
421
+ }
422
+
423
+ // src/core/validate.ts
424
+ import { z } from "zod";
425
+ function check(schema, values, label) {
426
+ const r = schema.safeParse(values);
427
+ if (!r.success) {
428
+ const lines = r.error.issues.map((i) => (i.path.length ? `${i.path.join(".")}: ` : "") + i.message).join("\n ");
429
+ fail(`${label}
430
+ ${lines}`);
431
+ }
432
+ }
433
+ var int = () => z.coerce.number().int();
434
+ var validators = {
435
+ // Sequence step: step ≥1, every step needs a body, step 1 needs a subject,
436
+ // variant A/B, split weight 1–100. (Matches backend preflight + sequence rules.)
437
+ "sequence.add"(v) {
438
+ check(
439
+ z.object({
440
+ step: int().min(1, "step number must be \u2265 1"),
441
+ subject: z.string().optional(),
442
+ body: z.string().min(1, "body is required \u2014 every sequence step needs a body"),
443
+ variant: z.enum(["A", "B", "C", "D"]).optional(),
444
+ splitWeight: int().min(1).max(100, "split weight must be 1\u2013100").optional(),
445
+ delayDays: int().min(0).optional(),
446
+ delayHours: int().min(0).optional()
447
+ }).superRefine((d, ctx2) => {
448
+ if (d.step === 1 && !d.subject?.trim()) {
449
+ ctx2.addIssue({ code: z.ZodIssueCode.custom, path: ["subject"], message: "step 1 requires a subject line" });
450
+ }
451
+ }),
452
+ v,
453
+ "Invalid sequence step:"
454
+ );
455
+ },
456
+ "sequence.update"(v) {
457
+ check(
458
+ z.object({
459
+ body: z.string().min(1, "body cannot be empty").optional(),
460
+ variant: z.enum(["A", "B"]).optional(),
461
+ splitWeight: int().min(1).max(100, "split weight must be 1\u2013100").optional(),
462
+ step: int().min(1, "step number must be \u2265 1").optional()
463
+ }),
464
+ v,
465
+ "Invalid sequence step update:"
466
+ );
467
+ },
468
+ // Per-campaign daily limit has no upper cap but must be ≥ 1.
469
+ "campaign.save"(v) {
470
+ check(
471
+ z.object({ dailyLimit: int().min(1, "daily send limit must be \u2265 1").optional() }),
472
+ v,
473
+ "Invalid campaign settings:"
474
+ );
475
+ },
476
+ // Account daily limit is 1–50 (the CLI cannot change it above the server ceiling).
477
+ "account.update"(v) {
478
+ check(
479
+ z.object({ dailyLimit: int().min(1).max(50, "daily limit must be 1\u201350").optional() }),
480
+ v,
481
+ "Invalid account update:"
482
+ );
483
+ },
484
+ // Warmup hard caps: sends 1–50 (MS is really 1–5, enforced server-side by provider),
485
+ // receives 6–10, reply rate 0–100.
486
+ "warmup.settings"(v) {
487
+ check(
488
+ z.object({
489
+ sendTarget: int().min(1).max(50, "warmup send target must be 1\u201350 (MS accounts cap at 5)").optional(),
490
+ receiveTarget: int().min(6).max(10, "warmup receive target must be 6\u201310").optional(),
491
+ replyRate: int().min(0).max(100, "reply rate must be 0\u2013100").optional()
492
+ }),
493
+ v,
494
+ "Invalid warmup settings:"
495
+ );
496
+ },
497
+ // AI config temperature is 0.0–1.0.
498
+ "ai.config"(v) {
499
+ check(
500
+ z.object({ temperature: z.coerce.number().min(0).max(1, "temperature must be 0.0\u20131.0").optional() }),
501
+ v,
502
+ "Invalid AI config:"
503
+ );
504
+ }
505
+ };
506
+
507
+ // src/util.ts
508
+ import { readFileSync as readFileSync2 } from "fs";
509
+ function splitCsv(s) {
510
+ if (!s) return void 0;
511
+ const parts = s.split(",").map((x) => x.trim()).filter(Boolean);
512
+ return parts.length ? parts : void 0;
513
+ }
514
+ function parseJsonOpt(s) {
515
+ if (!s) return void 0;
516
+ try {
517
+ return JSON.parse(s);
518
+ } catch {
519
+ fail(`invalid JSON: ${s.slice(0, 60)}`);
520
+ }
521
+ }
522
+ function loadJsonFile(path) {
523
+ try {
524
+ const raw = path === "-" ? readFileSync2(0, "utf8") : readFileSync2(path, "utf8");
525
+ return JSON.parse(raw);
526
+ } catch (e) {
527
+ fail(`could not read JSON ${path === "-" ? "from stdin" : `file ${path}`}: ${e?.message || e}`);
528
+ }
529
+ }
530
+ function timeToMinutes(s) {
531
+ if (!s) return void 0;
532
+ const m = /^(\d{1,2})(?::(\d{2}))?$/.exec(s.trim());
533
+ if (!m) fail(`invalid time "${s}" (use HH:MM, e.g. 09:00)`);
534
+ const h = Number(m[1]);
535
+ const min = m[2] ? Number(m[2]) : 0;
536
+ if (h > 23 || min > 59) fail(`invalid time "${s}"`);
537
+ return h * 60 + min;
538
+ }
539
+ var DAY_BITS = {
540
+ mon: 1,
541
+ tue: 2,
542
+ wed: 4,
543
+ thu: 8,
544
+ fri: 16,
545
+ sat: 32,
546
+ sun: 64
547
+ };
548
+ function daysToBitmask(s) {
549
+ if (!s) return void 0;
550
+ const low = s.trim().toLowerCase();
551
+ if (low === "weekdays") return 31;
552
+ if (low === "all" || low === "everyday") return 127;
553
+ let mask = 0;
554
+ for (const d of low.split(",").map((x) => x.trim()).filter(Boolean)) {
555
+ const bit = DAY_BITS[d.slice(0, 3)];
556
+ if (bit === void 0) fail(`invalid day "${d}" (use mon,tue,wed,thu,fri,sat,sun)`);
557
+ mask |= bit;
558
+ }
559
+ return mask || void 0;
560
+ }
561
+
562
+ // src/core/registry.ts
563
+ function snake(s) {
564
+ return s.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase());
565
+ }
566
+ function kebab(s) {
567
+ return s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
568
+ }
569
+ function camelOfFlag(flag) {
570
+ const long = flag.replace(/^--/, "").replace(/\s+.*$/, "");
571
+ return long.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
572
+ }
573
+ function coerce(p, v) {
574
+ if (v === void 0 || v === null || v === "") return void 0;
575
+ switch (p.type) {
576
+ case "number":
577
+ return typeof v === "number" ? v : Number(v);
578
+ case "csv":
579
+ return Array.isArray(v) ? v : splitCsv(String(v));
580
+ case "json":
581
+ return typeof v === "string" ? parseJsonOpt(v) : v;
582
+ case "jsonfile":
583
+ return typeof v === "string" ? loadJsonFile(v) : v;
584
+ case "minutes":
585
+ return typeof v === "number" ? v : timeToMinutes(String(v));
586
+ case "days":
587
+ return typeof v === "number" ? v : daysToBitmask(String(v));
588
+ default:
589
+ return v;
590
+ }
591
+ }
592
+ function appendQuery(path, query) {
593
+ const parts = [];
594
+ for (const [k, v] of Object.entries(query)) {
595
+ if (v === void 0 || v === null || v === "") continue;
596
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
597
+ }
598
+ if (!parts.length) return path;
599
+ return path + (path.includes("?") ? "&" : "?") + parts.join("&");
600
+ }
601
+ function assemble(def, values) {
602
+ let path = def.path;
603
+ const query = {};
604
+ const body = {};
605
+ let bodyRoot;
606
+ let hasRoot = false;
607
+ let fileParam;
608
+ let fileValue;
609
+ for (const p of def.params ?? []) {
610
+ if (p.type === "file") {
611
+ const fv = values[p.name];
612
+ if (fv !== void 0) {
613
+ fileParam = p;
614
+ fileValue = String(fv);
615
+ }
616
+ continue;
617
+ }
618
+ if (p.type === "boolean") {
619
+ const raw = values[p.name];
620
+ if (!p.always && !raw) continue;
621
+ const bv = p.invert ? !raw : !!raw;
622
+ const bloc = p.loc ?? (def.method === "GET" ? "query" : "body");
623
+ if (bloc === "query") query[p.apiName ?? snake(p.name)] = bv;
624
+ else if (bloc === "bodyRoot") {
625
+ bodyRoot = bv;
626
+ hasRoot = true;
627
+ } else body[p.apiName ?? snake(p.name)] = bv;
628
+ continue;
629
+ }
630
+ const val = coerce(p, values[p.name]);
631
+ if (val === void 0) continue;
632
+ const loc = p.loc ?? (p.positional ? "path" : def.method === "GET" ? "query" : "body");
633
+ if (loc === "path") {
634
+ path = path.replace(`:${p.name}`, encodeURIComponent(String(val)));
635
+ if (p.echoBody) body[p.apiName ?? snake(p.name)] = val;
636
+ } else if (loc === "query") {
637
+ query[p.apiName ?? snake(p.name)] = val;
638
+ } else if (loc === "bodyRoot") {
639
+ bodyRoot = val;
640
+ hasRoot = true;
641
+ } else {
642
+ body[p.apiName ?? snake(p.name)] = val;
643
+ }
644
+ }
645
+ return { path, query, body: hasRoot ? bodyRoot : body, fileParam, fileValue };
646
+ }
647
+ async function runCommand(def, values, ctx2) {
648
+ const a = assemble(def, values);
649
+ if (def.upload && a.fileValue) {
650
+ const fields = {};
651
+ for (const [k, v] of Object.entries(a.body)) fields[k] = String(v);
652
+ if (!("name" in fields)) fields.name = a.fileValue.split(/[\\/]/).pop() || a.fileValue;
653
+ const res2 = await ctx2.api.upload(a.path, a.fileValue, fields);
654
+ out(ctx2, res2, def.output?.columns, def.output?.headers, def.output?.noun);
655
+ if (def.success) ok(def.success);
656
+ return res2;
657
+ }
658
+ const mpath = appendQuery(a.path, a.query);
659
+ let res;
660
+ if (def.method === "GET") {
661
+ if (def.paged) {
662
+ const page = Number(values.page ?? 1);
663
+ const limit = Number(values.limit ?? 50);
664
+ res = await ctx2.api.paged(a.path, a.query, page, limit, !!values.all);
665
+ } else {
666
+ res = await ctx2.api.get(a.path, a.query);
667
+ }
668
+ } else if (def.method === "DELETE") {
669
+ res = await ctx2.api.del(a.path, a.query);
670
+ } else if (def.method === "POST") {
671
+ res = await ctx2.api.post(mpath, a.body);
672
+ } else if (def.method === "PUT") {
673
+ res = await ctx2.api.put(mpath, a.body);
674
+ } else {
675
+ res = await ctx2.api.patch(mpath, a.body);
676
+ }
677
+ out(ctx2, res, def.output?.columns, def.output?.headers, def.output?.noun);
678
+ if (def.success) ok(def.success);
679
+ return res;
680
+ }
681
+ function flagFor(p) {
682
+ const base = `--${kebab(p.name)}`;
683
+ if (p.type === "boolean" || p.type === "tri") return base;
684
+ const ph = p.type === "number" || p.type === "minutes" || p.type === "days" ? "<n>" : p.type === "csv" ? "<a,b,c>" : p.type === "file" ? "<path>" : "<value>";
685
+ return `${base} ${ph}`;
686
+ }
687
+ function addCommand(parent, def, ctxFactory) {
688
+ const cmd = parent.command(def.name).description(def.summary);
689
+ const positionals = (def.params ?? []).filter((p) => p.positional);
690
+ const options = (def.params ?? []).filter((p) => !p.positional);
691
+ for (const p of positionals) {
692
+ cmd.argument(p.required === false ? `[${snake(p.name)}]` : `<${snake(p.name)}>`, p.desc);
693
+ }
694
+ for (const p of options) {
695
+ if (p.required && p.type !== "boolean" && p.type !== "tri") {
696
+ cmd.requiredOption(flagFor(p), p.desc, p.default);
697
+ } else {
698
+ cmd.option(flagFor(p), p.desc, p.default);
699
+ }
700
+ if (p.type === "tri" && p.negFlag) cmd.option(p.negFlag, `(opposite of --${kebab(p.name)})`);
701
+ }
702
+ if (def.paged) {
703
+ cmd.option("--page <n>", "page number", "1");
704
+ cmd.option("--limit <n>", "rows per page", "50");
705
+ cmd.option("--all", "fetch every page");
706
+ }
707
+ if (def.confirm) cmd.option("--yes", "skip the confirmation prompt");
708
+ cmd.action(async (...args) => {
709
+ const command = args.pop();
710
+ const opts = args.pop() ?? {};
711
+ const posVals = args;
712
+ const ctx2 = ctxFactory();
713
+ const values = {};
714
+ positionals.forEach((p, i) => values[p.name] = posVals[i]);
715
+ for (const p of options) {
716
+ if (p.type === "tri") {
717
+ const pos = opts[p.name];
718
+ const neg = p.negFlag ? opts[camelOfFlag(p.negFlag)] : void 0;
719
+ values[p.name] = pos ? true : neg ? false : void 0;
720
+ } else {
721
+ values[p.name] = opts[p.name];
722
+ }
723
+ }
724
+ if (def.paged) {
725
+ values.page = opts.page;
726
+ values.limit = opts.limit;
727
+ values.all = opts.all;
728
+ }
729
+ if (def.validate) validators[def.validate]?.(values);
730
+ if (def.confirm && !await confirmAction(def.confirm, { yes: opts.yes, dryRun: ctx2.dryRun })) return;
731
+ await runCommand(def, values, ctx2);
732
+ });
733
+ }
734
+ function registerGroup(program2, group, ctxFactory) {
735
+ const g = program2.command(group.name).description(group.summary);
736
+ for (const c of group.commands) addCommand(g, c, ctxFactory);
737
+ for (const sg of group.subgroups ?? []) {
738
+ const s = g.command(sg.name).description(sg.summary);
739
+ for (const c of sg.commands) addCommand(s, c, ctxFactory);
740
+ }
741
+ }
742
+
743
+ // src/defs/campaigns.ts
744
+ var REPLY_COLS = ["id", "from_email", "subject", "sentiment_category", "lead_status", "read_at"];
745
+ var REPLY_HDRS = ["ID", "From", "Subject", "Sentiment", "Status", "Read"];
746
+ var replyFilters = [
747
+ { name: "interested", type: "tri", negFlag: "--not-interested", apiName: "is_interested", desc: "only interested / not-interested replies" },
748
+ { name: "read", type: "tri", negFlag: "--unread", apiName: "is_read", desc: "only read / unread replies" },
749
+ { name: "ooo", type: "tri", negFlag: "--exclude-ooo", apiName: "is_out_of_office", desc: "only / exclude out-of-office auto-replies" },
750
+ { name: "sentiment", apiName: "sentiment_category", desc: "interested|not_interested|not_now|out_of_office|referral|question|unsubscribe|bounce" },
751
+ { name: "status", apiName: "lead_status", desc: "lead status: none|interested|not_interested|meeting_booked|follow_up|closed_won|closed_lost" },
752
+ { name: "search", desc: "match sender/subject/body" },
753
+ { name: "since", desc: "ISO datetime lower bound" },
754
+ { name: "archived", type: "boolean", apiName: "include_archived", desc: "include archived replies" }
755
+ ];
756
+ var campaigns = {
757
+ name: "campaigns",
758
+ summary: "Campaigns \u2014 create, schedule, launch, monitor. A campaign sends a sequence to its contacts from its assigned accounts, on a schedule.",
759
+ commands: [
760
+ {
761
+ name: "list",
762
+ summary: "List campaigns (draft/active/paused/completed/archived).",
763
+ method: "GET",
764
+ path: "/campaigns",
765
+ paged: true,
766
+ params: [
767
+ { name: "status", desc: "draft|active|paused|completed|archived" },
768
+ { name: "tag", desc: "filter by tag" },
769
+ { name: "search", desc: "match campaign name" }
770
+ ],
771
+ output: { columns: ["id", "name", "status", "daily_send_limit", "created_at"], headers: ["ID", "Name", "Status", "Daily", "Created"], noun: "campaign(s)" }
772
+ },
773
+ {
774
+ name: "get",
775
+ summary: "Show one campaign in full.",
776
+ method: "GET",
777
+ path: "/campaigns/:id",
778
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
779
+ },
780
+ {
781
+ name: "create",
782
+ summary: "Create a campaign (starts in draft). Set the schedule here or via `update`; add a sequence + contacts + accounts before activating.",
783
+ method: "POST",
784
+ path: "/campaigns",
785
+ params: [
786
+ { name: "name", required: true, desc: "campaign name" },
787
+ { name: "dailyLimit", type: "number", apiName: "daily_send_limit", desc: "per-campaign emails/day (default 50, no upper cap)" },
788
+ { name: "timezone", desc: "IANA tz for the send window (default America/New_York)" },
789
+ { name: "startTime", type: "minutes", apiName: "send_start_hour", desc: "send window start, HH:MM (default 09:00)" },
790
+ { name: "endTime", type: "minutes", apiName: "send_end_hour", desc: "send window end, HH:MM (default 17:00)" },
791
+ { name: "days", type: "days", apiName: "send_days", desc: "sending days e.g. mon,tue,fri or 'weekdays' (default weekdays)" },
792
+ { name: "tags", type: "csv", desc: "comma-separated tags" }
793
+ ],
794
+ validate: "campaign.save",
795
+ success: "Campaign created (draft)."
796
+ },
797
+ {
798
+ name: "update",
799
+ summary: "Update a draft/paused campaign's name, daily limit, or schedule (only fields you pass change). Use activate/pause to change status.",
800
+ method: "PATCH",
801
+ path: "/campaigns/:id",
802
+ params: [
803
+ { name: "id", positional: true, desc: "campaign id" },
804
+ { name: "name", desc: "new name" },
805
+ { name: "dailyLimit", type: "number", apiName: "daily_send_limit", desc: "per-campaign emails/day" },
806
+ { name: "timezone", desc: "IANA tz" },
807
+ { name: "startTime", type: "minutes", apiName: "send_start_hour", desc: "send window start HH:MM" },
808
+ { name: "endTime", type: "minutes", apiName: "send_end_hour", desc: "send window end HH:MM" },
809
+ { name: "days", type: "days", apiName: "send_days", desc: "sending days e.g. mon,tue,fri" }
810
+ ],
811
+ validate: "campaign.save",
812
+ success: "Updated."
813
+ },
814
+ {
815
+ name: "activate",
816
+ summary: "Activate a campaign and START SENDING. Runs preflight first (needs a sequence, contacts, and \u22651 connected non-paused account).",
817
+ method: "POST",
818
+ path: "/campaigns/:id/activate",
819
+ confirm: "Activate this campaign and START SENDING emails?",
820
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
821
+ success: "Activated."
822
+ },
823
+ {
824
+ name: "pause",
825
+ summary: "Pause an active campaign (cancels pending sends, resets scheduled contacts to pending).",
826
+ method: "POST",
827
+ path: "/campaigns/:id/pause",
828
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
829
+ success: "Paused."
830
+ },
831
+ {
832
+ name: "archive",
833
+ summary: "Archive a campaign (must not be active).",
834
+ method: "POST",
835
+ path: "/campaigns/:id/archive",
836
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
837
+ success: "Archived."
838
+ },
839
+ {
840
+ name: "unarchive",
841
+ summary: "Unarchive a campaign (returns it to paused).",
842
+ method: "POST",
843
+ path: "/campaigns/:id/unarchive",
844
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
845
+ success: "Unarchived."
846
+ },
847
+ {
848
+ name: "duplicate",
849
+ summary: "Duplicate a campaign (copies sequence + settings, not contacts).",
850
+ method: "POST",
851
+ path: "/campaigns/:id/duplicate",
852
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
853
+ success: "Duplicated."
854
+ },
855
+ {
856
+ name: "delete",
857
+ summary: "Delete a campaign permanently.",
858
+ method: "DELETE",
859
+ path: "/campaigns/:id",
860
+ confirm: "Delete this campaign permanently?",
861
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
862
+ success: "Deleted."
863
+ },
864
+ {
865
+ name: "bulk-action",
866
+ summary: "Apply one action (pause/archive/delete) to many campaigns.",
867
+ method: "POST",
868
+ path: "/campaigns/bulk-action",
869
+ confirm: "Apply this action to all listed campaigns?",
870
+ params: [
871
+ { name: "ids", type: "csv", required: true, apiName: "campaign_ids", desc: "comma-separated campaign ids" },
872
+ { name: "action", required: true, desc: "pause|archive|delete" }
873
+ ],
874
+ success: "Applied."
875
+ },
876
+ {
877
+ name: "tags",
878
+ summary: "Replace a campaign's tags.",
879
+ method: "PATCH",
880
+ path: "/campaigns/:id/tags",
881
+ params: [
882
+ { name: "id", positional: true, desc: "campaign id" },
883
+ { name: "tags", type: "csv", loc: "bodyRoot", required: true, desc: "comma-separated tags (replaces all)" }
884
+ ],
885
+ success: "Tags updated."
886
+ },
887
+ // Read-only reports
888
+ {
889
+ name: "preflight",
890
+ summary: "Pre-activation readiness check (what's blocking send). DNS warnings are advisory, not blocking.",
891
+ method: "GET",
892
+ path: "/campaigns/:id/preflight",
893
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
894
+ },
895
+ {
896
+ name: "stats",
897
+ summary: "Send/open/reply/bounce summary.",
898
+ method: "GET",
899
+ path: "/campaigns/:id/stats",
900
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
901
+ },
902
+ {
903
+ name: "timeseries",
904
+ summary: "Daily stats over a window.",
905
+ method: "GET",
906
+ path: "/campaigns/:id/stats/timeseries",
907
+ params: [{ name: "id", positional: true, desc: "campaign id" }, { name: "days", type: "number", default: "30", desc: "how many days back (default 30)" }]
908
+ },
909
+ {
910
+ name: "queue",
911
+ summary: "Scheduled/queued sends for the campaign.",
912
+ method: "GET",
913
+ path: "/campaigns/:id/queue",
914
+ params: [{ name: "id", positional: true, desc: "campaign id" }, { name: "status", desc: "pending|processing|sent|failed" }],
915
+ output: { columns: ["scheduled_for", "status", "error_message"], headers: ["Scheduled", "Status", "Error"], noun: "queued send(s)" }
916
+ },
917
+ {
918
+ name: "ab",
919
+ summary: "A/B variant results (winner declared at \u226530 sends/variant, >5% reply-rate gap).",
920
+ method: "GET",
921
+ path: "/campaigns/:id/ab-results",
922
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
923
+ },
924
+ {
925
+ name: "step-performance",
926
+ summary: "Per-step reply/bounce performance.",
927
+ method: "GET",
928
+ path: "/campaigns/:id/step-performance",
929
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
930
+ },
931
+ {
932
+ name: "esp",
933
+ summary: "ESP breakdown of the recipient list (Gmail/Outlook/other).",
934
+ method: "GET",
935
+ path: "/campaigns/:id/esp-breakdown",
936
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
937
+ },
938
+ {
939
+ name: "dns",
940
+ summary: "SPF/DKIM/DMARC status of the assigned sending accounts.",
941
+ method: "GET",
942
+ path: "/campaigns/:id/dns-status",
943
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
944
+ },
945
+ {
946
+ name: "export",
947
+ summary: "Export campaign contacts as CSV.",
948
+ method: "GET",
949
+ path: "/campaigns/:id/contacts/export",
950
+ params: [{ name: "id", positional: true, desc: "campaign id" }]
951
+ },
952
+ {
953
+ name: "replies",
954
+ summary: "List replies for one campaign, with sentiment/status filters.",
955
+ method: "GET",
956
+ path: "/campaigns/:id/replies",
957
+ paged: true,
958
+ params: [{ name: "id", positional: true, desc: "campaign id" }, ...replyFilters],
959
+ output: { columns: REPLY_COLS, headers: REPLY_HDRS, noun: "reply(ies)" }
960
+ },
961
+ // Account assignment
962
+ {
963
+ name: "accounts",
964
+ summary: "List sending accounts assigned to a campaign.",
965
+ method: "GET",
966
+ path: "/campaigns/:id/accounts",
967
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
968
+ output: { columns: ["id", "email", "status", "daily_limit"], headers: ["ID", "Email", "Status", "Limit"], noun: "account(s)" }
969
+ },
970
+ {
971
+ name: "assign",
972
+ summary: "Set the campaign's sending accounts (REPLACES the current set).",
973
+ method: "POST",
974
+ path: "/campaigns/:id/accounts",
975
+ params: [
976
+ { name: "id", positional: true, echoBody: true, apiName: "campaign_id", desc: "campaign id" },
977
+ { name: "ids", type: "csv", required: true, apiName: "account_ids", desc: "comma-separated account ids (full replace set)" }
978
+ ],
979
+ success: "Accounts assigned."
980
+ },
981
+ {
982
+ name: "unassign",
983
+ summary: "Remove specific sending accounts from a campaign.",
984
+ method: "POST",
985
+ path: "/campaigns/:id/accounts/batch-unassign",
986
+ params: [
987
+ { name: "id", positional: true, desc: "campaign id" },
988
+ { name: "ids", type: "csv", required: true, apiName: "account_ids", desc: "comma-separated account ids to remove" }
989
+ ],
990
+ success: "Accounts unassigned."
991
+ }
992
+ ],
993
+ subgroups: [
994
+ {
995
+ name: "sequence",
996
+ summary: "Sequence steps \u2014 the ordered emails a campaign sends. Step 1 needs a subject; every step needs a body. Follow-ups thread as 'Re:'.",
997
+ commands: [
998
+ {
999
+ name: "list",
1000
+ summary: "List a campaign's sequence steps.",
1001
+ method: "GET",
1002
+ path: "/campaigns/:id/sequences",
1003
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
1004
+ output: { columns: ["id", "step_number", "subject", "variant", "delay_days"], headers: ["ID", "Step", "Subject", "Variant", "Delay(d)"], noun: "step(s)" }
1005
+ },
1006
+ {
1007
+ name: "add",
1008
+ summary: "Add one sequence step. Spintax {a|b} and {{variables}} are supported in subject/body.",
1009
+ method: "POST",
1010
+ path: "/sequences",
1011
+ params: [
1012
+ { name: "campaignId", positional: true, loc: "body", apiName: "campaign_id", desc: "campaign id" },
1013
+ { name: "step", type: "number", required: true, apiName: "step_number", desc: "step number (1 = first email)" },
1014
+ { name: "subject", desc: "subject line (required on step 1; follow-ups reuse the thread subject if blank)" },
1015
+ { name: "body", required: true, desc: "email body (plain text; spintax + {{vars}} allowed)" },
1016
+ { name: "delayDays", type: "number", apiName: "delay_days", desc: "days to wait after the previous step (min 1 on follow-ups)" },
1017
+ { name: "delayHours", type: "number", apiName: "delay_hours", desc: "additional hours to wait" },
1018
+ { name: "variant", desc: "A/B test variant: A, B, C, or D (default A). Same --step, different variant = the variants of that step." },
1019
+ { name: "splitWeight", type: "number", apiName: "split_weight", desc: "A/B weight 1-100 (blank = equal split)" }
1020
+ ],
1021
+ validate: "sequence.add",
1022
+ success: "Step added."
1023
+ },
1024
+ {
1025
+ name: "update",
1026
+ summary: "Update one sequence step (only fields you pass change).",
1027
+ method: "PATCH",
1028
+ path: "/sequences/:id",
1029
+ params: [
1030
+ { name: "id", positional: true, desc: "sequence step id" },
1031
+ { name: "subject", desc: "subject line" },
1032
+ { name: "body", desc: "email body" },
1033
+ { name: "delayDays", type: "number", apiName: "delay_days", desc: "days after previous step" },
1034
+ { name: "delayHours", type: "number", apiName: "delay_hours", desc: "additional hours" },
1035
+ { name: "step", type: "number", apiName: "step_number", desc: "step number" },
1036
+ { name: "splitWeight", type: "number", apiName: "split_weight", desc: "A/B weight 1-100" }
1037
+ ],
1038
+ validate: "sequence.update",
1039
+ success: "Step updated."
1040
+ },
1041
+ {
1042
+ name: "delete",
1043
+ summary: "Delete a sequence step.",
1044
+ method: "DELETE",
1045
+ path: "/sequences/:id",
1046
+ confirm: "Delete this sequence step?",
1047
+ params: [{ name: "id", positional: true, desc: "sequence step id" }],
1048
+ success: "Step deleted."
1049
+ },
1050
+ {
1051
+ name: "test",
1052
+ summary: "Send a test of one step from a real account to an address you choose.",
1053
+ method: "POST",
1054
+ path: "/campaigns/:id/sequences/:step/test",
1055
+ params: [
1056
+ { name: "id", positional: true, desc: "campaign id" },
1057
+ { name: "step", positional: true, desc: "step number" },
1058
+ { name: "account", required: true, apiName: "account_id", desc: "account id to send from" },
1059
+ { name: "to", required: true, apiName: "to_email", desc: "where to send the test" },
1060
+ { name: "sample", type: "json", apiName: "sample_data", desc: "sample contact JSON for {{variables}}" }
1061
+ ],
1062
+ success: "Test sent."
1063
+ }
1064
+ ]
1065
+ },
1066
+ {
1067
+ name: "material",
1068
+ summary: "Campaign materials \u2014 files/links the AI reply agent can reference or attach.",
1069
+ commands: [
1070
+ {
1071
+ name: "list",
1072
+ summary: "List a campaign's materials.",
1073
+ method: "GET",
1074
+ path: "/campaigns/:id/materials",
1075
+ params: [{ name: "id", positional: true, desc: "campaign id" }],
1076
+ output: { columns: ["id", "name", "type", "ai_enabled", "attachable"], headers: ["ID", "Name", "Type", "AI", "Attach"], noun: "material(s)" }
1077
+ },
1078
+ {
1079
+ name: "link",
1080
+ summary: "Attach a URL as a material.",
1081
+ method: "POST",
1082
+ path: "/campaigns/:id/materials/link",
1083
+ params: [
1084
+ { name: "id", positional: true, desc: "campaign id" },
1085
+ { name: "name", required: true, desc: "display name" },
1086
+ { name: "linkUrl", apiName: "url", required: true, desc: "link URL" }
1087
+ ],
1088
+ success: "Linked."
1089
+ },
1090
+ {
1091
+ name: "upload",
1092
+ summary: "Upload a file material (PDF/DOCX/TXT/CSV/PNG/JPEG).",
1093
+ method: "POST",
1094
+ path: "/campaigns/:id/materials/upload",
1095
+ upload: true,
1096
+ params: [
1097
+ { name: "id", positional: true, desc: "campaign id" },
1098
+ { name: "file", type: "file", positional: true, desc: "path to the file" },
1099
+ { name: "name", desc: "material name (defaults to filename)" }
1100
+ ],
1101
+ success: "Uploaded."
1102
+ },
1103
+ {
1104
+ name: "download",
1105
+ summary: "Get a short-lived signed download URL for a material.",
1106
+ method: "GET",
1107
+ path: "/materials/:id/download",
1108
+ params: [{ name: "id", positional: true, desc: "material id" }]
1109
+ },
1110
+ {
1111
+ name: "update",
1112
+ summary: "Update a material's name / AI / attachable flags.",
1113
+ method: "PATCH",
1114
+ path: "/materials/:id",
1115
+ params: [
1116
+ { name: "id", positional: true, desc: "material id" },
1117
+ { name: "name", desc: "new name" },
1118
+ { name: "aiEnabled", type: "tri", negFlag: "--ai-disabled", apiName: "ai_enabled", desc: "let the AI reply agent use this material" },
1119
+ { name: "attachable", type: "tri", negFlag: "--not-attachable", apiName: "attachable", desc: "allow attaching this file to replies" }
1120
+ ],
1121
+ success: "Updated."
1122
+ },
1123
+ {
1124
+ name: "delete",
1125
+ summary: "Delete a material.",
1126
+ method: "DELETE",
1127
+ path: "/materials/:id",
1128
+ confirm: "Delete this material?",
1129
+ params: [{ name: "id", positional: true, desc: "material id" }],
1130
+ success: "Deleted."
1131
+ }
1132
+ ]
1133
+ }
1134
+ ]
1135
+ };
1136
+
1137
+ // src/defs/accounts.ts
1138
+ var accounts = {
1139
+ name: "accounts",
1140
+ summary: "Sending accounts (mailboxes). Connect/reconnect happens in the app; here you list, inspect, edit, scan, and remove them.",
1141
+ commands: [
1142
+ {
1143
+ name: "list",
1144
+ summary: "List sending accounts with operational filters.",
1145
+ method: "GET",
1146
+ path: "/accounts",
1147
+ paged: true,
1148
+ params: [
1149
+ { name: "status", desc: "connected|connecting|disconnected|error" },
1150
+ { name: "domain", desc: "filter by sending domain" },
1151
+ { name: "tag", desc: "filter by tag" },
1152
+ { name: "search", desc: "match email" },
1153
+ { name: "minBounceRate", type: "number", apiName: "min_bounce_rate", desc: "min bounce rate %, 0\u2013100 (e.g. 3 = 3%)" },
1154
+ { name: "maxBounceRate", type: "number", apiName: "max_bounce_rate", desc: "max bounce rate %, 0\u2013100 (e.g. 5 = 5%)" },
1155
+ { name: "reputation", desc: "tier (just health_score buckets): excellent 85+ | good 65-84 | fair 40-64 | low <40" },
1156
+ { name: "warmupEnabled", type: "tri", negFlag: "--warmup-off", apiName: "warmup_enabled", desc: "only warming / only not-warming accounts" }
1157
+ ],
1158
+ output: { columns: ["email", "status", "daily_limit", "health_score", "bounce_count", "total_sent", "warmup_enabled"], headers: ["Email", "Status", "Limit", "Score", "Bounces", "Sent", "Warmup"], noun: "account(s)" }
1159
+ },
1160
+ {
1161
+ name: "get",
1162
+ summary: "Show one account in full.",
1163
+ method: "GET",
1164
+ path: "/accounts/:id",
1165
+ params: [{ name: "id", positional: true, desc: "account id" }]
1166
+ },
1167
+ {
1168
+ name: "stats",
1169
+ summary: "Sending stats for one account.",
1170
+ method: "GET",
1171
+ path: "/accounts/:id/stats",
1172
+ params: [{ name: "id", positional: true, desc: "account id" }]
1173
+ },
1174
+ {
1175
+ name: "update",
1176
+ summary: "Edit an account: sender name, daily limit (1\u201350), pause state, or tags.",
1177
+ method: "PATCH",
1178
+ path: "/accounts/:id",
1179
+ params: [
1180
+ { name: "id", positional: true, desc: "account id" },
1181
+ { name: "senderName", apiName: "sender_name", desc: "the From display name" },
1182
+ { name: "dailyLimit", type: "number", apiName: "daily_limit", desc: "emails/day, 1\u201350 (default 5)" },
1183
+ { name: "paused", type: "tri", negFlag: "--active", apiName: "is_paused", desc: "pause (stop sending) / re-activate the account" },
1184
+ { name: "tags", type: "csv", desc: "replace the account's tags" }
1185
+ ],
1186
+ validate: "account.update",
1187
+ success: "Account updated."
1188
+ },
1189
+ {
1190
+ name: "bulk-action",
1191
+ summary: "Apply one action (pause/unpause/delete) to up to 200 accounts.",
1192
+ method: "POST",
1193
+ path: "/accounts/bulk-action",
1194
+ confirm: "Apply this action to all listed accounts?",
1195
+ params: [
1196
+ { name: "ids", type: "csv", required: true, apiName: "account_ids", desc: "comma-separated account ids (1\u2013200)" },
1197
+ { name: "action", required: true, desc: "pause|unpause|delete" }
1198
+ ],
1199
+ success: "Applied."
1200
+ },
1201
+ {
1202
+ name: "dns-check",
1203
+ summary: "Run an SPF/DKIM/DMARC check on the account's sending domain.",
1204
+ method: "POST",
1205
+ path: "/accounts/:id/dns-check",
1206
+ params: [{ name: "id", positional: true, desc: "account id" }],
1207
+ success: "DNS check run."
1208
+ },
1209
+ {
1210
+ name: "delete",
1211
+ summary: "Delete (disconnect) an account.",
1212
+ method: "DELETE",
1213
+ path: "/accounts/:id",
1214
+ confirm: "Delete this account?",
1215
+ params: [{ name: "id", positional: true, desc: "account id" }],
1216
+ success: "Deleted."
1217
+ }
1218
+ ]
1219
+ };
1220
+
1221
+ // src/defs/leads.ts
1222
+ var LEAD_COLS = ["id", "email", "first_name", "company", "status"];
1223
+ var LEAD_HDRS = ["ID", "Email", "First", "Company", "Status"];
1224
+ var leadFields = [
1225
+ { name: "firstName", apiName: "first_name", desc: "first name" },
1226
+ { name: "lastName", apiName: "last_name", desc: "last name" },
1227
+ { name: "company", desc: "company name" },
1228
+ { name: "title", desc: "job title" },
1229
+ { name: "linkedin", desc: "LinkedIn URL" },
1230
+ { name: "website", desc: "company website (used by AI web-scrape enrichment)" },
1231
+ { name: "customFields", type: "json", apiName: "custom_fields", desc: "JSON object of custom fields (merged, never overwritten)" }
1232
+ ];
1233
+ var leads = {
1234
+ name: "leads",
1235
+ summary: "Leads / contacts. A contact belongs to a campaign; its assigned sending account is fixed once and never changes.",
1236
+ commands: [
1237
+ {
1238
+ name: "list",
1239
+ summary: "List the leads in a campaign.",
1240
+ method: "GET",
1241
+ path: "/campaigns/:campaign/leads",
1242
+ paged: true,
1243
+ params: [
1244
+ { name: "campaign", positional: true, desc: "campaign id" },
1245
+ { name: "status", desc: "pending|scheduled|active|completed|replied|bounced|failed|unsubscribed|won|lost|..." },
1246
+ { name: "search", desc: "match email/name/company" }
1247
+ ],
1248
+ output: { columns: LEAD_COLS, headers: LEAD_HDRS, noun: "lead(s)" }
1249
+ },
1250
+ {
1251
+ name: "search",
1252
+ summary: "Search leads across the whole workspace.",
1253
+ method: "GET",
1254
+ path: "/leads/search",
1255
+ paged: true,
1256
+ params: [
1257
+ { name: "q", required: true, desc: "email, name, or company" },
1258
+ { name: "campaign", apiName: "campaign_id", desc: "restrict to one campaign" },
1259
+ { name: "status", desc: "contact status" }
1260
+ ],
1261
+ output: { columns: LEAD_COLS, headers: LEAD_HDRS, noun: "lead(s)" }
1262
+ },
1263
+ {
1264
+ name: "get",
1265
+ summary: "Show one lead.",
1266
+ method: "GET",
1267
+ path: "/leads/:id",
1268
+ params: [{ name: "id", positional: true, desc: "lead id" }]
1269
+ },
1270
+ {
1271
+ name: "add",
1272
+ summary: "Add one lead to a campaign.",
1273
+ method: "POST",
1274
+ path: "/leads",
1275
+ params: [
1276
+ { name: "campaign", positional: true, loc: "body", apiName: "campaign_id", desc: "campaign id" },
1277
+ { name: "email", required: true, desc: "lead email" },
1278
+ ...leadFields
1279
+ ],
1280
+ success: "Lead added."
1281
+ },
1282
+ {
1283
+ name: "upsert",
1284
+ summary: "Create or update a lead by email within a campaign.",
1285
+ method: "POST",
1286
+ path: "/leads/upsert",
1287
+ params: [
1288
+ { name: "campaign", positional: true, loc: "body", apiName: "campaign_id", desc: "campaign id" },
1289
+ { name: "email", required: true, desc: "lead email (match key)" },
1290
+ ...leadFields
1291
+ ],
1292
+ success: "Lead upserted."
1293
+ },
1294
+ {
1295
+ name: "bulk-add",
1296
+ summary: "Bulk-add leads to a campaign from a JSON file (max 10,000).",
1297
+ method: "POST",
1298
+ path: "/leads/bulk",
1299
+ params: [
1300
+ { name: "campaign", positional: true, loc: "body", apiName: "campaign_id", desc: "campaign id" },
1301
+ { name: "file", type: "jsonfile", loc: "body", apiName: "leads", required: true, desc: "path to a JSON array of lead objects (or - to read from stdin / a pipe)" }
1302
+ ],
1303
+ success: "Leads bulk-added."
1304
+ },
1305
+ {
1306
+ name: "update",
1307
+ summary: "Update a lead (only fields you pass change).",
1308
+ method: "PATCH",
1309
+ path: "/leads/:id",
1310
+ params: [
1311
+ { name: "id", positional: true, desc: "lead id" },
1312
+ { name: "email", desc: "new email" },
1313
+ { name: "status", desc: "contact status" },
1314
+ ...leadFields
1315
+ ],
1316
+ success: "Lead updated."
1317
+ },
1318
+ {
1319
+ name: "delete",
1320
+ summary: "Delete a lead.",
1321
+ method: "DELETE",
1322
+ path: "/leads/:id",
1323
+ confirm: "Delete this lead?",
1324
+ params: [{ name: "id", positional: true, desc: "lead id" }],
1325
+ success: "Deleted."
1326
+ },
1327
+ {
1328
+ name: "bulk-status",
1329
+ summary: "Set the status on many leads at once.",
1330
+ method: "PATCH",
1331
+ path: "/leads/bulk-status",
1332
+ params: [
1333
+ { name: "ids", type: "csv", required: true, apiName: "lead_ids", desc: "comma-separated lead ids" },
1334
+ { name: "status", required: true, desc: "new contact status" }
1335
+ ],
1336
+ success: "Status updated."
1337
+ },
1338
+ {
1339
+ name: "bulk-delete",
1340
+ summary: "Bulk-delete contacts from a campaign.",
1341
+ method: "POST",
1342
+ path: "/campaigns/:campaign/contacts/bulk-delete",
1343
+ confirm: "Delete these contacts?",
1344
+ params: [
1345
+ { name: "campaign", positional: true, desc: "campaign id" },
1346
+ { name: "ids", type: "csv", required: true, apiName: "contact_ids", desc: "comma-separated contact ids" }
1347
+ ],
1348
+ success: "Contacts deleted."
1349
+ },
1350
+ {
1351
+ name: "retry",
1352
+ summary: "Retry failed contacts in a campaign (omit --ids for all failed).",
1353
+ method: "POST",
1354
+ path: "/campaigns/:campaign/contacts/retry",
1355
+ params: [
1356
+ { name: "campaign", positional: true, desc: "campaign id" },
1357
+ { name: "ids", type: "csv", apiName: "contact_ids", desc: "specific contact ids (optional)" }
1358
+ ],
1359
+ success: "Retrying."
1360
+ },
1361
+ {
1362
+ name: "unsubscribe",
1363
+ summary: "Unsubscribe a lead (adds to suppression).",
1364
+ method: "POST",
1365
+ path: "/leads/:id/unsubscribe",
1366
+ params: [{ name: "id", positional: true, desc: "lead id" }],
1367
+ success: "Unsubscribed."
1368
+ },
1369
+ {
1370
+ name: "emails",
1371
+ summary: "List emails sent to a lead.",
1372
+ method: "GET",
1373
+ path: "/leads/:id/emails",
1374
+ params: [{ name: "id", positional: true, desc: "lead id" }]
1375
+ },
1376
+ {
1377
+ name: "replies",
1378
+ summary: "List replies from a lead.",
1379
+ method: "GET",
1380
+ path: "/leads/:id/replies",
1381
+ params: [{ name: "id", positional: true, desc: "lead id" }],
1382
+ output: { columns: ["from_email", "subject", "sentiment_category", "received_at"], headers: ["From", "Subject", "Sentiment", "Received"], noun: "reply(ies)" }
1383
+ },
1384
+ {
1385
+ name: "timeline",
1386
+ summary: "Full activity timeline for a contact.",
1387
+ method: "GET",
1388
+ path: "/contacts/:id/timeline",
1389
+ params: [{ name: "id", positional: true, desc: "contact id" }]
1390
+ },
1391
+ {
1392
+ name: "mark-replied",
1393
+ summary: "Manually mark a contact as replied (stops follow-ups).",
1394
+ method: "POST",
1395
+ path: "/contacts/:id/mark-replied",
1396
+ params: [{ name: "id", positional: true, desc: "contact id" }],
1397
+ success: "Marked replied."
1398
+ }
1399
+ ]
1400
+ };
1401
+
1402
+ // src/defs/inbox.ts
1403
+ var REPLY_COLS2 = ["id", "from_email", "subject", "sentiment_category", "lead_status", "read_at"];
1404
+ var REPLY_HDRS2 = ["ID", "From", "Subject", "Sentiment", "Status", "Read"];
1405
+ var inbox = {
1406
+ name: "inbox",
1407
+ summary: "Inbox / replies. Sentiment is AI-classified (8 categories); lead status is an editable tag. The AI reply agent only ever drafts \u2014 it never auto-sends.",
1408
+ commands: [
1409
+ {
1410
+ name: "list",
1411
+ summary: "List replies with sentiment / status filters.",
1412
+ method: "GET",
1413
+ path: "/replies",
1414
+ paged: true,
1415
+ params: [
1416
+ { name: "campaign", apiName: "campaign_id", desc: "restrict to one campaign" },
1417
+ { name: "interested", type: "tri", negFlag: "--not-interested", apiName: "is_interested", desc: "only interested / not-interested" },
1418
+ { name: "read", type: "tri", negFlag: "--unread", apiName: "is_read", desc: "only read / unread" },
1419
+ { name: "ooo", type: "tri", negFlag: "--exclude-ooo", apiName: "is_out_of_office", desc: "only / exclude out-of-office" },
1420
+ { name: "sentiment", apiName: "sentiment_category", desc: "interested|not_interested|not_now|out_of_office|referral|question|unsubscribe|bounce" },
1421
+ { name: "status", apiName: "lead_status", desc: "lead status filter" },
1422
+ { name: "search", desc: "match sender/subject/body" },
1423
+ { name: "since", desc: "ISO datetime lower bound" },
1424
+ { name: "archived", type: "boolean", apiName: "include_archived", desc: "include archived" }
1425
+ ],
1426
+ output: { columns: REPLY_COLS2, headers: REPLY_HDRS2, noun: "reply(ies)" }
1427
+ },
1428
+ {
1429
+ name: "get",
1430
+ summary: "Show one reply.",
1431
+ method: "GET",
1432
+ path: "/replies/:id",
1433
+ params: [{ name: "id", positional: true, desc: "reply id" }]
1434
+ },
1435
+ {
1436
+ name: "thread",
1437
+ summary: "Show the full conversation thread for a reply.",
1438
+ method: "GET",
1439
+ path: "/replies/:id/thread",
1440
+ params: [{ name: "id", positional: true, desc: "reply id" }]
1441
+ },
1442
+ {
1443
+ name: "reply",
1444
+ summary: "Send a reply in the thread.",
1445
+ method: "POST",
1446
+ path: "/replies/:id/reply",
1447
+ params: [
1448
+ { name: "id", positional: true, desc: "reply id" },
1449
+ { name: "body", required: true, desc: "reply text" },
1450
+ { name: "cc", type: "csv", desc: "comma-separated CC addresses" }
1451
+ ],
1452
+ success: "Reply sent."
1453
+ },
1454
+ {
1455
+ name: "interested",
1456
+ summary: "Mark a reply interested (or --not-interested).",
1457
+ method: "PATCH",
1458
+ path: "/replies/:id/interested",
1459
+ params: [
1460
+ { name: "id", positional: true, desc: "reply id" },
1461
+ { name: "notInterested", type: "boolean", apiName: "is_interested", invert: true, always: true, desc: "mark NOT interested instead" }
1462
+ ],
1463
+ success: "Updated."
1464
+ },
1465
+ {
1466
+ name: "read",
1467
+ summary: "Mark a reply read (or --unread).",
1468
+ method: "PATCH",
1469
+ path: "/replies/:id/read",
1470
+ params: [
1471
+ { name: "id", positional: true, desc: "reply id" },
1472
+ { name: "unread", type: "boolean", apiName: "is_read", invert: true, always: true, desc: "mark unread instead" }
1473
+ ],
1474
+ success: "Updated."
1475
+ },
1476
+ {
1477
+ name: "status",
1478
+ summary: "Set the lead status on a reply (none|interested|not_interested|meeting_booked|follow_up|closed_won|closed_lost, or a custom slug).",
1479
+ method: "PATCH",
1480
+ path: "/replies/:id/status",
1481
+ params: [
1482
+ { name: "id", positional: true, desc: "reply id" },
1483
+ { name: "status", required: true, desc: "lead status" }
1484
+ ],
1485
+ success: "Status set."
1486
+ },
1487
+ {
1488
+ name: "archive",
1489
+ summary: "Archive a reply (archives the whole conversation).",
1490
+ method: "POST",
1491
+ path: "/replies/:id/archive",
1492
+ params: [{ name: "id", positional: true, desc: "reply id" }],
1493
+ success: "Archived."
1494
+ },
1495
+ {
1496
+ name: "delete",
1497
+ summary: "Delete a reply.",
1498
+ method: "DELETE",
1499
+ path: "/replies/:id",
1500
+ confirm: "Delete this reply?",
1501
+ params: [{ name: "id", positional: true, desc: "reply id" }],
1502
+ success: "Deleted."
1503
+ },
1504
+ {
1505
+ name: "draft",
1506
+ summary: "Save a human draft on a reply (does not send).",
1507
+ method: "PUT",
1508
+ path: "/replies/:id/draft",
1509
+ params: [
1510
+ { name: "id", positional: true, desc: "reply id" },
1511
+ { name: "body", apiName: "draft", required: true, desc: "draft text to save" }
1512
+ ],
1513
+ success: "Draft saved."
1514
+ },
1515
+ {
1516
+ name: "ai-regenerate",
1517
+ summary: "Regenerate the AI draft for a reply (draft only \u2014 never sends).",
1518
+ method: "POST",
1519
+ path: "/replies/:id/ai-regenerate",
1520
+ params: [{ name: "id", positional: true, desc: "reply id" }]
1521
+ },
1522
+ {
1523
+ name: "ai-discard",
1524
+ summary: "Discard the AI draft for a reply.",
1525
+ method: "POST",
1526
+ path: "/replies/:id/ai-discard",
1527
+ params: [{ name: "id", positional: true, desc: "reply id" }]
1528
+ },
1529
+ {
1530
+ name: "attachment",
1531
+ summary: "Fetch reply attachment metadata (signed download URL).",
1532
+ method: "GET",
1533
+ path: "/replies/:reply/attachments/:attachment",
1534
+ params: [
1535
+ { name: "reply", positional: true, desc: "reply id" },
1536
+ { name: "attachment", positional: true, desc: "attachment id" }
1537
+ ]
1538
+ },
1539
+ { name: "mark-all-read", summary: "Mark every reply as read.", method: "POST", path: "/inbox/mark-all-read", success: "All marked read." },
1540
+ { name: "unread-count", summary: "Count unread replies.", method: "GET", path: "/inbox/unread-count" }
1541
+ ]
1542
+ };
1543
+
1544
+ // src/defs/warmup.ts
1545
+ var warmupSettings = [
1546
+ { name: "sendTarget", type: "number", apiName: "daily_send_target", desc: "warmup sends/day (MS 1\u20135, Google 1\u201350)" },
1547
+ { name: "receiveTarget", type: "number", apiName: "daily_receive_target", desc: "warmup receives/day (6\u201310)" },
1548
+ { name: "replyRate", type: "number", apiName: "reply_rate", desc: "reply rate % (0\u2013100, default 50)" },
1549
+ { name: "rampUp", type: "tri", negFlag: "--ramp-up-off", apiName: "ramp_up_enabled", desc: "enable / disable gradual ramp-up" },
1550
+ { name: "rampStart", type: "number", apiName: "ramp_up_start", desc: "ramp-up starting volume" },
1551
+ { name: "rampDays", type: "number", apiName: "ramp_up_days", desc: "days to reach full volume" }
1552
+ ];
1553
+ var warmup = {
1554
+ name: "warmup",
1555
+ summary: "Warmup. Accounts warm cross-workspace (never same workspace); an account is campaign-ready at warmup score \u226580 AND \u22655 days warming.",
1556
+ commands: [
1557
+ {
1558
+ name: "list",
1559
+ summary: "List accounts with their warmup status.",
1560
+ method: "GET",
1561
+ path: "/warmup/accounts",
1562
+ paged: true,
1563
+ params: [{ name: "active", type: "tri", negFlag: "--inactive", apiName: "is_active", desc: "only warming / only not-warming" }],
1564
+ output: { columns: ["id", "email", "warmup_enabled", "warmup_score"], headers: ["ID", "Email", "Enabled", "Score"], noun: "account(s)" }
1565
+ },
1566
+ {
1567
+ name: "get",
1568
+ summary: "Show one account's warmup detail.",
1569
+ method: "GET",
1570
+ path: "/warmup/accounts/:id",
1571
+ params: [{ name: "id", positional: true, desc: "account id" }]
1572
+ },
1573
+ {
1574
+ name: "enable",
1575
+ summary: "Enable warmup for an account (must be connected). Optionally set targets.",
1576
+ method: "POST",
1577
+ path: "/warmup/accounts/:id/enable",
1578
+ params: [{ name: "id", positional: true, desc: "account id" }, ...warmupSettings],
1579
+ validate: "warmup.settings",
1580
+ success: "Warmup enabled."
1581
+ },
1582
+ {
1583
+ name: "disable",
1584
+ summary: "Disable warmup for an account (cancels pending warmup sends).",
1585
+ method: "POST",
1586
+ path: "/warmup/accounts/:id/disable",
1587
+ params: [{ name: "id", positional: true, desc: "account id" }],
1588
+ success: "Warmup disabled."
1589
+ },
1590
+ {
1591
+ name: "settings",
1592
+ summary: "Update warmup targets for an account.",
1593
+ method: "PATCH",
1594
+ path: "/warmup/accounts/:id/settings",
1595
+ params: [{ name: "id", positional: true, desc: "account id" }, ...warmupSettings],
1596
+ validate: "warmup.settings",
1597
+ success: "Warmup settings updated."
1598
+ },
1599
+ { name: "pool-stats", summary: "Warmup pool statistics (size, capacity, placement).", method: "GET", path: "/warmup/pool/stats" },
1600
+ {
1601
+ name: "history",
1602
+ summary: "Warmup send/receive history for an account.",
1603
+ method: "GET",
1604
+ path: "/warmup/history/:id",
1605
+ params: [{ name: "id", positional: true, desc: "account id" }, { name: "limit", type: "number", default: "50", desc: "rows (default 50)" }]
1606
+ }
1607
+ ]
1608
+ };
1609
+
1610
+ // src/defs/blacklist.ts
1611
+ var blacklist = {
1612
+ name: "blacklist",
1613
+ summary: "Blocklist / do-not-contact. Suppresses sending to specific emails or whole domains.",
1614
+ commands: [
1615
+ {
1616
+ name: "list",
1617
+ summary: "List blocklisted emails and domains.",
1618
+ method: "GET",
1619
+ path: "/blacklist",
1620
+ paged: true,
1621
+ params: [
1622
+ { name: "type", desc: "email|domain" },
1623
+ { name: "search", desc: "match address/domain" },
1624
+ { name: "reason", desc: "manual|bounce|complaint|unsubscribe" }
1625
+ ],
1626
+ output: { columns: ["email", "domain", "reason", "created_at"], headers: ["Email", "Domain", "Reason", "Added"], noun: "entr(ies)" }
1627
+ },
1628
+ {
1629
+ name: "add-email",
1630
+ summary: "Blocklist one email.",
1631
+ method: "POST",
1632
+ path: "/blacklist/emails",
1633
+ params: [
1634
+ { name: "email", positional: true, loc: "body", desc: "email to block" },
1635
+ { name: "reason", default: "manual", desc: "manual|bounce|complaint|unsubscribe" }
1636
+ ],
1637
+ success: "Blocked."
1638
+ },
1639
+ {
1640
+ name: "bulk-emails",
1641
+ summary: "Blocklist many emails at once.",
1642
+ method: "POST",
1643
+ path: "/blacklist/emails/bulk",
1644
+ params: [
1645
+ { name: "emails", type: "csv", required: true, desc: "comma-separated emails" },
1646
+ { name: "reason", default: "manual", desc: "reason for all" }
1647
+ ],
1648
+ success: "Blocked."
1649
+ },
1650
+ {
1651
+ name: "remove-email",
1652
+ summary: "Remove an email from the blocklist.",
1653
+ method: "DELETE",
1654
+ path: "/blacklist/emails/:email",
1655
+ params: [{ name: "email", positional: true, desc: "email to unblock" }],
1656
+ success: "Removed."
1657
+ },
1658
+ {
1659
+ name: "add-domain",
1660
+ summary: "Blocklist a whole domain.",
1661
+ method: "POST",
1662
+ path: "/blacklist/domains",
1663
+ params: [
1664
+ { name: "domain", positional: true, loc: "body", desc: "domain to block" },
1665
+ { name: "reason", default: "manual", desc: "reason" }
1666
+ ],
1667
+ success: "Blocked."
1668
+ },
1669
+ {
1670
+ name: "remove-domain",
1671
+ summary: "Remove a domain from the blocklist.",
1672
+ method: "DELETE",
1673
+ path: "/blacklist/domains/:domain",
1674
+ params: [{ name: "domain", positional: true, desc: "domain to unblock" }],
1675
+ success: "Removed."
1676
+ }
1677
+ ]
1678
+ };
1679
+
1680
+ // src/defs/deliverability.ts
1681
+ var deliverability = {
1682
+ name: "deliverability",
1683
+ summary: "Deliverability \u2014 bounces and domain health.",
1684
+ commands: [
1685
+ {
1686
+ name: "bounces",
1687
+ summary: "List bounced/failed emails with SMTP error detail.",
1688
+ method: "GET",
1689
+ path: "/deliverability/bounces",
1690
+ params: [
1691
+ { name: "campaign", apiName: "campaign_id", desc: "restrict to one campaign" },
1692
+ { name: "severity", desc: "harmful|warning|benign" },
1693
+ { name: "category", desc: "bounce category" },
1694
+ { name: "since", desc: "ISO datetime lower bound" },
1695
+ { name: "page", type: "number", default: "1", desc: "page number" },
1696
+ { name: "pageSize", type: "number", apiName: "page_size", default: "50", desc: "rows per page" }
1697
+ ],
1698
+ output: { columns: ["contact_email", "severity", "category", "smtp_code", "bounced_at"], headers: ["Email", "Severity", "Category", "Code", "When"], noun: "bounce(s)" }
1699
+ },
1700
+ {
1701
+ name: "bounces-summary",
1702
+ summary: "Bounce summary over a window.",
1703
+ method: "GET",
1704
+ path: "/deliverability/bounces/summary",
1705
+ params: [
1706
+ { name: "campaign", apiName: "campaign_id", desc: "restrict to one campaign" },
1707
+ { name: "days", type: "number", default: "30", desc: "days back (default 30)" }
1708
+ ]
1709
+ },
1710
+ {
1711
+ name: "domains",
1712
+ summary: "Domain health list.",
1713
+ method: "GET",
1714
+ path: "/deliverability/domains",
1715
+ output: { columns: ["domain", "total_accounts", "health_status"], headers: ["Domain", "Accounts", "Health"], noun: "domain(s)" }
1716
+ },
1717
+ {
1718
+ name: "domain",
1719
+ summary: "Health of one domain.",
1720
+ method: "GET",
1721
+ path: "/deliverability/domains/:domain",
1722
+ params: [{ name: "domain", positional: true, desc: "domain" }]
1723
+ },
1724
+ {
1725
+ name: "error-codes",
1726
+ summary: "Reference of SMTP error codes.",
1727
+ method: "GET",
1728
+ path: "/deliverability/error-codes",
1729
+ params: [{ name: "severity", desc: "filter by severity" }, { name: "category", desc: "filter by category" }]
1730
+ }
1731
+ ]
1732
+ };
1733
+
1734
+ // src/defs/analytics.ts
1735
+ var analytics = {
1736
+ name: "analytics",
1737
+ summary: "Analytics \u2014 workspace and dashboard metrics.",
1738
+ commands: [
1739
+ {
1740
+ name: "dashboard",
1741
+ summary: "Workspace dashboard metrics.",
1742
+ method: "GET",
1743
+ path: "/analytics/dashboard",
1744
+ params: [{ name: "days", type: "number", desc: "window in days, max 365 (default 14)" }]
1745
+ },
1746
+ {
1747
+ name: "workspace",
1748
+ summary: "Workspace-level analytics.",
1749
+ method: "GET",
1750
+ path: "/analytics/workspace",
1751
+ params: [{ name: "days", type: "number", desc: "window in days, max 365 (default 30)" }]
1752
+ }
1753
+ ]
1754
+ };
1755
+
1756
+ // src/defs/templates.ts
1757
+ var templates = {
1758
+ name: "templates",
1759
+ summary: "Sequence templates \u2014 reusable multi-step sequences you can save and apply to campaigns.",
1760
+ commands: [
1761
+ {
1762
+ name: "list",
1763
+ summary: "List sequence templates.",
1764
+ method: "GET",
1765
+ path: "/templates",
1766
+ paged: true,
1767
+ params: [{ name: "category", desc: "filter by category" }, { name: "search", desc: "match name" }],
1768
+ output: { columns: ["id", "name", "category"], headers: ["ID", "Name", "Category"], noun: "template(s)" }
1769
+ },
1770
+ {
1771
+ name: "get",
1772
+ summary: "Show one template.",
1773
+ method: "GET",
1774
+ path: "/templates/:id",
1775
+ params: [{ name: "id", positional: true, desc: "template id" }]
1776
+ },
1777
+ {
1778
+ name: "create",
1779
+ summary: "Create a template from a JSON sequence file.",
1780
+ method: "POST",
1781
+ path: "/templates",
1782
+ params: [
1783
+ { name: "name", required: true, desc: "template name" },
1784
+ { name: "description", desc: "description" },
1785
+ { name: "category", desc: "category" },
1786
+ { name: "sequencesFile", type: "jsonfile", apiName: "sequences", required: true, desc: "path to a JSON array of sequence-step objects" },
1787
+ { name: "tags", type: "csv", desc: "comma-separated tags" }
1788
+ ],
1789
+ success: "Template created."
1790
+ },
1791
+ {
1792
+ name: "from-campaign",
1793
+ summary: "Save a campaign's sequence as a template.",
1794
+ method: "POST",
1795
+ path: "/templates/from-campaign/:campaign",
1796
+ params: [
1797
+ { name: "campaign", positional: true, desc: "campaign id" },
1798
+ { name: "name", required: true, desc: "template name" },
1799
+ { name: "description", desc: "description" }
1800
+ ],
1801
+ success: "Saved as template."
1802
+ },
1803
+ {
1804
+ name: "apply",
1805
+ summary: "Apply a template's sequence to a campaign.",
1806
+ method: "POST",
1807
+ path: "/templates/:template/apply/:campaign",
1808
+ params: [
1809
+ { name: "template", positional: true, desc: "template id" },
1810
+ { name: "campaign", positional: true, desc: "campaign id" },
1811
+ { name: "replace", type: "boolean", desc: "replace the campaign's existing steps" }
1812
+ ],
1813
+ success: "Template applied."
1814
+ },
1815
+ {
1816
+ name: "delete",
1817
+ summary: "Delete a template.",
1818
+ method: "DELETE",
1819
+ path: "/templates/:id",
1820
+ confirm: "Delete this template?",
1821
+ params: [{ name: "id", positional: true, desc: "template id" }],
1822
+ success: "Deleted."
1823
+ }
1824
+ ]
1825
+ };
1826
+
1827
+ // src/defs/webhooks.ts
1828
+ var EVENTS = "email.sent, email.bounced, email.failed, email.opened, email.clicked, lead.replied, lead.unsubscribed, lead.status_changed, campaign.activated, campaign.paused, campaign.completed, account.connected, account.disconnected";
1829
+ var webhooks = {
1830
+ name: "webhooks",
1831
+ summary: "Outgoing webhooks \u2014 subscribe an external URL to real-time ArcSend events.",
1832
+ commands: [
1833
+ {
1834
+ name: "list",
1835
+ summary: "List webhooks.",
1836
+ method: "GET",
1837
+ path: "/webhooks",
1838
+ output: { columns: ["id", "url", "events", "is_active"], headers: ["ID", "URL", "Events", "Active"], noun: "webhook(s)" }
1839
+ },
1840
+ {
1841
+ name: "get",
1842
+ summary: "Show one webhook.",
1843
+ method: "GET",
1844
+ path: "/webhooks/:id",
1845
+ params: [{ name: "id", positional: true, desc: "webhook id" }]
1846
+ },
1847
+ {
1848
+ name: "create",
1849
+ summary: "Create a webhook.",
1850
+ method: "POST",
1851
+ path: "/webhooks",
1852
+ params: [
1853
+ { name: "endpoint", apiName: "url", required: true, desc: "destination URL" },
1854
+ { name: "events", type: "csv", required: true, desc: `comma-separated. Valid: ${EVENTS}` }
1855
+ ],
1856
+ success: "Webhook created."
1857
+ },
1858
+ {
1859
+ name: "update",
1860
+ summary: "Update a webhook (events replace the existing set).",
1861
+ method: "PATCH",
1862
+ path: "/webhooks/:id",
1863
+ params: [
1864
+ { name: "id", positional: true, desc: "webhook id" },
1865
+ { name: "endpoint", apiName: "url", desc: "destination URL" },
1866
+ { name: "events", type: "csv", desc: "replacement event list" },
1867
+ { name: "active", type: "tri", negFlag: "--inactive", apiName: "is_active", desc: "enable / disable" }
1868
+ ],
1869
+ success: "Webhook updated."
1870
+ },
1871
+ {
1872
+ name: "delete",
1873
+ summary: "Delete a webhook.",
1874
+ method: "DELETE",
1875
+ path: "/webhooks/:id",
1876
+ confirm: "Delete this webhook?",
1877
+ params: [{ name: "id", positional: true, desc: "webhook id" }],
1878
+ success: "Deleted."
1879
+ },
1880
+ {
1881
+ name: "test",
1882
+ summary: "Send a test event to a webhook.",
1883
+ method: "POST",
1884
+ path: "/webhooks/:id/test",
1885
+ params: [{ name: "id", positional: true, desc: "webhook id" }],
1886
+ success: "Test sent."
1887
+ },
1888
+ {
1889
+ name: "deliveries",
1890
+ summary: "List recent delivery attempts (newest first).",
1891
+ method: "GET",
1892
+ path: "/webhooks/:id/deliveries",
1893
+ params: [{ name: "id", positional: true, desc: "webhook id" }, { name: "status", desc: "pending|delivered|failed" }],
1894
+ output: { columns: ["id", "event_type", "status", "response_code", "attempts", "created_at"], headers: ["ID", "Event", "Status", "HTTP", "Tries", "Created"], noun: "delivery(ies)" }
1895
+ },
1896
+ {
1897
+ name: "replay",
1898
+ summary: "Resend a past delivery (re-signs + re-POSTs the original payload).",
1899
+ method: "POST",
1900
+ path: "/webhooks/deliveries/:id/replay",
1901
+ params: [{ name: "id", positional: true, desc: "delivery id" }],
1902
+ success: "Replayed."
1903
+ }
1904
+ ]
1905
+ };
1906
+
1907
+ // src/defs/team.ts
1908
+ var team = {
1909
+ name: "team",
1910
+ summary: "Team members + invitations. Roles: owner (the workspace creator, fixed), admin, member.",
1911
+ commands: [
1912
+ {
1913
+ name: "members",
1914
+ summary: "List team members.",
1915
+ method: "GET",
1916
+ path: "/team/members",
1917
+ output: { columns: ["id", "email", "role", "status"], headers: ["ID", "Email", "Role", "Status"], noun: "member(s)" }
1918
+ },
1919
+ {
1920
+ name: "invite",
1921
+ summary: "Invite a member by email.",
1922
+ method: "POST",
1923
+ path: "/team/invitations",
1924
+ params: [
1925
+ { name: "email", required: true, desc: "invitee email" },
1926
+ { name: "role", default: "member", desc: "admin|member" },
1927
+ { name: "permissions", type: "json", desc: "permissions object as JSON" },
1928
+ { name: "workspaces", type: "csv", apiName: "workspace_ids", desc: "comma-separated workspace ids" }
1929
+ ],
1930
+ success: "Invited."
1931
+ },
1932
+ {
1933
+ name: "revoke-invite",
1934
+ summary: "Revoke a pending invitation.",
1935
+ method: "DELETE",
1936
+ path: "/team/invitations/:id",
1937
+ params: [{ name: "id", positional: true, desc: "invitation id" }],
1938
+ success: "Revoked."
1939
+ },
1940
+ {
1941
+ name: "set-role",
1942
+ summary: "Set a member's role.",
1943
+ method: "PATCH",
1944
+ path: "/team/members/:id/role",
1945
+ params: [{ name: "id", positional: true, desc: "member id" }, { name: "role", required: true, desc: "admin|member" }],
1946
+ success: "Role updated."
1947
+ },
1948
+ {
1949
+ name: "set-permissions",
1950
+ summary: "Set a member's granular permissions.",
1951
+ method: "PATCH",
1952
+ path: "/team/members/:id/permissions",
1953
+ params: [{ name: "id", positional: true, desc: "member id" }, { name: "permissions", type: "json", required: true, desc: "permissions object as JSON" }],
1954
+ success: "Permissions updated."
1955
+ },
1956
+ {
1957
+ name: "remove",
1958
+ summary: "Remove a team member.",
1959
+ method: "DELETE",
1960
+ path: "/team/members/:id",
1961
+ confirm: "Remove this member?",
1962
+ params: [{ name: "id", positional: true, desc: "member id" }],
1963
+ success: "Removed."
1964
+ }
1965
+ ]
1966
+ };
1967
+
1968
+ // src/defs/workspaces.ts
1969
+ var workspaces = {
1970
+ name: "workspaces",
1971
+ summary: "Workspaces \u2014 the ones this key can see, plus team-workspace CRUD. Your API key is scoped to one workspace.",
1972
+ commands: [
1973
+ {
1974
+ name: "list",
1975
+ summary: "List workspaces this key can see.",
1976
+ method: "GET",
1977
+ path: "/workspaces",
1978
+ output: { columns: ["id", "name"], headers: ["ID", "Workspace"], noun: "workspace(s)" }
1979
+ },
1980
+ {
1981
+ name: "team-list",
1982
+ summary: "List team workspaces.",
1983
+ method: "GET",
1984
+ path: "/team/workspaces",
1985
+ output: { columns: ["id", "name", "slug"], headers: ["ID", "Name", "Slug"], noun: "workspace(s)" }
1986
+ },
1987
+ {
1988
+ name: "create",
1989
+ summary: "Create a team workspace.",
1990
+ method: "POST",
1991
+ path: "/team/workspaces",
1992
+ params: [{ name: "name", required: true, desc: "workspace name" }, { name: "slug", desc: "optional slug" }],
1993
+ success: "Workspace created."
1994
+ },
1995
+ {
1996
+ name: "update",
1997
+ summary: "Update a team workspace.",
1998
+ method: "PATCH",
1999
+ path: "/team/workspaces/:id",
2000
+ params: [{ name: "id", positional: true, desc: "workspace id" }, { name: "name", desc: "new name" }, { name: "slug", desc: "new slug" }],
2001
+ success: "Workspace updated."
2002
+ },
2003
+ {
2004
+ name: "delete",
2005
+ summary: "Delete a team workspace.",
2006
+ method: "DELETE",
2007
+ path: "/team/workspaces/:id",
2008
+ confirm: "Delete this workspace?",
2009
+ params: [{ name: "id", positional: true, desc: "workspace id" }],
2010
+ success: "Deleted."
2011
+ }
2012
+ ]
2013
+ };
2014
+
2015
+ // src/defs/index.ts
2016
+ var groups = [
2017
+ workspaces,
2018
+ campaigns,
2019
+ accounts,
2020
+ leads,
2021
+ inbox,
2022
+ warmup,
2023
+ blacklist,
2024
+ deliverability,
2025
+ analytics,
2026
+ templates,
2027
+ webhooks,
2028
+ team
2029
+ ];
2030
+
2031
+ // src/commands/auth.ts
2032
+ function registerAuth(program2) {
2033
+ const auth = program2.command("auth").description("API key setup + identity");
2034
+ auth.command("setup").description("Save your API key + instance URL to ~/.arcsend/config.txt (chmod 600)").requiredOption("--key <key>", "Your arc_ API key").option("--url <url>", "Instance URL", DEFAULT_URL).action((opts) => {
2035
+ saveConfig(opts.key, opts.url);
2036
+ ok(`Saved credentials to ${CONFIG_PATH}`);
2037
+ });
2038
+ auth.command("whoami").description("Show the workspace this key belongs to").action(async () => {
2039
+ const c = ctx();
2040
+ out(c, await c.api.get("/workspaces"), ["id", "name"], ["ID", "Workspace"], "workspace(s)");
2041
+ });
2042
+ }
2043
+
2044
+ // src/commands/extras.ts
2045
+ function registerExtras(program2) {
2046
+ const campaigns2 = program2.commands.find((c) => c.name() === "campaigns");
2047
+ const seq = campaigns2?.commands.find((c) => c.name() === "sequence");
2048
+ if (seq) {
2049
+ seq.command("set").description("Add an entire sequence at once from a JSON file (one create call per step).").argument("<campaign_id>").requiredOption("--file <path>", "JSON array of step objects (step_number, subject, body, delay_days, \u2026)").action(async (id, o) => {
2050
+ const c = ctx();
2051
+ const steps = loadJsonFile(o.file);
2052
+ if (!Array.isArray(steps)) fail("--file must contain a JSON array of step objects");
2053
+ for (const s of steps) await c.api.post("/sequences", { campaign_id: id, ...s });
2054
+ ok(`Created ${steps.length} step(s).`);
2055
+ });
2056
+ }
2057
+ }
2058
+
2059
+ // src/index.ts
2060
+ var program = new Command();
2061
+ program.name("arcsend").description(
2062
+ "ArcSend \u2014 cold email automation from your terminal.\n\nAuth: run `arcsend auth setup --key <arc_...>`, or set ARCSEND_API_KEY.\nOutput: default = table; -j = JSON; --csv = spreadsheet; --fields a,b,c = pick columns.\nSafety: --dry-run previews any change without doing it. Flags work before OR after the command."
2063
+ ).version("1.0.0", "-v, --version").option("-j, --json", "Output raw JSON (for scripts / pipes)").option("--csv", "Output CSV (opens in Excel/Sheets)").option("--fields <cols>", "Comma-separated columns to show on list output").option("--api-key <key>", "Override the API key for this command").option("--url <url>", "Override the instance URL for this command").option("--dry-run", "Preview any change (create/update/delete/activate/send) WITHOUT executing").option("-q, --quiet", "Suppress output; use the exit code only (for scripts)");
2064
+ function ctx() {
2065
+ const o = program.opts();
2066
+ const dryRun = !!o.dryRun;
2067
+ const quiet = !!o.quiet;
2068
+ setDryRun(dryRun);
2069
+ setQuiet(quiet);
2070
+ return {
2071
+ api: new Api({ apiKey: o.apiKey, url: o.url, dryRun }),
2072
+ json: !!o.json,
2073
+ csv: !!o.csv,
2074
+ quiet,
2075
+ fields: o.fields ? String(o.fields).split(",").map((s) => s.trim()).filter(Boolean) : void 0,
2076
+ dryRun
2077
+ };
2078
+ }
2079
+ registerAuth(program);
2080
+ for (const g of groups) registerGroup(program, g, ctx);
2081
+ registerExtras(program);
2082
+ var HOIST_BOOL = /* @__PURE__ */ new Set(["-j", "--json", "--csv", "--dry-run", "-q", "--quiet"]);
2083
+ var HOIST_VAL = /* @__PURE__ */ new Set(["--fields"]);
2084
+ function hoist(argv) {
2085
+ const front = [];
2086
+ const rest = [];
2087
+ for (let i = 0; i < argv.length; i++) {
2088
+ const a = argv[i];
2089
+ if (HOIST_BOOL.has(a)) front.push(a);
2090
+ else if (HOIST_VAL.has(a)) {
2091
+ front.push(a);
2092
+ if (i + 1 < argv.length) front.push(argv[++i]);
2093
+ } else if (a.startsWith("--fields=")) front.push(a);
2094
+ else rest.push(a);
2095
+ }
2096
+ return [...front, ...rest];
2097
+ }
2098
+ program.parseAsync(["node", "arcsend", ...hoist(process.argv.slice(2))]).catch((e) => {
2099
+ console.error(e?.message || e);
2100
+ process.exit(1);
2101
+ });
2102
+ export {
2103
+ ctx,
2104
+ program
2105
+ };