argorant 0.10.0 → 0.12.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 +100 -17
  2. package/bin/argorant.js +631 -64
  3. package/package.json +3 -2
package/bin/argorant.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- // Argorant CLI — a thin, dependency-free wrapper over the Argorant REST API.
5
- // Search, count, reveal, export, and verify B2B contacts from the terminal.
4
+ // Argorant CLI - a thin, dependency-free wrapper over the Argorant REST API.
5
+ // Search, count, reveal, export, and verify B2B contacts, and find work emails, from the terminal.
6
6
  // Auth: an Argorant API key (ag_live_*) via `argorant login`, or ARGORANT_API_KEY.
7
7
 
8
8
  const crypto = require("crypto");
@@ -83,7 +83,7 @@ const VALUE_FLAGS = {
83
83
  };
84
84
  // Boolean filter flags (presence => "true"). --verified-only is positive intent
85
85
  // only (deliverable contacts); there is deliberately NO flag to query invalid or
86
- // any raw verification status — that is never exposed on any surface.
86
+ // any raw verification status - that is never exposed on any surface.
87
87
  const BOOL_FLAGS = {
88
88
  "--has-phone": "has_phone",
89
89
  "--has-linkedin": "has_linkedin",
@@ -124,7 +124,7 @@ function warnGradeGap(scope) {
124
124
  // A value flag must be followed by an actual value. Silently swallowing the
125
125
  // NEXT FLAG (`search "CFO" --title --base http://…` → title="--base") or a
126
126
  // missing trailing value (→ undefined, dropped by request()) sends a request
127
- // the user never asked for — against the wrong host, with the wrong filters.
127
+ // the user never asked for - against the wrong host, with the wrong filters.
128
128
  // Fail loud instead. "-" stays legal: it is the documented stdin sentinel.
129
129
  function flagValue(argv, i, flag) {
130
130
  const v = argv[i + 1];
@@ -134,7 +134,7 @@ function flagValue(argv, i, flag) {
134
134
  }
135
135
  return v;
136
136
  }
137
- // -n/--limit drives billed row counts on reveal/export — a typo must never
137
+ // -n/--limit drives billed row counts on reveal/export - a typo must never
138
138
  // fall through to the default (a mistyped `-n` used to become a 1000-row
139
139
  // billed export).
140
140
  function parseLimit(raw, flag) {
@@ -146,7 +146,7 @@ function parseLimit(raw, flag) {
146
146
  }
147
147
 
148
148
  function parseArgs(argv) {
149
- 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 };
149
+ 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, phones: false };
150
150
  for (let i = 0; i < argv.length; i++) {
151
151
  const a = argv[i];
152
152
  if (a === "--json") out.json = true;
@@ -158,6 +158,9 @@ function parseArgs(argv) {
158
158
  else if (a === "--base") { out.base = flagValue(argv, i++, a); out.baseExplicit = true; }
159
159
  else if (a === "--name") out.name = flagValue(argv, i++, a);
160
160
  else if (a === "--include-exported") out.includeExported = true;
161
+ // Phone numbers are opt-in on every surface (2026-10-07): only with --phones, 10 credits per
162
+ // number actually returned, nothing when none is found or the account already has it.
163
+ else if (a === "--phones" || a === "--include-phones") out.phones = true;
161
164
  else if (a === "--batch") out.batch = true;
162
165
  else if (a === "--grade") setGrade(out, flagValue(argv, i++, a));
163
166
  else if (a in VALUE_FLAGS) out.filters[VALUE_FLAGS[a]] = flagValue(argv, i++, a);
@@ -252,7 +255,7 @@ function downloadTo(base, urlPath, key, dest) {
252
255
  // `detail` is a plain string on most endpoints, a structured object on some
253
256
  // (plan_required, campaign launch blockers, native-delivery readiness) and a
254
257
  // LIST of validation errors on any FastAPI 422. All three have to render as
255
- // something a human or an agent can read — never "[object Object]".
258
+ // something a human or an agent can read - never "[object Object]".
256
259
  function detailMsg(detail) {
257
260
  if (detail == null) return null;
258
261
  if (typeof detail === "string") return detail;
@@ -292,7 +295,7 @@ function need(res, what) {
292
295
  const url = d.upgrade_url || d.url || "https://argorant.com/pricing";
293
296
  die(`${msg}${msg.includes(url) ? "" : `\nUpgrade: ${url}`}`, EXIT.UPGRADE);
294
297
  }
295
- if (res.status === 403) die(detailMsg(detail) || `forbidden — your key lacks the scope for ${what}.`, EXIT.FORBIDDEN);
298
+ if (res.status === 403) die(detailMsg(detail) || `forbidden - your key lacks the scope for ${what}.`, EXIT.FORBIDDEN);
296
299
  if (res.status === 429) die(detailMsg(detail) || "rate limit / daily quota reached.", EXIT.RATE_LIMIT);
297
300
  if (res.status >= 400) die(detailMsg(detail) || `${what} failed (HTTP ${res.status}).`);
298
301
  return res.json || {};
@@ -351,7 +354,7 @@ async function cmdLogin(args) {
351
354
  let key = args._[0];
352
355
  if (!key) key = await prompt("Paste your Argorant API key (ag_live_…): ", { hidden: true });
353
356
  if (!key) die("no key provided.");
354
- if (!/^ag_(live|test)_/.test(key)) process.stderr.write(dim("note: keys normally start with ag_live_ — continuing anyway.\n"));
357
+ if (!/^ag_(live|test)_/.test(key)) process.stderr.write(dim("note: keys normally start with ag_live_ - continuing anyway.\n"));
355
358
  const res = await request("GET", args.base, "/api/mcp/account", { key });
356
359
  if (res.status === 428 && res.json?.detail?.error === "access_key_activation_required") {
357
360
  const url = res.json.detail.claim_url;
@@ -370,7 +373,7 @@ async function cmdLogin(args) {
370
373
 
371
374
  async function cmdLogout() {
372
375
  if (!fs.existsSync(CONFIG_PATH)) {
373
- console.log(dim(`Nothing to do — no saved credentials at ${CONFIG_PATH}.`));
376
+ console.log(dim(`Nothing to do - no saved credentials at ${CONFIG_PATH}.`));
374
377
  } else {
375
378
  try {
376
379
  fs.unlinkSync(CONFIG_PATH);
@@ -380,7 +383,7 @@ async function cmdLogout() {
380
383
  console.log(green("✓") + ` Removed saved key and base from ${bold(CONFIG_PATH)}`);
381
384
  }
382
385
  if (process.env.ARGORANT_API_KEY) {
383
- warn("ARGORANT_API_KEY is still set in this environment and takes precedence — unset it too.");
386
+ warn("ARGORANT_API_KEY is still set in this environment and takes precedence - unset it too.");
384
387
  }
385
388
  }
386
389
 
@@ -389,8 +392,8 @@ async function cmdWhoami(args) {
389
392
  const res = await request("GET", args.base, "/api/mcp/account", { key });
390
393
  const a = need(res, "whoami");
391
394
  if (args.json) return console.log(JSON.stringify(a, null, 2));
392
- console.log(`${bold("Account")} ${a.email || "—"} ${dim("(" + (a.role || "member") + ")")}`);
393
- console.log(`${bold("Scopes")} ${(a.scopes || []).join(", ") || "—"}`);
395
+ console.log(`${bold("Account")} ${a.email || "-"} ${dim("(" + (a.role || "member") + ")")}`);
396
+ console.log(`${bold("Scopes")} ${(a.scopes || []).join(", ") || "-"}`);
394
397
  const u = a.usage || {};
395
398
  // Keys/fields must match _mcp_usage_summary: actions are count_requests /
396
399
  // preview_rows / reveal_rows / export_rows and each entry carries
@@ -471,7 +474,7 @@ async function cmdCompany(args) {
471
474
  for (const person of r.results) {
472
475
  const who = [person.preview, person.title].filter(Boolean).join(" · ");
473
476
  const where = [person.country].filter(Boolean).join(", ");
474
- console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
477
+ console.log(` ${bold(who || "-")}${where ? dim(" " + where) : ""}`);
475
478
  }
476
479
  }
477
480
  }
@@ -484,13 +487,13 @@ async function cmdSearch(args) {
484
487
  const res = await request("GET", args.base, "/api/mcp/people/preview", { key, query });
485
488
  const r = need(res, "search");
486
489
  if (args.json) return console.log(JSON.stringify(r, null, 2));
487
- console.log(dim(`${Number(r.total).toLocaleString()} total · showing ${r.returned} (details redacted — use \`reveal\` or \`export\`)`));
490
+ console.log(dim(`${Number(r.total).toLocaleString()} total · showing ${r.returned} (details redacted - use \`reveal\` or \`export\`)`));
488
491
  for (const p of r.results || []) {
489
492
  // _redacted_preview returns the masked identity as `preview` (e.g. "A* P"),
490
- // never `name` — without this the row rendered as title-only.
493
+ // never `name` - without this the row rendered as title-only.
491
494
  const who = [p.preview || p.name, p.title].filter(Boolean).join(" · ");
492
495
  const where = [p.company || p.company_name, p.country].filter(Boolean).join(", ");
493
- console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
496
+ console.log(` ${bold(who || "-")}${where ? dim(" " + where) : ""}`);
494
497
  }
495
498
  }
496
499
 
@@ -546,40 +549,132 @@ async function cmdSample(args) {
546
549
  `${bold(Number(job.total_companies || 0).toLocaleString())} matching companies`
547
550
  );
548
551
  for (const lead of job.results || []) {
549
- console.log(` ${bold(lead.full_name || "—")} ${dim("· " + (lead.title || "—"))}`);
550
- console.log(` ${(lead.company || "—")} ${dim("· " + (lead.company_domain || "—"))}`);
551
- console.log(` ${cyan(lead.email || "—")} ${green("✓ Valid")}`);
552
+ console.log(` ${bold(lead.full_name || "-")} ${dim("· " + (lead.title || "-"))}`);
553
+ console.log(` ${(lead.company || "-")} ${dim("· " + (lead.company_domain || "-"))}`);
554
+ console.log(` ${cyan(lead.email || "-")} ${green("✓ Valid")}`);
552
555
  }
553
556
  if (args.output) console.log(green("✓") + ` Saved CSV → ${bold(args.output)}`);
554
557
  console.log(dim(`These 25 are the sample; the full pool contains ${Number(job.total_companies || 0).toLocaleString()} companies.`));
555
558
  }
556
559
 
560
+ // ---- phone numbers (opt-in, --phones) ----
561
+ const PHONE_FIELDS = ["phone", "phone_1", "phone_2", "phones_all", "additional_phones", "phone_masked"];
562
+ function stripPhones(rec) {
563
+ if (rec && typeof rec === "object") for (const k of PHONE_FIELDS) delete rec[k];
564
+ return rec;
565
+ }
566
+
567
+ // The price and whether the plan includes phone numbers, before anything is spent. An older server
568
+ // without the pricing route falls back to the published price.
569
+ async function phonePricing(args, key) {
570
+ try {
571
+ const res = await request("GET", args.base, "/api/v1/people/phones/pricing", { key });
572
+ if (res.status === 200 && res.json) return res.json;
573
+ } catch {
574
+ /* fall back below */
575
+ }
576
+ return { price_per_phone: 10, plan_includes_phones: null };
577
+ }
578
+
579
+ function phonePriceText(pricing) {
580
+ const price = Number((pricing && pricing.price_per_phone) || 10);
581
+ return `Phone numbers cost ${price} credits each, only when a number is found. Numbers you already have are free.`;
582
+ }
583
+
584
+ // Phone numbers for people already revealed (POST /api/v1/people/phones). A refusal never undoes the
585
+ // emails that were just revealed: it comes back as {included:false, message}.
586
+ async function addPhones(args, key, records) {
587
+ const ids = [];
588
+ for (const r of records) {
589
+ const id = String(r.person_id || r.record_id || "").trim();
590
+ if (id && !ids.includes(id)) ids.push(id);
591
+ }
592
+ records.forEach(stripPhones);
593
+ if (!ids.length) return { included: true, phones_returned: 0, credits_charged: 0, summary: "No people to look up phone numbers for, so nothing was charged." };
594
+ const res = await request("POST", args.base, "/api/v1/people/phones", {
595
+ key,
596
+ body: { record_ids: ids.slice(0, 100), channel: "cli" },
597
+ headers: { "X-Argorant-Channel": "cli" },
598
+ });
599
+ if (res.status !== 200 || !res.json) {
600
+ const detail = res.json && res.json.detail;
601
+ const code = detail && typeof detail === "object" ? detail.error : null;
602
+ let message = detailMsg(detail) || `phone numbers failed (HTTP ${res.status}).`;
603
+ if (code === "phones_not_in_plan") message = "Your plan does not include phone numbers, so none were added and nothing was charged for them.";
604
+ else if (code === "insufficient_credits") message = "Not enough credits for a phone number, so none were added and nothing was charged for them.";
605
+ else if (code === "mcp_quota_exceeded") message = "Today's reveal limit is used up, so no phone numbers were added. It resets at midnight UTC.";
606
+ for (const r of records) r.phone_status = "not_added";
607
+ return { included: false, status: res.status, error: code || "error", message, summary: message };
608
+ }
609
+ const byId = new Map((res.json.results || []).map((row) => [String(row.record_id), row]));
610
+ for (const r of records) {
611
+ const row = byId.get(String(r.person_id || r.record_id || "")) || {};
612
+ r.phone = row.phone || null;
613
+ if (row.additional_phones && row.additional_phones.length) r.additional_phones = row.additional_phones;
614
+ r.phone_status = row.phone_status || "not_found";
615
+ r.phone_credits_charged = Number(row.credits_charged || 0);
616
+ }
617
+ return { included: true, ...res.json, results: undefined };
618
+ }
619
+
620
+ function phoneLine(p) {
621
+ if (!p || !p.phone_status) return null;
622
+ if (p.phone) {
623
+ const extra = p.additional_phones && p.additional_phones.length ? dim(` (+${p.additional_phones.length} more)`) : "";
624
+ const cost = p.phone_credits_charged ? dim(` · ${p.phone_credits_charged} credits`) : p.phone_status === "already_unlocked" ? dim(" · already yours") : "";
625
+ return `${p.phone}${extra}${cost}`;
626
+ }
627
+ if (p.phone_status === "none_found" || p.phone_status === "not_found") return dim("no phone number on record · not charged");
628
+ if (p.phone_status === "not_enough_credits") return dim("phone not added · not enough credits");
629
+ if (p.phone_status === "not_in_plan") return dim("phone not added · not in your plan");
630
+ if (p.phone_status === "daily_limit_reached") return dim("phone not added · daily limit reached");
631
+ return null;
632
+ }
633
+
557
634
  async function cmdReveal(args) {
558
635
  const key = requireKey();
559
636
  warnExcludeTitleGap(args.filters);
560
637
  if (args.gradeExplicit) warnGradeGap("reveal");
561
638
  const limit = args.limit || 10;
639
+ const pricing = args.phones ? await phonePricing(args, key) : null;
640
+ if (pricing && pricing.plan_includes_phones === false) {
641
+ warn("Your plan does not include phone numbers. Emails are revealed as usual; no phone numbers will be added or charged.");
642
+ }
562
643
  // Confirmation is interactive-only by design: with --yes, --json, or a
563
644
  // non-TTY stdin (CI, agents, pipes) this spends credits with no prompt.
564
645
  if (!args.yes && !args.json && process.stdin.isTTY) {
565
- const ans = await prompt(`Reveal up to ${bold(limit)} contacts? This uses your quota/credits. [y/N] `);
646
+ const phonesText = args.phones ? ` ${phonePriceText(pricing)}` : "";
647
+ const ans = await prompt(`Reveal up to ${bold(limit)} contacts${args.phones ? " with phone numbers" : ""}? Each new working email costs 1 credit.${phonesText} [y/N] `);
566
648
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
649
+ } else if (args.phones && !args.json) {
650
+ process.stderr.write(dim(phonePriceText(pricing) + "\n"));
567
651
  }
568
652
  // Sent for forward-compatibility: the platform has no per-request grade
569
653
  // control on reveal yet (see GODMODE-PLAN.md), so this is a no-op today.
570
654
  const query = { ...args.filters, limit, grade: args.grade === "valid-plus-catchall" ? "valid_plus_catchall" : "valid" };
571
655
  const res = await request("GET", args.base, "/api/mcp/people/reveal", { key, query });
572
656
  const r = need(res, "reveal");
657
+ const people = (r.results || []).filter((x) => x && typeof x === "object");
658
+ if (args.phones && people.length) r.phones = await addPhones(args, key, people);
659
+ else people.forEach(stripPhones);
573
660
  if (args.json) return console.log(JSON.stringify(r, null, 2));
574
661
  console.log(dim(`${Number(r.total).toLocaleString()} total · revealed ${r.returned}`));
575
662
  for (const p of r.results || []) {
576
- // _revealed_contact returns full_name / first_name / last_name — there is
663
+ // _revealed_contact returns full_name / first_name / last_name - there is
577
664
  // no `name` key. The customer just paid for this row; print who it is.
578
665
  const name = p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") || p.name;
579
666
  const who = [name, p.title].filter(Boolean).join(" · ");
580
- console.log(` ${bold(who || "—")}`);
581
- const bits = [p.email && cyan(p.email), p.phone, p.linkedin_url, [p.company || p.company_name, p.country].filter(Boolean).join(", ")].filter(Boolean);
667
+ console.log(` ${bold(who || "-")}`);
668
+ const bits = [p.email && cyan(p.email), p.linkedin_url, [p.company || p.company_name, p.country].filter(Boolean).join(", ")].filter(Boolean);
582
669
  if (bits.length) console.log(" " + bits.join(dim(" · ")));
670
+ const phone = args.phones ? phoneLine(p) : null;
671
+ if (phone) console.log(" " + phone);
672
+ }
673
+ const charged = Number(r.charged || 0);
674
+ console.log(dim(`${charged} credit${charged === 1 ? "" : "s"} charged for emails`));
675
+ if (r.phones) {
676
+ if (r.phones.included) console.log(dim(r.phones.summary || `${r.phones.credits_charged || 0} credits charged for phone numbers`));
677
+ else warn(r.phones.message || "No phone numbers were added.");
583
678
  }
584
679
  }
585
680
 
@@ -622,16 +717,30 @@ async function cmdEnrich(args) {
622
717
  else if (name) { body.name = name; body.domain = domain; }
623
718
  else body.domain = domain;
624
719
  const personMode = Boolean(body.email || body.name);
720
+ if (args.phones && !personMode) warn("Phone numbers are for people. A company lookup returns the company profile only.");
721
+ const wantPhones = args.phones && personMode;
722
+ const pricing = wantPhones ? await phonePricing(args, key) : null;
723
+ if (pricing && pricing.plan_includes_phones === false) {
724
+ warn("Your plan does not include phone numbers. The email lookup runs as usual; no phone number will be added or charged.");
725
+ }
625
726
  // Company mode is free, so it never asks. Person mode is billed exactly like
626
727
  // a reveal, so it gets the same interactive-only confirmation as `reveal`.
627
728
  if (personMode && !args.yes && !args.json && process.stdin.isTTY) {
628
729
  const ans = await prompt(
629
- `Enrich this contact? A match costs 1 credit; a miss and a non-deliverable address are free. [y/N] `
730
+ `Enrich this contact? A match costs 1 credit; a miss and a non-deliverable address are free.` +
731
+ (wantPhones ? ` ${phonePriceText(pricing)}` : "") + ` [y/N] `
630
732
  );
631
733
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
734
+ } else if (wantPhones && !args.json) {
735
+ process.stderr.write(dim(phonePriceText(pricing) + "\n"));
632
736
  }
633
737
  const res = await request("POST", args.base, "/api/v1/enrich", { key, body });
634
738
  const r = need(res, "enrich");
739
+ if (r && r.type === "person" && r.person && typeof r.person === "object") {
740
+ if (!r.person.person_id && r.person_id) r.person.person_id = r.person_id;
741
+ if (wantPhones && r.found) r.phones = await addPhones(args, key, [r.person]);
742
+ else stripPhones(r.person);
743
+ }
635
744
  if (args.json) {
636
745
  console.log(JSON.stringify(r, null, 2));
637
746
  if (!r.found) process.exit(EXIT.ERROR);
@@ -653,14 +762,16 @@ async function cmdEnrich(args) {
653
762
  }
654
763
  const p = r.person || {};
655
764
  const who = [p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" "), p.title].filter(Boolean).join(" · ");
656
- console.log(` ${bold(who || "—")}`);
765
+ console.log(` ${bold(who || "-")}`);
657
766
  const bits = [
658
767
  p.email && cyan(p.email),
659
- p.phone,
660
768
  p.linkedin_url,
661
769
  [p.current_company_name, p.current_company_domain, p.country].filter(Boolean).join(", "),
662
770
  ].filter(Boolean);
663
771
  if (bits.length) console.log(" " + bits.join(dim(" · ")));
772
+ const phone = wantPhones ? phoneLine(p) : null;
773
+ if (phone) console.log(" " + phone);
774
+ if (r.phones && !r.phones.included) warn(r.phones.message || "No phone number was added.");
664
775
  if (r.deliverable === false) {
665
776
  console.log(" " + dim(r.message || "This address did not pass live verification, so nothing was charged."));
666
777
  }
@@ -683,7 +794,7 @@ function partPath(dest, n) {
683
794
  }
684
795
 
685
796
  // >50k rows come back as {type:"batch", batch_id, status_api_path:
686
- // /api/mcp/export-batches/{id}} with NO job_id and NO download_api_path — the
797
+ // /api/mcp/export-batches/{id}} with NO job_id and NO download_api_path - the
687
798
  // old code fell through to /api/mcp/exports/undefined/download, so large
688
799
  // exports were simply impossible from the CLI. Poll the batch, then download
689
800
  // each completed chunk.
@@ -740,10 +851,10 @@ async function exportStatusCmd(args, key, id) {
740
851
  const s = need(res, "export status");
741
852
  if (args.json) return console.log(JSON.stringify(s, null, 2));
742
853
  if (isBatch) {
743
- console.log(`${bold("Batch #" + (s.batch_id ?? id))} ${dim(s.status || "—")}`);
854
+ console.log(`${bold("Batch #" + (s.batch_id ?? id))} ${dim(s.status || "-")}`);
744
855
  console.log(` ${s.completed_chunks || 0}/${s.total_chunks || 0} chunks · ${Number(s.verified_rows || 0).toLocaleString()} rows · ${s.progress_pct || 0}%`);
745
856
  } else {
746
- console.log(`${bold("Export #" + (s.job_id ?? id))} ${dim(s.status || "—")}`);
857
+ console.log(`${bold("Export #" + (s.job_id ?? id))} ${dim(s.status || "-")}`);
747
858
  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") : ""}`);
748
859
  }
749
860
  if (s.error_message) console.log(red(" " + s.error_message));
@@ -756,7 +867,7 @@ async function exportDownloadCmd(args, key, id) {
756
867
  const res = await request("GET", args.base, `/api/mcp/export-batches/${id}`, { key });
757
868
  const b = need(res, "export batch status");
758
869
  if (!EXPORT_TERMINAL_OK.includes(String(b.status || "").toLowerCase())) {
759
- die(`export batch #${id} is ${b.status || "not ready"} — run \`argorant export status ${id} --batch\`.`);
870
+ die(`export batch #${id} is ${b.status || "not ready"} - run \`argorant export status ${id} --batch\`.`);
760
871
  }
761
872
  const files = await downloadBatchChunks(args, key, b, dest);
762
873
  if (args.json) return console.log(JSON.stringify({ ok: true, batch_id: id, files }, null, 2));
@@ -766,7 +877,7 @@ async function exportDownloadCmd(args, key, id) {
766
877
  const res = await request("GET", args.base, `/api/mcp/exports/${id}`, { key });
767
878
  const s = need(res, "export status");
768
879
  if (!s.downloadable && !s.download_api_path) {
769
- die(`export #${id} is ${s.status || "not ready"} — run \`argorant export status ${id}\`.`);
880
+ die(`export #${id} is ${s.status || "not ready"} - run \`argorant export status ${id}\`.`);
770
881
  }
771
882
  await downloadTo(args.base, s.download_api_path || `/api/mcp/exports/${id}/download`, key, dest);
772
883
  if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: id }, null, 2));
@@ -776,7 +887,7 @@ async function exportDownloadCmd(args, key, id) {
776
887
 
777
888
  async function cmdExport(args) {
778
889
  const key = requireKey();
779
- // `export status <id>` / `export download <id>` — recovery subcommands, no
890
+ // `export status <id>` / `export download <id>` - recovery subcommands, no
780
891
  // job is created and nothing is billed.
781
892
  const sub = (args._[0] || "").toLowerCase();
782
893
  if (sub === "status" || sub === "download") {
@@ -791,9 +902,18 @@ async function cmdExport(args) {
791
902
  const dest = ensureWritable(args.output || "argorant-leads.csv");
792
903
  // Confirmation is interactive-only by design: with --yes, --json, or a
793
904
  // non-TTY stdin (CI, agents, pipes) this spends credits with no prompt.
905
+ const pricing = args.phones ? await phonePricing(args, key) : null;
906
+ if (pricing && pricing.plan_includes_phones === false) {
907
+ die("Your plan does not include phone numbers. Run the export without --phones.", EXIT.UPGRADE);
908
+ }
794
909
  if (!args.yes && !args.json && process.stdin.isTTY) {
795
- const ans = await prompt(`Export up to ${bold(limit)} verified contacts to ${bold(dest)}? Uses quota/credits. [y/N] `);
910
+ const phonesText = args.phones
911
+ ? ` Phone numbers are included. Each number in the file costs ${Number((pricing && pricing.price_per_phone) || 10)} more credits, and people without one cost nothing extra.`
912
+ : "";
913
+ const ans = await prompt(`Export up to ${bold(limit)} verified contacts to ${bold(dest)}? Each person with a working email costs 1 credit.${phonesText} [y/N] `);
796
914
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
915
+ } else if (args.phones && !args.json) {
916
+ process.stderr.write(dim(`Phone numbers are included. Each number in the file costs ${Number((pricing && pricing.price_per_phone) || 10)} more credits.\n`));
797
917
  }
798
918
  // Match the MCP/app defaults so the CLI yields the same rows: business email
799
919
  // present by default, and skip rows already exported (override with
@@ -808,7 +928,7 @@ async function cmdExport(args) {
808
928
  const grades = args.grade === "valid-plus-catchall" ? ["valid", "catch_all"] : ["valid"];
809
929
  const create = await request("POST", args.base, "/api/mcp/exports/create", {
810
930
  key,
811
- body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported, grades },
931
+ body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported, grades, ...(args.phones ? { include_phones: true } : {}) },
812
932
  });
813
933
  const job = need(create, "export");
814
934
  // >EXPORT_MAX_ROWS (50k) → a multi-chunk batch, a different status endpoint
@@ -817,7 +937,7 @@ async function cmdExport(args) {
817
937
  const batchPath = job.status_api_path || `/api/mcp/export-batches/${job.batch_id}`;
818
938
  if (!args.json) {
819
939
  process.stdout.write(
820
- 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`)
940
+ 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`)
821
941
  );
822
942
  }
823
943
  const batch = await pollExportBatch(args, key, batchPath, { quiet: !!args.json });
@@ -833,7 +953,7 @@ async function cmdExport(args) {
833
953
  if (args.json) return console.log(JSON.stringify(job, null, 2));
834
954
  return console.log("Export queued. " + JSON.stringify(job));
835
955
  }
836
- if (!args.json) process.stdout.write(dim("Export queued — verifying & building CSV"));
956
+ if (!args.json) process.stdout.write(dim("Export queued - verifying & building CSV"));
837
957
  let downloadPath = job.download_api_path || null;
838
958
  const started = Date.now();
839
959
  // Poll until the job reports a terminal state.
@@ -851,7 +971,7 @@ async function cmdExport(args) {
851
971
  }
852
972
  if (EXPORT_TERMINAL_FAIL.includes(status)) die(`\nexport ${status}${s.error_message ? `: ${s.error_message}` : "."}`);
853
973
  if (Date.now() - started > 1000 * 60 * 20) {
854
- die(`\nexport timed out after 20 minutes. It is still running server-side — check with \`argorant export status ${job.job_id}\`.`);
974
+ die(`\nexport timed out after 20 minutes. It is still running server-side - check with \`argorant export status ${job.job_id}\`.`);
855
975
  }
856
976
  }
857
977
  if (!args.json) process.stdout.write("\n");
@@ -862,7 +982,7 @@ async function cmdExport(args) {
862
982
  try {
863
983
  await downloadTo(args.base, downloadPath, key, dest);
864
984
  } catch (e) {
865
- // The job is already paid for — always tell the user how to get it back.
985
+ // The job is already paid for - always tell the user how to get it back.
866
986
  die(`${e.message}\nThe export itself completed. Retry the download with \`argorant export download ${job.job_id} -o ${dest}\`.`);
867
987
  }
868
988
  if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: job.job_id }, null, 2));
@@ -870,7 +990,7 @@ async function cmdExport(args) {
870
990
  console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
871
991
  }
872
992
 
873
- // ---- verify: external email verification (own lists) — the verification pool,
993
+ // ---- verify: external email verification (own lists) - the verification pool,
874
994
  // separate from contact credits. 60-day re-checks are free. ----
875
995
  const EMAIL_RE = /[^\s,;"']+@[^\s,;"']+\.[^\s,;"']+/;
876
996
 
@@ -911,7 +1031,7 @@ async function cmdVerifyFile(args, key) {
911
1031
  emails = [...new Set(emails)];
912
1032
  if (!emails.length) die("no email addresses found in file (try --column <name>)");
913
1033
  const out = ensureWritable(args.output || "argorant-verified.csv");
914
- // Interactive-only by design — see reveal/export.
1034
+ // Interactive-only by design - see reveal/export.
915
1035
  if (!args.yes && !args.json && process.stdin.isTTY) {
916
1036
  const ans = await prompt(`Verify ${bold(emails.length.toLocaleString())} emails? Fresh checks cost half a credit each; recent re-checks are free. [y/N] `);
917
1037
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
@@ -938,7 +1058,7 @@ async function cmdVerifyFile(args, key) {
938
1058
  }
939
1059
 
940
1060
  // ---- list: save & inspect reusable lead lists (parity with MCP/app). Creating a
941
- // filtered list is free and does NOT reveal contacts — the server counts the
1061
+ // filtered list is free and does NOT reveal contacts - the server counts the
942
1062
  // matches itself, so the list reports its real size right away. ----
943
1063
  async function cmdList(args) {
944
1064
  const key = requireKey();
@@ -952,7 +1072,7 @@ async function cmdList(args) {
952
1072
  const r = need(res, "list create");
953
1073
  if (args.json) return console.log(JSON.stringify(r, null, 2));
954
1074
  const total = Number(r.snapshot_total || 0);
955
- console.log(green("✓") + ` Created list ${bold("#" + r.list_id)} ${dim("“" + r.name + "”")} — ${bold(total.toLocaleString())} matching contacts`);
1075
+ console.log(green("✓") + ` Created list ${bold("#" + r.list_id)} ${dim("“" + r.name + "”")} - ${bold(total.toLocaleString())} matching contacts`);
956
1076
  console.log(dim(`Export it with: argorant export ${Object.entries(args.filters).filter(([, v]) => v).map(([k, v]) => `--${k.replace(/_/g, "-")} ${/\s/.test(String(v)) ? `"${v}"` : v}`).join(" ")} -o leads.csv`));
957
1077
  return;
958
1078
  }
@@ -960,14 +1080,14 @@ async function cmdList(args) {
960
1080
  const id = args._[1] || args.name;
961
1081
  if (!id) die("usage: argorant list status <list_id>");
962
1082
  // Validate locally: the API path param is an int, so anything else came
963
- // back as a FastAPI 422 whose detail is an array — a useless error for a
1083
+ // back as a FastAPI 422 whose detail is an array - a useless error for a
964
1084
  // plain typo.
965
1085
  if (!/^\d+$/.test(String(id).trim())) die(`list id must be a number (got "${id}").`);
966
1086
  const res = await request("GET", args.base, `/api/mcp/lists/${encodeURIComponent(String(id).trim())}`, { key });
967
1087
  const r = need(res, "list status");
968
1088
  if (args.json) return console.log(JSON.stringify(r, null, 2));
969
1089
  const total = Number(r.snapshot_total ?? r.item_count ?? 0);
970
- console.log(`${bold("List #" + (r.list_id ?? id))} ${dim("“" + (r.name || "—") + "”")}`);
1090
+ console.log(`${bold("List #" + (r.list_id ?? id))} ${dim("“" + (r.name || "-") + "”")}`);
971
1091
  console.log(` ${bold(total.toLocaleString())} contacts · ${dim((r.selection_mode || "filtered") + " · " + (r.record_type || "person"))}`);
972
1092
  return;
973
1093
  }
@@ -977,9 +1097,9 @@ async function cmdList(args) {
977
1097
  // =============================================================================
978
1098
  // campaigns: god-mode native outbound campaign control from the terminal.
979
1099
  //
980
- // OPERATOR KEYS ONLY. Every command above talks to /api/mcp/* — the
1100
+ // OPERATOR KEYS ONLY. Every command above talks to /api/mcp/* - the
981
1101
  // customer-facing contact-data API, gated by plan scopes. Everything below
982
- // talks to /api/sequencer/* — the internal Argorant Sequencer that runs live
1102
+ // talks to /api/sequencer/* - the internal Argorant Sequencer that runs live
983
1103
  // outbound sends. It authenticates via the SAME ag_live_ Bearer key, but only
984
1104
  // works for a key that (a) belongs to an owner/admin account and (b) carries
985
1105
  // the `argorant:operator` scope (see cli/GODMODE-PLAN.md). Any other key gets
@@ -987,14 +1107,14 @@ async function cmdList(args) {
987
1107
  // access.
988
1108
  //
989
1109
  // Kept on its own tiny flag reader (readFlags) instead of the top-level
990
- // parseArgs — these subcommands have their own vocabulary (--step, --subject,
1110
+ // parseArgs - these subcommands have their own vocabulary (--step, --subject,
991
1111
  // --count, --pool, --csv, ...) that would otherwise collide with, or be
992
1112
  // rejected by, the generic filter-flag parser used for search/reveal/export.
993
1113
  // =============================================================================
994
1114
 
995
1115
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
996
1116
 
997
- // Filter flags for `campaigns leads add --query ...` — the same names/mapping
1117
+ // Filter flags for `campaigns leads add --query ...` - the same names/mapping
998
1118
  // as the top-level VALUE_FLAGS/BOOL_FLAGS (minus the ones the sequencer's
999
1119
  // filter-enroll endpoint doesn't accept, e.g. --verified-only), plus --query
1000
1120
  // as an explicit alias for free-text `q` (clearer than a bare positional in a
@@ -1053,7 +1173,7 @@ function readFlags(argv, valueFlags = {}, boolFlags = {}) {
1053
1173
  // A saved base from `argorant login --base …` only applies when the caller did
1054
1174
  // NOT pass --base. Inferring "no flag given" from the VALUE (=== DEFAULT_BASE)
1055
1175
  // meant `--base https://argorant.com` was silently ignored after a staging
1056
- // login — requests went to the wrong host with no indication.
1176
+ // login - requests went to the wrong host with no indication.
1057
1177
  function applySavedBase(args) {
1058
1178
  if (args.baseExplicit || process.env.ARGORANT_API_BASE) return args;
1059
1179
  const saved = loadConfig().base;
@@ -1088,7 +1208,7 @@ async function resolveCampaign(base, key, identifier) {
1088
1208
  }
1089
1209
  if (matches.length > 1) {
1090
1210
  die(
1091
- `"${identifier}" matches ${matches.length} campaigns — be more specific:\n` +
1211
+ `"${identifier}" matches ${matches.length} campaigns - be more specific:\n` +
1092
1212
  matches.map((c) => ` ${c.name} ${dim(c.id)}`).join("\n")
1093
1213
  );
1094
1214
  }
@@ -1106,8 +1226,8 @@ async function campaignsList(argv) {
1106
1226
  for (const c of campaigns) {
1107
1227
  const sent = Number(c.sent_count || 0);
1108
1228
  const replied = Number(c.replied_count || 0);
1109
- const rate = sent > 0 ? `${((replied / sent) * 100).toFixed(1)}%` : "—";
1110
- console.log(`${bold(c.name || "—")} ${dim(c.id)}`);
1229
+ const rate = sent > 0 ? `${((replied / sent) * 100).toFixed(1)}%` : "-";
1230
+ console.log(`${bold(c.name || "-")} ${dim(c.id)}`);
1111
1231
  console.log(
1112
1232
  ` ${c.status}` +
1113
1233
  dim(" · leads ") + Number(c.lead_count || 0).toLocaleString() +
@@ -1248,7 +1368,7 @@ async function campaignsSteps(argv) {
1248
1368
  }
1249
1369
  body = (body || "").trim();
1250
1370
  if (!body) die("body is empty");
1251
- // The CLI never generates copy — the operator/agent writes it; this command
1371
+ // The CLI never generates copy - the operator/agent writes it; this command
1252
1372
  // only upserts what it's given.
1253
1373
  const campaignId = await resolveCampaign(args.base, key, identifier);
1254
1374
  const stepBody = { step_number: stepNumber, subject, body, copy_status: args.approve ? "approved" : "draft" };
@@ -1270,7 +1390,7 @@ async function campaignsInboxes(argv) {
1270
1390
  if (!count || count < 1) die("--count must be a positive integer");
1271
1391
  const campaignId = await resolveCampaign(args.base, key, identifier);
1272
1392
 
1273
- // Fleet changes only ever happen via this explicit command — never
1393
+ // Fleet changes only ever happen via this explicit command - never
1274
1394
  // implicitly from create/start. Exclude whatever's already attached to THIS
1275
1395
  // campaign (an inbox can serve multiple campaigns; "unattached" is relative
1276
1396
  // to this one), then page through the healthy/usable pool for candidates.
@@ -1405,7 +1525,7 @@ async function campaignsStatus(argv) {
1405
1525
  const r = need(await request("GET", args.base, `/api/v1/campaigns/${campaignId}`, { key }), "campaign");
1406
1526
  if (args.json) return console.log(JSON.stringify(r, null, 2));
1407
1527
  const c = r.campaign || {};
1408
- console.log(`${bold(c.name || "—")} ${dim(c.id)}`);
1528
+ console.log(`${bold(c.name || "-")} ${dim(c.id)}`);
1409
1529
  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()}`);
1410
1530
  console.log(` emails ${(r.emails || []).length} · senders ${(r.senders || []).length}`);
1411
1531
  printSettings({ settings: r.settings });
@@ -1511,7 +1631,7 @@ async function campaignsReplies(argv) {
1511
1631
  if (!(r.replies || []).length) return console.log(dim("No replies yet."));
1512
1632
  for (const e of r.replies) {
1513
1633
  console.log(`${bold(e.from_email || "")} ${dim([e.first_name, e.last_name].filter(Boolean).join(" "))} ${e.classification || ""} ${dim(e.created_at || "")}`);
1514
- console.log(dim(` ${(e.subject || "").slice(0, 80)} — ${(e.body || "").replace(/\s+/g, " ").slice(0, 140)}`));
1634
+ console.log(dim(` ${(e.subject || "").slice(0, 80)} - ${(e.body || "").replace(/\s+/g, " ").slice(0, 140)}`));
1515
1635
  }
1516
1636
  }
1517
1637
 
@@ -1530,7 +1650,7 @@ async function cmdInboxThreads(argv) {
1530
1650
  for (const e of r.events) {
1531
1651
  console.log(`${dim(e.id)}`);
1532
1652
  console.log(` ${bold(e.from_email || e.lead_email || "")} ${e.classification || ""} ${dim(e.campaign_name || "")} ${dim(e.created_at || "")}`);
1533
- console.log(dim(` ${(e.subject || "").slice(0, 80)} — ${(e.body || e.snippet || "").replace(/\s+/g, " ").slice(0, 140)}`));
1653
+ console.log(dim(` ${(e.subject || "").slice(0, 80)} - ${(e.body || e.snippet || "").replace(/\s+/g, " ").slice(0, 140)}`));
1534
1654
  }
1535
1655
  return;
1536
1656
  }
@@ -1624,7 +1744,7 @@ async function cmdBlocklist(argv) {
1624
1744
  function campaignsHelp() {
1625
1745
  const p = bold("argorant campaigns");
1626
1746
  console.log(`
1627
- ${bold("Argorant Campaigns")} — email outreach from the terminal
1747
+ ${bold("Argorant Campaigns")} - email outreach from the terminal
1628
1748
 
1629
1749
  Works with any Argorant API key. Nothing is sent until you launch.
1630
1750
 
@@ -1935,6 +2055,434 @@ async function cmdInboxes(argv) {
1935
2055
  die(`unknown inboxes subcommand: ${sub} (list, set, disconnect, connect-google, endings, suggest, quote, order, status, renewal, cancel-bundle)`);
1936
2056
  }
1937
2057
 
2058
+ // =============================================================================
2059
+ // find: the email finder. A person's name plus a company domain in, a work
2060
+ // email out. Priced per outcome by the server (GET /api/v1/email-finder/pricing):
2061
+ // an address returned costs 1 credit, a lookup that finds nothing 0.25 credit,
2062
+ // and lookups that cannot run (no mail server, try again later, invalid input)
2063
+ // are free. Nothing is charged on any error response.
2064
+ //
2065
+ // Own flag reader (readFlags) like `campaigns`: --first/--last/--max-credits
2066
+ // are not search filters, and `--name` means the job name with --file but the
2067
+ // person's name on a single lookup (same habit as `enrich --name`).
2068
+ // Exit codes: a single lookup exits 1 when no email came back (like `enrich`);
2069
+ // a --file job that pauses exits 5 (out of credits) or 4 (daily limit or slowed
2070
+ // down), so scripts can branch without parsing output.
2071
+ // =============================================================================
2072
+
2073
+ const FIND_API = "/api/v1/email-finder";
2074
+ const FIND_DEFAULT_OUT = "argorant-found.csv";
2075
+ const FIND_POLL_MS = 2500;
2076
+ const FIND_VALUE_FLAGS = {
2077
+ "--domain": "domain",
2078
+ "--first": "first",
2079
+ "--first-name": "first",
2080
+ "--last": "last",
2081
+ "--last-name": "last",
2082
+ "--name": "name",
2083
+ "-f": "file",
2084
+ "--file": "file",
2085
+ "-o": "output",
2086
+ "--output": "output",
2087
+ "--max-credits": "maxCredits",
2088
+ };
2089
+ const FIND_STATUS_LABELS = {
2090
+ valid: "Confirmed",
2091
+ catch_all: "Unconfirmed, catch-all domain",
2092
+ not_found: "Not found",
2093
+ no_mail_domain: "No mail server",
2094
+ retry_later: "Try again later",
2095
+ invalid_input: "Invalid input",
2096
+ pending: "Pending",
2097
+ };
2098
+ const FIND_PAUSED_REASONS = {
2099
+ insufficient_credits: "your workspace ran out of credits. Add credits, then resume.",
2100
+ spend_limit: "the job reached its credit cap (--max-credits). Resume with a higher cap to continue.",
2101
+ daily_limit: "the daily lookup limit was reached.",
2102
+ miss_rate: "almost none of the recent lookups found an address, so lookups are slowed down for a while.",
2103
+ };
2104
+ const FIND_TERMINAL = ["done", "cancelled", "canceled", "paused"];
2105
+ const FIND_USAGE =
2106
+ 'usage: argorant find "<first last>" --domain <company.com>\n' +
2107
+ " argorant find --first <first> --last <last> --domain <company.com>\n" +
2108
+ ' argorant find --file people.csv [-o found.csv] [--max-credits N] [--name "Q4 list"]\n' +
2109
+ " argorant find status | download | resume | cancel <job_id>\n" +
2110
+ " argorant find pricing";
2111
+
2112
+ // 1 -> "1 credit", 0.25 -> "0.25 credit", 2 / 0 -> "2 credits" / "0 credits".
2113
+ function fmtCredits(n) {
2114
+ const v = Number(n || 0);
2115
+ return `${v.toLocaleString("en-US", { maximumFractionDigits: 2 })} credit${v > 0 && v <= 1 ? "" : "s"}`;
2116
+ }
2117
+ function fmtCharge(n) {
2118
+ return Number(n || 0) > 0 ? fmtCredits(n) : "not charged";
2119
+ }
2120
+ function fmtWait(sec) {
2121
+ if (sec < 90) return `${sec} seconds`;
2122
+ if (sec < 5400) return `${Math.round(sec / 60)} minutes`;
2123
+ return `${Math.round(sec / 3600)} hours`;
2124
+ }
2125
+
2126
+ // The finder answers errors FastAPI-style under `detail`; tolerate a bare
2127
+ // {error, message} body too, and turn Retry-After into a plain wait time.
2128
+ function findNeed(res, what, { expect } = {}) {
2129
+ const j = res.json;
2130
+ let r = res;
2131
+ // need() lets any status below 400 through. A redirect (http -> https, a
2132
+ // wrong --base) must not read as "no email found" or "job resumed".
2133
+ if (res.status >= 300 && res.status < 400) {
2134
+ const to = res.res && res.res.headers && res.res.headers.location;
2135
+ die(`${what}: the server answered with a redirect (HTTP ${res.status}${to ? ` to ${to}` : ""}). Check --base / ARGORANT_API_BASE.`);
2136
+ }
2137
+ if (res.status >= 400 && j && typeof j === "object" && !Array.isArray(j) && j.detail === undefined && (j.error || j.message)) {
2138
+ r = { ...res, json: { detail: j } };
2139
+ }
2140
+ if (r.status === 429) {
2141
+ const ra = Number((res.res && res.res.headers && res.res.headers["retry-after"]) || 0);
2142
+ const msg = detailMsg(r.json && r.json.detail) || "the email finder limit was reached.";
2143
+ die(Number.isFinite(ra) && ra > 0 ? `${msg} Try again in ${fmtWait(Math.ceil(ra))}. Nothing was charged.` : `${msg} Nothing was charged.`, EXIT.RATE_LIMIT);
2144
+ }
2145
+ let out = need(r, what);
2146
+ // Job endpoints answer {ok, job: {...}}; the commands work on the job itself.
2147
+ if (out && typeof out === "object" && out.job && typeof out.job === "object" && !Array.isArray(out.job)) out = out.job;
2148
+ if (expect && (out === null || typeof out !== "object" || out[expect] === undefined || out[expect] === null)) {
2149
+ die(`${what}: unexpected response from the server (no "${expect}" field). Check --base / ARGORANT_API_BASE.`);
2150
+ }
2151
+ return out;
2152
+ }
2153
+
2154
+ function parseMaxCredits(raw) {
2155
+ if (raw === undefined || raw === null) return null;
2156
+ const n = Number(raw);
2157
+ if (!Number.isFinite(n) || n <= 0) die(`--max-credits must be a positive number (got "${raw}").`);
2158
+ return n;
2159
+ }
2160
+
2161
+ function findJobId(args, sub) {
2162
+ const id = String(args._[1] || "").trim();
2163
+ if (!id) die(`usage: argorant find ${sub} <job_id>${sub === "download" ? " [-o found.csv]" : sub === "resume" ? " [--max-credits N]" : ""}`);
2164
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id)) die(`that does not look like a job id: ${id}`);
2165
+ return id;
2166
+ }
2167
+ const findJobPath = (id, suffix = "") => `${FIND_API}/jobs/${encodeURIComponent(id)}${suffix}`;
2168
+
2169
+ // Server prices, tolerant of a flat or nested shape. Fallbacks are the
2170
+ // documented defaults; the server stays the source of truth for charges.
2171
+ function findPrices(p) {
2172
+ const pr = (p && (p.prices || p.pricing)) || {};
2173
+ const pick = (...vals) => {
2174
+ for (let v of vals) {
2175
+ if (v && typeof v === "object") v = v.credits;
2176
+ if (v !== undefined && v !== null && v !== "" && Number.isFinite(Number(v))) return Number(v);
2177
+ }
2178
+ return null;
2179
+ };
2180
+ const found = pick(pr.valid, pr.found, p && p.price_found);
2181
+ return {
2182
+ known: found !== null,
2183
+ found: found ?? 1,
2184
+ catchAll: pick(pr.catch_all, p && p.price_catch_all) ?? found ?? 1,
2185
+ notFound: pick(pr.not_found, p && p.price_not_found) ?? 0.25,
2186
+ };
2187
+ }
2188
+
2189
+ function findCounts(job) {
2190
+ const c = job.counts || {};
2191
+ const confirmed = Number(c.valid || 0);
2192
+ const catchAll = Number(c.catch_all || 0);
2193
+ return {
2194
+ confirmed,
2195
+ catchAll,
2196
+ found: confirmed + catchAll,
2197
+ notFound: Number(c.not_found || 0),
2198
+ free: Number(c.no_mail_domain || 0) + Number(c.retry_later || 0) + Number(c.invalid_input || 0),
2199
+ };
2200
+ }
2201
+ function findProgress(job) {
2202
+ const k = findCounts(job);
2203
+ return `${job.status || "queued"} ${Number(job.processed || 0).toLocaleString()}/${Number(job.total || 0).toLocaleString()} processed · ${k.found.toLocaleString()} found · ${fmtCredits(job.credits_charged)} charged`;
2204
+ }
2205
+ function findSummaryLines(job) {
2206
+ const k = findCounts(job);
2207
+ const lines = [
2208
+ ` ${Number(job.processed || 0).toLocaleString()}/${Number(job.total || 0).toLocaleString()} processed · ` +
2209
+ `${bold(k.found.toLocaleString())} found ${dim(`(${k.confirmed.toLocaleString()} confirmed, ${k.catchAll.toLocaleString()} unconfirmed catch-all)`)} · ` +
2210
+ `${k.notFound.toLocaleString()} not found · ${k.free.toLocaleString()} not charged`,
2211
+ ` ${fmtCredits(job.credits_charged)} charged` + (job.max_credits != null ? dim(` · cap ${fmtCredits(job.max_credits)}`) : ""),
2212
+ ];
2213
+ if (Number(job.duplicates_removed || 0) > 0) lines.push(dim(` ${Number(job.duplicates_removed).toLocaleString()} duplicate rows removed`));
2214
+ const bad = Array.isArray(job.invalid_rows) ? job.invalid_rows : [];
2215
+ if (bad.length) {
2216
+ lines.push(dim(` ${bad.length.toLocaleString()} rows skipped as invalid (row ${bad.slice(0, 10).join(", ")}${bad.length > 10 ? ", ..." : ""})`));
2217
+ }
2218
+ return lines;
2219
+ }
2220
+ function findPausedLines(job, id) {
2221
+ const r = job.paused_reason;
2222
+ const lines = [yellow(`Paused: ${FIND_PAUSED_REASONS[r] || (r ? `reason "${r}".` : "no reason given.")}`)];
2223
+ if (job.resume_after) lines.push(dim(`It can continue after ${job.resume_after}.`));
2224
+ lines.push(`Resume: argorant find resume ${id} ${r === "spend_limit" ? "--max-credits <new cap>" : "[--max-credits N]"}`);
2225
+ return lines;
2226
+ }
2227
+ function findPausedExit(reason) {
2228
+ if (reason === "insufficient_credits") return EXIT.UPGRADE;
2229
+ if (reason === "daily_limit" || reason === "miss_rate") return EXIT.RATE_LIMIT;
2230
+ return EXIT.OK; // spend_limit: the job stopped at the cap the caller set.
2231
+ }
2232
+ function findStatusTag(status) {
2233
+ const s = String(status || "").toLowerCase();
2234
+ if (s === "done") return green(s);
2235
+ if (s === "paused") return yellow(s);
2236
+ return dim(s || "unknown");
2237
+ }
2238
+
2239
+ async function findSingle(args, key) {
2240
+ const raw = String(args.domain || "").trim();
2241
+ const domain = normalizeDomain(raw.includes("@") ? raw.split("@").pop() : raw);
2242
+ const first = String(args.first || "").trim();
2243
+ const last = String(args.last || "").trim();
2244
+ const name = (args._.join(" ") || String(args.name || "")).trim();
2245
+ if (!domain && !first && !last && !name) die(FIND_USAGE);
2246
+ if (!domain) die(`--domain is required (the company the person works at).\n${FIND_USAGE}`);
2247
+ if (!domain.includes(".")) die(`--domain needs a company domain like acme.com (got "${raw}").`);
2248
+ if ((first || last) && name) die("pass either a full name or --first/--last, not both.");
2249
+ if (!first && !last && !name) die(`a name is required.\n${FIND_USAGE}`);
2250
+ if (args.output) warn("-o is for --file jobs; a single lookup prints its result.");
2251
+ if (args.maxCredits !== undefined) warn("--max-credits is for --file jobs; a single lookup costs at most 1 credit.");
2252
+ const body = first || last ? { first_name: first, last_name: last, domain } : { name, domain };
2253
+ const r = findNeed(await request("POST", args.base, `${FIND_API}/find`, { key, body }), "find", { expect: "status" });
2254
+ const gotEmail = Boolean(r.email);
2255
+ if (args.json) {
2256
+ console.log(JSON.stringify(r, null, 2));
2257
+ if (!gotEmail) process.exit(EXIT.ERROR);
2258
+ return;
2259
+ }
2260
+ const label = FIND_STATUS_LABELS[r.status] || r.status || "unknown";
2261
+ const tag = r.status === "valid" ? green(label) : r.status === "catch_all" ? yellow(label) : dim(label);
2262
+ const parts = [gotEmail ? bold(r.email) : dim("no email found"), tag];
2263
+ if (gotEmail && r.confidence !== undefined && r.confidence !== null) parts.push(`confidence ${r.confidence}`);
2264
+ parts.push(fmtCharge(r.credits_charged));
2265
+ console.log(parts.join(dim(" · ")));
2266
+ if (!gotEmail) process.exit(EXIT.ERROR);
2267
+ }
2268
+
2269
+ async function pollFindJob(args, key, job) {
2270
+ const id = job.job_id;
2271
+ const tty = Boolean(process.stdout.isTTY);
2272
+ const recovery = `The job keeps running. Check it with \`argorant find status ${id}\` and download with \`argorant find download ${id} -o ${args.output || FIND_DEFAULT_OUT}\`.`;
2273
+ const onInt = () => {
2274
+ if (tty && !args.json) process.stdout.write("\n");
2275
+ process.stderr.write(dim(`Stopped watching. ${recovery}\n`));
2276
+ process.exit(130);
2277
+ };
2278
+ process.once("SIGINT", onInt);
2279
+ const started = Date.now();
2280
+ let shown = "";
2281
+ let failures = 0;
2282
+ try {
2283
+ for (;;) {
2284
+ if (!args.json) {
2285
+ const line = findProgress(job);
2286
+ if (line !== shown) {
2287
+ if (tty) process.stdout.write(`\r\x1b[K${dim(line)}`);
2288
+ else console.log(line);
2289
+ shown = line;
2290
+ }
2291
+ }
2292
+ if (FIND_TERMINAL.includes(String(job.status || "").toLowerCase())) break;
2293
+ if (Date.now() - started > 1000 * 60 * 60 * 12) die(`\nstopped watching after 12 hours. ${recovery}`);
2294
+ await new Promise((r) => setTimeout(r, FIND_POLL_MS));
2295
+ let res;
2296
+ try {
2297
+ res = await request("GET", args.base, findJobPath(id), { key });
2298
+ } catch (e) {
2299
+ res = { error: e };
2300
+ }
2301
+ // Ride out short network blips and gateway errors; the job is server-side.
2302
+ if (res.error || res.status >= 500) {
2303
+ failures += 1;
2304
+ if (failures > 5) die(`\n${res.error ? res.error.message : `job status failed (HTTP ${res.status})`}. ${recovery}`);
2305
+ continue;
2306
+ }
2307
+ failures = 0;
2308
+ job = findNeed(res, "find job status", { expect: "status" });
2309
+ }
2310
+ } finally {
2311
+ process.removeListener("SIGINT", onInt);
2312
+ }
2313
+ if (tty && !args.json) process.stdout.write("\n");
2314
+ return job;
2315
+ }
2316
+
2317
+ async function findFile(args, key) {
2318
+ if (args._.length) die("pass either a name or --file, not both.");
2319
+ if (args.domain || args.first || args.last) {
2320
+ die("--domain/--first/--last are for a single lookup; with --file every row carries its own name and domain.");
2321
+ }
2322
+ const maxCredits = parseMaxCredits(args.maxCredits);
2323
+ let text;
2324
+ try {
2325
+ text = fs.readFileSync(args.file, "utf8");
2326
+ } catch {
2327
+ die(`cannot read file: ${args.file}`);
2328
+ }
2329
+ text = text.replace(/^/, "");
2330
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
2331
+ if (!lines.length) die("file is empty");
2332
+ // Local count for the prompt only; the server parses the file and reports
2333
+ // the real total, duplicates and invalid rows on the job.
2334
+ const rows = lines.length - 1;
2335
+ if (rows < 1) die("file has a header but no rows.");
2336
+ // A paid job must never die on a path problem after it was created.
2337
+ const destArg = args.output || FIND_DEFAULT_OUT;
2338
+ const dest = ensureWritable(destArg);
2339
+ // Interactive-only by design, like reveal/export: with --yes, --json or a
2340
+ // non-TTY stdin this spends credits with no prompt.
2341
+ if (!args.yes && !args.json && process.stdin.isTTY) {
2342
+ const p = findNeed(await request("GET", args.base, `${FIND_API}/pricing`, { key }), "find pricing");
2343
+ const pr = findPrices(p);
2344
+ if (!pr.known) warn("the server did not return prices in a known shape; showing the standard prices.");
2345
+ let max = rows * Math.max(pr.found, pr.catchAll, pr.notFound);
2346
+ if (maxCredits !== null) max = Math.min(max, maxCredits);
2347
+ const ans = await prompt(
2348
+ `Find emails for ${bold(rows.toLocaleString())} rows? ${fmtCredits(pr.found)} per email found, ` +
2349
+ `${fmtCredits(pr.notFound)} when nothing is found (maximum ${fmtCredits(max)}). [y/N] `
2350
+ );
2351
+ if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
2352
+ }
2353
+ const body = { csv: text };
2354
+ if (args.name) body.name = String(args.name).trim();
2355
+ if (maxCredits !== null) body.max_credits = maxCredits;
2356
+ const created = findNeed(await request("POST", args.base, `${FIND_API}/jobs`, { key, body }), "find job", { expect: "job_id" });
2357
+ const id = created.job_id;
2358
+ if (args.json) {
2359
+ // stdout stays pure JSON; the id still reaches the terminal for recovery.
2360
+ process.stderr.write(dim(`job ${id}\n`));
2361
+ } else {
2362
+ const extra = [];
2363
+ if (Number(created.duplicates_removed || 0) > 0) extra.push(`${created.duplicates_removed} duplicates removed`);
2364
+ if (Array.isArray(created.invalid_rows) && created.invalid_rows.length) extra.push(`${created.invalid_rows.length} invalid rows skipped`);
2365
+ console.log(
2366
+ green("✓") + ` Job ${bold(id)} created: ${Number(created.total ?? rows).toLocaleString()} rows` + (extra.length ? dim(` (${extra.join(", ")})`) : "")
2367
+ );
2368
+ console.log(dim("Ctrl-C stops watching; the job keeps running."));
2369
+ }
2370
+ const job = await pollFindJob(args, key, created);
2371
+ const status = String(job.status || "").toLowerCase();
2372
+ try {
2373
+ await downloadTo(args.base, findJobPath(id, "/results?format=csv"), key, dest);
2374
+ } catch (e) {
2375
+ die(`${e.message}\nThe job itself is saved. Download it again with \`argorant find download ${id} -o ${destArg}\`.`);
2376
+ }
2377
+ const exitCode = status === "paused" ? findPausedExit(job.paused_reason) : EXIT.OK;
2378
+ if (args.json) {
2379
+ console.log(JSON.stringify({ ...job, file: dest }, null, 2));
2380
+ if (exitCode !== EXIT.OK) process.exit(exitCode);
2381
+ return;
2382
+ }
2383
+ const head = status === "done" ? green("✓") + " Done" : status === "paused" ? yellow("Paused") : dim(status || "stopped");
2384
+ console.log(`${head} ${bold(id)}`);
2385
+ for (const l of findSummaryLines(job)) console.log(l);
2386
+ const saved = countCsvRows(dest);
2387
+ console.log(
2388
+ ` Saved ${saved !== null ? bold(saved.toLocaleString()) + " rows → " : ""}${bold(dest)}` +
2389
+ (status === "done" ? "" : dim(" (rows not processed yet show as pending)"))
2390
+ );
2391
+ if (status === "paused") for (const l of findPausedLines(job, id)) console.log(l);
2392
+ if (exitCode !== EXIT.OK) process.exit(exitCode);
2393
+ }
2394
+
2395
+ async function findStatus(args, key) {
2396
+ const id = findJobId(args, "status");
2397
+ const job = findNeed(await request("GET", args.base, findJobPath(id), { key }), "find job status", { expect: "status" });
2398
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
2399
+ const status = String(job.status || "").toLowerCase();
2400
+ console.log(`${bold("Job " + (job.job_id || id))}${job.name ? " " + dim(`"${job.name}"`) : ""} ${findStatusTag(status)}`);
2401
+ for (const l of findSummaryLines(job)) console.log(l);
2402
+ if (status === "paused") for (const l of findPausedLines(job, id)) console.log(" " + l);
2403
+ if (Number(job.processed || 0) > 0 || FIND_TERMINAL.includes(status)) {
2404
+ console.log(dim(` Download: argorant find download ${id} -o found.csv`));
2405
+ }
2406
+ }
2407
+
2408
+ async function findDownload(args, key) {
2409
+ const id = findJobId(args, "download");
2410
+ const dest = ensureWritable(args.output || FIND_DEFAULT_OUT);
2411
+ const job = findNeed(await request("GET", args.base, findJobPath(id), { key }), "find job status", { expect: "status" });
2412
+ await downloadTo(args.base, findJobPath(id, "/results?format=csv"), key, dest);
2413
+ const status = String(job.status || "").toLowerCase();
2414
+ if (args.json) return console.log(JSON.stringify({ ok: true, job_id: id, status, file: dest }, null, 2));
2415
+ const rows = countCsvRows(dest);
2416
+ console.log(green("✓") + ` Saved ${rows !== null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
2417
+ if (status !== "done") {
2418
+ console.log(dim(` Job is ${status || "not finished"}: ${Number(job.processed || 0).toLocaleString()}/${Number(job.total || 0).toLocaleString()} processed, the rest show as pending.`));
2419
+ }
2420
+ }
2421
+
2422
+ async function findResume(args, key) {
2423
+ const id = findJobId(args, "resume");
2424
+ const maxCredits = parseMaxCredits(args.maxCredits);
2425
+ const body = maxCredits !== null ? { max_credits: maxCredits } : {};
2426
+ const job = findNeed(await request("POST", args.base, findJobPath(id, "/resume"), { key, body }), "find job resume", { expect: "status" });
2427
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
2428
+ console.log(green("✓") + ` Resumed job ${bold(job.job_id || id)} ${findStatusTag(job.status)}`);
2429
+ for (const l of findSummaryLines(job)) console.log(l);
2430
+ console.log(dim(` Follow it with: argorant find status ${id}`));
2431
+ }
2432
+
2433
+ async function findCancel(args, key) {
2434
+ const id = findJobId(args, "cancel");
2435
+ const job = findNeed(await request("POST", args.base, findJobPath(id, "/cancel"), { key }), "find job cancel", { expect: "status" });
2436
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
2437
+ console.log(green("✓") + ` Cancelled job ${bold(job.job_id || id)}. Remaining rows will not be looked up or charged.`);
2438
+ for (const l of findSummaryLines(job)) console.log(l);
2439
+ if (Number(job.processed || 0) > 0) console.log(dim(` Download what was found: argorant find download ${id} -o found.csv`));
2440
+ }
2441
+
2442
+ async function findPricing(args, key) {
2443
+ const p = findNeed(await request("GET", args.base, `${FIND_API}/pricing`, { key }), "find pricing");
2444
+ if (args.json) return console.log(JSON.stringify(p, null, 2));
2445
+ const pr = findPrices(p);
2446
+ if (!pr.known) warn("the server did not return prices in a known shape; showing the standard prices (see --json for the raw answer).");
2447
+ console.log(bold("Email finder prices"));
2448
+ console.log(` Email found and confirmed ${fmtCredits(pr.found)}`);
2449
+ console.log(` Catch-all domain, unconfirmed ${fmtCredits(pr.catchAll)}`);
2450
+ console.log(` Nothing found ${fmtCredits(pr.notFound)}`);
2451
+ console.log(` No mail server, try again later, invalid input ${green("free")}`);
2452
+ const b = p.balance || {};
2453
+ const bal = [];
2454
+ if (b.credits !== undefined && b.credits !== null) bal.push(`${bold(Number(b.credits).toLocaleString("en-US", { maximumFractionDigits: 2 }))} credits`);
2455
+ const pre = b.prepaid_no_result_lookups;
2456
+ if (pre !== undefined && pre !== null) bal.push(`${Number(pre).toLocaleString()} prepaid no-result lookup${Number(pre) === 1 ? "" : "s"}`);
2457
+ if (bal.length) console.log(`${bold("Balance")} ${bal.join(" · ")}`);
2458
+ const l = p.limits || {};
2459
+ const perMin = l.per_minute ?? l.single_per_minute ?? l.minute;
2460
+ const perDay = l.per_day ?? l.daily ?? l.daily_limit;
2461
+ const used = l.used_today;
2462
+ const lim = [];
2463
+ if (perMin !== undefined && perMin !== null) lim.push(`${Number(perMin).toLocaleString()} lookups per minute`);
2464
+ if (perDay !== undefined && perDay !== null) lim.push(`${Number(perDay).toLocaleString()} per day` + (used !== undefined && used !== null ? ` (${Number(used).toLocaleString()} used today)` : ""));
2465
+ if (lim.length) console.log(`${bold("Limits")} ${lim.join(" · ")}`);
2466
+ if (p.billing_exempt) console.log(dim("Lookups on this account are not charged."));
2467
+ console.log(dim("Nothing is charged when a request fails. A 0.25 charge takes 1 whole credit and keeps the rest for your next no-result lookups."));
2468
+ }
2469
+
2470
+ async function cmdFind(argv) {
2471
+ const args = readFlags(argv, FIND_VALUE_FLAGS);
2472
+ const sub = String(args._[0] || "").toLowerCase();
2473
+ if (sub === "help" || (!args._.length && !args.file && !args.domain && !args.first && !args.last && !args.name)) {
2474
+ if (sub === "help") return console.log(FIND_USAGE);
2475
+ die(FIND_USAGE);
2476
+ }
2477
+ const subs = { status: findStatus, download: findDownload, resume: findResume, cancel: findCancel, pricing: findPricing, prices: findPricing };
2478
+ const key = requireKey();
2479
+ // A subcommand word only counts as one when no single-lookup flags are set,
2480
+ // so `find "Pricing" --domain x.com` still looks up a person.
2481
+ if (subs[sub] && !args.file && !args.domain && !args.first && !args.last) return subs[sub](args, key);
2482
+ if (args.file) return findFile(args, key);
2483
+ return findSingle(args, key);
2484
+ }
2485
+
1938
2486
  function money(cents, currency) {
1939
2487
  if (cents === null || cents === undefined) return "?";
1940
2488
  const sign = String(currency || "usd").toLowerCase() === "eur" ? "€" : "$";
@@ -1944,7 +2492,7 @@ function money(cents, currency) {
1944
2492
  function help() {
1945
2493
  const p = bold("argorant");
1946
2494
  console.log(`
1947
- ${bold("Argorant")} — verified B2B contacts from your terminal ${dim("v" + VERSION)}
2495
+ ${bold("Argorant")} - verified B2B contacts from your terminal ${dim("v" + VERSION)}
1948
2496
 
1949
2497
  ${bold("USAGE")}
1950
2498
  ${p} <command> "<query>" [filters]
@@ -1958,16 +2506,24 @@ ${bold("COMMANDS")}
1958
2506
  ${cyan("search")} "<query>" -n 10 Preview matches, details redacted ${dim("(0 contact credits)")}
1959
2507
  ${cyan("sample")} <company.com> Build 25 distinct, live-valid company leads ${dim("(free sample)")}
1960
2508
  ${cyan("reveal")} "<query>" -n 25 Reveal full contact details ${dim("(uses credits; live-verified, pay only for deliverable)")}
2509
+ ${cyan("reveal")} ... --phones Also add phone numbers ${dim("(10 credits per number found; none found = free)")}
1961
2510
  ${cyan("enrich")} --email <a@b.com> One address → the full person profile ${dim("(1 credit per match; miss = free)")}
1962
2511
  ${cyan("enrich")} --name "<n>" --domain <d> Find that person + reveal a verified email ${dim("(1 credit per match)")}
1963
2512
  ${cyan("enrich")} --domain <d> Company profile ${dim("(0 contact credits)")}
2513
+ ${cyan("enrich")} ... --phones Also add the person's phone number ${dim("(10 credits if found)")}
1964
2514
  ${cyan("export")} "<query>" -n 1000 -o leads.csv Verified CSV export ${dim("(uses credits)")}
2515
+ ${cyan("export")} ... --phones Include phone numbers in the file ${dim("(10 credits per number in the file)")}
1965
2516
  ${cyan("export status")} <job_id> Status of an existing export ${dim("(free; add --batch for >50k)")}
1966
2517
  ${cyan("export download")} <job_id> -o leads.csv Re-download a finished export ${dim("(free)")}
1967
2518
  ${cyan("list create")} --name "<n>" [filters] Save a reusable list ${dim("(free)")}
1968
2519
  ${cyan("list status")} <id> Show a saved list's size ${dim("(free)")}
1969
2520
  ${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
1970
2521
  ${cyan("verify")} --file emails.csv -o out.csv Bulk-verify your own list ${dim("(recent re-checks free)")}
2522
+ ${cyan("find")} "<first last>" --domain <d> Find a person's work email ${dim("(1 credit found, 0.25 credit nothing found)")}
2523
+ ${cyan("find")} --first <f> --last <l> --domain <d> Same, with the name in two parts
2524
+ ${cyan("find")} --file people.csv -o found.csv Find emails for a list ${dim("(same prices; --max-credits caps the job)")}
2525
+ ${cyan("find")} status | download | resume | cancel <job_id> Manage a find job ${dim("(free)")}
2526
+ ${cyan("find pricing")} Finder prices, balance, limits ${dim("(free)")}
1971
2527
  ${cyan("campaigns")} ... Email outreach: create, write, enroll, launch ${dim("(argorant campaigns help)")}
1972
2528
  ${cyan("inboxes")} list | connect-google Connected mailboxes; connect a Google Workspace
1973
2529
  ${cyan("inboxes")} quote | order | status | renewal New mailboxes on new domains ${dim("(saved card or payment link)")}
@@ -1991,20 +2547,26 @@ ${bold("OPTIONS")}
1991
2547
  --json Raw JSON output -y, --yes Skip confirmations
1992
2548
  --base <url> Override API base (or ARGORANT_API_BASE)
1993
2549
  --batch Treat the id in \`export status/download\` as a batch id
2550
+ --phones Include phone numbers (reveal, enrich, export). Off by default.
2551
+ 10 credits per phone number returned, nothing if none is found;
2552
+ numbers you already unlocked are free. From the Pro plan.
2553
+ --max-credits <n> Credit cap for a \`find --file\` job (it pauses at the cap)
1994
2554
  --grade <g> valid (default) or valid-plus-catchall - which deliverable
1995
2555
  grade to include on reveal/export. You only ever pay for
1996
2556
  deliverable contacts; this is the one grade distinction
1997
2557
  exposed anywhere. ${dim("(coming soon - currently a no-op; see docs)")}
1998
2558
 
1999
2559
  ${bold("NON-INTERACTIVE USE")} ${dim("(agents, CI, pipes)")}
2000
- ${red("reveal, export, and verify --file SPEND CREDITS WITHOUT A PROMPT")} whenever
2560
+ ${red("reveal, export, verify --file, and find SPEND CREDITS WITHOUT A PROMPT")} whenever
2001
2561
  stdin is not a TTY, or when --yes / --json is passed. The confirmation is a
2002
2562
  convenience for humans at a terminal, never a safety net. Check your -n.
2563
+ A single \`find\` never asks: each lookup costs at most 1 credit.
2003
2564
 
2004
2565
  ${bold("EXIT CODES")}
2005
2566
  0 ok · 1 error · 2 not authenticated · 3 forbidden (missing scope)
2006
2567
  4 rate limit / daily quota · 5 plan upgrade required
2007
2568
  ${dim("`enrich` exits 1 when nothing matched, so scripts can branch without parsing the payload.")}
2569
+ ${dim("`find` exits 1 when no email came back; a paused `find --file` job exits 5 (out of credits) or 4 (daily limit).")}
2008
2570
 
2009
2571
  ${bold("EXAMPLES")}
2010
2572
  ${p} count "fintech CFOs in germany"
@@ -2015,8 +2577,12 @@ ${bold("EXAMPLES")}
2015
2577
  ${p} enrich --email patrick@stripe.com --json
2016
2578
  ${p} enrich --name "Patrick Collison" --domain stripe.com
2017
2579
  ${p} enrich --domain stripe.com
2580
+ ${p} reveal --title CFO --domain stripe.com -n 5 --phones
2581
+ ${p} export --title CFO --country Germany -n 200 --phones -o cfos.csv
2018
2582
  ${p} verify ceo@stripe.com
2019
2583
  ${p} verify --file my-list.csv -o verified.csv
2584
+ ${p} find "Patrick Collison" --domain stripe.com
2585
+ ${p} find --file people.csv -o found.csv --max-credits 200
2020
2586
 
2021
2587
  Docs: ${cyan("https://argorant.com/docs/cli")}
2022
2588
  `);
@@ -2027,12 +2593,13 @@ async function main() {
2027
2593
  const cmd = argv[0];
2028
2594
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help();
2029
2595
  if (cmd === "version" || cmd === "--version" || cmd === "-v") return console.log(VERSION);
2030
- // `campaigns` has its own flag vocabulary (--step, --count, --pool, --csv, ...)
2031
- // handled by readFlags — it never goes through the generic filter parser
2596
+ // `campaigns` and `find` have their own flag vocabulary (--step, --count, --first, --max-credits, ...)
2597
+ // handled by readFlags - it never goes through the generic filter parser
2032
2598
  // below, which would reject those flags as unknown.
2033
- if (cmd === "campaigns" || cmd === "campaign" || cmd === "inboxes" || cmd === "mailboxes" || cmd === "inbox" || cmd === "unibox" || cmd === "blocklist") {
2599
+ if (cmd === "campaigns" || cmd === "campaign" || cmd === "inboxes" || cmd === "mailboxes" || cmd === "inbox" || cmd === "unibox" || cmd === "blocklist" || cmd === "find") {
2034
2600
  try {
2035
2601
  if (cmd.startsWith("campaign")) await cmdCampaigns(argv.slice(1));
2602
+ else if (cmd === "find") await cmdFind(argv.slice(1));
2036
2603
  else if (cmd === "blocklist") await cmdBlocklist(argv.slice(1));
2037
2604
  else if (cmd === "inbox" || cmd === "unibox") await cmdInboxThreads(argv.slice(1));
2038
2605
  else await cmdInboxes(argv.slice(1));