argorant 0.5.1 → 0.6.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 +21 -33
  2. package/bin/argorant.js +258 -161
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -122,43 +122,31 @@ needs a paid plan" apart from "something broke".
122
122
  command always wins over the stored one, `ARGORANT_API_BASE` overrides both,
123
123
  and `argorant logout` removes the file.
124
124
 
125
- ## Campaigns operator keys only
125
+ ## Campaigns and inboxes
126
126
 
127
- `argorant campaigns ...` drives live outbound campaigns end to end from the
128
- terminal (create → copy → inboxes → leads → start), in about two minutes.
129
- This group talks to a different, internal surface than everything above and
130
- only works for an `ag_live_` key that belongs to an **owner/admin** account
131
- and carries the `argorant:operator` scope — a normal customer key gets a
132
- 401/403, same as a browser session would without outbound access.
127
+ Email outreach from the terminal, with any Argorant API key. Nothing sends until you launch.
133
128
 
134
- ```sh
135
- npx argorant campaigns create --name "Q3 CFO outreach" --brand argorant \
136
- --timezone America/New_York --window 08:00-17:00 --skip-weekends
137
- npx argorant campaigns steps set "Q3 CFO outreach" --step 1 \
138
- --subject "Quick question" --body-file ./copy/step1.txt --approve
139
- npx argorant campaigns inboxes attach "Q3 CFO outreach" --count 5 --pool argorant
140
- npx argorant campaigns leads add "Q3 CFO outreach" --query "CFO" --industry fintech --country Germany
141
- npx argorant campaigns start "Q3 CFO outreach"
129
+ ```
130
+ argorant campaigns list
131
+ argorant campaigns create --name "Q4 CFO outreach" [--daily-limit 40] [--timezone Europe/Berlin] [--gap 60-120] [--start 2026-10-01]
132
+ argorant campaigns emails set <campaign> --step 1 --subject "Quick question, {{first_name}}" --body-file ./step1.txt
133
+ argorant campaigns emails set <campaign> --step 2 --body "Bumping this." --delay-days 3
134
+ argorant campaigns emails set <campaign> --file emails.json # whole sequence at once
135
+ argorant campaigns leads add <campaign> --list <list-id> # a saved list
136
+ argorant campaigns leads add <campaign> --csv leads.csv # email, first_name, last_name, company, title
137
+ argorant campaigns senders set <campaign> --emails a@x.com,b@y.com # or --all
138
+ argorant campaigns launch <campaign> [-y] # asks for confirmation
139
+ argorant campaigns pause | stop | status <campaign>
140
+
141
+ argorant inboxes list
142
+ argorant inboxes connect-google --admin admin@yourdomain.com # lists mailboxes (or the delegation setup to do first)
143
+ argorant inboxes connect-google --admin admin@yourdomain.com --emails a@x.com,b@x.com # or --all
142
144
  ```
143
145
 
144
- | Command | What it does |
145
- | --- | --- |
146
- | `campaigns list [--brand <key>]` | Name, status, contacted, replies, reply rate |
147
- | `campaigns create --name "<n>" [--brand] [--timezone] [--window HH:MM-HH:MM] [--skip-weekends]` | New native campaign |
148
- | `campaigns steps set <campaign> --step <n> --subject "..." (--body-file <path> \| --body <text\|->) [--approve]` | Upsert one sequence step's copy. The CLI never writes copy for you. |
149
- | `campaigns inboxes attach <campaign> --count <n> [--pool <brand>]` | Attach N healthy, unattached sending inboxes (explicit fleet change; prints exactly which ones) |
150
- | `campaigns leads add <campaign> --csv <file>` | Import leads from a CSV (email + optional first_name/last_name/company/title/...) |
151
- | `campaigns leads add <campaign> --query "..." [filters]` | Enroll leads straight from a server-side search (same filters as above) |
152
- | `campaigns start <campaign>` / `pause <campaign>` | Start (auto-approves draft copy + campaign, background-schedules sends) / pause |
153
- | `campaigns status <campaign>` | Setup completeness (steps/inboxes/leads) + launch blockers |
154
-
155
- `<campaign>` accepts a raw id or an unambiguous case-insensitive name prefix;
156
- an ambiguous prefix lists every match instead of guessing.
157
-
158
- **Known gap:** `campaigns leads add --query` has no server-side row cap yet —
159
- `-n/--limit` is accepted for a familiar CLI surface but not forwarded/honored
160
- (it enrolls every valid match up to the platform's own cap). The CLI warns
161
- when you pass it. See `cli/GODMODE-PLAN.md`.
146
+ `<campaign>` accepts an id or an unambiguous case-insensitive name prefix. Every address is verified before
147
+ it is enrolled; only valid ones send unless `--include-catch-all`. Variables: `{{first_name}}`, `{{last_name}}`,
148
+ `{{company}}`, `{{title}}`; spintax `{a|b}` varies phrasing. `--query "..."` filter enrollment stays available
149
+ for operator keys.
162
150
 
163
151
  ## Releasing
164
152
 
package/bin/argorant.js CHANGED
@@ -26,6 +26,7 @@ const dim = (s) => c("2", s);
26
26
  const green = (s) => c("32", s);
27
27
  const red = (s) => c("31", s);
28
28
  const cyan = (s) => c("36", s);
29
+ const yellow = (s) => c("33", s);
29
30
 
30
31
  function die(msg, code = 1) {
31
32
  process.stderr.write(red("error: ") + msg + "\n");
@@ -351,6 +352,14 @@ async function cmdLogin(args) {
351
352
  if (!key) die("no key provided.");
352
353
  if (!/^ag_(live|test)_/.test(key)) process.stderr.write(dim("note: keys normally start with ag_live_ — continuing anyway.\n"));
353
354
  const res = await request("GET", args.base, "/api/mcp/account", { key });
355
+ if (res.status === 428 && res.json?.detail?.error === "access_key_activation_required") {
356
+ const url = res.json.detail.claim_url;
357
+ process.stdout.write(cyan("Your access key is reserved. Confirm your email to activate it:\n"));
358
+ process.stdout.write(`${url}\n\n`);
359
+ process.stdout.write(dim("After confirmation, run the same login command again.\n"));
360
+ process.exitCode = 3;
361
+ return;
362
+ }
354
363
  if (res.status === 401) die("that key was rejected (401). Double-check you copied the whole ag_live_ key.", 2);
355
364
  const acct = need(res, "login");
356
365
  saveConfig({ apiKey: key, base: args.baseExplicit && args.base !== DEFAULT_BASE ? args.base : undefined });
@@ -1060,36 +1069,34 @@ function readStdin() {
1060
1069
  });
1061
1070
  }
1062
1071
 
1063
- async function fetchCampaigns(base, key, { brand, includeCounts = true } = {}) {
1064
- const query = { include_counts: includeCounts ? "true" : "false", limit: "500" };
1065
- if (brand) query.brand = brand;
1066
- const res = await request("GET", base, "/api/sequencer/campaigns", { key, query });
1072
+ async function fetchCampaigns(base, key) {
1073
+ const res = await request("GET", base, "/api/v1/campaigns", { key, query: { limit: "500" } });
1067
1074
  const r = need(res, "campaigns list");
1068
1075
  return r.campaigns || [];
1069
1076
  }
1070
1077
 
1071
- // <campaign> accepts a raw id (uuid) or an unambiguous case-insensitive name
1072
- // prefix. Errors listing every match when the prefix is ambiguous.
1073
1078
  async function resolveCampaign(base, key, identifier) {
1074
- if (!identifier) die("campaign id or name is required.");
1075
1079
  if (UUID_RE.test(identifier)) return identifier;
1076
- const campaigns = await fetchCampaigns(base, key, { includeCounts: false });
1077
- const needle = identifier.trim().toLowerCase();
1078
- const matches = campaigns.filter((c) => String(c.name || "").toLowerCase().startsWith(needle));
1079
- if (matches.length === 1) return matches[0].id;
1080
- if (matches.length === 0) {
1080
+ const needle = String(identifier || "").toLowerCase();
1081
+ const campaigns = await fetchCampaigns(base, key);
1082
+ const exact = campaigns.filter((c) => String(c.name || "").toLowerCase() === needle);
1083
+ const matches = exact.length ? exact : campaigns.filter((c) => String(c.name || "").toLowerCase().startsWith(needle));
1084
+ if (!matches.length) {
1081
1085
  die(`no campaign matching "${identifier}". Run \`argorant campaigns list\` to see names.`);
1082
1086
  }
1083
- die(
1084
- `"${identifier}" matches ${matches.length} campaigns — be more specific:\n` +
1085
- matches.map((c) => ` ${c.id} ${c.name}`).join("\n")
1086
- );
1087
+ if (matches.length > 1) {
1088
+ die(
1089
+ `"${identifier}" matches ${matches.length} campaigns — be more specific:\n` +
1090
+ matches.map((c) => ` ${c.name} ${dim(c.id)}`).join("\n")
1091
+ );
1092
+ }
1093
+ return matches[0].id;
1087
1094
  }
1088
1095
 
1089
1096
  async function campaignsList(argv) {
1090
1097
  const key = requireKey();
1091
- const args = readFlags(argv, { "--brand": "brand" });
1092
- const campaigns = await fetchCampaigns(args.base, key, { brand: args.brand, includeCounts: true });
1098
+ const args = readFlags(argv);
1099
+ const campaigns = await fetchCampaigns(args.base, key);
1093
1100
  if (args.json) return console.log(JSON.stringify(campaigns, null, 2));
1094
1101
  if (!campaigns.length) {
1095
1102
  return console.log(dim('No campaigns yet. Create one with `argorant campaigns create --name "..."`.'));
@@ -1101,7 +1108,9 @@ async function campaignsList(argv) {
1101
1108
  console.log(`${bold(c.name || "—")} ${dim(c.id)}`);
1102
1109
  console.log(
1103
1110
  ` ${c.status}` +
1104
- dim(" · contacted ") + sent.toLocaleString() +
1111
+ dim(" · leads ") + Number(c.lead_count || 0).toLocaleString() +
1112
+ dim(" · senders ") + Number(c.inbox_count || 0).toLocaleString() +
1113
+ dim(" · sent ") + sent.toLocaleString() +
1105
1114
  dim(" · replies ") + replied.toLocaleString() +
1106
1115
  dim(" · reply rate ") + rate
1107
1116
  );
@@ -1112,43 +1121,102 @@ async function campaignsCreate(argv) {
1112
1121
  const key = requireKey();
1113
1122
  const args = readFlags(
1114
1123
  argv,
1115
- { "--name": "name", "--brand": "brand", "--timezone": "timezone", "--window": "window" },
1116
- { "--skip-weekends": "skipWeekends", "--no-skip-weekends": "noSkipWeekends" }
1124
+ { "--name": "name", "--timezone": "timezone", "--daily-limit": "dailyLimit", "--gap": "gap", "--start": "start" },
1125
+ { "--html": "html" }
1117
1126
  );
1118
1127
  const name = (args.name || "").trim();
1119
1128
  if (!name) {
1129
+ die('usage: argorant campaigns create --name "<name>" [--daily-limit 100] [--timezone America/New_York] [--gap 60-120] [--start YYYY-MM-DD] [--html]');
1130
+ }
1131
+ const body = { name };
1132
+ if (args.timezone) body.timezone = args.timezone;
1133
+ if (args.dailyLimit) body.daily_limit = parseLimit(args.dailyLimit, "--daily-limit");
1134
+ if (args.gap) {
1135
+ const m = /^(\d+)(?:-(\d+))?$/.exec(args.gap);
1136
+ if (!m) die("--gap must look like 60-120 (minutes between two sends from one mailbox)");
1137
+ body.gap_minutes_min = Number(m[1]);
1138
+ body.gap_minutes_max = Number(m[2] || m[1]);
1139
+ }
1140
+ if (args.start) {
1141
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(args.start)) die("--start must be YYYY-MM-DD");
1142
+ body.planned_start_date = args.start;
1143
+ }
1144
+ if (args.html) body.plain_text = false;
1145
+ const res = await request("POST", args.base, "/api/v1/campaigns", { key, body });
1146
+ const r = need(res, "campaigns create");
1147
+ const c = r.campaign || r;
1148
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1149
+ console.log(green("✓") + ` Created campaign ${bold(c.name || name)} ${dim(c.id)}`);
1150
+ console.log(dim(` ${c.status || "draft"} · ${c.default_timezone || body.timezone || "America/New_York"} · ${c.daily_limit || body.daily_limit || 100} a day`));
1151
+ console.log(dim(`Next: argorant campaigns emails set ${c.id} --step 1 --subject "..." --body-file ./copy.txt`));
1152
+ }
1153
+
1154
+ // emails: the whole sequence at once (--file emails.json, or - for stdin) or
1155
+ // one step merged into what is already there (--step n ...).
1156
+ async function campaignsEmails(argv) {
1157
+ const sub = argv[0];
1158
+ if (sub !== "set") {
1120
1159
  die(
1121
- 'usage: argorant campaigns create --name "<name>" [--brand <key>] [--timezone <tz>] ' +
1122
- "[--window HH:MM-HH:MM] [--skip-weekends | --no-skip-weekends]"
1160
+ 'usage: argorant campaigns emails set <campaign> --file emails.json\n' +
1161
+ ' or: argorant campaigns emails set <campaign> --step <n> --subject "..." (--body-file <path> | --body <text|->) [--delay-days <d>] [--new-thread]\n' +
1162
+ ' emails.json: [{"subject":"...","body":"...","delay_days":0},{"body":"...","delay_days":3}]'
1123
1163
  );
1124
1164
  }
1125
- // lead_source defaults to "argorant_campaign" server-side, which requires a
1126
- // source_outbound_campaign_id this command doesn't collect. CLI-created
1127
- // campaigns add leads afterwards via `campaigns leads add`, so force "manual"
1128
- // it's a plain attribute on the campaign row, independent of how leads
1129
- // actually get imported later.
1130
- const body = { name, lead_source: "manual" };
1131
- if (args.brand) body.product_key = args.brand;
1132
- if (args.timezone) body.default_timezone = args.timezone;
1133
- if (args.window) {
1134
- const m = /^(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$/.exec(args.window);
1135
- if (!m) die("--window must look like 08:00-17:00");
1136
- body.sending_window_start = m[1];
1137
- body.sending_window_end = m[2];
1138
- }
1139
- if (args.skipWeekends) body.skip_weekends = true;
1140
- if (args.noSkipWeekends) body.skip_weekends = false;
1141
- const res = await request("POST", args.base, "/api/sequencer/campaigns", { key, body });
1142
- const r = need(res, "campaigns create");
1143
- const c = r.campaign || {};
1144
- if (args.json) return console.log(JSON.stringify(r, null, 2));
1145
- console.log(green("✓") + ` Created campaign ${bold(c.name)} ${dim(c.id)}`);
1146
- console.log(
1147
- dim(
1148
- ` ${c.status} · ${c.default_timezone} · ${c.sending_window_start}–${c.sending_window_end} · skip weekends: ${c.skip_weekends}`
1149
- )
1165
+ const key = requireKey();
1166
+ const args = readFlags(
1167
+ argv.slice(1),
1168
+ { "--file": "file", "--step": "step", "--subject": "subject", "--body": "body", "--body-file": "bodyFile", "--delay-days": "delayDays" },
1169
+ { "--new-thread": "newThread" }
1150
1170
  );
1151
- console.log(dim(`Next: argorant campaigns steps set ${c.id} --step 1 --subject "..." --body-file ./copy.txt`));
1171
+ const identifier = args._[0];
1172
+ if (!identifier) die("usage: argorant campaigns emails set <campaign> ...");
1173
+ const campaignId = await resolveCampaign(args.base, key, identifier);
1174
+ let emails;
1175
+ if (args.file) {
1176
+ const text = args.file === "-" ? await readStdin() : (() => { try { return fs.readFileSync(args.file, "utf8"); } catch { die(`cannot read file: ${args.file}`); } })();
1177
+ try { emails = JSON.parse(text); } catch (e) { die(`${args.file}: not valid JSON (${e.message})`); }
1178
+ if (!Array.isArray(emails) || !emails.length) die("emails.json must be a non-empty array");
1179
+ } else {
1180
+ const step = Number(args.step);
1181
+ if (!Number.isInteger(step) || step < 1) die("--step must be 1, 2, 3 …");
1182
+ let body = args.body;
1183
+ if (args.bodyFile) { try { body = fs.readFileSync(args.bodyFile, "utf8"); } catch { die(`cannot read file: ${args.bodyFile}`); } }
1184
+ else if (body === "-") body = await readStdin();
1185
+ if (!body || !body.trim()) die("give the email text with --body or --body-file");
1186
+ if (step === 1 && !(args.subject || "").trim()) die("the first email needs --subject");
1187
+ // merge into the current sequence
1188
+ const cur = need(await request("GET", args.base, `/api/v1/campaigns/${campaignId}`, { key }), "campaign");
1189
+ const existing = (cur.emails || []).map((e) => ({ subject: e.subject || "", body: e.body || "", delay_days: e.delay_days || 0, same_thread: e.same_thread !== false }));
1190
+ while (existing.length < step) existing.push({ subject: "", body: "", delay_days: 3, same_thread: true });
1191
+ existing[step - 1] = {
1192
+ subject: (args.subject || existing[step - 1].subject || "").trim(),
1193
+ body,
1194
+ delay_days: args.delayDays !== undefined ? Number(args.delayDays) : (step === 1 ? 0 : existing[step - 1].delay_days || 3),
1195
+ same_thread: !args.newThread,
1196
+ };
1197
+ emails = existing.filter((e) => e.body && e.body.trim());
1198
+ }
1199
+ const res = await request("POST", args.base, `/api/v1/campaigns/${campaignId}/emails`, { key, body: { emails } });
1200
+ const r = need(res, "campaigns emails set");
1201
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1202
+ console.log(green("✓") + ` Saved ${(r.emails || emails).length} email(s) for ${dim(campaignId)}`);
1203
+ }
1204
+
1205
+ // senders: which connected mailboxes send this campaign.
1206
+ async function campaignsSenders(argv) {
1207
+ const sub = argv[0];
1208
+ if (sub !== "set") die("usage: argorant campaigns senders set <campaign> --emails a@x.com,b@y.com | --all");
1209
+ const key = requireKey();
1210
+ const args = readFlags(argv.slice(1), { "--emails": "emails" }, { "--all": "all" });
1211
+ const identifier = args._[0];
1212
+ if (!identifier) die("usage: argorant campaigns senders set <campaign> --emails a@x.com,b@y.com | --all");
1213
+ const campaignId = await resolveCampaign(args.base, key, identifier);
1214
+ const emails = String(args.emails || "").split(",").map((x) => x.trim()).filter(Boolean);
1215
+ if (!emails.length && !args.all) die("give --emails <a,b,c> or --all (see: argorant inboxes list)");
1216
+ const res = await request("POST", args.base, `/api/v1/campaigns/${campaignId}/senders`, { key, body: { emails, all_connected: !!args.all } });
1217
+ const r = need(res, "campaigns senders set");
1218
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1219
+ console.log(green("✓") + ` ${r.attached || 0} sender(s) attached` + (r.senders && r.senders.length ? dim(` ${r.senders.join(", ")}`) : ""));
1152
1220
  }
1153
1221
 
1154
1222
  async function campaignsSteps(argv) {
@@ -1258,153 +1326,133 @@ async function campaignsInboxes(argv) {
1258
1326
 
1259
1327
  async function campaignsLeads(argv) {
1260
1328
  const sub = argv[0];
1261
- if (sub !== "add") {
1262
- die(
1263
- 'usage: argorant campaigns leads add <campaign> --csv <file>\n' +
1264
- ' or: argorant campaigns leads add <campaign> --query "..." [filters] -n <n>'
1265
- );
1266
- }
1329
+ const usage =
1330
+ 'usage: argorant campaigns leads add <campaign> --list <list-id>\n' +
1331
+ ' or: argorant campaigns leads add <campaign> --csv <file> (columns: email, first_name, last_name, company, title)\n' +
1332
+ ' or: argorant campaigns leads add <campaign> --query "..." [filters] -n <n> (operator keys)';
1333
+ if (sub !== "add") die(usage);
1267
1334
  const key = requireKey();
1268
- const valueFlags = { ...CAMPAIGN_FILTER_VALUE_FLAGS, "--csv": "csv", "-n": "limit", "--limit": "limit" };
1269
- const args = readFlags(argv.slice(1), valueFlags, CAMPAIGN_FILTER_BOOL_FLAGS);
1335
+ const valueFlags = { ...CAMPAIGN_FILTER_VALUE_FLAGS, "--csv": "csv", "--file": "csv", "--list": "list", "-n": "limit", "--limit": "limit" };
1336
+ const args = readFlags(argv.slice(1), valueFlags, { ...CAMPAIGN_FILTER_BOOL_FLAGS, "--include-catch-all": "includeCatchAll" });
1270
1337
  const identifier = args._[0];
1271
- if (!identifier) {
1272
- die(
1273
- 'usage: argorant campaigns leads add <campaign> --csv <file>\n' +
1274
- ' or: argorant campaigns leads add <campaign> --query "..." [filters] -n <n>'
1275
- );
1276
- }
1338
+ if (!identifier) die(usage);
1277
1339
  const campaignId = await resolveCampaign(args.base, key, identifier);
1278
- const body = {};
1279
- if (args.csv) {
1280
- let text;
1281
- try {
1282
- text = fs.readFileSync(args.csv, "utf8");
1283
- } catch {
1284
- die(`cannot read file: ${args.csv}`);
1285
- }
1286
- if (!text.trim()) die(`file is empty: ${args.csv}`);
1287
- body.csv_text = text;
1288
- } else {
1289
- const filters = {};
1290
- for (const field of Object.values(CAMPAIGN_FILTER_VALUE_FLAGS)) if (args[field]) filters[field] = args[field];
1291
- for (const field of Object.values(CAMPAIGN_FILTER_BOOL_FLAGS)) if (args[field]) filters[field] = "true";
1292
- if (!Object.keys(filters).length) {
1293
- die(
1294
- 'provide --csv <file>, or at least one filter: --query "..." --title --country --industry ' +
1295
- "--seniority --department --state --city --company --domain --has-phone --has-linkedin --has-email"
1296
- );
1297
- }
1298
- // GAP (see GODMODE-PLAN.md): the sequencer's filter-enroll endpoint has no
1299
- // per-request row cap — it enrolls every valid match up to its own
1300
- // server-side limit (currently 50,000). -n/--limit is accepted here for a
1301
- // familiar CLI surface but is NOT forwarded/honored; warn rather than
1302
- // silently ignoring a flag the caller explicitly set.
1303
- if (args.limit) {
1304
- warn("-n/--limit is not honored by campaign lead enrollment yet (platform-side gap, see GODMODE-PLAN.md) — it enrolls every valid match.");
1340
+
1341
+ if (args.list || args.csv) {
1342
+ const body = { include_catch_all: !!args.includeCatchAll };
1343
+ if (args.list) body.list_id = parseLimit(args.list, "--list");
1344
+ else {
1345
+ let text;
1346
+ try { text = fs.readFileSync(args.csv, "utf8"); } catch { die(`cannot read file: ${args.csv}`); }
1347
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
1348
+ if (lines.length < 2) die(`file has no rows: ${args.csv}`);
1349
+ const head = lines.shift().split(",").map((h) => h.trim().toLowerCase().replace(/\s+/g, "_"));
1350
+ if (!head.includes("email")) die("the CSV needs an email column");
1351
+ body.rows = lines.map((ln) => {
1352
+ const cells = ln.split(",");
1353
+ const row = {};
1354
+ head.forEach((h, i) => { if (h) row[h] = (cells[i] || "").trim(); });
1355
+ return row;
1356
+ });
1305
1357
  }
1306
- body.filters = filters;
1358
+ const res = await request("POST", args.base, `/api/v1/campaigns/${campaignId}/leads`, { key, body });
1359
+ const r = need(res, "campaigns leads add");
1360
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1361
+ const v = r.verification_counts || {};
1362
+ console.log(green("✓") + ` Enrolled ${Number(r.inserted || 0).toLocaleString()} of ${Number(r.input_rows || 0).toLocaleString()}`);
1363
+ console.log(dim(` duplicates ${r.duplicates_skipped || 0} · not valid ${r.skipped_not_valid || 0} (invalid ${v.invalid || 0}, catch-all ${v.catch_all || 0}) · checking in background ${r.queued_for_verification || 0}`));
1364
+ return;
1307
1365
  }
1366
+
1367
+ // Filter enrollment straight from the database: operator keys.
1368
+ const filters = {};
1369
+ for (const field of Object.values(CAMPAIGN_FILTER_VALUE_FLAGS)) if (args[field]) filters[field] = args[field];
1370
+ for (const field of Object.values(CAMPAIGN_FILTER_BOOL_FLAGS)) if (args[field]) filters[field] = "true";
1371
+ if (!Object.keys(filters).length) die(usage);
1372
+ const body = { filters };
1373
+ if (args.limit) body.limit = parseLimit(args.limit, "-n");
1308
1374
  const res = await request("POST", args.base, `/api/sequencer/campaigns/${campaignId}/leads/import`, { key, body });
1309
1375
  const r = need(res, "campaigns leads add");
1310
1376
  if (args.json) return console.log(JSON.stringify(r, null, 2));
1311
- console.log(
1312
- green("✓") +
1313
- ` ${bold(r.inserted || 0)} lead(s) added` +
1314
- (r.duplicates_skipped ? dim(` (${r.duplicates_skipped} duplicate already in campaign)`) : "")
1315
- );
1316
- if (r.queued_for_verification) {
1317
- console.log(dim(` ${r.queued_for_verification} queued for verification — will join once confirmed deliverable`));
1318
- }
1319
- if (r.skipped_not_valid) console.log(dim(` ${r.skipped_not_valid} skipped (not deliverable)`));
1320
- if (r.invalid_email_rows) console.log(dim(` ${r.invalid_email_rows} row(s) had no usable email`));
1377
+ console.log(green("✓") + ` Imported ${Number(r.inserted || 0).toLocaleString()} lead(s)` + dim(` (skipped ${r.duplicates_skipped || 0} duplicates, ${r.invalid_rows || 0} invalid)`));
1321
1378
  }
1322
1379
 
1323
- async function campaignsSetStatus(argv, status, verb) {
1380
+ async function campaignsSetStatus(argv, action) {
1324
1381
  const key = requireKey();
1325
- const args = readFlags(argv, {});
1382
+ const args = readFlags(argv);
1326
1383
  const identifier = args._[0];
1327
- if (!identifier) die(`usage: argorant campaigns ${verb} <campaign>`);
1384
+ if (!identifier) die(`usage: argorant campaigns ${action} <campaign>`);
1328
1385
  const campaignId = await resolveCampaign(args.base, key, identifier);
1329
- const res = await request("PATCH", args.base, `/api/sequencer/campaigns/${campaignId}`, { key, body: { status } });
1330
- if (res.status === 400 && res.json && res.json.detail && typeof res.json.detail === "object") {
1331
- const d = res.json.detail;
1332
- const blockers = d.blockers || [];
1333
- die(
1334
- `cannot ${verb} campaign: ${d.message || "blocked"}` +
1335
- (blockers.length ? "\n" + blockers.map((b) => ` - ${b}`).join("\n") : "")
1336
- );
1386
+ if (action === "launch" && !args.yes) {
1387
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1388
+ const ok = await new Promise((r) => rl.question(`Launch ${campaignId} and start sending real email? [y/N] `, (a) => { rl.close(); r(/^y/i.test(a)); }));
1389
+ if (!ok) return console.log(dim("aborted"));
1337
1390
  }
1338
- const r = need(res, `campaigns ${verb}`);
1391
+ const res = await request("POST", args.base, `/api/v1/campaigns/${campaignId}/${action}`, { key, body: {} });
1392
+ const r = need(res, `campaigns ${action}`);
1339
1393
  if (args.json) return console.log(JSON.stringify(r, null, 2));
1340
1394
  const c = r.campaign || {};
1341
- console.log(green("✓") + ` Campaign ${bold(c.name || campaignId)} is now ${bold(c.status || status)}`);
1395
+ console.log(green("✓") + ` ${bold(c.name || campaignId)} is now ${bold(c.status || action)}`);
1342
1396
  }
1343
1397
 
1344
1398
  async function campaignsStatus(argv) {
1345
1399
  const key = requireKey();
1346
- const args = readFlags(argv, {});
1400
+ const args = readFlags(argv);
1347
1401
  const identifier = args._[0];
1348
1402
  if (!identifier) die("usage: argorant campaigns status <campaign>");
1349
1403
  const campaignId = await resolveCampaign(args.base, key, identifier);
1350
- const res = await request("GET", args.base, `/api/sequencer/campaigns/${campaignId}`, { key });
1351
- const r = need(res, "campaigns status");
1404
+ const r = need(await request("GET", args.base, `/api/v1/campaigns/${campaignId}`, { key }), "campaign");
1405
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1352
1406
  const c = r.campaign || {};
1353
- if (args.json) return console.log(JSON.stringify(c, null, 2));
1354
- const stepsOk = Number(c.approved_step_count || 0) > 0;
1355
- const inboxesOk = Number(c.inbox_count || 0) > 0;
1356
- const leadsOk = Number(c.queued_count || 0) > 0 || Number(c.lead_count || 0) > 0;
1357
- const mark = (ok) => (ok ? green("✓") : red("✗"));
1358
- console.log(`${bold(c.name || "—")} ${dim(c.id)} ${dim(c.status)}`);
1359
- console.log(` ${mark(stepsOk)} steps approved ${dim(`${c.approved_step_count || 0}/${c.step_count || 0}`)}`);
1360
- console.log(` ${mark(inboxesOk)} inboxes attached ${dim(String(c.inbox_count || 0))}`);
1361
- console.log(
1362
- ` ${mark(leadsOk)} leads queued ${dim(`${c.queued_count || 0} queued / ${c.lead_count || 0} total`)}`
1363
- );
1364
- const blockers = c.launch_blockers || [];
1365
- if (blockers.length) {
1366
- console.log(bold("\nLaunch blockers:"));
1367
- for (const b of blockers) console.log(` - ${b}`);
1368
- } else {
1369
- console.log(green("\n✓ Ready to launch") + dim(` — argorant campaigns start ${c.id}`));
1370
- }
1407
+ console.log(`${bold(c.name || "—")} ${dim(c.id)}`);
1408
+ console.log(` status ${bold(c.status)} · leads ${Number(c.lead_count || 0).toLocaleString()} · sent ${Number(c.sent_count || 0).toLocaleString()} · replies ${Number(c.replied_count || 0).toLocaleString()} · bounced ${Number(c.bounced_count || 0).toLocaleString()}`);
1409
+ console.log(` emails ${(r.emails || []).length} · senders ${(r.senders || []).length} · ${c.daily_limit || "?"} a day · ${c.default_timezone || ""}`);
1410
+ for (const e of r.emails || []) console.log(dim(` ${e.step}. ${e.subject || "(same thread)"}${e.step > 1 ? ` · +${e.delay_days || 0} days` : ""}`));
1411
+ if (r.launch_blockers && r.launch_blockers.length) {
1412
+ console.log(yellow(" before launch:"));
1413
+ for (const b of r.launch_blockers) console.log(yellow(` · ${b}`));
1414
+ }
1415
+ if (c.url) console.log(dim(` ${c.url}`));
1371
1416
  }
1372
1417
 
1373
1418
  function campaignsHelp() {
1374
1419
  const p = bold("argorant campaigns");
1375
1420
  console.log(`
1376
- ${bold("Argorant Campaigns")} — god-mode outbound campaign control ${dim("(operator keys only)")}
1421
+ ${bold("Argorant Campaigns")} — email outreach from the terminal
1377
1422
 
1378
- Drives the internal Argorant Sequencer (${dim("/api/sequencer/*")}) a live campaign
1379
- in about two minutes from the terminal. Requires an ag_live_ key belonging to
1380
- an owner/admin account with the ${bold("argorant:operator")} scope; any other key gets
1381
- a 401/403, same as it would in a browser without outbound access.
1423
+ Works with any Argorant API key. Nothing is sent until you launch.
1382
1424
 
1383
1425
  ${bold("USAGE")}
1384
- ${p} list [--brand <key>]
1385
- ${p} create --name "<name>" [--brand <key>] [--timezone <tz>] [--window HH:MM-HH:MM] [--skip-weekends]
1386
- ${p} steps set <campaign> --step <n> --subject "..." (--body-file <path> | --body <text|->) [--approve]
1387
- ${p} inboxes attach <campaign> --count <n> [--pool <brand>]
1388
- ${p} leads add <campaign> --csv <file>
1389
- ${p} leads add <campaign> --query "..." [filters]
1390
- ${p} start <campaign>
1426
+ ${p} list
1427
+ ${p} create --name "<name>" [--daily-limit 100] [--timezone <tz>] [--gap 60-120] [--start YYYY-MM-DD]
1428
+ ${p} emails set <campaign> --file emails.json
1429
+ ${p} emails set <campaign> --step <n> --subject "..." (--body-file <path> | --body <text|->) [--delay-days <d>]
1430
+ ${p} leads add <campaign> --list <list-id> | --csv <file> [--include-catch-all]
1431
+ ${p} senders set <campaign> --emails a@x.com,b@y.com | --all
1432
+ ${p} launch <campaign> [-y] ${dim("asks for confirmation; sends real email")}
1391
1433
  ${p} pause <campaign>
1434
+ ${p} stop <campaign>
1392
1435
  ${p} status <campaign>
1393
1436
 
1394
- ${dim("<campaign> accepts a raw id or an unambiguous case-insensitive name prefix.")}
1395
- ${dim("The CLI never writes copy for you steps set only upserts what you give it.")}
1437
+ ${bold("argorant inboxes")} list
1438
+ ${bold("argorant inboxes")} connect-google --admin <admin@yourdomain.com> [--emails a,b | --all]
1439
+
1440
+ ${dim("<campaign> accepts an id or an unambiguous case-insensitive name prefix.")}
1441
+ ${dim("Variables in emails: {{first_name}} {{last_name}} {{company}} {{title}}. Spintax {a|b} varies phrasing.")}
1442
+ ${dim("Every address is verified before it is enrolled; only valid ones send unless --include-catch-all.")}
1396
1443
 
1397
- ${bold("EXAMPLE — live in two minutes")}
1398
- ${p} create --name "Q3 CFO outreach" --brand argorant --timezone America/New_York --window 08:00-17:00 --skip-weekends
1399
- ${p} steps set "Q3 CFO outreach" --step 1 --subject "Quick question" --body-file ./copy/step1.txt --approve
1400
- ${p} inboxes attach "Q3 CFO outreach" --count 5 --pool argorant
1401
- ${p} leads add "Q3 CFO outreach" --query "CFO" --industry fintech --country Germany
1402
- ${p} start "Q3 CFO outreach"
1444
+ ${bold("EXAMPLE")}
1445
+ ${p} create --name "Q4 CFO outreach" --daily-limit 40
1446
+ ${p} emails set "Q4 CFO" --step 1 --subject "Quick question, {{first_name}}" --body-file ./step1.txt
1447
+ ${p} emails set "Q4 CFO" --step 2 --body "Bumping this up." --delay-days 3
1448
+ ${p} leads add "Q4 CFO" --list 1240
1449
+ ${p} senders set "Q4 CFO" --all
1450
+ ${p} launch "Q4 CFO"
1403
1451
 
1404
1452
  ${bold("OPTIONS")}
1405
1453
  --json Raw JSON output --base <url> Override API base
1406
1454
 
1407
- Docs: ${cyan("https://argorant.com/docs/cli")} · Gap notes: cli/GODMODE-PLAN.md
1455
+ Docs: ${cyan("https://argorant.com/docs/cli")}
1408
1456
  `);
1409
1457
  }
1410
1458
 
@@ -1415,18 +1463,65 @@ async function cmdCampaigns(argv) {
1415
1463
  const table = {
1416
1464
  list: campaignsList,
1417
1465
  create: campaignsCreate,
1466
+ emails: campaignsEmails,
1418
1467
  steps: campaignsSteps,
1468
+ senders: campaignsSenders,
1419
1469
  inboxes: campaignsInboxes,
1420
1470
  leads: campaignsLeads,
1421
- start: (a) => campaignsSetStatus(a, "active", "start"),
1422
- pause: (a) => campaignsSetStatus(a, "paused", "pause"),
1471
+ launch: (a) => campaignsSetStatus(a, "launch"),
1472
+ start: (a) => campaignsSetStatus(a, "launch"),
1473
+ pause: (a) => campaignsSetStatus(a, "pause"),
1474
+ stop: (a) => campaignsSetStatus(a, "stop"),
1423
1475
  status: campaignsStatus,
1476
+ get: campaignsStatus,
1424
1477
  };
1425
1478
  const fn = table[sub];
1426
1479
  if (!fn) die(`unknown campaigns subcommand: ${sub}\nRun \`argorant campaigns help\` for usage.`);
1427
1480
  await fn(rest);
1428
1481
  }
1429
1482
 
1483
+ async function cmdInboxes(argv) {
1484
+ const sub = argv[0] || "list";
1485
+ const key = requireKey();
1486
+ if (sub === "list") {
1487
+ const args = readFlags(argv.slice(1));
1488
+ const r = need(await request("GET", args.base, "/api/v1/inboxes", { key }), "inboxes list");
1489
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1490
+ const rows = r.inboxes || [];
1491
+ if (!rows.length) return console.log(dim("No mailboxes connected yet. Connect Google Workspace with `argorant inboxes connect-google --admin ...` or Microsoft 365 in the app."));
1492
+ for (const i of rows) {
1493
+ console.log(`${bold(i.email)} ${dim(i.name || "")}`);
1494
+ console.log(dim(` ${i.provider} · ${i.status}${i.health ? ` · ${i.health}` : ""} · today ${i.sent_today || 0}/${i.daily_limit || "?"}`));
1495
+ }
1496
+ return;
1497
+ }
1498
+ if (sub === "connect-google") {
1499
+ const args = readFlags(argv.slice(1), { "--admin": "admin", "--emails": "emails" }, { "--all": "all" });
1500
+ if (!args.admin) die("usage: argorant inboxes connect-google --admin admin@yourdomain.com [--emails a@x.com,b@x.com | --all]");
1501
+ const body = { admin_email: args.admin };
1502
+ if (args.all) body.emails = [];
1503
+ else if (args.emails) body.emails = String(args.emails).split(",").map((x) => x.trim()).filter(Boolean);
1504
+ const r = need(await request("POST", args.base, "/api/v1/inboxes/google-workspace", { key, body }), "inboxes connect-google");
1505
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1506
+ if (r.ok === false && r.setup) {
1507
+ console.log(yellow("Delegation is not active yet.") + ` ${r.reason || ""}`);
1508
+ console.log(`In Google Admin > Security > API controls > Domain-wide delegation add:\n Client ID: ${bold(r.setup.client_id)}\n Scopes: ${r.setup.scopes_csv || (r.setup.scopes || []).join(",")}`);
1509
+ if (r.setup.delegation_url) console.log(dim(` ${r.setup.delegation_url}`));
1510
+ console.log(dim("Then run this command again."));
1511
+ return;
1512
+ }
1513
+ if (r.mailboxes) {
1514
+ for (const m of r.mailboxes) console.log(`${m.connected ? green("✓") : " "} ${m.email} ${dim(m.name || "")}${m.alias ? dim(" alias") : ""}`);
1515
+ console.log(dim("\nconnect with: --emails a@x.com,b@x.com or --all"));
1516
+ return;
1517
+ }
1518
+ console.log(green("✓") + ` Connected ${(r.connected || []).length} mailbox(es)`);
1519
+ for (const c of r.connected || []) console.log(dim(` ${c.email} · ${c.health || "ok"}`));
1520
+ return;
1521
+ }
1522
+ die(`unknown inboxes subcommand: ${sub} (list, connect-google)`);
1523
+ }
1524
+
1430
1525
  function help() {
1431
1526
  const p = bold("argorant");
1432
1527
  console.log(`
@@ -1454,7 +1549,8 @@ ${bold("COMMANDS")}
1454
1549
  ${cyan("list status")} <id> Show a saved list's size ${dim("(free)")}
1455
1550
  ${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
1456
1551
  ${cyan("verify")} --file emails.csv -o out.csv Bulk-verify your own list ${dim("(recent re-checks free)")}
1457
- ${cyan("campaigns")} ... Live outbound campaigns from the terminal ${dim("(operator keys only — argorant campaigns help)")}
1552
+ ${cyan("campaigns")} ... Email outreach: create, write, enroll, launch ${dim("(argorant campaigns help)")}
1553
+ ${cyan("inboxes")} list | connect-google Connected mailboxes; connect a Google Workspace
1458
1554
 
1459
1555
  ${bold("FILTERS")}
1460
1556
  --keywords <k> Comma = OR. The widest, most reliable filter - prefer it
@@ -1512,9 +1608,10 @@ async function main() {
1512
1608
  // `campaigns` has its own flag vocabulary (--step, --count, --pool, --csv, ...)
1513
1609
  // handled by readFlags — it never goes through the generic filter parser
1514
1610
  // below, which would reject those flags as unknown.
1515
- if (cmd === "campaigns") {
1611
+ if (cmd === "campaigns" || cmd === "campaign" || cmd === "inboxes" || cmd === "inbox") {
1516
1612
  try {
1517
- await cmdCampaigns(argv.slice(1));
1613
+ if (cmd.startsWith("campaign")) await cmdCampaigns(argv.slice(1));
1614
+ else await cmdInboxes(argv.slice(1));
1518
1615
  } catch (e) {
1519
1616
  die(e && e.message ? e.message : String(e));
1520
1617
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argorant",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Search, count, reveal, export, and verify B2B contacts from the Argorant database — from your terminal, scripts, or coding agent.",
5
5
  "bin": {
6
6
  "argorant": "bin/argorant.js"