argorant 0.10.0 → 0.11.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 +81 -15
  2. package/bin/argorant.js +496 -57
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # argorant
2
2
 
3
3
  Search, count, reveal, export, and verify B2B contacts from the Argorant
4
- database — from your terminal, scripts, or coding agent. No install required.
4
+ database, and find work emails by name and company domain, from your
5
+ terminal, scripts, or coding agent. No install required.
5
6
 
6
7
  ```sh
7
8
  npx argorant count "fintech CFOs in germany"
@@ -9,7 +10,7 @@ npx argorant count "fintech CFOs in germany"
9
10
 
10
11
  Company lookups, counts, and masked searches spend **zero contact credits on
11
12
  an active plan**. Reveals and exports draw on your Argorant workspace
12
- quota/credits — the same pool as the app, API, and MCP server.
13
+ quota/credits - the same pool as the app, API, and MCP server.
13
14
 
14
15
  ## Authenticate
15
16
 
@@ -19,15 +20,15 @@ Create an API key at **app.argorant.com/profile** (API keys), then:
19
20
  npx argorant login # paste your ag_live_ key (stored in ~/.argorant)
20
21
  ```
21
22
 
22
- Or set `ARGORANT_API_KEY=ag_live_…` in your environment — ideal for scripts,
23
+ Or set `ARGORANT_API_KEY=ag_live_…` in your environment - ideal for scripts,
23
24
  CI, and agents.
24
25
 
25
26
  ## Commands
26
27
 
27
28
  | Command | What it does | Cost |
28
29
  | --- | --- | --- |
29
- | `login [key]` | Save an API key | — |
30
- | `logout` | Forget the saved key and base (`~/.argorant/config.json`) | — |
30
+ | `login [key]` | Save an API key | - |
31
+ | `logout` | Forget the saved key and base (`~/.argorant/config.json`) | - |
31
32
  | `whoami` | Account, scopes, daily quota | free |
32
33
  | `count "<query>"` | Count matching contacts | free |
33
34
  | `company <company.com> -n 5` | Count people at a company, split business-email coverage, preview masked roles | free |
@@ -40,13 +41,77 @@ CI, and agents.
40
41
  | `list create --name "<n>" [filters]` | Save a reusable filtered list (server counts it) | free |
41
42
  | `list status <id>` | A saved list's size and mode | free |
42
43
  | `verify <email>` / `verify --file emails.csv -o out.csv` | Verify your own addresses (verification pool; recent re-checks free) | pool |
43
- | `campaigns …` | Live outbound campaigns — **operator keys only**, see below | — |
44
+ | `find "<first last>" --domain <d>` / `find --file people.csv -o found.csv` | Find a person's work email from a name and company domain, one or a whole list ([details](#find-emails)) | 1 credit found, 0.25 nothing found |
45
+ | `find status \| download \| resume \| cancel <job_id>`, `find pricing` | Manage a find job; show prices, balance and limits | free |
46
+ | `campaigns …` | Live outbound campaigns - **operator keys only**, see below | - |
44
47
 
45
48
  Exports above 50,000 rows are created as a multi-chunk batch: the CLI polls
46
49
  `export status --batch` for you and writes one file per chunk
47
50
  (`leads-part1.csv`, `leads-part2.csv`, …). The batch id is printed so you can
48
51
  re-download any time with `export download <batch_id> --batch`.
49
52
 
53
+ ## Find emails
54
+
55
+ Give a name and the company's domain, get the person's work email.
56
+
57
+ ```sh
58
+ npx argorant find "Jane Doe" --domain acme.com
59
+ npx argorant find --first Jane --last Doe --domain acme.com
60
+ # jane.doe@acme.com · Confirmed · confidence 95 · 1 credit
61
+ ```
62
+
63
+ Each lookup prints one line: the email (or `no email found`), its status, the
64
+ confidence and what it cost. Statuses:
65
+
66
+ | Status | Meaning | Cost |
67
+ | --- | --- | --- |
68
+ | Confirmed | Address found and confirmed | 1 credit |
69
+ | Unconfirmed, catch-all domain | The domain accepts every address, so the best address is returned without confirmation | 1 credit |
70
+ | Not found | No address found for this person | 0.25 credit |
71
+ | No mail server | The domain does not receive email | free |
72
+ | Try again later | No answer for this domain right now; run it again later | free |
73
+
74
+ Invalid rows and every failed request (out of credits, limits, a busy
75
+ server) are free too. A 0.25 charge takes one whole credit and keeps the rest
76
+ as prepaid no-result lookups, which cover your next three misses. Run
77
+ `argorant find pricing` for the live prices, your balance, prepaid lookups and
78
+ limits.
79
+
80
+ A whole list runs as a job. The file is a CSV with a header row and the
81
+ columns `first_name`, `last_name` and `domain`; an optional `ref` column is
82
+ passed through to the results:
83
+
84
+ ```sh
85
+ npx argorant find --file people.csv -o found.csv --max-credits 200 --name "Q4 list"
86
+ ```
87
+
88
+ The CLI prints the job id, shows progress (processed/total, found, charged)
89
+ and saves the results CSV when the job is done (`argorant-found.csv` by
90
+ default). Columns: `row, ref, first_name, last_name, domain, email, status,
91
+ confirmed, confidence, credits_charged`. The output path is checked before the
92
+ job is created. On a terminal the CLI asks first, showing the prices and the
93
+ maximum charge; with `--yes`, `--json` or no TTY it starts right away.
94
+ Duplicate rows are removed before anything is charged. `--max-credits` caps
95
+ the job: it pauses at the cap instead of spending more.
96
+
97
+ A job pauses when your credits run out, when it reaches `--max-credits`, at
98
+ the daily limit, or when almost none of the recent lookups found anything. The
99
+ CLI says why and still saves what was found so far. Ctrl-C only stops
100
+ watching; the job keeps running.
101
+
102
+ ```sh
103
+ argorant find status <job_id> # progress and counts
104
+ argorant find download <job_id> -o found.csv # the results CSV, any time
105
+ argorant find resume <job_id> [--max-credits 300] # continue a paused job
106
+ argorant find cancel <job_id> # stop; rows not processed yet are not charged
107
+ ```
108
+
109
+ Exit codes: a single `find` exits `1` when no email came back, so scripts can
110
+ branch without parsing. A `find --file` job that pauses exits `5` when out of
111
+ credits and `4` at the daily limit or when slowed down; a job that stops at
112
+ your own `--max-credits` cap exits `0`. Errors use the codes below (`5` out of
113
+ credits, `4` rate limit with the wait time in the message).
114
+
50
115
  ## Filters
51
116
 
52
117
  Combine free text with structured filters:
@@ -57,7 +122,7 @@ Combine free text with structured filters:
57
122
  --has-phone --has-linkedin --has-email --verified-only
58
123
  ```
59
124
 
60
- `--keywords` is the widest, most reliable filter (comma = OR) — prefer it over
125
+ `--keywords` is the widest, most reliable filter (comma = OR) - prefer it over
61
126
  `--industry`. `--country`/`--geography` accept regions (Europe, EMEA, DACH,
62
127
  Nordics, APAC, LATAM, GCC…).
63
128
 
@@ -84,13 +149,14 @@ npx argorant company stripe.com --json
84
149
  npx argorant reveal "heads of procurement" --country Germany -n 25 --json --yes
85
150
  ```
86
151
 
87
- ### Non-interactive behaviour — read this before scripting `reveal`/`export`
152
+ ### Non-interactive behaviour - read this before scripting `reveal`/`export`
88
153
 
89
- `reveal`, `export`, and `verify --file` ask for confirmation **only** when
90
- stdin is a TTY and neither `-y/--yes` nor `--json` was passed. In CI, in a
91
- pipe, or inside an agent loop there is **no prompt at all** — these commands
92
- spend credits immediately. The prompt is a convenience for humans at a
93
- terminal, never a safety net. Check your `-n` before you run them.
154
+ `reveal`, `export`, `verify --file` and `find --file` ask for confirmation
155
+ **only** when stdin is a TTY and neither `-y/--yes` nor `--json` was passed.
156
+ In CI, in a pipe, or inside an agent loop there is **no prompt at all**, these
157
+ commands spend credits immediately. A single `find` never asks; each lookup
158
+ costs at most 1 credit. The prompt is a convenience for humans at a terminal,
159
+ never a safety net. Check your `-n` and `--max-credits` before you run them.
94
160
 
95
161
  Related guardrails, so a mistake stays cheap:
96
162
 
@@ -108,9 +174,9 @@ Related guardrails, so a mistake stays cheap:
108
174
  | `0` | success |
109
175
  | `1` | generic error (bad usage, network, failed job) |
110
176
  | `2` | not authenticated (no key, or the key was rejected) |
111
- | `3` | forbidden — the key lacks the required scope |
177
+ | `3` | forbidden - the key lacks the required scope |
112
178
  | `4` | rate limit or daily quota reached |
113
- | `5` | plan upgrade required (HTTP 402); the message carries the upgrade URL |
179
+ | `5` | plan upgrade required or out of credits (HTTP 402); the message carries the upgrade URL |
114
180
 
115
181
  Exit `5` is deliberately distinct from `1`: an agent can tell "this account
116
182
  needs a paid plan" apart from "something broke".
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) {
@@ -252,7 +252,7 @@ function downloadTo(base, urlPath, key, dest) {
252
252
  // `detail` is a plain string on most endpoints, a structured object on some
253
253
  // (plan_required, campaign launch blockers, native-delivery readiness) and a
254
254
  // 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]".
255
+ // something a human or an agent can read - never "[object Object]".
256
256
  function detailMsg(detail) {
257
257
  if (detail == null) return null;
258
258
  if (typeof detail === "string") return detail;
@@ -292,7 +292,7 @@ function need(res, what) {
292
292
  const url = d.upgrade_url || d.url || "https://argorant.com/pricing";
293
293
  die(`${msg}${msg.includes(url) ? "" : `\nUpgrade: ${url}`}`, EXIT.UPGRADE);
294
294
  }
295
- if (res.status === 403) die(detailMsg(detail) || `forbidden — your key lacks the scope for ${what}.`, EXIT.FORBIDDEN);
295
+ if (res.status === 403) die(detailMsg(detail) || `forbidden - your key lacks the scope for ${what}.`, EXIT.FORBIDDEN);
296
296
  if (res.status === 429) die(detailMsg(detail) || "rate limit / daily quota reached.", EXIT.RATE_LIMIT);
297
297
  if (res.status >= 400) die(detailMsg(detail) || `${what} failed (HTTP ${res.status}).`);
298
298
  return res.json || {};
@@ -351,7 +351,7 @@ async function cmdLogin(args) {
351
351
  let key = args._[0];
352
352
  if (!key) key = await prompt("Paste your Argorant API key (ag_live_…): ", { hidden: true });
353
353
  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"));
354
+ if (!/^ag_(live|test)_/.test(key)) process.stderr.write(dim("note: keys normally start with ag_live_ - continuing anyway.\n"));
355
355
  const res = await request("GET", args.base, "/api/mcp/account", { key });
356
356
  if (res.status === 428 && res.json?.detail?.error === "access_key_activation_required") {
357
357
  const url = res.json.detail.claim_url;
@@ -370,7 +370,7 @@ async function cmdLogin(args) {
370
370
 
371
371
  async function cmdLogout() {
372
372
  if (!fs.existsSync(CONFIG_PATH)) {
373
- console.log(dim(`Nothing to do — no saved credentials at ${CONFIG_PATH}.`));
373
+ console.log(dim(`Nothing to do - no saved credentials at ${CONFIG_PATH}.`));
374
374
  } else {
375
375
  try {
376
376
  fs.unlinkSync(CONFIG_PATH);
@@ -380,7 +380,7 @@ async function cmdLogout() {
380
380
  console.log(green("✓") + ` Removed saved key and base from ${bold(CONFIG_PATH)}`);
381
381
  }
382
382
  if (process.env.ARGORANT_API_KEY) {
383
- warn("ARGORANT_API_KEY is still set in this environment and takes precedence — unset it too.");
383
+ warn("ARGORANT_API_KEY is still set in this environment and takes precedence - unset it too.");
384
384
  }
385
385
  }
386
386
 
@@ -389,8 +389,8 @@ async function cmdWhoami(args) {
389
389
  const res = await request("GET", args.base, "/api/mcp/account", { key });
390
390
  const a = need(res, "whoami");
391
391
  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(", ") || "—"}`);
392
+ console.log(`${bold("Account")} ${a.email || "-"} ${dim("(" + (a.role || "member") + ")")}`);
393
+ console.log(`${bold("Scopes")} ${(a.scopes || []).join(", ") || "-"}`);
394
394
  const u = a.usage || {};
395
395
  // Keys/fields must match _mcp_usage_summary: actions are count_requests /
396
396
  // preview_rows / reveal_rows / export_rows and each entry carries
@@ -471,7 +471,7 @@ async function cmdCompany(args) {
471
471
  for (const person of r.results) {
472
472
  const who = [person.preview, person.title].filter(Boolean).join(" · ");
473
473
  const where = [person.country].filter(Boolean).join(", ");
474
- console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
474
+ console.log(` ${bold(who || "-")}${where ? dim(" " + where) : ""}`);
475
475
  }
476
476
  }
477
477
  }
@@ -484,13 +484,13 @@ async function cmdSearch(args) {
484
484
  const res = await request("GET", args.base, "/api/mcp/people/preview", { key, query });
485
485
  const r = need(res, "search");
486
486
  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\`)`));
487
+ console.log(dim(`${Number(r.total).toLocaleString()} total · showing ${r.returned} (details redacted - use \`reveal\` or \`export\`)`));
488
488
  for (const p of r.results || []) {
489
489
  // _redacted_preview returns the masked identity as `preview` (e.g. "A* P"),
490
- // never `name` — without this the row rendered as title-only.
490
+ // never `name` - without this the row rendered as title-only.
491
491
  const who = [p.preview || p.name, p.title].filter(Boolean).join(" · ");
492
492
  const where = [p.company || p.company_name, p.country].filter(Boolean).join(", ");
493
- console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
493
+ console.log(` ${bold(who || "-")}${where ? dim(" " + where) : ""}`);
494
494
  }
495
495
  }
496
496
 
@@ -546,9 +546,9 @@ async function cmdSample(args) {
546
546
  `${bold(Number(job.total_companies || 0).toLocaleString())} matching companies`
547
547
  );
548
548
  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")}`);
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
552
  }
553
553
  if (args.output) console.log(green("✓") + ` Saved CSV → ${bold(args.output)}`);
554
554
  console.log(dim(`These 25 are the sample; the full pool contains ${Number(job.total_companies || 0).toLocaleString()} companies.`));
@@ -573,11 +573,11 @@ async function cmdReveal(args) {
573
573
  if (args.json) return console.log(JSON.stringify(r, null, 2));
574
574
  console.log(dim(`${Number(r.total).toLocaleString()} total · revealed ${r.returned}`));
575
575
  for (const p of r.results || []) {
576
- // _revealed_contact returns full_name / first_name / last_name — there is
576
+ // _revealed_contact returns full_name / first_name / last_name - there is
577
577
  // no `name` key. The customer just paid for this row; print who it is.
578
578
  const name = p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") || p.name;
579
579
  const who = [name, p.title].filter(Boolean).join(" · ");
580
- console.log(` ${bold(who || "—")}`);
580
+ console.log(` ${bold(who || "-")}`);
581
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);
582
582
  if (bits.length) console.log(" " + bits.join(dim(" · ")));
583
583
  }
@@ -653,7 +653,7 @@ async function cmdEnrich(args) {
653
653
  }
654
654
  const p = r.person || {};
655
655
  const who = [p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" "), p.title].filter(Boolean).join(" · ");
656
- console.log(` ${bold(who || "—")}`);
656
+ console.log(` ${bold(who || "-")}`);
657
657
  const bits = [
658
658
  p.email && cyan(p.email),
659
659
  p.phone,
@@ -683,7 +683,7 @@ function partPath(dest, n) {
683
683
  }
684
684
 
685
685
  // >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
686
+ // /api/mcp/export-batches/{id}} with NO job_id and NO download_api_path - the
687
687
  // old code fell through to /api/mcp/exports/undefined/download, so large
688
688
  // exports were simply impossible from the CLI. Poll the batch, then download
689
689
  // each completed chunk.
@@ -740,10 +740,10 @@ async function exportStatusCmd(args, key, id) {
740
740
  const s = need(res, "export status");
741
741
  if (args.json) return console.log(JSON.stringify(s, null, 2));
742
742
  if (isBatch) {
743
- console.log(`${bold("Batch #" + (s.batch_id ?? id))} ${dim(s.status || "—")}`);
743
+ console.log(`${bold("Batch #" + (s.batch_id ?? id))} ${dim(s.status || "-")}`);
744
744
  console.log(` ${s.completed_chunks || 0}/${s.total_chunks || 0} chunks · ${Number(s.verified_rows || 0).toLocaleString()} rows · ${s.progress_pct || 0}%`);
745
745
  } else {
746
- console.log(`${bold("Export #" + (s.job_id ?? id))} ${dim(s.status || "—")}`);
746
+ console.log(`${bold("Export #" + (s.job_id ?? id))} ${dim(s.status || "-")}`);
747
747
  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
748
  }
749
749
  if (s.error_message) console.log(red(" " + s.error_message));
@@ -756,7 +756,7 @@ async function exportDownloadCmd(args, key, id) {
756
756
  const res = await request("GET", args.base, `/api/mcp/export-batches/${id}`, { key });
757
757
  const b = need(res, "export batch status");
758
758
  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\`.`);
759
+ die(`export batch #${id} is ${b.status || "not ready"} - run \`argorant export status ${id} --batch\`.`);
760
760
  }
761
761
  const files = await downloadBatchChunks(args, key, b, dest);
762
762
  if (args.json) return console.log(JSON.stringify({ ok: true, batch_id: id, files }, null, 2));
@@ -766,7 +766,7 @@ async function exportDownloadCmd(args, key, id) {
766
766
  const res = await request("GET", args.base, `/api/mcp/exports/${id}`, { key });
767
767
  const s = need(res, "export status");
768
768
  if (!s.downloadable && !s.download_api_path) {
769
- die(`export #${id} is ${s.status || "not ready"} — run \`argorant export status ${id}\`.`);
769
+ die(`export #${id} is ${s.status || "not ready"} - run \`argorant export status ${id}\`.`);
770
770
  }
771
771
  await downloadTo(args.base, s.download_api_path || `/api/mcp/exports/${id}/download`, key, dest);
772
772
  if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: id }, null, 2));
@@ -776,7 +776,7 @@ async function exportDownloadCmd(args, key, id) {
776
776
 
777
777
  async function cmdExport(args) {
778
778
  const key = requireKey();
779
- // `export status <id>` / `export download <id>` — recovery subcommands, no
779
+ // `export status <id>` / `export download <id>` - recovery subcommands, no
780
780
  // job is created and nothing is billed.
781
781
  const sub = (args._[0] || "").toLowerCase();
782
782
  if (sub === "status" || sub === "download") {
@@ -817,7 +817,7 @@ async function cmdExport(args) {
817
817
  const batchPath = job.status_api_path || `/api/mcp/export-batches/${job.batch_id}`;
818
818
  if (!args.json) {
819
819
  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`)
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`)
821
821
  );
822
822
  }
823
823
  const batch = await pollExportBatch(args, key, batchPath, { quiet: !!args.json });
@@ -833,7 +833,7 @@ async function cmdExport(args) {
833
833
  if (args.json) return console.log(JSON.stringify(job, null, 2));
834
834
  return console.log("Export queued. " + JSON.stringify(job));
835
835
  }
836
- if (!args.json) process.stdout.write(dim("Export queued — verifying & building CSV"));
836
+ if (!args.json) process.stdout.write(dim("Export queued - verifying & building CSV"));
837
837
  let downloadPath = job.download_api_path || null;
838
838
  const started = Date.now();
839
839
  // Poll until the job reports a terminal state.
@@ -851,7 +851,7 @@ async function cmdExport(args) {
851
851
  }
852
852
  if (EXPORT_TERMINAL_FAIL.includes(status)) die(`\nexport ${status}${s.error_message ? `: ${s.error_message}` : "."}`);
853
853
  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}\`.`);
854
+ die(`\nexport timed out after 20 minutes. It is still running server-side - check with \`argorant export status ${job.job_id}\`.`);
855
855
  }
856
856
  }
857
857
  if (!args.json) process.stdout.write("\n");
@@ -862,7 +862,7 @@ async function cmdExport(args) {
862
862
  try {
863
863
  await downloadTo(args.base, downloadPath, key, dest);
864
864
  } catch (e) {
865
- // The job is already paid for — always tell the user how to get it back.
865
+ // The job is already paid for - always tell the user how to get it back.
866
866
  die(`${e.message}\nThe export itself completed. Retry the download with \`argorant export download ${job.job_id} -o ${dest}\`.`);
867
867
  }
868
868
  if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: job.job_id }, null, 2));
@@ -870,7 +870,7 @@ async function cmdExport(args) {
870
870
  console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
871
871
  }
872
872
 
873
- // ---- verify: external email verification (own lists) — the verification pool,
873
+ // ---- verify: external email verification (own lists) - the verification pool,
874
874
  // separate from contact credits. 60-day re-checks are free. ----
875
875
  const EMAIL_RE = /[^\s,;"']+@[^\s,;"']+\.[^\s,;"']+/;
876
876
 
@@ -911,7 +911,7 @@ async function cmdVerifyFile(args, key) {
911
911
  emails = [...new Set(emails)];
912
912
  if (!emails.length) die("no email addresses found in file (try --column <name>)");
913
913
  const out = ensureWritable(args.output || "argorant-verified.csv");
914
- // Interactive-only by design — see reveal/export.
914
+ // Interactive-only by design - see reveal/export.
915
915
  if (!args.yes && !args.json && process.stdin.isTTY) {
916
916
  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
917
  if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
@@ -938,7 +938,7 @@ async function cmdVerifyFile(args, key) {
938
938
  }
939
939
 
940
940
  // ---- 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
941
+ // filtered list is free and does NOT reveal contacts - the server counts the
942
942
  // matches itself, so the list reports its real size right away. ----
943
943
  async function cmdList(args) {
944
944
  const key = requireKey();
@@ -952,7 +952,7 @@ async function cmdList(args) {
952
952
  const r = need(res, "list create");
953
953
  if (args.json) return console.log(JSON.stringify(r, null, 2));
954
954
  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`);
955
+ console.log(green("✓") + ` Created list ${bold("#" + r.list_id)} ${dim("“" + r.name + "”")} - ${bold(total.toLocaleString())} matching contacts`);
956
956
  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
957
  return;
958
958
  }
@@ -960,14 +960,14 @@ async function cmdList(args) {
960
960
  const id = args._[1] || args.name;
961
961
  if (!id) die("usage: argorant list status <list_id>");
962
962
  // 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
963
+ // back as a FastAPI 422 whose detail is an array - a useless error for a
964
964
  // plain typo.
965
965
  if (!/^\d+$/.test(String(id).trim())) die(`list id must be a number (got "${id}").`);
966
966
  const res = await request("GET", args.base, `/api/mcp/lists/${encodeURIComponent(String(id).trim())}`, { key });
967
967
  const r = need(res, "list status");
968
968
  if (args.json) return console.log(JSON.stringify(r, null, 2));
969
969
  const total = Number(r.snapshot_total ?? r.item_count ?? 0);
970
- console.log(`${bold("List #" + (r.list_id ?? id))} ${dim("“" + (r.name || "—") + "”")}`);
970
+ console.log(`${bold("List #" + (r.list_id ?? id))} ${dim("“" + (r.name || "-") + "”")}`);
971
971
  console.log(` ${bold(total.toLocaleString())} contacts · ${dim((r.selection_mode || "filtered") + " · " + (r.record_type || "person"))}`);
972
972
  return;
973
973
  }
@@ -977,9 +977,9 @@ async function cmdList(args) {
977
977
  // =============================================================================
978
978
  // campaigns: god-mode native outbound campaign control from the terminal.
979
979
  //
980
- // OPERATOR KEYS ONLY. Every command above talks to /api/mcp/* — the
980
+ // OPERATOR KEYS ONLY. Every command above talks to /api/mcp/* - the
981
981
  // customer-facing contact-data API, gated by plan scopes. Everything below
982
- // talks to /api/sequencer/* — the internal Argorant Sequencer that runs live
982
+ // talks to /api/sequencer/* - the internal Argorant Sequencer that runs live
983
983
  // outbound sends. It authenticates via the SAME ag_live_ Bearer key, but only
984
984
  // works for a key that (a) belongs to an owner/admin account and (b) carries
985
985
  // the `argorant:operator` scope (see cli/GODMODE-PLAN.md). Any other key gets
@@ -987,14 +987,14 @@ async function cmdList(args) {
987
987
  // access.
988
988
  //
989
989
  // Kept on its own tiny flag reader (readFlags) instead of the top-level
990
- // parseArgs — these subcommands have their own vocabulary (--step, --subject,
990
+ // parseArgs - these subcommands have their own vocabulary (--step, --subject,
991
991
  // --count, --pool, --csv, ...) that would otherwise collide with, or be
992
992
  // rejected by, the generic filter-flag parser used for search/reveal/export.
993
993
  // =============================================================================
994
994
 
995
995
  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
996
 
997
- // Filter flags for `campaigns leads add --query ...` — the same names/mapping
997
+ // Filter flags for `campaigns leads add --query ...` - the same names/mapping
998
998
  // as the top-level VALUE_FLAGS/BOOL_FLAGS (minus the ones the sequencer's
999
999
  // filter-enroll endpoint doesn't accept, e.g. --verified-only), plus --query
1000
1000
  // as an explicit alias for free-text `q` (clearer than a bare positional in a
@@ -1053,7 +1053,7 @@ function readFlags(argv, valueFlags = {}, boolFlags = {}) {
1053
1053
  // A saved base from `argorant login --base …` only applies when the caller did
1054
1054
  // NOT pass --base. Inferring "no flag given" from the VALUE (=== DEFAULT_BASE)
1055
1055
  // meant `--base https://argorant.com` was silently ignored after a staging
1056
- // login — requests went to the wrong host with no indication.
1056
+ // login - requests went to the wrong host with no indication.
1057
1057
  function applySavedBase(args) {
1058
1058
  if (args.baseExplicit || process.env.ARGORANT_API_BASE) return args;
1059
1059
  const saved = loadConfig().base;
@@ -1088,7 +1088,7 @@ async function resolveCampaign(base, key, identifier) {
1088
1088
  }
1089
1089
  if (matches.length > 1) {
1090
1090
  die(
1091
- `"${identifier}" matches ${matches.length} campaigns — be more specific:\n` +
1091
+ `"${identifier}" matches ${matches.length} campaigns - be more specific:\n` +
1092
1092
  matches.map((c) => ` ${c.name} ${dim(c.id)}`).join("\n")
1093
1093
  );
1094
1094
  }
@@ -1106,8 +1106,8 @@ async function campaignsList(argv) {
1106
1106
  for (const c of campaigns) {
1107
1107
  const sent = Number(c.sent_count || 0);
1108
1108
  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)}`);
1109
+ const rate = sent > 0 ? `${((replied / sent) * 100).toFixed(1)}%` : "-";
1110
+ console.log(`${bold(c.name || "-")} ${dim(c.id)}`);
1111
1111
  console.log(
1112
1112
  ` ${c.status}` +
1113
1113
  dim(" · leads ") + Number(c.lead_count || 0).toLocaleString() +
@@ -1248,7 +1248,7 @@ async function campaignsSteps(argv) {
1248
1248
  }
1249
1249
  body = (body || "").trim();
1250
1250
  if (!body) die("body is empty");
1251
- // The CLI never generates copy — the operator/agent writes it; this command
1251
+ // The CLI never generates copy - the operator/agent writes it; this command
1252
1252
  // only upserts what it's given.
1253
1253
  const campaignId = await resolveCampaign(args.base, key, identifier);
1254
1254
  const stepBody = { step_number: stepNumber, subject, body, copy_status: args.approve ? "approved" : "draft" };
@@ -1270,7 +1270,7 @@ async function campaignsInboxes(argv) {
1270
1270
  if (!count || count < 1) die("--count must be a positive integer");
1271
1271
  const campaignId = await resolveCampaign(args.base, key, identifier);
1272
1272
 
1273
- // Fleet changes only ever happen via this explicit command — never
1273
+ // Fleet changes only ever happen via this explicit command - never
1274
1274
  // implicitly from create/start. Exclude whatever's already attached to THIS
1275
1275
  // campaign (an inbox can serve multiple campaigns; "unattached" is relative
1276
1276
  // to this one), then page through the healthy/usable pool for candidates.
@@ -1405,7 +1405,7 @@ async function campaignsStatus(argv) {
1405
1405
  const r = need(await request("GET", args.base, `/api/v1/campaigns/${campaignId}`, { key }), "campaign");
1406
1406
  if (args.json) return console.log(JSON.stringify(r, null, 2));
1407
1407
  const c = r.campaign || {};
1408
- console.log(`${bold(c.name || "—")} ${dim(c.id)}`);
1408
+ console.log(`${bold(c.name || "-")} ${dim(c.id)}`);
1409
1409
  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
1410
  console.log(` emails ${(r.emails || []).length} · senders ${(r.senders || []).length}`);
1411
1411
  printSettings({ settings: r.settings });
@@ -1511,7 +1511,7 @@ async function campaignsReplies(argv) {
1511
1511
  if (!(r.replies || []).length) return console.log(dim("No replies yet."));
1512
1512
  for (const e of r.replies) {
1513
1513
  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)}`));
1514
+ console.log(dim(` ${(e.subject || "").slice(0, 80)} - ${(e.body || "").replace(/\s+/g, " ").slice(0, 140)}`));
1515
1515
  }
1516
1516
  }
1517
1517
 
@@ -1530,7 +1530,7 @@ async function cmdInboxThreads(argv) {
1530
1530
  for (const e of r.events) {
1531
1531
  console.log(`${dim(e.id)}`);
1532
1532
  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)}`));
1533
+ console.log(dim(` ${(e.subject || "").slice(0, 80)} - ${(e.body || e.snippet || "").replace(/\s+/g, " ").slice(0, 140)}`));
1534
1534
  }
1535
1535
  return;
1536
1536
  }
@@ -1624,7 +1624,7 @@ async function cmdBlocklist(argv) {
1624
1624
  function campaignsHelp() {
1625
1625
  const p = bold("argorant campaigns");
1626
1626
  console.log(`
1627
- ${bold("Argorant Campaigns")} — email outreach from the terminal
1627
+ ${bold("Argorant Campaigns")} - email outreach from the terminal
1628
1628
 
1629
1629
  Works with any Argorant API key. Nothing is sent until you launch.
1630
1630
 
@@ -1935,6 +1935,434 @@ async function cmdInboxes(argv) {
1935
1935
  die(`unknown inboxes subcommand: ${sub} (list, set, disconnect, connect-google, endings, suggest, quote, order, status, renewal, cancel-bundle)`);
1936
1936
  }
1937
1937
 
1938
+ // =============================================================================
1939
+ // find: the email finder. A person's name plus a company domain in, a work
1940
+ // email out. Priced per outcome by the server (GET /api/v1/email-finder/pricing):
1941
+ // an address returned costs 1 credit, a lookup that finds nothing 0.25 credit,
1942
+ // and lookups that cannot run (no mail server, try again later, invalid input)
1943
+ // are free. Nothing is charged on any error response.
1944
+ //
1945
+ // Own flag reader (readFlags) like `campaigns`: --first/--last/--max-credits
1946
+ // are not search filters, and `--name` means the job name with --file but the
1947
+ // person's name on a single lookup (same habit as `enrich --name`).
1948
+ // Exit codes: a single lookup exits 1 when no email came back (like `enrich`);
1949
+ // a --file job that pauses exits 5 (out of credits) or 4 (daily limit or slowed
1950
+ // down), so scripts can branch without parsing output.
1951
+ // =============================================================================
1952
+
1953
+ const FIND_API = "/api/v1/email-finder";
1954
+ const FIND_DEFAULT_OUT = "argorant-found.csv";
1955
+ const FIND_POLL_MS = 2500;
1956
+ const FIND_VALUE_FLAGS = {
1957
+ "--domain": "domain",
1958
+ "--first": "first",
1959
+ "--first-name": "first",
1960
+ "--last": "last",
1961
+ "--last-name": "last",
1962
+ "--name": "name",
1963
+ "-f": "file",
1964
+ "--file": "file",
1965
+ "-o": "output",
1966
+ "--output": "output",
1967
+ "--max-credits": "maxCredits",
1968
+ };
1969
+ const FIND_STATUS_LABELS = {
1970
+ valid: "Confirmed",
1971
+ catch_all: "Unconfirmed, catch-all domain",
1972
+ not_found: "Not found",
1973
+ no_mail_domain: "No mail server",
1974
+ retry_later: "Try again later",
1975
+ invalid_input: "Invalid input",
1976
+ pending: "Pending",
1977
+ };
1978
+ const FIND_PAUSED_REASONS = {
1979
+ insufficient_credits: "your workspace ran out of credits. Add credits, then resume.",
1980
+ spend_limit: "the job reached its credit cap (--max-credits). Resume with a higher cap to continue.",
1981
+ daily_limit: "the daily lookup limit was reached.",
1982
+ miss_rate: "almost none of the recent lookups found an address, so lookups are slowed down for a while.",
1983
+ };
1984
+ const FIND_TERMINAL = ["done", "cancelled", "canceled", "paused"];
1985
+ const FIND_USAGE =
1986
+ 'usage: argorant find "<first last>" --domain <company.com>\n' +
1987
+ " argorant find --first <first> --last <last> --domain <company.com>\n" +
1988
+ ' argorant find --file people.csv [-o found.csv] [--max-credits N] [--name "Q4 list"]\n' +
1989
+ " argorant find status | download | resume | cancel <job_id>\n" +
1990
+ " argorant find pricing";
1991
+
1992
+ // 1 -> "1 credit", 0.25 -> "0.25 credit", 2 / 0 -> "2 credits" / "0 credits".
1993
+ function fmtCredits(n) {
1994
+ const v = Number(n || 0);
1995
+ return `${v.toLocaleString("en-US", { maximumFractionDigits: 2 })} credit${v > 0 && v <= 1 ? "" : "s"}`;
1996
+ }
1997
+ function fmtCharge(n) {
1998
+ return Number(n || 0) > 0 ? fmtCredits(n) : "not charged";
1999
+ }
2000
+ function fmtWait(sec) {
2001
+ if (sec < 90) return `${sec} seconds`;
2002
+ if (sec < 5400) return `${Math.round(sec / 60)} minutes`;
2003
+ return `${Math.round(sec / 3600)} hours`;
2004
+ }
2005
+
2006
+ // The finder answers errors FastAPI-style under `detail`; tolerate a bare
2007
+ // {error, message} body too, and turn Retry-After into a plain wait time.
2008
+ function findNeed(res, what, { expect } = {}) {
2009
+ const j = res.json;
2010
+ let r = res;
2011
+ // need() lets any status below 400 through. A redirect (http -> https, a
2012
+ // wrong --base) must not read as "no email found" or "job resumed".
2013
+ if (res.status >= 300 && res.status < 400) {
2014
+ const to = res.res && res.res.headers && res.res.headers.location;
2015
+ die(`${what}: the server answered with a redirect (HTTP ${res.status}${to ? ` to ${to}` : ""}). Check --base / ARGORANT_API_BASE.`);
2016
+ }
2017
+ if (res.status >= 400 && j && typeof j === "object" && !Array.isArray(j) && j.detail === undefined && (j.error || j.message)) {
2018
+ r = { ...res, json: { detail: j } };
2019
+ }
2020
+ if (r.status === 429) {
2021
+ const ra = Number((res.res && res.res.headers && res.res.headers["retry-after"]) || 0);
2022
+ const msg = detailMsg(r.json && r.json.detail) || "the email finder limit was reached.";
2023
+ die(Number.isFinite(ra) && ra > 0 ? `${msg} Try again in ${fmtWait(Math.ceil(ra))}. Nothing was charged.` : `${msg} Nothing was charged.`, EXIT.RATE_LIMIT);
2024
+ }
2025
+ let out = need(r, what);
2026
+ // Job endpoints answer {ok, job: {...}}; the commands work on the job itself.
2027
+ if (out && typeof out === "object" && out.job && typeof out.job === "object" && !Array.isArray(out.job)) out = out.job;
2028
+ if (expect && (out === null || typeof out !== "object" || out[expect] === undefined || out[expect] === null)) {
2029
+ die(`${what}: unexpected response from the server (no "${expect}" field). Check --base / ARGORANT_API_BASE.`);
2030
+ }
2031
+ return out;
2032
+ }
2033
+
2034
+ function parseMaxCredits(raw) {
2035
+ if (raw === undefined || raw === null) return null;
2036
+ const n = Number(raw);
2037
+ if (!Number.isFinite(n) || n <= 0) die(`--max-credits must be a positive number (got "${raw}").`);
2038
+ return n;
2039
+ }
2040
+
2041
+ function findJobId(args, sub) {
2042
+ const id = String(args._[1] || "").trim();
2043
+ if (!id) die(`usage: argorant find ${sub} <job_id>${sub === "download" ? " [-o found.csv]" : sub === "resume" ? " [--max-credits N]" : ""}`);
2044
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id)) die(`that does not look like a job id: ${id}`);
2045
+ return id;
2046
+ }
2047
+ const findJobPath = (id, suffix = "") => `${FIND_API}/jobs/${encodeURIComponent(id)}${suffix}`;
2048
+
2049
+ // Server prices, tolerant of a flat or nested shape. Fallbacks are the
2050
+ // documented defaults; the server stays the source of truth for charges.
2051
+ function findPrices(p) {
2052
+ const pr = (p && (p.prices || p.pricing)) || {};
2053
+ const pick = (...vals) => {
2054
+ for (let v of vals) {
2055
+ if (v && typeof v === "object") v = v.credits;
2056
+ if (v !== undefined && v !== null && v !== "" && Number.isFinite(Number(v))) return Number(v);
2057
+ }
2058
+ return null;
2059
+ };
2060
+ const found = pick(pr.valid, pr.found, p && p.price_found);
2061
+ return {
2062
+ known: found !== null,
2063
+ found: found ?? 1,
2064
+ catchAll: pick(pr.catch_all, p && p.price_catch_all) ?? found ?? 1,
2065
+ notFound: pick(pr.not_found, p && p.price_not_found) ?? 0.25,
2066
+ };
2067
+ }
2068
+
2069
+ function findCounts(job) {
2070
+ const c = job.counts || {};
2071
+ const confirmed = Number(c.valid || 0);
2072
+ const catchAll = Number(c.catch_all || 0);
2073
+ return {
2074
+ confirmed,
2075
+ catchAll,
2076
+ found: confirmed + catchAll,
2077
+ notFound: Number(c.not_found || 0),
2078
+ free: Number(c.no_mail_domain || 0) + Number(c.retry_later || 0) + Number(c.invalid_input || 0),
2079
+ };
2080
+ }
2081
+ function findProgress(job) {
2082
+ const k = findCounts(job);
2083
+ return `${job.status || "queued"} ${Number(job.processed || 0).toLocaleString()}/${Number(job.total || 0).toLocaleString()} processed · ${k.found.toLocaleString()} found · ${fmtCredits(job.credits_charged)} charged`;
2084
+ }
2085
+ function findSummaryLines(job) {
2086
+ const k = findCounts(job);
2087
+ const lines = [
2088
+ ` ${Number(job.processed || 0).toLocaleString()}/${Number(job.total || 0).toLocaleString()} processed · ` +
2089
+ `${bold(k.found.toLocaleString())} found ${dim(`(${k.confirmed.toLocaleString()} confirmed, ${k.catchAll.toLocaleString()} unconfirmed catch-all)`)} · ` +
2090
+ `${k.notFound.toLocaleString()} not found · ${k.free.toLocaleString()} not charged`,
2091
+ ` ${fmtCredits(job.credits_charged)} charged` + (job.max_credits != null ? dim(` · cap ${fmtCredits(job.max_credits)}`) : ""),
2092
+ ];
2093
+ if (Number(job.duplicates_removed || 0) > 0) lines.push(dim(` ${Number(job.duplicates_removed).toLocaleString()} duplicate rows removed`));
2094
+ const bad = Array.isArray(job.invalid_rows) ? job.invalid_rows : [];
2095
+ if (bad.length) {
2096
+ lines.push(dim(` ${bad.length.toLocaleString()} rows skipped as invalid (row ${bad.slice(0, 10).join(", ")}${bad.length > 10 ? ", ..." : ""})`));
2097
+ }
2098
+ return lines;
2099
+ }
2100
+ function findPausedLines(job, id) {
2101
+ const r = job.paused_reason;
2102
+ const lines = [yellow(`Paused: ${FIND_PAUSED_REASONS[r] || (r ? `reason "${r}".` : "no reason given.")}`)];
2103
+ if (job.resume_after) lines.push(dim(`It can continue after ${job.resume_after}.`));
2104
+ lines.push(`Resume: argorant find resume ${id} ${r === "spend_limit" ? "--max-credits <new cap>" : "[--max-credits N]"}`);
2105
+ return lines;
2106
+ }
2107
+ function findPausedExit(reason) {
2108
+ if (reason === "insufficient_credits") return EXIT.UPGRADE;
2109
+ if (reason === "daily_limit" || reason === "miss_rate") return EXIT.RATE_LIMIT;
2110
+ return EXIT.OK; // spend_limit: the job stopped at the cap the caller set.
2111
+ }
2112
+ function findStatusTag(status) {
2113
+ const s = String(status || "").toLowerCase();
2114
+ if (s === "done") return green(s);
2115
+ if (s === "paused") return yellow(s);
2116
+ return dim(s || "unknown");
2117
+ }
2118
+
2119
+ async function findSingle(args, key) {
2120
+ const raw = String(args.domain || "").trim();
2121
+ const domain = normalizeDomain(raw.includes("@") ? raw.split("@").pop() : raw);
2122
+ const first = String(args.first || "").trim();
2123
+ const last = String(args.last || "").trim();
2124
+ const name = (args._.join(" ") || String(args.name || "")).trim();
2125
+ if (!domain && !first && !last && !name) die(FIND_USAGE);
2126
+ if (!domain) die(`--domain is required (the company the person works at).\n${FIND_USAGE}`);
2127
+ if (!domain.includes(".")) die(`--domain needs a company domain like acme.com (got "${raw}").`);
2128
+ if ((first || last) && name) die("pass either a full name or --first/--last, not both.");
2129
+ if (!first && !last && !name) die(`a name is required.\n${FIND_USAGE}`);
2130
+ if (args.output) warn("-o is for --file jobs; a single lookup prints its result.");
2131
+ if (args.maxCredits !== undefined) warn("--max-credits is for --file jobs; a single lookup costs at most 1 credit.");
2132
+ const body = first || last ? { first_name: first, last_name: last, domain } : { name, domain };
2133
+ const r = findNeed(await request("POST", args.base, `${FIND_API}/find`, { key, body }), "find", { expect: "status" });
2134
+ const gotEmail = Boolean(r.email);
2135
+ if (args.json) {
2136
+ console.log(JSON.stringify(r, null, 2));
2137
+ if (!gotEmail) process.exit(EXIT.ERROR);
2138
+ return;
2139
+ }
2140
+ const label = FIND_STATUS_LABELS[r.status] || r.status || "unknown";
2141
+ const tag = r.status === "valid" ? green(label) : r.status === "catch_all" ? yellow(label) : dim(label);
2142
+ const parts = [gotEmail ? bold(r.email) : dim("no email found"), tag];
2143
+ if (gotEmail && r.confidence !== undefined && r.confidence !== null) parts.push(`confidence ${r.confidence}`);
2144
+ parts.push(fmtCharge(r.credits_charged));
2145
+ console.log(parts.join(dim(" · ")));
2146
+ if (!gotEmail) process.exit(EXIT.ERROR);
2147
+ }
2148
+
2149
+ async function pollFindJob(args, key, job) {
2150
+ const id = job.job_id;
2151
+ const tty = Boolean(process.stdout.isTTY);
2152
+ 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}\`.`;
2153
+ const onInt = () => {
2154
+ if (tty && !args.json) process.stdout.write("\n");
2155
+ process.stderr.write(dim(`Stopped watching. ${recovery}\n`));
2156
+ process.exit(130);
2157
+ };
2158
+ process.once("SIGINT", onInt);
2159
+ const started = Date.now();
2160
+ let shown = "";
2161
+ let failures = 0;
2162
+ try {
2163
+ for (;;) {
2164
+ if (!args.json) {
2165
+ const line = findProgress(job);
2166
+ if (line !== shown) {
2167
+ if (tty) process.stdout.write(`\r\x1b[K${dim(line)}`);
2168
+ else console.log(line);
2169
+ shown = line;
2170
+ }
2171
+ }
2172
+ if (FIND_TERMINAL.includes(String(job.status || "").toLowerCase())) break;
2173
+ if (Date.now() - started > 1000 * 60 * 60 * 12) die(`\nstopped watching after 12 hours. ${recovery}`);
2174
+ await new Promise((r) => setTimeout(r, FIND_POLL_MS));
2175
+ let res;
2176
+ try {
2177
+ res = await request("GET", args.base, findJobPath(id), { key });
2178
+ } catch (e) {
2179
+ res = { error: e };
2180
+ }
2181
+ // Ride out short network blips and gateway errors; the job is server-side.
2182
+ if (res.error || res.status >= 500) {
2183
+ failures += 1;
2184
+ if (failures > 5) die(`\n${res.error ? res.error.message : `job status failed (HTTP ${res.status})`}. ${recovery}`);
2185
+ continue;
2186
+ }
2187
+ failures = 0;
2188
+ job = findNeed(res, "find job status", { expect: "status" });
2189
+ }
2190
+ } finally {
2191
+ process.removeListener("SIGINT", onInt);
2192
+ }
2193
+ if (tty && !args.json) process.stdout.write("\n");
2194
+ return job;
2195
+ }
2196
+
2197
+ async function findFile(args, key) {
2198
+ if (args._.length) die("pass either a name or --file, not both.");
2199
+ if (args.domain || args.first || args.last) {
2200
+ die("--domain/--first/--last are for a single lookup; with --file every row carries its own name and domain.");
2201
+ }
2202
+ const maxCredits = parseMaxCredits(args.maxCredits);
2203
+ let text;
2204
+ try {
2205
+ text = fs.readFileSync(args.file, "utf8");
2206
+ } catch {
2207
+ die(`cannot read file: ${args.file}`);
2208
+ }
2209
+ text = text.replace(/^/, "");
2210
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
2211
+ if (!lines.length) die("file is empty");
2212
+ // Local count for the prompt only; the server parses the file and reports
2213
+ // the real total, duplicates and invalid rows on the job.
2214
+ const rows = lines.length - 1;
2215
+ if (rows < 1) die("file has a header but no rows.");
2216
+ // A paid job must never die on a path problem after it was created.
2217
+ const destArg = args.output || FIND_DEFAULT_OUT;
2218
+ const dest = ensureWritable(destArg);
2219
+ // Interactive-only by design, like reveal/export: with --yes, --json or a
2220
+ // non-TTY stdin this spends credits with no prompt.
2221
+ if (!args.yes && !args.json && process.stdin.isTTY) {
2222
+ const p = findNeed(await request("GET", args.base, `${FIND_API}/pricing`, { key }), "find pricing");
2223
+ const pr = findPrices(p);
2224
+ if (!pr.known) warn("the server did not return prices in a known shape; showing the standard prices.");
2225
+ let max = rows * Math.max(pr.found, pr.catchAll, pr.notFound);
2226
+ if (maxCredits !== null) max = Math.min(max, maxCredits);
2227
+ const ans = await prompt(
2228
+ `Find emails for ${bold(rows.toLocaleString())} rows? ${fmtCredits(pr.found)} per email found, ` +
2229
+ `${fmtCredits(pr.notFound)} when nothing is found (maximum ${fmtCredits(max)}). [y/N] `
2230
+ );
2231
+ if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
2232
+ }
2233
+ const body = { csv: text };
2234
+ if (args.name) body.name = String(args.name).trim();
2235
+ if (maxCredits !== null) body.max_credits = maxCredits;
2236
+ const created = findNeed(await request("POST", args.base, `${FIND_API}/jobs`, { key, body }), "find job", { expect: "job_id" });
2237
+ const id = created.job_id;
2238
+ if (args.json) {
2239
+ // stdout stays pure JSON; the id still reaches the terminal for recovery.
2240
+ process.stderr.write(dim(`job ${id}\n`));
2241
+ } else {
2242
+ const extra = [];
2243
+ if (Number(created.duplicates_removed || 0) > 0) extra.push(`${created.duplicates_removed} duplicates removed`);
2244
+ if (Array.isArray(created.invalid_rows) && created.invalid_rows.length) extra.push(`${created.invalid_rows.length} invalid rows skipped`);
2245
+ console.log(
2246
+ green("✓") + ` Job ${bold(id)} created: ${Number(created.total ?? rows).toLocaleString()} rows` + (extra.length ? dim(` (${extra.join(", ")})`) : "")
2247
+ );
2248
+ console.log(dim("Ctrl-C stops watching; the job keeps running."));
2249
+ }
2250
+ const job = await pollFindJob(args, key, created);
2251
+ const status = String(job.status || "").toLowerCase();
2252
+ try {
2253
+ await downloadTo(args.base, findJobPath(id, "/results?format=csv"), key, dest);
2254
+ } catch (e) {
2255
+ die(`${e.message}\nThe job itself is saved. Download it again with \`argorant find download ${id} -o ${destArg}\`.`);
2256
+ }
2257
+ const exitCode = status === "paused" ? findPausedExit(job.paused_reason) : EXIT.OK;
2258
+ if (args.json) {
2259
+ console.log(JSON.stringify({ ...job, file: dest }, null, 2));
2260
+ if (exitCode !== EXIT.OK) process.exit(exitCode);
2261
+ return;
2262
+ }
2263
+ const head = status === "done" ? green("✓") + " Done" : status === "paused" ? yellow("Paused") : dim(status || "stopped");
2264
+ console.log(`${head} ${bold(id)}`);
2265
+ for (const l of findSummaryLines(job)) console.log(l);
2266
+ const saved = countCsvRows(dest);
2267
+ console.log(
2268
+ ` Saved ${saved !== null ? bold(saved.toLocaleString()) + " rows → " : ""}${bold(dest)}` +
2269
+ (status === "done" ? "" : dim(" (rows not processed yet show as pending)"))
2270
+ );
2271
+ if (status === "paused") for (const l of findPausedLines(job, id)) console.log(l);
2272
+ if (exitCode !== EXIT.OK) process.exit(exitCode);
2273
+ }
2274
+
2275
+ async function findStatus(args, key) {
2276
+ const id = findJobId(args, "status");
2277
+ const job = findNeed(await request("GET", args.base, findJobPath(id), { key }), "find job status", { expect: "status" });
2278
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
2279
+ const status = String(job.status || "").toLowerCase();
2280
+ console.log(`${bold("Job " + (job.job_id || id))}${job.name ? " " + dim(`"${job.name}"`) : ""} ${findStatusTag(status)}`);
2281
+ for (const l of findSummaryLines(job)) console.log(l);
2282
+ if (status === "paused") for (const l of findPausedLines(job, id)) console.log(" " + l);
2283
+ if (Number(job.processed || 0) > 0 || FIND_TERMINAL.includes(status)) {
2284
+ console.log(dim(` Download: argorant find download ${id} -o found.csv`));
2285
+ }
2286
+ }
2287
+
2288
+ async function findDownload(args, key) {
2289
+ const id = findJobId(args, "download");
2290
+ const dest = ensureWritable(args.output || FIND_DEFAULT_OUT);
2291
+ const job = findNeed(await request("GET", args.base, findJobPath(id), { key }), "find job status", { expect: "status" });
2292
+ await downloadTo(args.base, findJobPath(id, "/results?format=csv"), key, dest);
2293
+ const status = String(job.status || "").toLowerCase();
2294
+ if (args.json) return console.log(JSON.stringify({ ok: true, job_id: id, status, file: dest }, null, 2));
2295
+ const rows = countCsvRows(dest);
2296
+ console.log(green("✓") + ` Saved ${rows !== null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
2297
+ if (status !== "done") {
2298
+ console.log(dim(` Job is ${status || "not finished"}: ${Number(job.processed || 0).toLocaleString()}/${Number(job.total || 0).toLocaleString()} processed, the rest show as pending.`));
2299
+ }
2300
+ }
2301
+
2302
+ async function findResume(args, key) {
2303
+ const id = findJobId(args, "resume");
2304
+ const maxCredits = parseMaxCredits(args.maxCredits);
2305
+ const body = maxCredits !== null ? { max_credits: maxCredits } : {};
2306
+ const job = findNeed(await request("POST", args.base, findJobPath(id, "/resume"), { key, body }), "find job resume", { expect: "status" });
2307
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
2308
+ console.log(green("✓") + ` Resumed job ${bold(job.job_id || id)} ${findStatusTag(job.status)}`);
2309
+ for (const l of findSummaryLines(job)) console.log(l);
2310
+ console.log(dim(` Follow it with: argorant find status ${id}`));
2311
+ }
2312
+
2313
+ async function findCancel(args, key) {
2314
+ const id = findJobId(args, "cancel");
2315
+ const job = findNeed(await request("POST", args.base, findJobPath(id, "/cancel"), { key }), "find job cancel", { expect: "status" });
2316
+ if (args.json) return console.log(JSON.stringify(job, null, 2));
2317
+ console.log(green("✓") + ` Cancelled job ${bold(job.job_id || id)}. Remaining rows will not be looked up or charged.`);
2318
+ for (const l of findSummaryLines(job)) console.log(l);
2319
+ if (Number(job.processed || 0) > 0) console.log(dim(` Download what was found: argorant find download ${id} -o found.csv`));
2320
+ }
2321
+
2322
+ async function findPricing(args, key) {
2323
+ const p = findNeed(await request("GET", args.base, `${FIND_API}/pricing`, { key }), "find pricing");
2324
+ if (args.json) return console.log(JSON.stringify(p, null, 2));
2325
+ const pr = findPrices(p);
2326
+ if (!pr.known) warn("the server did not return prices in a known shape; showing the standard prices (see --json for the raw answer).");
2327
+ console.log(bold("Email finder prices"));
2328
+ console.log(` Email found and confirmed ${fmtCredits(pr.found)}`);
2329
+ console.log(` Catch-all domain, unconfirmed ${fmtCredits(pr.catchAll)}`);
2330
+ console.log(` Nothing found ${fmtCredits(pr.notFound)}`);
2331
+ console.log(` No mail server, try again later, invalid input ${green("free")}`);
2332
+ const b = p.balance || {};
2333
+ const bal = [];
2334
+ if (b.credits !== undefined && b.credits !== null) bal.push(`${bold(Number(b.credits).toLocaleString("en-US", { maximumFractionDigits: 2 }))} credits`);
2335
+ const pre = b.prepaid_no_result_lookups;
2336
+ if (pre !== undefined && pre !== null) bal.push(`${Number(pre).toLocaleString()} prepaid no-result lookup${Number(pre) === 1 ? "" : "s"}`);
2337
+ if (bal.length) console.log(`${bold("Balance")} ${bal.join(" · ")}`);
2338
+ const l = p.limits || {};
2339
+ const perMin = l.per_minute ?? l.single_per_minute ?? l.minute;
2340
+ const perDay = l.per_day ?? l.daily ?? l.daily_limit;
2341
+ const used = l.used_today;
2342
+ const lim = [];
2343
+ if (perMin !== undefined && perMin !== null) lim.push(`${Number(perMin).toLocaleString()} lookups per minute`);
2344
+ if (perDay !== undefined && perDay !== null) lim.push(`${Number(perDay).toLocaleString()} per day` + (used !== undefined && used !== null ? ` (${Number(used).toLocaleString()} used today)` : ""));
2345
+ if (lim.length) console.log(`${bold("Limits")} ${lim.join(" · ")}`);
2346
+ if (p.billing_exempt) console.log(dim("Lookups on this account are not charged."));
2347
+ 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."));
2348
+ }
2349
+
2350
+ async function cmdFind(argv) {
2351
+ const args = readFlags(argv, FIND_VALUE_FLAGS);
2352
+ const sub = String(args._[0] || "").toLowerCase();
2353
+ if (sub === "help" || (!args._.length && !args.file && !args.domain && !args.first && !args.last && !args.name)) {
2354
+ if (sub === "help") return console.log(FIND_USAGE);
2355
+ die(FIND_USAGE);
2356
+ }
2357
+ const subs = { status: findStatus, download: findDownload, resume: findResume, cancel: findCancel, pricing: findPricing, prices: findPricing };
2358
+ const key = requireKey();
2359
+ // A subcommand word only counts as one when no single-lookup flags are set,
2360
+ // so `find "Pricing" --domain x.com` still looks up a person.
2361
+ if (subs[sub] && !args.file && !args.domain && !args.first && !args.last) return subs[sub](args, key);
2362
+ if (args.file) return findFile(args, key);
2363
+ return findSingle(args, key);
2364
+ }
2365
+
1938
2366
  function money(cents, currency) {
1939
2367
  if (cents === null || cents === undefined) return "?";
1940
2368
  const sign = String(currency || "usd").toLowerCase() === "eur" ? "€" : "$";
@@ -1944,7 +2372,7 @@ function money(cents, currency) {
1944
2372
  function help() {
1945
2373
  const p = bold("argorant");
1946
2374
  console.log(`
1947
- ${bold("Argorant")} — verified B2B contacts from your terminal ${dim("v" + VERSION)}
2375
+ ${bold("Argorant")} - verified B2B contacts from your terminal ${dim("v" + VERSION)}
1948
2376
 
1949
2377
  ${bold("USAGE")}
1950
2378
  ${p} <command> "<query>" [filters]
@@ -1968,6 +2396,11 @@ ${bold("COMMANDS")}
1968
2396
  ${cyan("list status")} <id> Show a saved list's size ${dim("(free)")}
1969
2397
  ${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
1970
2398
  ${cyan("verify")} --file emails.csv -o out.csv Bulk-verify your own list ${dim("(recent re-checks free)")}
2399
+ ${cyan("find")} "<first last>" --domain <d> Find a person's work email ${dim("(1 credit found, 0.25 credit nothing found)")}
2400
+ ${cyan("find")} --first <f> --last <l> --domain <d> Same, with the name in two parts
2401
+ ${cyan("find")} --file people.csv -o found.csv Find emails for a list ${dim("(same prices; --max-credits caps the job)")}
2402
+ ${cyan("find")} status | download | resume | cancel <job_id> Manage a find job ${dim("(free)")}
2403
+ ${cyan("find pricing")} Finder prices, balance, limits ${dim("(free)")}
1971
2404
  ${cyan("campaigns")} ... Email outreach: create, write, enroll, launch ${dim("(argorant campaigns help)")}
1972
2405
  ${cyan("inboxes")} list | connect-google Connected mailboxes; connect a Google Workspace
1973
2406
  ${cyan("inboxes")} quote | order | status | renewal New mailboxes on new domains ${dim("(saved card or payment link)")}
@@ -1991,20 +2424,23 @@ ${bold("OPTIONS")}
1991
2424
  --json Raw JSON output -y, --yes Skip confirmations
1992
2425
  --base <url> Override API base (or ARGORANT_API_BASE)
1993
2426
  --batch Treat the id in \`export status/download\` as a batch id
2427
+ --max-credits <n> Credit cap for a \`find --file\` job (it pauses at the cap)
1994
2428
  --grade <g> valid (default) or valid-plus-catchall - which deliverable
1995
2429
  grade to include on reveal/export. You only ever pay for
1996
2430
  deliverable contacts; this is the one grade distinction
1997
2431
  exposed anywhere. ${dim("(coming soon - currently a no-op; see docs)")}
1998
2432
 
1999
2433
  ${bold("NON-INTERACTIVE USE")} ${dim("(agents, CI, pipes)")}
2000
- ${red("reveal, export, and verify --file SPEND CREDITS WITHOUT A PROMPT")} whenever
2434
+ ${red("reveal, export, verify --file, and find SPEND CREDITS WITHOUT A PROMPT")} whenever
2001
2435
  stdin is not a TTY, or when --yes / --json is passed. The confirmation is a
2002
2436
  convenience for humans at a terminal, never a safety net. Check your -n.
2437
+ A single \`find\` never asks: each lookup costs at most 1 credit.
2003
2438
 
2004
2439
  ${bold("EXIT CODES")}
2005
2440
  0 ok · 1 error · 2 not authenticated · 3 forbidden (missing scope)
2006
2441
  4 rate limit / daily quota · 5 plan upgrade required
2007
2442
  ${dim("`enrich` exits 1 when nothing matched, so scripts can branch without parsing the payload.")}
2443
+ ${dim("`find` exits 1 when no email came back; a paused `find --file` job exits 5 (out of credits) or 4 (daily limit).")}
2008
2444
 
2009
2445
  ${bold("EXAMPLES")}
2010
2446
  ${p} count "fintech CFOs in germany"
@@ -2017,6 +2453,8 @@ ${bold("EXAMPLES")}
2017
2453
  ${p} enrich --domain stripe.com
2018
2454
  ${p} verify ceo@stripe.com
2019
2455
  ${p} verify --file my-list.csv -o verified.csv
2456
+ ${p} find "Patrick Collison" --domain stripe.com
2457
+ ${p} find --file people.csv -o found.csv --max-credits 200
2020
2458
 
2021
2459
  Docs: ${cyan("https://argorant.com/docs/cli")}
2022
2460
  `);
@@ -2027,12 +2465,13 @@ async function main() {
2027
2465
  const cmd = argv[0];
2028
2466
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help();
2029
2467
  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
2468
+ // `campaigns` and `find` have their own flag vocabulary (--step, --count, --first, --max-credits, ...)
2469
+ // handled by readFlags - it never goes through the generic filter parser
2032
2470
  // below, which would reject those flags as unknown.
2033
- if (cmd === "campaigns" || cmd === "campaign" || cmd === "inboxes" || cmd === "mailboxes" || cmd === "inbox" || cmd === "unibox" || cmd === "blocklist") {
2471
+ if (cmd === "campaigns" || cmd === "campaign" || cmd === "inboxes" || cmd === "mailboxes" || cmd === "inbox" || cmd === "unibox" || cmd === "blocklist" || cmd === "find") {
2034
2472
  try {
2035
2473
  if (cmd.startsWith("campaign")) await cmdCampaigns(argv.slice(1));
2474
+ else if (cmd === "find") await cmdFind(argv.slice(1));
2036
2475
  else if (cmd === "blocklist") await cmdBlocklist(argv.slice(1));
2037
2476
  else if (cmd === "inbox" || cmd === "unibox") await cmdInboxThreads(argv.slice(1));
2038
2477
  else await cmdInboxes(argv.slice(1));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "argorant",
3
- "version": "0.10.0",
4
- "description": "Search, count, reveal, export, and verify B2B contacts from the Argorant database — from your terminal, scripts, or coding agent.",
3
+ "version": "0.11.0",
4
+ "description": "Search, count, reveal, export, and verify B2B contacts from the Argorant database, and find work emails by name and company domain, from your terminal, scripts, or coding agent.",
5
5
  "bin": {
6
6
  "argorant": "bin/argorant.js"
7
7
  },
@@ -19,6 +19,7 @@
19
19
  "leads",
20
20
  "prospecting",
21
21
  "email-verification",
22
+ "email-finder",
22
23
  "sales",
23
24
  "contacts",
24
25
  "cli"