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.
- package/README.md +100 -17
- package/bin/argorant.js +631 -64
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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_
|
|
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
|
|
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
|
|
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 || "
|
|
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 || "
|
|
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
|
|
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`
|
|
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 || "
|
|
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 || "
|
|
550
|
-
console.log(` ${(lead.company || "
|
|
551
|
-
console.log(` ${cyan(lead.email || "
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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"}
|
|
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"}
|
|
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>`
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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 + "”")}
|
|
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
|
|
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/*
|
|
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/*
|
|
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
|
|
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 ...`
|
|
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
|
|
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
|
|
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 || "
|
|
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
|
|
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
|
|
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 || "
|
|
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)}
|
|
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)}
|
|
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")}
|
|
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")}
|
|
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,
|
|
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`
|
|
2031
|
-
// handled by readFlags
|
|
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));
|