argorant 0.4.0 → 0.5.1

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 +126 -10
  2. package/bin/argorant.js +1064 -51
  3. package/package.json +10 -4
package/bin/argorant.js CHANGED
@@ -31,6 +31,9 @@ function die(msg, code = 1) {
31
31
  process.stderr.write(red("error: ") + msg + "\n");
32
32
  process.exit(code);
33
33
  }
34
+ function warn(msg) {
35
+ process.stderr.write(dim("note: ") + msg + "\n");
36
+ }
34
37
 
35
38
  // ---- config / key storage ----
36
39
  function loadConfig() {
@@ -57,6 +60,11 @@ const VALUE_FLAGS = {
57
60
  "--department": "departments",
58
61
  "--departments": "departments",
59
62
  "--industry": "industry",
63
+ // --keywords is the highest-recall door in the index (matches source keyword
64
+ // tags + derived company tags, comma = OR). Measured against real segments it
65
+ // beats --industry by 2-4x, so it leads the docs and examples below.
66
+ "--keywords": "keywords",
67
+ "--keyword": "keywords",
60
68
  "--country": "country",
61
69
  // --geography is an alias for --country; the API expands regions like
62
70
  // "Europe", "EMEA", "DACH", "APAC" into their member countries.
@@ -66,6 +74,10 @@ const VALUE_FLAGS = {
66
74
  "--city": "city",
67
75
  "--company": "company_name",
68
76
  "--domain": "company_domain",
77
+ "--website": "website",
78
+ // Used by `enrich` (email → full profile). Harmless on the other commands,
79
+ // which simply ignore an unknown query field.
80
+ "--email": "email",
69
81
  };
70
82
  // Boolean filter flags (presence => "true"). --verified-only is positive intent
71
83
  // only (deliverable contacts); there is deliberately NO flag to query invalid or
@@ -77,24 +89,81 @@ const BOOL_FLAGS = {
77
89
  "--verified-only": "verified_only",
78
90
  };
79
91
 
92
+ // ---- --grade: the only user-visible deliverability distinction is "valid"
93
+ // vs "valid + catch-all" - never raw verification status. Both values are
94
+ // currently platform-side no-ops (see GODMODE-PLAN.md / README); the flag is
95
+ // wired end-to-end here so it activates automatically once the platform
96
+ // supports narrowing, with no further CLI changes.
97
+ const GRADE_VALUES = new Set(["valid", "valid-plus-catchall"]);
98
+ function setGrade(out, v) {
99
+ if (!GRADE_VALUES.has(v)) {
100
+ die(`invalid --grade value: ${v} (expected "valid" or "valid-plus-catchall")`);
101
+ }
102
+ out.grade = v;
103
+ out.gradeExplicit = true;
104
+ }
105
+
106
+ // --exclude-title is fully applied by `export` and `list create` today (the
107
+ // platform forwards it into the title-exclusion query on those two paths).
108
+ // `count`, `search`, and `reveal` go through a separate read path that does
109
+ // not yet apply it (see GODMODE-PLAN.md). Warn instead of silently dropping
110
+ // a filter the user asked for.
111
+ function warnExcludeTitleGap(filters) {
112
+ if (filters.exclude_title) {
113
+ warn(`--exclude-title is not applied by this command yet (platform-side gap) - it works with \`export\` and \`list create\`.`);
114
+ }
115
+ }
116
+ function warnGradeGap(scope) {
117
+ if (scope === "browse") warn(`--grade has no effect on count/search - grading only applies at reveal/export time.`);
118
+ else if (scope === "reveal") warn(`--grade is coming soon for reveal - it currently always returns the platform's standard deliverable set.`);
119
+ else if (scope === "export") warn(`--grade is coming soon for export - it currently always exports the platform's standard deliverable set (valid + catch-all).`);
120
+ }
121
+
122
+ // A value flag must be followed by an actual value. Silently swallowing the
123
+ // NEXT FLAG (`search "CFO" --title --base http://…` → title="--base") or a
124
+ // missing trailing value (→ undefined, dropped by request()) sends a request
125
+ // the user never asked for — against the wrong host, with the wrong filters.
126
+ // Fail loud instead. "-" stays legal: it is the documented stdin sentinel.
127
+ function flagValue(argv, i, flag) {
128
+ const v = argv[i + 1];
129
+ if (v === undefined) die(`${flag} needs a value.`);
130
+ if (v.startsWith("-") && v !== "-" && !/^-\d/.test(v)) {
131
+ die(`${flag} needs a value, but the next argument is another flag (${v}).`);
132
+ }
133
+ return v;
134
+ }
135
+ // -n/--limit drives billed row counts on reveal/export — a typo must never
136
+ // fall through to the default (a mistyped `-n` used to become a 1000-row
137
+ // billed export).
138
+ function parseLimit(raw, flag) {
139
+ const n = Number(raw);
140
+ if (!Number.isInteger(n) || n < 1) {
141
+ die(`${flag} must be a positive whole number (got "${raw}").`);
142
+ }
143
+ return n;
144
+ }
145
+
80
146
  function parseArgs(argv) {
81
- const out = { _: [], filters: {}, limit: null, output: null, file: null, column: null, json: false, yes: false, base: DEFAULT_BASE, name: null, includeExported: false };
147
+ const out = { _: [], filters: {}, limit: null, output: null, file: null, column: null, json: false, yes: false, base: DEFAULT_BASE, baseExplicit: false, batch: false, name: null, includeExported: false, grade: "valid", gradeExplicit: false };
82
148
  for (let i = 0; i < argv.length; i++) {
83
149
  const a = argv[i];
84
150
  if (a === "--json") out.json = true;
85
151
  else if (a === "--yes" || a === "-y") out.yes = true;
86
- else if (a === "-n" || a === "--limit") out.limit = parseInt(argv[++i], 10);
87
- else if (a === "-o" || a === "--output") out.output = argv[++i];
88
- else if (a === "-f" || a === "--file") out.file = argv[++i];
89
- else if (a === "--column") out.column = argv[++i];
90
- else if (a === "--base") out.base = argv[++i];
91
- else if (a === "--name") out.name = argv[++i];
152
+ else if (a === "-n" || a === "--limit") out.limit = parseLimit(flagValue(argv, i++, a), a);
153
+ else if (a === "-o" || a === "--output") out.output = flagValue(argv, i++, a);
154
+ else if (a === "-f" || a === "--file") out.file = flagValue(argv, i++, a);
155
+ else if (a === "--column") out.column = flagValue(argv, i++, a);
156
+ else if (a === "--base") { out.base = flagValue(argv, i++, a); out.baseExplicit = true; }
157
+ else if (a === "--name") out.name = flagValue(argv, i++, a);
92
158
  else if (a === "--include-exported") out.includeExported = true;
93
- else if (a in VALUE_FLAGS) out.filters[VALUE_FLAGS[a]] = argv[++i];
159
+ else if (a === "--batch") out.batch = true;
160
+ else if (a === "--grade") setGrade(out, flagValue(argv, i++, a));
161
+ else if (a in VALUE_FLAGS) out.filters[VALUE_FLAGS[a]] = flagValue(argv, i++, a);
94
162
  else if (a in BOOL_FLAGS) out.filters[BOOL_FLAGS[a]] = "true";
95
163
  else if (a.startsWith("--") && a.includes("=")) {
96
164
  const [k, v] = [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)];
97
165
  if (k in VALUE_FLAGS) out.filters[VALUE_FLAGS[k]] = v;
166
+ else if (k === "--grade") setGrade(out, v);
98
167
  else die(`unknown flag: ${k}`);
99
168
  } else if (a.startsWith("-") && a !== "-") {
100
169
  die(`unknown flag: ${a}`);
@@ -178,14 +247,71 @@ function downloadTo(base, urlPath, key, dest) {
178
247
  });
179
248
  }
180
249
 
250
+ // `detail` is a plain string on most endpoints, a structured object on some
251
+ // (plan_required, campaign launch blockers, native-delivery readiness) and a
252
+ // LIST of validation errors on any FastAPI 422. All three have to render as
253
+ // something a human or an agent can read — never "[object Object]".
254
+ function detailMsg(detail) {
255
+ if (detail == null) return null;
256
+ if (typeof detail === "string") return detail;
257
+ if (Array.isArray(detail)) {
258
+ const parts = detail
259
+ .map((d) => {
260
+ if (d == null) return null;
261
+ if (typeof d === "string") return d;
262
+ const where = Array.isArray(d.loc) ? d.loc.filter((x) => x !== "body" && x !== "query").join(".") : null;
263
+ const msg = d.msg || d.message || d.type || JSON.stringify(d);
264
+ return where ? `${where}: ${msg}` : msg;
265
+ })
266
+ .filter(Boolean);
267
+ return parts.length ? parts.join("; ") : JSON.stringify(detail);
268
+ }
269
+ if (typeof detail === "object") {
270
+ // {error, message} is the platform's structured-error shape.
271
+ if (detail.message) return String(detail.message);
272
+ if (detail.detail) return detailMsg(detail.detail);
273
+ if (detail.error) return String(detail.error);
274
+ }
275
+ return JSON.stringify(detail);
276
+ }
277
+ // Exit codes are part of the CLI's contract with agents/CI:
278
+ // 0 ok · 1 generic failure · 2 not authenticated · 3 forbidden (scope)
279
+ // 4 rate limit / daily quota · 5 plan upgrade required (402)
280
+ const EXIT = { OK: 0, ERROR: 1, AUTH: 2, FORBIDDEN: 3, RATE_LIMIT: 4, UPGRADE: 5 };
281
+
181
282
  function need(res, what) {
182
- if (res.status === 401) die("not authenticated. Run `argorant login` or set ARGORANT_API_KEY.", 2);
183
- if (res.status === 403) die((res.json && res.json.detail) || `forbidden your key lacks the scope for ${what}.`, 3);
184
- if (res.status === 429) die((res.json && res.json.detail) || "rate limit / daily quota reached.", 4);
185
- if (res.status >= 400) die((res.json && res.json.detail) || `${what} failed (HTTP ${res.status}).`);
283
+ const detail = res.json && res.json.detail;
284
+ if (res.status === 401) die("not authenticated. Run `argorant login` or set ARGORANT_API_KEY.", EXIT.AUTH);
285
+ if (res.status === 402) {
286
+ // Distinct from a hard error: nothing is broken, the plan just doesn't
287
+ // include this. Agents branch on exit 5 to surface an upgrade, not a bug.
288
+ const d = detail && typeof detail === "object" && !Array.isArray(detail) ? detail : {};
289
+ const msg = detailMsg(detail) || `${what} requires a paid Argorant plan.`;
290
+ const url = d.upgrade_url || d.url || "https://argorant.com/pricing";
291
+ die(`${msg}${msg.includes(url) ? "" : `\nUpgrade: ${url}`}`, EXIT.UPGRADE);
292
+ }
293
+ if (res.status === 403) die(detailMsg(detail) || `forbidden — your key lacks the scope for ${what}.`, EXIT.FORBIDDEN);
294
+ if (res.status === 429) die(detailMsg(detail) || "rate limit / daily quota reached.", EXIT.RATE_LIMIT);
295
+ if (res.status >= 400) die(detailMsg(detail) || `${what} failed (HTTP ${res.status}).`);
186
296
  return res.json || {};
187
297
  }
188
298
 
299
+ // A paid job must never die on a path problem AFTER it was billed: check the
300
+ // destination is writable before anything is created server-side.
301
+ function ensureWritable(dest) {
302
+ const resolved = path.resolve(dest);
303
+ const dir = path.dirname(resolved);
304
+ if (!fs.existsSync(dir)) die(`output directory does not exist: ${dir}`);
305
+ const existed = fs.existsSync(resolved);
306
+ try {
307
+ fs.closeSync(fs.openSync(resolved, existed ? "a" : "w"));
308
+ if (!existed) fs.unlinkSync(resolved);
309
+ } catch (e) {
310
+ die(`cannot write to ${dest}: ${e.message}`);
311
+ }
312
+ return resolved;
313
+ }
314
+
189
315
  function requireKey() {
190
316
  const k = resolveKey();
191
317
  if (!k) die("no API key. Run `argorant login` or set ARGORANT_API_KEY.", 2);
@@ -227,11 +353,27 @@ async function cmdLogin(args) {
227
353
  const res = await request("GET", args.base, "/api/mcp/account", { key });
228
354
  if (res.status === 401) die("that key was rejected (401). Double-check you copied the whole ag_live_ key.", 2);
229
355
  const acct = need(res, "login");
230
- saveConfig({ apiKey: key, base: args.base !== DEFAULT_BASE ? args.base : undefined });
356
+ saveConfig({ apiKey: key, base: args.baseExplicit && args.base !== DEFAULT_BASE ? args.base : undefined });
231
357
  console.log(green("✓") + ` Logged in as ${bold(acct.email || "your account")} ${dim("(" + (acct.role || "member") + ")")}`);
232
358
  console.log(dim(`Key saved to ${CONFIG_PATH}`));
233
359
  }
234
360
 
361
+ async function cmdLogout() {
362
+ if (!fs.existsSync(CONFIG_PATH)) {
363
+ console.log(dim(`Nothing to do — no saved credentials at ${CONFIG_PATH}.`));
364
+ } else {
365
+ try {
366
+ fs.unlinkSync(CONFIG_PATH);
367
+ } catch (e) {
368
+ die(`could not remove ${CONFIG_PATH}: ${e.message}`);
369
+ }
370
+ console.log(green("✓") + ` Removed saved key and base from ${bold(CONFIG_PATH)}`);
371
+ }
372
+ if (process.env.ARGORANT_API_KEY) {
373
+ warn("ARGORANT_API_KEY is still set in this environment and takes precedence — unset it too.");
374
+ }
375
+ }
376
+
235
377
  async function cmdWhoami(args) {
236
378
  const key = requireKey();
237
379
  const res = await request("GET", args.base, "/api/mcp/account", { key });
@@ -240,84 +382,442 @@ async function cmdWhoami(args) {
240
382
  console.log(`${bold("Account")} ${a.email || "—"} ${dim("(" + (a.role || "member") + ")")}`);
241
383
  console.log(`${bold("Scopes")} ${(a.scopes || []).join(", ") || "—"}`);
242
384
  const u = a.usage || {};
243
- const line = (label, k) => {
385
+ // Keys/fields must match _mcp_usage_summary: actions are count_requests /
386
+ // preview_rows / reveal_rows / export_rows and each entry carries
387
+ // `used_today` (NOT `used`). Getting this wrong printed a bare header.
388
+ const QUOTA_ROWS = [
389
+ ["count", "count_requests"],
390
+ ["preview", "preview_rows"],
391
+ ["reveal", "reveal_rows"],
392
+ ["export", "export_rows"],
393
+ ["find", "find_email_requests"],
394
+ ["verify", "verify_email_requests"],
395
+ ];
396
+ const render = (label, k) => {
244
397
  const x = u[k];
245
- if (!x) return;
246
- const lim = x.daily_limit == null ? "unlimited" : x.daily_limit;
247
- console.log(` ${label.padEnd(8)} ${x.used ?? 0}/${lim} today`);
398
+ if (!x || typeof x !== "object") return null;
399
+ const lim = x.daily_limit == null ? "unlimited" : Number(x.daily_limit).toLocaleString();
400
+ const used = Number(x.used_today ?? x.used ?? 0).toLocaleString();
401
+ return ` ${label.padEnd(8)} ${used}/${lim} today`;
248
402
  };
249
- if (!u.unlimited) {
250
- console.log(bold("Quota (today)"));
251
- line("count", "count");
252
- line("preview", "preview");
253
- line("reveal", "reveal");
254
- line("export", "export");
255
- } else {
403
+ if (u.unlimited) {
256
404
  console.log(dim("Quota: unlimited"));
405
+ return;
257
406
  }
407
+ const lines = QUOTA_ROWS.map(([label, k]) => render(label, k)).filter(Boolean);
408
+ // No recognizable quota map (older/newer server, or a shape we don't know):
409
+ // print nothing rather than an empty "Quota (today)" header.
410
+ if (!lines.length) return;
411
+ console.log(bold("Quota (today)"));
412
+ for (const l of lines) console.log(l);
258
413
  }
259
414
 
260
415
  async function cmdCount(args) {
261
416
  const key = requireKey();
417
+ warnExcludeTitleGap(args.filters);
418
+ if (args.gradeExplicit) warnGradeGap("browse");
262
419
  const res = await request("GET", args.base, "/api/mcp/people/count", { key, query: args.filters });
263
420
  const r = need(res, "count");
264
421
  if (args.json) return console.log(JSON.stringify(r, null, 2));
265
422
  console.log(bold(Number(r.count).toLocaleString()) + dim(" matching contacts"));
266
423
  }
267
424
 
425
+ async function cmdCompany(args) {
426
+ const key = requireKey();
427
+ const domain = String(args.filters.company_domain || args._[0] || "")
428
+ .trim()
429
+ .replace(/^https?:\/\//i, "")
430
+ .replace(/^www\./i, "")
431
+ .split(/[/?#]/, 1)[0]
432
+ .toLowerCase();
433
+ if (!domain || !domain.includes(".")) {
434
+ die("usage: argorant company <company.com> [--title <role>] [-n 5] [--json]");
435
+ }
436
+ const query = {
437
+ title: args.filters.title,
438
+ seniority: args.filters.seniority,
439
+ departments: args.filters.departments,
440
+ country: args.filters.country,
441
+ limit: args.limit || 5,
442
+ };
443
+ const res = await request(
444
+ "GET",
445
+ args.base,
446
+ `/api/mcp/companies/${encodeURIComponent(domain)}/people`,
447
+ { key, query }
448
+ );
449
+ const r = need(res, "company people lookup");
450
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
451
+ const company = r.company || {};
452
+ const companyLabel = company.company_name || domain;
453
+ console.log(`${bold(companyLabel)} ${dim(domain)}`);
454
+ console.log(` ${bold(Number(r.people_count || 0).toLocaleString())} people in Argorant`);
455
+ console.log(` ${bold(Number(r.business_email_coverage_count || 0).toLocaleString())} with business-email coverage`);
456
+ if (company.employee_count != null) {
457
+ console.log(` ${dim("Company-reported employee estimate: " + Number(company.employee_count).toLocaleString())}`);
458
+ }
459
+ if ((r.results || []).length) {
460
+ console.log(dim(` Masked role preview (${r.returned || r.results.length}):`));
461
+ for (const person of r.results) {
462
+ const who = [person.preview, person.title].filter(Boolean).join(" · ");
463
+ const where = [person.country].filter(Boolean).join(", ");
464
+ console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
465
+ }
466
+ }
467
+ }
468
+
268
469
  async function cmdSearch(args) {
269
470
  const key = requireKey();
471
+ warnExcludeTitleGap(args.filters);
472
+ if (args.gradeExplicit) warnGradeGap("browse");
270
473
  const query = { ...args.filters, limit: args.limit || 5 };
271
474
  const res = await request("GET", args.base, "/api/mcp/people/preview", { key, query });
272
475
  const r = need(res, "search");
273
476
  if (args.json) return console.log(JSON.stringify(r, null, 2));
274
477
  console.log(dim(`${Number(r.total).toLocaleString()} total · showing ${r.returned} (details redacted — use \`reveal\` or \`export\`)`));
275
478
  for (const p of r.results || []) {
276
- const who = [p.name, p.title].filter(Boolean).join(" · ");
479
+ // _redacted_preview returns the masked identity as `preview` (e.g. "A* P"),
480
+ // never `name` — without this the row rendered as title-only.
481
+ const who = [p.preview || p.name, p.title].filter(Boolean).join(" · ");
277
482
  const where = [p.company || p.company_name, p.country].filter(Boolean).join(", ");
278
483
  console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
279
484
  }
280
485
  }
281
486
 
487
+ async function cmdSample(args) {
488
+ const key = requireKey();
489
+ const website = (args.filters.website || args._[0] || args.filters.q || "").trim();
490
+ if (!website) {
491
+ die("usage: argorant sample <company.com> [--json] [-o sample.csv]");
492
+ }
493
+ if (args.output) ensureWritable(args.output);
494
+ const create = await request("POST", args.base, "/api/onboarding/website-sample", {
495
+ key,
496
+ body: { website },
497
+ });
498
+ let job = need(create, "website sample");
499
+ if (!job.job_id) die("website sample did not return a job id.");
500
+ if (!args.json) {
501
+ process.stdout.write(dim(`Building a company-first sample for ${job.domain || website}`));
502
+ }
503
+ const started = Date.now();
504
+ let lastPhase = job.phase;
505
+ while (!["done", "failed"].includes((job.status || "").toLowerCase())) {
506
+ await new Promise((resolve) => setTimeout(resolve, 2500));
507
+ const status = await request("GET", args.base, `/api/onboarding/website-sample/${job.job_id}`, { key });
508
+ job = need(status, "website sample status");
509
+ if (!args.json && job.phase !== lastPhase) {
510
+ const labels = {
511
+ reading: "reading website",
512
+ matching_companies: "matching companies",
513
+ finding_buyers: "finding buyers",
514
+ live_verifying: "live-verifying work emails",
515
+ };
516
+ process.stdout.write(`\n${dim("→ " + (labels[job.phase] || job.phase))}`);
517
+ lastPhase = job.phase;
518
+ } else if (!args.json) {
519
+ process.stdout.write(".");
520
+ }
521
+ if (Date.now() - started > 1000 * 60 * 20) die("\nwebsite sample timed out after 20 minutes.");
522
+ }
523
+ if (!args.json) process.stdout.write("\n");
524
+ if (job.status === "failed") die(job.error || "website sample failed.");
525
+ if (args.output) {
526
+ const quoteCsv = (value) => `"${String(value == null ? "" : value).replace(/"/g, '""')}"`;
527
+ const columns = ["full_name", "title", "company", "company_domain", "email", "country", "verification"];
528
+ const csv = [columns.join(",")]
529
+ .concat((job.results || []).map((row) => columns.map((column) => quoteCsv(row[column])).join(",")))
530
+ .join("\n") + "\n";
531
+ fs.writeFileSync(args.output, csv, "utf8");
532
+ }
533
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
534
+ console.log(
535
+ green("✓") + ` Your first ${bold(String((job.results || []).length))} of ` +
536
+ `${bold(Number(job.total_companies || 0).toLocaleString())} matching companies`
537
+ );
538
+ for (const lead of job.results || []) {
539
+ console.log(` ${bold(lead.full_name || "—")} ${dim("· " + (lead.title || "—"))}`);
540
+ console.log(` ${(lead.company || "—")} ${dim("· " + (lead.company_domain || "—"))}`);
541
+ console.log(` ${cyan(lead.email || "—")} ${green("✓ Valid")}`);
542
+ }
543
+ if (args.output) console.log(green("✓") + ` Saved CSV → ${bold(args.output)}`);
544
+ console.log(dim(`These 25 are the sample; the full pool contains ${Number(job.total_companies || 0).toLocaleString()} companies.`));
545
+ }
546
+
282
547
  async function cmdReveal(args) {
283
548
  const key = requireKey();
549
+ warnExcludeTitleGap(args.filters);
550
+ if (args.gradeExplicit) warnGradeGap("reveal");
284
551
  const limit = args.limit || 10;
552
+ // Confirmation is interactive-only by design: with --yes, --json, or a
553
+ // non-TTY stdin (CI, agents, pipes) this spends credits with no prompt.
285
554
  if (!args.yes && !args.json && process.stdin.isTTY) {
286
555
  const ans = await prompt(`Reveal up to ${bold(limit)} contacts? This uses your quota/credits. [y/N] `);
287
556
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
288
557
  }
289
- const query = { ...args.filters, limit };
558
+ // Sent for forward-compatibility: the platform has no per-request grade
559
+ // control on reveal yet (see GODMODE-PLAN.md), so this is a no-op today.
560
+ const query = { ...args.filters, limit, grade: args.grade === "valid-plus-catchall" ? "valid_plus_catchall" : "valid" };
290
561
  const res = await request("GET", args.base, "/api/mcp/people/reveal", { key, query });
291
562
  const r = need(res, "reveal");
292
563
  if (args.json) return console.log(JSON.stringify(r, null, 2));
293
564
  console.log(dim(`${Number(r.total).toLocaleString()} total · revealed ${r.returned}`));
294
565
  for (const p of r.results || []) {
295
- const who = [p.name, p.title].filter(Boolean).join(" · ");
566
+ // _revealed_contact returns full_name / first_name / last_name — there is
567
+ // no `name` key. The customer just paid for this row; print who it is.
568
+ const name = p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") || p.name;
569
+ const who = [name, p.title].filter(Boolean).join(" · ");
296
570
  console.log(` ${bold(who || "—")}`);
297
571
  const bits = [p.email && cyan(p.email), p.phone, p.linkedin_url, [p.company || p.company_name, p.country].filter(Boolean).join(", ")].filter(Boolean);
298
572
  if (bits.length) console.log(" " + bits.join(dim(" · ")));
299
573
  }
300
574
  }
301
575
 
576
+ // ---- enrich: one record in, one record out. Three modes, one endpoint:
577
+ // --email <addr> → the person behind an address (billed like a reveal)
578
+ // --name "<n>" --domain <d> → find that person and reveal a verified email
579
+ // --domain <d> → the company profile, free
580
+ // Exit code is part of the contract: a miss returns 0 rows and exits 1, so a
581
+ // script or agent can branch on it without parsing the payload.
582
+ function normalizeDomain(raw) {
583
+ return String(raw || "")
584
+ .trim()
585
+ .replace(/^https?:\/\//i, "")
586
+ .replace(/^www\./i, "")
587
+ .split(/[/?#]/, 1)[0]
588
+ .toLowerCase();
589
+ }
590
+
591
+ async function cmdEnrich(args) {
592
+ const key = requireKey();
593
+ const usage =
594
+ 'usage: argorant enrich --email <name@company.com>\n' +
595
+ ' argorant enrich --name "Jane Doe" --domain <company.com>\n' +
596
+ " argorant enrich --domain <company.com>";
597
+ const positional = (args._[0] || "").trim();
598
+ let email = String(args.filters.email || "").trim().toLowerCase();
599
+ let domain = String(args.filters.company_domain || "").trim();
600
+ const name = String(args.name || "").trim();
601
+ // Bare argument: an address is an email, anything else is a domain.
602
+ if (!email && !domain && positional) {
603
+ if (positional.includes("@")) email = positional.toLowerCase();
604
+ else domain = positional;
605
+ }
606
+ domain = domain ? normalizeDomain(domain) : "";
607
+ if (email && !email.includes("@")) die(`--email needs a full address (got "${email}").`);
608
+ if (!email && !domain) die(usage);
609
+ if (name && !domain) die(`--name needs --domain (the company the person works at).\n${usage}`);
610
+ const body = {};
611
+ if (email) body.email = email;
612
+ else if (name) { body.name = name; body.domain = domain; }
613
+ else body.domain = domain;
614
+ const personMode = Boolean(body.email || body.name);
615
+ // Company mode is free, so it never asks. Person mode is billed exactly like
616
+ // a reveal, so it gets the same interactive-only confirmation as `reveal`.
617
+ if (personMode && !args.yes && !args.json && process.stdin.isTTY) {
618
+ const ans = await prompt(
619
+ `Enrich this contact? A match costs 1 credit; a miss and a non-deliverable address are free. [y/N] `
620
+ );
621
+ if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
622
+ }
623
+ const res = await request("POST", args.base, "/api/v1/enrich", { key, body });
624
+ const r = need(res, "enrich");
625
+ if (args.json) {
626
+ console.log(JSON.stringify(r, null, 2));
627
+ if (!r.found) process.exit(EXIT.ERROR);
628
+ return;
629
+ }
630
+ if (!r.found) {
631
+ console.log(dim(`no ${r.type || (personMode ? "person" : "company")} found · 0 credits charged`));
632
+ process.exit(EXIT.ERROR);
633
+ }
634
+ if (r.type === "company") {
635
+ const co = r.company || {};
636
+ console.log(`${bold(co.company || domain)} ${dim(co.company_domain || domain)}`);
637
+ const where = [co.city, co.state, co.country].filter(Boolean).join(", ");
638
+ const what = co.industry || (co.industries || [])[0];
639
+ if (what) console.log(` ${what}`);
640
+ if (where) console.log(` ${dim(where)}`);
641
+ console.log(dim(" company profile · 0 credits charged"));
642
+ return;
643
+ }
644
+ const p = r.person || {};
645
+ const who = [p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" "), p.title].filter(Boolean).join(" · ");
646
+ console.log(` ${bold(who || "—")}`);
647
+ const bits = [
648
+ p.email && cyan(p.email),
649
+ p.phone,
650
+ p.linkedin_url,
651
+ [p.current_company_name, p.current_company_domain, p.country].filter(Boolean).join(", "),
652
+ ].filter(Boolean);
653
+ if (bits.length) console.log(" " + bits.join(dim(" · ")));
654
+ if (r.deliverable === false) {
655
+ console.log(" " + dim(r.message || "This address did not pass live verification, so nothing was charged."));
656
+ }
657
+ const charged = Number(r.charged || 0);
658
+ console.log(
659
+ dim(
660
+ ` ${charged === 0 ? "0 credits charged" : `${charged} credit${charged === 1 ? "" : "s"} charged`}` +
661
+ (r.already_revealed ? " · already in your workspace" : "")
662
+ )
663
+ );
664
+ }
665
+
666
+ const EXPORT_TERMINAL_OK = ["completed", "done", "ready", "succeeded"];
667
+ const EXPORT_TERMINAL_FAIL = ["failed", "error", "cancelled", "canceled"];
668
+
669
+ // Insert "-partN" before the extension: leads.csv → leads-part1.csv.
670
+ function partPath(dest, n) {
671
+ const ext = path.extname(dest);
672
+ return dest.slice(0, dest.length - ext.length) + `-part${n}` + (ext || ".csv");
673
+ }
674
+
675
+ // >50k rows come back as {type:"batch", batch_id, status_api_path:
676
+ // /api/mcp/export-batches/{id}} with NO job_id and NO download_api_path — the
677
+ // old code fell through to /api/mcp/exports/undefined/download, so large
678
+ // exports were simply impossible from the CLI. Poll the batch, then download
679
+ // each completed chunk.
680
+ async function pollExportBatch(args, key, statusPath, { quiet = false } = {}) {
681
+ const started = Date.now();
682
+ let lastDone = -1;
683
+ for (;;) {
684
+ const st = await request("GET", args.base, statusPath, { key });
685
+ const b = need(st, "export batch status");
686
+ const status = (b.status || "").toLowerCase();
687
+ if (!quiet && b.completed_chunks !== lastDone) {
688
+ lastDone = b.completed_chunks;
689
+ process.stdout.write(`\r${dim(`chunks ${b.completed_chunks || 0}/${b.total_chunks || "?"} · ${b.verified_rows || 0} rows`)}`);
690
+ } else if (!quiet) {
691
+ process.stdout.write(".");
692
+ }
693
+ if (EXPORT_TERMINAL_FAIL.includes(status)) die(`\nexport batch ${status}${b.error_message ? `: ${b.error_message}` : "."}`);
694
+ if (EXPORT_TERMINAL_OK.includes(status)) return b;
695
+ if (Date.now() - started > 1000 * 60 * 60) die("\nexport batch timed out after 60 minutes.");
696
+ await new Promise((r) => setTimeout(r, 5000));
697
+ }
698
+ }
699
+
700
+ async function downloadBatchChunks(args, key, batch, dest) {
701
+ const chunks = (batch.chunks || []).filter((c) => c.downloadable || EXPORT_TERMINAL_OK.includes(String(c.status || "").toLowerCase()));
702
+ if (!chunks.length) die("export batch reported done but returned no downloadable chunks.");
703
+ const files = [];
704
+ let i = 0;
705
+ for (const chunk of chunks) {
706
+ i += 1;
707
+ const target = chunks.length === 1 ? dest : partPath(dest, chunk.chunk_index != null ? chunk.chunk_index + 1 : i);
708
+ const dl = chunk.download_api_path || `/api/mcp/exports/${chunk.job_id}/download`;
709
+ await downloadTo(args.base, dl, key, target);
710
+ files.push(target);
711
+ }
712
+ return files;
713
+ }
714
+
715
+ function countCsvRows(file) {
716
+ try {
717
+ return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).length - 1;
718
+ } catch {
719
+ return null;
720
+ }
721
+ }
722
+
723
+ // `argorant export status <id>` / `argorant export download <id> -o file`:
724
+ // a paid export must never be unrecoverable because the CLI died after the
725
+ // job was created (bad path, ctrl-c, dropped connection).
726
+ async function exportStatusCmd(args, key, id) {
727
+ const isBatch = args.batch;
728
+ const p = isBatch ? `/api/mcp/export-batches/${id}` : `/api/mcp/exports/${id}`;
729
+ const res = await request("GET", args.base, p, { key });
730
+ const s = need(res, "export status");
731
+ if (args.json) return console.log(JSON.stringify(s, null, 2));
732
+ if (isBatch) {
733
+ console.log(`${bold("Batch #" + (s.batch_id ?? id))} ${dim(s.status || "—")}`);
734
+ console.log(` ${s.completed_chunks || 0}/${s.total_chunks || 0} chunks · ${Number(s.verified_rows || 0).toLocaleString()} rows · ${s.progress_pct || 0}%`);
735
+ } else {
736
+ console.log(`${bold("Export #" + (s.job_id ?? id))} ${dim(s.status || "—")}`);
737
+ console.log(` ${Number(s.verified_rows || 0).toLocaleString()}/${Number(s.total_rows || 0).toLocaleString()} rows · ${s.progress_pct || 0}%${s.downloadable ? green(" · ready to download") : ""}`);
738
+ }
739
+ if (s.error_message) console.log(red(" " + s.error_message));
740
+ if (s.downloadable) console.log(dim(` argorant export download ${id}${isBatch ? " --batch" : ""} -o leads.csv`));
741
+ }
742
+
743
+ async function exportDownloadCmd(args, key, id) {
744
+ const dest = ensureWritable(args.output || "argorant-leads.csv");
745
+ if (args.batch) {
746
+ const res = await request("GET", args.base, `/api/mcp/export-batches/${id}`, { key });
747
+ const b = need(res, "export batch status");
748
+ if (!EXPORT_TERMINAL_OK.includes(String(b.status || "").toLowerCase())) {
749
+ die(`export batch #${id} is ${b.status || "not ready"} — run \`argorant export status ${id} --batch\`.`);
750
+ }
751
+ const files = await downloadBatchChunks(args, key, b, dest);
752
+ if (args.json) return console.log(JSON.stringify({ ok: true, batch_id: id, files }, null, 2));
753
+ for (const f of files) console.log(green("✓") + ` ${bold(String(countCsvRows(f) ?? "?"))} rows → ${bold(f)}`);
754
+ return;
755
+ }
756
+ const res = await request("GET", args.base, `/api/mcp/exports/${id}`, { key });
757
+ const s = need(res, "export status");
758
+ if (!s.downloadable && !s.download_api_path) {
759
+ die(`export #${id} is ${s.status || "not ready"} — run \`argorant export status ${id}\`.`);
760
+ }
761
+ await downloadTo(args.base, s.download_api_path || `/api/mcp/exports/${id}/download`, key, dest);
762
+ if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: id }, null, 2));
763
+ const rows = countCsvRows(dest);
764
+ console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
765
+ }
766
+
302
767
  async function cmdExport(args) {
303
768
  const key = requireKey();
769
+ // `export status <id>` / `export download <id>` — recovery subcommands, no
770
+ // job is created and nothing is billed.
771
+ const sub = (args._[0] || "").toLowerCase();
772
+ if (sub === "status" || sub === "download") {
773
+ const id = args._[1];
774
+ if (!id || !/^\d+$/.test(String(id))) die(`usage: argorant export ${sub} <job_id> [--batch]${sub === "download" ? " [-o file.csv]" : ""} (job id must be a number)`);
775
+ return sub === "status" ? exportStatusCmd(args, key, id) : exportDownloadCmd(args, key, id);
776
+ }
777
+ if (args.gradeExplicit) warnGradeGap("export");
304
778
  const limit = args.limit || 1000;
305
- const dest = args.output || "argorant-leads.csv";
779
+ // Validate the destination BEFORE creating the job: the old order billed the
780
+ // export and then died on ENOENT, with no way to fetch the CSV again.
781
+ const dest = ensureWritable(args.output || "argorant-leads.csv");
782
+ // Confirmation is interactive-only by design: with --yes, --json, or a
783
+ // non-TTY stdin (CI, agents, pipes) this spends credits with no prompt.
306
784
  if (!args.yes && !args.json && process.stdin.isTTY) {
307
785
  const ans = await prompt(`Export up to ${bold(limit)} verified contacts to ${bold(dest)}? Uses quota/credits. [y/N] `);
308
786
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
309
787
  }
310
788
  // Match the MCP/app defaults so the CLI yields the same rows: business email
311
789
  // present by default, and skip rows already exported (override with
312
- // --include-exported). Verification stays live at export time only deliverable
790
+ // --include-exported). Verification stays live at export time - only deliverable
313
791
  // rows are billed; no verification-status filter is exposed.
314
792
  const exportFilters = { ...args.filters };
315
793
  if (exportFilters.has_email === undefined) exportFilters.has_email = "true";
794
+ // `grades` is sent for forward-compatibility: the platform's MCP export
795
+ // endpoint has no per-request grade control yet (see GODMODE-PLAN.md), so
796
+ // this is a no-op today and every export includes the standard valid +
797
+ // catch-all set regardless of --grade.
798
+ const grades = args.grade === "valid-plus-catchall" ? ["valid", "catch_all"] : ["valid"];
316
799
  const create = await request("POST", args.base, "/api/mcp/exports/create", {
317
800
  key,
318
- body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported },
801
+ body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported, grades },
319
802
  });
320
803
  const job = need(create, "export");
804
+ // >EXPORT_MAX_ROWS (50k) → a multi-chunk batch, a different status endpoint
805
+ // and one download per chunk.
806
+ if (job.type === "batch" || (job.batch_id && !job.job_id)) {
807
+ const batchPath = job.status_api_path || `/api/mcp/export-batches/${job.batch_id}`;
808
+ if (!args.json) {
809
+ process.stdout.write(
810
+ dim(`Large export queued as batch #${job.batch_id} — ${job.total_chunks || "?"} chunk(s) of up to ${Number(job.chunk_size || 0).toLocaleString()} rows\n`)
811
+ );
812
+ }
813
+ const batch = await pollExportBatch(args, key, batchPath, { quiet: !!args.json });
814
+ if (!args.json) process.stdout.write("\n");
815
+ const files = await downloadBatchChunks(args, key, batch, dest);
816
+ if (args.json) return console.log(JSON.stringify({ ok: true, batch_id: job.batch_id, files }, null, 2));
817
+ for (const f of files) console.log(green("✓") + ` ${bold(String(countCsvRows(f) ?? "?"))} rows → ${bold(f)}`);
818
+ if (files.length > 1) console.log(dim(`Re-download any time: argorant export download ${job.batch_id} --batch -o ${dest}`));
819
+ return;
820
+ }
321
821
  const statusPath = job.status_api_path || (job.job_id ? `/api/mcp/exports/${job.job_id}` : null);
322
822
  if (!statusPath) {
323
823
  if (args.json) return console.log(JSON.stringify(job, null, 2));
@@ -339,23 +839,24 @@ async function cmdExport(args) {
339
839
  downloadPath = downloadPath || `/api/mcp/exports/${job.job_id}/download`;
340
840
  break;
341
841
  }
342
- if (["failed", "error", "cancelled", "canceled"].includes(status)) die(`\nexport ${status}.`);
343
- if (Date.now() - started > 1000 * 60 * 20) die("\nexport timed out after 20 minutes.");
842
+ if (EXPORT_TERMINAL_FAIL.includes(status)) die(`\nexport ${status}${s.error_message ? `: ${s.error_message}` : "."}`);
843
+ if (Date.now() - started > 1000 * 60 * 20) {
844
+ die(`\nexport timed out after 20 minutes. It is still running server-side — check with \`argorant export status ${job.job_id}\`.`);
845
+ }
344
846
  }
345
847
  if (!args.json) process.stdout.write("\n");
346
848
  if (!downloadPath) {
347
849
  if (args.json) return console.log(JSON.stringify(job, null, 2));
348
- return console.log("Export ready but no download path returned yet. Re-run with --json to inspect the job, or check the Exports page in the app.");
850
+ return console.log(`Export ready but no download path returned yet. Retry with \`argorant export download ${job.job_id} -o ${dest}\`.`);
851
+ }
852
+ try {
853
+ await downloadTo(args.base, downloadPath, key, dest);
854
+ } catch (e) {
855
+ // The job is already paid for — always tell the user how to get it back.
856
+ die(`${e.message}\nThe export itself completed. Retry the download with \`argorant export download ${job.job_id} -o ${dest}\`.`);
349
857
  }
350
- await downloadTo(args.base, downloadPath, key, dest);
351
858
  if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: job.job_id }, null, 2));
352
- const rows = (() => {
353
- try {
354
- return fs.readFileSync(dest, "utf8").split("\n").filter(Boolean).length - 1;
355
- } catch {
356
- return null;
357
- }
358
- })();
859
+ const rows = countCsvRows(dest);
359
860
  console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
360
861
  }
361
862
 
@@ -399,7 +900,8 @@ async function cmdVerifyFile(args, key) {
399
900
  }
400
901
  emails = [...new Set(emails)];
401
902
  if (!emails.length) die("no email addresses found in file (try --column <name>)");
402
- const out = args.output || "argorant-verified.csv";
903
+ const out = ensureWritable(args.output || "argorant-verified.csv");
904
+ // Interactive-only by design — see reveal/export.
403
905
  if (!args.yes && !args.json && process.stdin.isTTY) {
404
906
  const ans = await prompt(`Verify ${bold(emails.length.toLocaleString())} emails? You're billed only for fresh checks from your verification-check pool; recent re-checks are free. [y/N] `);
405
907
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
@@ -417,7 +919,8 @@ async function cmdVerifyFile(args, key) {
417
919
  }
418
920
  if (!args.json) process.stdout.write("\n");
419
921
  if (args.json) return console.log(JSON.stringify({ ok: true, total: all.length, checks_charged: charged, cached, results: all }, null, 2));
420
- const csv = ["email,status,deliverable", ...all.map((r) => `${r.email},${r.status},${r.deliverable}`)].join("\n") + "\n";
922
+ const q = (v) => `"${String(v == null ? "" : v).replace(/"/g, '""')}"`;
923
+ const csv = ["email,status,deliverable", ...all.map((r) => [r.email, r.status, r.deliverable].map(q).join(","))].join("\n") + "\n";
421
924
  fs.writeFileSync(out, csv);
422
925
  const deliverable = all.filter((r) => r.deliverable).length;
423
926
  console.log(green("✓") + ` ${bold(all.length.toLocaleString())} verified → ${bold(out)} ${dim(`(${deliverable.toLocaleString()} deliverable · ${charged.toLocaleString()} checks billed · ${cached.toLocaleString()} free)`)}`);
@@ -445,7 +948,11 @@ async function cmdList(args) {
445
948
  if (sub === "status" || sub === "show" || sub === "get") {
446
949
  const id = args._[1] || args.name;
447
950
  if (!id) die("usage: argorant list status <list_id>");
448
- const res = await request("GET", args.base, `/api/mcp/lists/${encodeURIComponent(id)}`, { key });
951
+ // Validate locally: the API path param is an int, so anything else came
952
+ // back as a FastAPI 422 whose detail is an array — a useless error for a
953
+ // plain typo.
954
+ if (!/^\d+$/.test(String(id).trim())) die(`list id must be a number (got "${id}").`);
955
+ const res = await request("GET", args.base, `/api/mcp/lists/${encodeURIComponent(String(id).trim())}`, { key });
449
956
  const r = need(res, "list status");
450
957
  if (args.json) return console.log(JSON.stringify(r, null, 2));
451
958
  const total = Number(r.snapshot_total ?? r.item_count ?? 0);
@@ -456,6 +963,470 @@ async function cmdList(args) {
456
963
  die("usage: argorant list create --name \"…\" [filters] | argorant list status <id>");
457
964
  }
458
965
 
966
+ // =============================================================================
967
+ // campaigns: god-mode native outbound campaign control from the terminal.
968
+ //
969
+ // OPERATOR KEYS ONLY. Every command above talks to /api/mcp/* — the
970
+ // customer-facing contact-data API, gated by plan scopes. Everything below
971
+ // talks to /api/sequencer/* — the internal Argorant Sequencer that runs live
972
+ // outbound sends. It authenticates via the SAME ag_live_ Bearer key, but only
973
+ // works for a key that (a) belongs to an owner/admin account and (b) carries
974
+ // the `argorant:operator` scope (see cli/GODMODE-PLAN.md). Any other key gets
975
+ // a 401/403 from the API, same as a browser session would without outbound
976
+ // access.
977
+ //
978
+ // Kept on its own tiny flag reader (readFlags) instead of the top-level
979
+ // parseArgs — these subcommands have their own vocabulary (--step, --subject,
980
+ // --count, --pool, --csv, ...) that would otherwise collide with, or be
981
+ // rejected by, the generic filter-flag parser used for search/reveal/export.
982
+ // =============================================================================
983
+
984
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
985
+
986
+ // Filter flags for `campaigns leads add --query ...` — the same names/mapping
987
+ // as the top-level VALUE_FLAGS/BOOL_FLAGS (minus the ones the sequencer's
988
+ // filter-enroll endpoint doesn't accept, e.g. --verified-only), plus --query
989
+ // as an explicit alias for free-text `q` (clearer than a bare positional in a
990
+ // command that already takes a campaign name/id as its first positional).
991
+ const CAMPAIGN_FILTER_VALUE_FLAGS = {
992
+ "--query": "q",
993
+ "--title": "title",
994
+ "--exclude-title": "exclude_title",
995
+ "--seniority": "seniority",
996
+ "--department": "departments",
997
+ "--departments": "departments",
998
+ "--industry": "industry",
999
+ "--keywords": "keywords",
1000
+ "--keyword": "keywords",
1001
+ "--country": "country",
1002
+ "--geography": "country",
1003
+ "--region": "country",
1004
+ "--state": "state",
1005
+ "--city": "city",
1006
+ "--company": "company_name",
1007
+ "--domain": "company_domain",
1008
+ };
1009
+ const CAMPAIGN_FILTER_BOOL_FLAGS = {
1010
+ "--has-phone": "has_phone",
1011
+ "--has-linkedin": "has_linkedin",
1012
+ "--has-email": "has_email",
1013
+ };
1014
+
1015
+ // Minimal flag reader shared by every `campaigns` subcommand: pulls out the
1016
+ // universal --json/--yes/--base plus whatever value/bool flags the caller
1017
+ // declares, leaves everything else as positionals, and dies on anything that
1018
+ // looks like a flag but isn't recognized (same "fail loud" behavior as the
1019
+ // top-level parser).
1020
+ function readFlags(argv, valueFlags = {}, boolFlags = {}) {
1021
+ const out = { _: [], json: false, yes: false, base: DEFAULT_BASE, baseExplicit: false };
1022
+ for (let i = 0; i < argv.length; i++) {
1023
+ const a = argv[i];
1024
+ if (a === "--json") out.json = true;
1025
+ else if (a === "--yes" || a === "-y") out.yes = true;
1026
+ else if (a === "--base") { out.base = flagValue(argv, i++, a); out.baseExplicit = true; }
1027
+ else if (a in valueFlags) out[valueFlags[a]] = flagValue(argv, i++, a);
1028
+ else if (a in boolFlags) out[boolFlags[a]] = true;
1029
+ else if (a.startsWith("--") && a.includes("=")) {
1030
+ const eq = a.indexOf("=");
1031
+ const k = a.slice(0, eq), v = a.slice(eq + 1);
1032
+ if (k in valueFlags) out[valueFlags[k]] = v;
1033
+ else if (k in boolFlags) out[boolFlags[k]] = v !== "false";
1034
+ else die(`unknown flag: ${k}`);
1035
+ } else if (a.startsWith("-") && a !== "-") die(`unknown flag: ${a}`);
1036
+ else out._.push(a);
1037
+ }
1038
+ applySavedBase(out);
1039
+ return out;
1040
+ }
1041
+
1042
+ // A saved base from `argorant login --base …` only applies when the caller did
1043
+ // NOT pass --base. Inferring "no flag given" from the VALUE (=== DEFAULT_BASE)
1044
+ // meant `--base https://argorant.com` was silently ignored after a staging
1045
+ // login — requests went to the wrong host with no indication.
1046
+ function applySavedBase(args) {
1047
+ if (args.baseExplicit || process.env.ARGORANT_API_BASE) return args;
1048
+ const saved = loadConfig().base;
1049
+ if (saved) args.base = saved;
1050
+ return args;
1051
+ }
1052
+
1053
+ function readStdin() {
1054
+ return new Promise((resolve, reject) => {
1055
+ let data = "";
1056
+ process.stdin.setEncoding("utf8");
1057
+ process.stdin.on("data", (chunk) => (data += chunk));
1058
+ process.stdin.on("end", () => resolve(data));
1059
+ process.stdin.on("error", reject);
1060
+ });
1061
+ }
1062
+
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 });
1067
+ const r = need(res, "campaigns list");
1068
+ return r.campaigns || [];
1069
+ }
1070
+
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
+ async function resolveCampaign(base, key, identifier) {
1074
+ if (!identifier) die("campaign id or name is required.");
1075
+ 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) {
1081
+ die(`no campaign matching "${identifier}". Run \`argorant campaigns list\` to see names.`);
1082
+ }
1083
+ die(
1084
+ `"${identifier}" matches ${matches.length} campaigns — be more specific:\n` +
1085
+ matches.map((c) => ` ${c.id} ${c.name}`).join("\n")
1086
+ );
1087
+ }
1088
+
1089
+ async function campaignsList(argv) {
1090
+ const key = requireKey();
1091
+ const args = readFlags(argv, { "--brand": "brand" });
1092
+ const campaigns = await fetchCampaigns(args.base, key, { brand: args.brand, includeCounts: true });
1093
+ if (args.json) return console.log(JSON.stringify(campaigns, null, 2));
1094
+ if (!campaigns.length) {
1095
+ return console.log(dim('No campaigns yet. Create one with `argorant campaigns create --name "..."`.'));
1096
+ }
1097
+ for (const c of campaigns) {
1098
+ const sent = Number(c.sent_count || 0);
1099
+ const replied = Number(c.replied_count || 0);
1100
+ const rate = sent > 0 ? `${((replied / sent) * 100).toFixed(1)}%` : "—";
1101
+ console.log(`${bold(c.name || "—")} ${dim(c.id)}`);
1102
+ console.log(
1103
+ ` ${c.status}` +
1104
+ dim(" · contacted ") + sent.toLocaleString() +
1105
+ dim(" · replies ") + replied.toLocaleString() +
1106
+ dim(" · reply rate ") + rate
1107
+ );
1108
+ }
1109
+ }
1110
+
1111
+ async function campaignsCreate(argv) {
1112
+ const key = requireKey();
1113
+ const args = readFlags(
1114
+ argv,
1115
+ { "--name": "name", "--brand": "brand", "--timezone": "timezone", "--window": "window" },
1116
+ { "--skip-weekends": "skipWeekends", "--no-skip-weekends": "noSkipWeekends" }
1117
+ );
1118
+ const name = (args.name || "").trim();
1119
+ if (!name) {
1120
+ die(
1121
+ 'usage: argorant campaigns create --name "<name>" [--brand <key>] [--timezone <tz>] ' +
1122
+ "[--window HH:MM-HH:MM] [--skip-weekends | --no-skip-weekends]"
1123
+ );
1124
+ }
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
+ )
1150
+ );
1151
+ console.log(dim(`Next: argorant campaigns steps set ${c.id} --step 1 --subject "..." --body-file ./copy.txt`));
1152
+ }
1153
+
1154
+ async function campaignsSteps(argv) {
1155
+ const sub = argv[0];
1156
+ if (sub !== "set") {
1157
+ die(
1158
+ 'usage: argorant campaigns steps set <campaign> --step <n> --subject "..." ' +
1159
+ "(--body-file <path> | --body <text|->) [--approve]"
1160
+ );
1161
+ }
1162
+ const key = requireKey();
1163
+ const args = readFlags(
1164
+ argv.slice(1),
1165
+ { "--step": "step", "--subject": "subject", "--body-file": "bodyFile", "--body": "body" },
1166
+ { "--approve": "approve" }
1167
+ );
1168
+ const identifier = args._[0];
1169
+ if (!identifier) die("usage: argorant campaigns steps set <campaign> --step <n> ...");
1170
+ const stepNumber = parseInt(args.step, 10);
1171
+ if (!stepNumber || stepNumber < 1) die("--step must be a positive integer (>= 1)");
1172
+ const subject = (args.subject || "").trim();
1173
+ if (!subject) die("--subject is required");
1174
+ let body;
1175
+ if (args.bodyFile) {
1176
+ try {
1177
+ body = fs.readFileSync(args.bodyFile, "utf8");
1178
+ } catch {
1179
+ die(`cannot read file: ${args.bodyFile}`);
1180
+ }
1181
+ } else if (args.body !== undefined) {
1182
+ body = args.body === "-" ? await readStdin() : args.body;
1183
+ } else {
1184
+ die("provide --body-file <path>, or --body <text> (--body - reads the body from stdin)");
1185
+ }
1186
+ body = (body || "").trim();
1187
+ if (!body) die("body is empty");
1188
+ // The CLI never generates copy — the operator/agent writes it; this command
1189
+ // only upserts what it's given.
1190
+ const campaignId = await resolveCampaign(args.base, key, identifier);
1191
+ const stepBody = { step_number: stepNumber, subject, body, copy_status: args.approve ? "approved" : "draft" };
1192
+ const res = await request("POST", args.base, `/api/sequencer/campaigns/${campaignId}/steps`, { key, body: stepBody });
1193
+ const r = need(res, "campaigns steps set");
1194
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1195
+ const s = r.step || {};
1196
+ console.log(green("✓") + ` Step ${bold(s.step_number)} saved (${s.copy_status})` + dim(` “${subject}”`));
1197
+ }
1198
+
1199
+ async function campaignsInboxes(argv) {
1200
+ const sub = argv[0];
1201
+ if (sub !== "attach") die("usage: argorant campaigns inboxes attach <campaign> --count <n> [--pool <brand>]");
1202
+ const key = requireKey();
1203
+ const args = readFlags(argv.slice(1), { "--count": "count", "--pool": "pool" });
1204
+ const identifier = args._[0];
1205
+ if (!identifier) die("usage: argorant campaigns inboxes attach <campaign> --count <n> [--pool <brand>]");
1206
+ const count = parseInt(args.count, 10);
1207
+ if (!count || count < 1) die("--count must be a positive integer");
1208
+ const campaignId = await resolveCampaign(args.base, key, identifier);
1209
+
1210
+ // Fleet changes only ever happen via this explicit command — never
1211
+ // implicitly from create/start. Exclude whatever's already attached to THIS
1212
+ // campaign (an inbox can serve multiple campaigns; "unattached" is relative
1213
+ // to this one), then page through the healthy/usable pool for candidates.
1214
+ const attachedRes = await request("GET", args.base, `/api/sequencer/campaigns/${campaignId}/inboxes`, { key });
1215
+ const already = need(attachedRes, "campaigns inboxes attach").inboxes || [];
1216
+ const attachedIds = new Set(already.map((i) => String(i.id)));
1217
+
1218
+ const candidates = [];
1219
+ const seen = new Set();
1220
+ let page = 1;
1221
+ for (;;) {
1222
+ const query = { status: "usable", page_size: "500", page: String(page) };
1223
+ if (args.pool) query.brand = args.pool;
1224
+ const res = await request("GET", args.base, "/api/sequencer/inboxes", { key, query });
1225
+ const r = need(res, "campaigns inboxes attach");
1226
+ const rows = r.inboxes || [];
1227
+ for (const row of rows) {
1228
+ const id = String(row.id);
1229
+ if (attachedIds.has(id) || seen.has(id)) continue;
1230
+ seen.add(id);
1231
+ candidates.push(row);
1232
+ if (candidates.length >= count) break;
1233
+ }
1234
+ const pg = r.pagination || {};
1235
+ if (candidates.length >= count || !pg.has_next || !rows.length) break;
1236
+ page += 1;
1237
+ }
1238
+ if (!candidates.length) {
1239
+ die(`no healthy, unattached inboxes found${args.pool ? ` in pool "${args.pool}"` : ""}.`);
1240
+ }
1241
+ const chosen = candidates.slice(0, count);
1242
+ const emails = chosen.map((i) => i.email);
1243
+ const attachRes = await request("POST", args.base, `/api/sequencer/campaigns/${campaignId}/inboxes`, {
1244
+ key,
1245
+ body: { inbox_emails: emails },
1246
+ });
1247
+ const r = need(attachRes, "campaigns inboxes attach");
1248
+ if (args.json) return console.log(JSON.stringify({ requested: count, attached: emails, result: r }, null, 2));
1249
+ console.log(
1250
+ green("✓") +
1251
+ ` Attached ${bold(r.attached ?? emails.length)} inbox(es)` +
1252
+ (chosen.length < count ? dim(` (only ${chosen.length} healthy unattached inboxes were available)`) : "") +
1253
+ ":"
1254
+ );
1255
+ for (const email of emails) console.log(` ${email}`);
1256
+ if (r.missing && r.missing.length) console.log(dim(` not found: ${r.missing.join(", ")}`));
1257
+ }
1258
+
1259
+ async function campaignsLeads(argv) {
1260
+ 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
+ }
1267
+ 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);
1270
+ 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
+ }
1277
+ 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.");
1305
+ }
1306
+ body.filters = filters;
1307
+ }
1308
+ const res = await request("POST", args.base, `/api/sequencer/campaigns/${campaignId}/leads/import`, { key, body });
1309
+ const r = need(res, "campaigns leads add");
1310
+ 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`));
1321
+ }
1322
+
1323
+ async function campaignsSetStatus(argv, status, verb) {
1324
+ const key = requireKey();
1325
+ const args = readFlags(argv, {});
1326
+ const identifier = args._[0];
1327
+ if (!identifier) die(`usage: argorant campaigns ${verb} <campaign>`);
1328
+ 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
+ );
1337
+ }
1338
+ const r = need(res, `campaigns ${verb}`);
1339
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
1340
+ const c = r.campaign || {};
1341
+ console.log(green("✓") + ` Campaign ${bold(c.name || campaignId)} is now ${bold(c.status || status)}`);
1342
+ }
1343
+
1344
+ async function campaignsStatus(argv) {
1345
+ const key = requireKey();
1346
+ const args = readFlags(argv, {});
1347
+ const identifier = args._[0];
1348
+ if (!identifier) die("usage: argorant campaigns status <campaign>");
1349
+ 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");
1352
+ 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
+ }
1371
+ }
1372
+
1373
+ function campaignsHelp() {
1374
+ const p = bold("argorant campaigns");
1375
+ console.log(`
1376
+ ${bold("Argorant Campaigns")} — god-mode outbound campaign control ${dim("(operator keys only)")}
1377
+
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.
1382
+
1383
+ ${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>
1391
+ ${p} pause <campaign>
1392
+ ${p} status <campaign>
1393
+
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.")}
1396
+
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"
1403
+
1404
+ ${bold("OPTIONS")}
1405
+ --json Raw JSON output --base <url> Override API base
1406
+
1407
+ Docs: ${cyan("https://argorant.com/docs/cli")} · Gap notes: cli/GODMODE-PLAN.md
1408
+ `);
1409
+ }
1410
+
1411
+ async function cmdCampaigns(argv) {
1412
+ const sub = argv[0];
1413
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") return campaignsHelp();
1414
+ const rest = argv.slice(1);
1415
+ const table = {
1416
+ list: campaignsList,
1417
+ create: campaignsCreate,
1418
+ steps: campaignsSteps,
1419
+ inboxes: campaignsInboxes,
1420
+ leads: campaignsLeads,
1421
+ start: (a) => campaignsSetStatus(a, "active", "start"),
1422
+ pause: (a) => campaignsSetStatus(a, "paused", "pause"),
1423
+ status: campaignsStatus,
1424
+ };
1425
+ const fn = table[sub];
1426
+ if (!fn) die(`unknown campaigns subcommand: ${sub}\nRun \`argorant campaigns help\` for usage.`);
1427
+ await fn(rest);
1428
+ }
1429
+
459
1430
  function help() {
460
1431
  const p = bold("argorant");
461
1432
  console.log(`
@@ -466,17 +1437,28 @@ ${bold("USAGE")}
466
1437
 
467
1438
  ${bold("COMMANDS")}
468
1439
  ${cyan("login")} [key] Save an API key (or set ARGORANT_API_KEY)
1440
+ ${cyan("logout")} Forget the saved key and base (~/.argorant/config.json)
469
1441
  ${cyan("whoami")} Account, scopes, and daily quota
470
- ${cyan("count")} "<query>" Count matching contacts ${dim("(free)")}
471
- ${cyan("search")} "<query>" -n 10 Preview matches, details redacted ${dim("(free)")}
1442
+ ${cyan("count")} "<query>" Count matching contacts ${dim("(0 contact credits)")}
1443
+ ${cyan("company")} <company.com> Count people at one company + masked role preview ${dim("(0 contact credits)")}
1444
+ ${cyan("search")} "<query>" -n 10 Preview matches, details redacted ${dim("(0 contact credits)")}
1445
+ ${cyan("sample")} <company.com> Build 25 distinct, live-valid company leads ${dim("(free sample)")}
472
1446
  ${cyan("reveal")} "<query>" -n 25 Reveal full contact details ${dim("(uses credits; live-verified, pay only for deliverable)")}
1447
+ ${cyan("enrich")} --email <a@b.com> One address → the full person profile ${dim("(1 credit per match; miss = free)")}
1448
+ ${cyan("enrich")} --name "<n>" --domain <d> Find that person + reveal a verified email ${dim("(1 credit per match)")}
1449
+ ${cyan("enrich")} --domain <d> Company profile ${dim("(0 contact credits)")}
473
1450
  ${cyan("export")} "<query>" -n 1000 -o leads.csv Verified CSV export ${dim("(uses credits)")}
1451
+ ${cyan("export status")} <job_id> Status of an existing export ${dim("(free; add --batch for >50k)")}
1452
+ ${cyan("export download")} <job_id> -o leads.csv Re-download a finished export ${dim("(free)")}
474
1453
  ${cyan("list create")} --name "<n>" [filters] Save a reusable list ${dim("(free)")}
475
1454
  ${cyan("list status")} <id> Show a saved list's size ${dim("(free)")}
476
1455
  ${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
477
1456
  ${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)")}
478
1458
 
479
1459
  ${bold("FILTERS")}
1460
+ --keywords <k> Comma = OR. The widest, most reliable filter - prefer it
1461
+ over --industry (matches tags most records carry).
480
1462
  --title <t> --exclude-title <t> --seniority <s> --department <d>
481
1463
  --industry <i> --country <c> --geography <r> --state <s>
482
1464
  --city <c> --company <name> --domain <domain>
@@ -484,16 +1466,37 @@ ${bold("FILTERS")}
484
1466
  ${dim("--title is abbreviation-aware (CFO ↔ Chief Financial Officer).")}
485
1467
  ${dim("--verified-only keeps deliverable contacts; export verifies live & bills only valid.")}
486
1468
  ${dim("--country / --geography accept regions: Europe, EMEA, DACH, Nordics, APAC, LATAM, GCC…")}
1469
+ ${dim("--exclude-title works fully with `export` and `list create`; `count`/`search`/`reveal` don't apply it yet (CLI warns).")}
487
1470
 
488
1471
  ${bold("OPTIONS")}
489
1472
  -n, --limit <n> Max rows -o, --output <file> CSV path (export)
490
1473
  --json Raw JSON output -y, --yes Skip confirmations
491
1474
  --base <url> Override API base (or ARGORANT_API_BASE)
1475
+ --batch Treat the id in \`export status/download\` as a batch id
1476
+ --grade <g> valid (default) or valid-plus-catchall - which deliverable
1477
+ grade to include on reveal/export. You only ever pay for
1478
+ deliverable contacts; this is the one grade distinction
1479
+ exposed anywhere. ${dim("(coming soon - currently a no-op; see docs)")}
1480
+
1481
+ ${bold("NON-INTERACTIVE USE")} ${dim("(agents, CI, pipes)")}
1482
+ ${red("reveal, export, and verify --file SPEND CREDITS WITHOUT A PROMPT")} whenever
1483
+ stdin is not a TTY, or when --yes / --json is passed. The confirmation is a
1484
+ convenience for humans at a terminal, never a safety net. Check your -n.
1485
+
1486
+ ${bold("EXIT CODES")}
1487
+ 0 ok · 1 error · 2 not authenticated · 3 forbidden (missing scope)
1488
+ 4 rate limit / daily quota · 5 plan upgrade required
1489
+ ${dim("`enrich` exits 1 when nothing matched, so scripts can branch without parsing the payload.")}
492
1490
 
493
1491
  ${bold("EXAMPLES")}
494
1492
  ${p} count "fintech CFOs in germany"
1493
+ ${p} company stripe.com
495
1494
  ${p} search "heads of procurement" --country Germany -n 10
1495
+ ${p} sample recruitcrm.io -o sample.csv
496
1496
  ${p} export --industry fintech --title CFO --country Germany -n 500 -o cfos.csv
1497
+ ${p} enrich --email patrick@stripe.com --json
1498
+ ${p} enrich --name "Patrick Collison" --domain stripe.com
1499
+ ${p} enrich --domain stripe.com
497
1500
  ${p} verify ceo@stripe.com
498
1501
  ${p} verify --file my-list.csv -o verified.csv
499
1502
 
@@ -506,18 +1509,28 @@ async function main() {
506
1509
  const cmd = argv[0];
507
1510
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help();
508
1511
  if (cmd === "version" || cmd === "--version" || cmd === "-v") return console.log(VERSION);
509
- const args = parseArgs(argv.slice(1));
510
- // Allow a saved non-default base from login.
511
- if (args.base === DEFAULT_BASE) {
512
- const saved = loadConfig().base;
513
- if (saved && !process.env.ARGORANT_API_BASE) args.base = saved;
1512
+ // `campaigns` has its own flag vocabulary (--step, --count, --pool, --csv, ...)
1513
+ // handled by readFlags it never goes through the generic filter parser
1514
+ // below, which would reject those flags as unknown.
1515
+ if (cmd === "campaigns") {
1516
+ try {
1517
+ await cmdCampaigns(argv.slice(1));
1518
+ } catch (e) {
1519
+ die(e && e.message ? e.message : String(e));
1520
+ }
1521
+ return;
514
1522
  }
1523
+ const args = applySavedBase(parseArgs(argv.slice(1)));
515
1524
  const table = {
516
1525
  login: cmdLogin,
1526
+ logout: cmdLogout,
517
1527
  whoami: cmdWhoami,
518
1528
  count: cmdCount,
1529
+ company: cmdCompany,
519
1530
  search: cmdSearch,
1531
+ sample: cmdSample,
520
1532
  reveal: cmdReveal,
1533
+ enrich: cmdEnrich,
521
1534
  export: cmdExport,
522
1535
  list: cmdList,
523
1536
  verify: cmdVerify,