argorant 0.11.0 → 0.13.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 +87 -3
- package/bin/argorant.js +1093 -72
- package/package.json +1 -1
package/bin/argorant.js
CHANGED
|
@@ -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);
|
|
@@ -178,6 +181,39 @@ function parseArgs(argv) {
|
|
|
178
181
|
return out;
|
|
179
182
|
}
|
|
180
183
|
|
|
184
|
+
// ---- Idempotency-Key on every write to /api/v1 ----
|
|
185
|
+
// A retried command must never do the work twice (a second export, a second
|
|
186
|
+
// reply, a second charge). Every POST/PUT/PATCH/DELETE to /api/v1 carries a
|
|
187
|
+
// key: a fresh random one by default, or the one given with
|
|
188
|
+
// --idempotency-key. With an explicit key the first write of the command uses
|
|
189
|
+
// it as given and later writes in the same command use <key>-2, <key>-3, so
|
|
190
|
+
// running the same command again with the same key replays every step.
|
|
191
|
+
const IDEM = { override: null, count: 0 };
|
|
192
|
+
const IDEM_KEY_RE = /^[A-Za-z0-9._:-]{1,120}$/;
|
|
193
|
+
function nextIdempotencyKey() {
|
|
194
|
+
IDEM.count += 1;
|
|
195
|
+
if (IDEM.override) return IDEM.count === 1 ? IDEM.override : `${IDEM.override}-${IDEM.count}`;
|
|
196
|
+
return crypto.randomUUID();
|
|
197
|
+
}
|
|
198
|
+
// Pull --idempotency-key out of argv before any parser sees it (every command
|
|
199
|
+
// accepts it). Returns argv without the flag.
|
|
200
|
+
function takeIdempotencyFlag(argv) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (let i = 0; i < argv.length; i++) {
|
|
203
|
+
const a = argv[i];
|
|
204
|
+
let v = null;
|
|
205
|
+
if (a === "--idempotency-key") { v = flagValue(argv, i, a); i += 1; }
|
|
206
|
+
else if (a.startsWith("--idempotency-key=")) v = a.slice("--idempotency-key=".length);
|
|
207
|
+
else { out.push(a); continue; }
|
|
208
|
+
if (!IDEM_KEY_RE.test(v)) die(`--idempotency-key may use letters, digits and . _ : - (1 to 120 characters), got "${v}".`);
|
|
209
|
+
IDEM.override = v;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function isV1Write(method, pathname) {
|
|
214
|
+
return !["GET", "HEAD", "OPTIONS"].includes(String(method).toUpperCase()) && /^\/api\/v1(\/|$)/.test(pathname);
|
|
215
|
+
}
|
|
216
|
+
|
|
181
217
|
// ---- HTTP ----
|
|
182
218
|
function request(method, base, urlPath, { key, query, body, headers: extraHeaders } = {}) {
|
|
183
219
|
return new Promise((resolve, reject) => {
|
|
@@ -195,47 +231,92 @@ function request(method, base, urlPath, { key, query, body, headers: extraHeader
|
|
|
195
231
|
const payload = body ? Buffer.from(JSON.stringify(body)) : null;
|
|
196
232
|
const headers = { Accept: "application/json", "User-Agent": `argorant-cli/${VERSION}`, ...(extraHeaders || {}) };
|
|
197
233
|
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
234
|
+
// Lets the server's purchase audit say a purchase came from the command line.
|
|
235
|
+
if (/^\/api\/v1(\/|$)/.test(u.pathname)) headers["X-Argorant-Channel"] = "cli";
|
|
236
|
+
if (isV1Write(method, u.pathname)) {
|
|
237
|
+
const own = Object.keys(headers).find((h) => h.toLowerCase() === "idempotency-key");
|
|
238
|
+
if (!own || IDEM.override) {
|
|
239
|
+
if (own) delete headers[own];
|
|
240
|
+
headers["Idempotency-Key"] = nextIdempotencyKey();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
198
243
|
if (payload) {
|
|
199
244
|
headers["Content-Type"] = "application/json";
|
|
200
245
|
headers["Content-Length"] = payload.length;
|
|
201
246
|
}
|
|
202
247
|
const lib = u.protocol === "http:" ? http : https;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
248
|
+
// The per-minute request limit: wait for the time the server names and send
|
|
249
|
+
// the SAME request again (same Idempotency-Key, so a write never runs twice).
|
|
250
|
+
// Longer waits (a daily limit) are not waited out; they surface as exit 4.
|
|
251
|
+
const send = (attempt) => {
|
|
252
|
+
const req = lib.request(
|
|
253
|
+
u,
|
|
254
|
+
{ method, headers },
|
|
255
|
+
(res) => {
|
|
256
|
+
const chunks = [];
|
|
257
|
+
res.on("data", (d) => chunks.push(d));
|
|
258
|
+
res.on("end", () => {
|
|
259
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
260
|
+
let json = null;
|
|
261
|
+
try {
|
|
262
|
+
json = raw ? JSON.parse(raw) : null;
|
|
263
|
+
} catch {
|
|
264
|
+
/* non-JSON (e.g. CSV download) */
|
|
265
|
+
}
|
|
266
|
+
const wait = res.statusCode === 429 ? retryWaitSeconds(res.headers["retry-after"], attempt) : null;
|
|
267
|
+
if (wait !== null) {
|
|
268
|
+
warn(`too many requests this minute; waiting ${wait} second${wait === 1 ? "" : "s"}, then trying again.`);
|
|
269
|
+
setTimeout(() => send(attempt + 1), wait * 1000);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
resolve({ status: res.statusCode, json, raw, res });
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
req.on("error", reject);
|
|
277
|
+
if (payload) req.write(payload);
|
|
278
|
+
req.end();
|
|
279
|
+
};
|
|
280
|
+
send(0);
|
|
224
281
|
});
|
|
225
282
|
}
|
|
226
283
|
|
|
284
|
+
// How long to wait before trying a 429 again, or null to give up and report it.
|
|
285
|
+
// At most RATE_RETRY.attempts tries, each wait at most RATE_RETRY.maxWait
|
|
286
|
+
// seconds (ARGORANT_MAX_RETRY_WAIT; 0 switches the retry off).
|
|
287
|
+
const RATE_RETRY = {
|
|
288
|
+
attempts: 3,
|
|
289
|
+
maxWait: (() => {
|
|
290
|
+
const v = Number(process.env.ARGORANT_MAX_RETRY_WAIT);
|
|
291
|
+
return Number.isFinite(v) && v >= 0 ? v : 65;
|
|
292
|
+
})(),
|
|
293
|
+
};
|
|
294
|
+
function retryWaitSeconds(header, attempt) {
|
|
295
|
+
if (attempt >= RATE_RETRY.attempts || RATE_RETRY.maxWait <= 0) return null;
|
|
296
|
+
const n = Math.ceil(Number(header));
|
|
297
|
+
if (!Number.isFinite(n) || n < 0 || n > RATE_RETRY.maxWait) return null;
|
|
298
|
+
return Math.max(1, n);
|
|
299
|
+
}
|
|
300
|
+
|
|
227
301
|
function downloadTo(base, urlPath, key, dest) {
|
|
228
302
|
return new Promise((resolve, reject) => {
|
|
229
303
|
const u = new URL(urlPath, base);
|
|
230
304
|
const lib = u.protocol === "http:" ? http : https;
|
|
231
305
|
const req = lib.request(
|
|
232
306
|
u,
|
|
233
|
-
{ method: "GET", headers: { Authorization: `Bearer ${key}`, "User-Agent": `argorant-cli/${VERSION}
|
|
307
|
+
{ method: "GET", headers: { Authorization: `Bearer ${key}`, "User-Agent": `argorant-cli/${VERSION}`, ...(/^\/api\/v1(\/|$)/.test(u.pathname) ? { "X-Argorant-Channel": "cli" } : {}) } },
|
|
234
308
|
(res) => {
|
|
235
309
|
if (res.statusCode !== 200) {
|
|
236
310
|
const chunks = [];
|
|
237
311
|
res.on("data", (d) => chunks.push(d));
|
|
238
|
-
res.on("end", () =>
|
|
312
|
+
res.on("end", () => {
|
|
313
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
314
|
+
let json = null;
|
|
315
|
+
try { json = raw ? JSON.parse(raw) : null; } catch { /* plain text */ }
|
|
316
|
+
const msg = errorMessage(json) || raw.slice(0, 300);
|
|
317
|
+
const rid = requestIdOf({ res, json });
|
|
318
|
+
reject(new Error(`download failed (HTTP ${res.statusCode}): ${msg}${rid ? ` (request id ${rid})` : ""}`));
|
|
319
|
+
});
|
|
239
320
|
return;
|
|
240
321
|
}
|
|
241
322
|
const file = fs.createWriteStream(dest);
|
|
@@ -276,26 +357,82 @@ function detailMsg(detail) {
|
|
|
276
357
|
}
|
|
277
358
|
return JSON.stringify(detail);
|
|
278
359
|
}
|
|
360
|
+
// Errors from /api/v1 come as one envelope:
|
|
361
|
+
// {"error": {"code", "message", "details"}, "request_id": "req_...", "detail": <old value>}
|
|
362
|
+
// Older endpoints answer {"detail": ...} or a bare {"error": "<code>", "message"}.
|
|
363
|
+
// errorMessage() reads all three; the envelope wins when present.
|
|
364
|
+
function errorMessage(json) {
|
|
365
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return null;
|
|
366
|
+
const e = json.error;
|
|
367
|
+
if (e && typeof e === "object" && !Array.isArray(e)) {
|
|
368
|
+
let msg = e.message ? String(e.message) : e.code ? String(e.code).replace(/_/g, " ") : null;
|
|
369
|
+
const d = e.details && typeof e.details === "object" ? e.details : {};
|
|
370
|
+
const blockers = Array.isArray(d.blockers) ? d.blockers : [];
|
|
371
|
+
if (blockers.length) {
|
|
372
|
+
msg = (msg || "blocked") + "\n" + blockers.map((b) => ` · ${typeof b === "string" ? b : b && (b.message || b.label) ? b.message || b.label : JSON.stringify(b)}`).join("\n");
|
|
373
|
+
}
|
|
374
|
+
const fields = Array.isArray(d.fields) ? d.fields : [];
|
|
375
|
+
if (fields.length && !blockers.length) {
|
|
376
|
+
const named = fields
|
|
377
|
+
.map((f) => (typeof f === "string" ? f : f && (f.field || f.name) ? `${f.field || f.name}${f.message ? `: ${f.message}` : ""}` : null))
|
|
378
|
+
.filter(Boolean);
|
|
379
|
+
if (named.length && !(msg || "").includes(named[0])) msg = `${msg || "invalid request"} (${named.join("; ")})`;
|
|
380
|
+
}
|
|
381
|
+
if (msg) return msg;
|
|
382
|
+
}
|
|
383
|
+
if (typeof e === "string" && json.message) return String(json.message);
|
|
384
|
+
return detailMsg(json.detail) || (typeof e === "string" ? e.replace(/_/g, " ") : null);
|
|
385
|
+
}
|
|
386
|
+
function errorCode(json) {
|
|
387
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return null;
|
|
388
|
+
const e = json.error;
|
|
389
|
+
if (e && typeof e === "object" && !Array.isArray(e)) return e.code ? String(e.code) : null;
|
|
390
|
+
if (typeof e === "string") return e;
|
|
391
|
+
const d = json.detail;
|
|
392
|
+
if (d && typeof d === "object" && !Array.isArray(d)) return d.error || d.code || null;
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
// The id the server gave this request (header X-Request-Id, or request_id in
|
|
396
|
+
// the error body). Printed with every error so a user can quote it to support.
|
|
397
|
+
function requestIdOf(res) {
|
|
398
|
+
const h = res && res.res && res.res.headers;
|
|
399
|
+
const fromHeader = h && (h["x-request-id"] || h["X-Request-Id"]);
|
|
400
|
+
return String(fromHeader || (res && res.json && typeof res.json === "object" && res.json.request_id) || "") || null;
|
|
401
|
+
}
|
|
402
|
+
|
|
279
403
|
// Exit codes are part of the CLI's contract with agents/CI:
|
|
280
404
|
// 0 ok · 1 generic failure · 2 not authenticated · 3 forbidden (scope)
|
|
281
405
|
// 4 rate limit / daily quota · 5 plan upgrade required (402)
|
|
282
406
|
const EXIT = { OK: 0, ERROR: 1, AUTH: 2, FORBIDDEN: 3, RATE_LIMIT: 4, UPGRADE: 5 };
|
|
283
407
|
|
|
284
408
|
function need(res, what) {
|
|
409
|
+
if (res.status < 400) return res.json || {};
|
|
285
410
|
const detail = res.json && res.json.detail;
|
|
286
|
-
|
|
411
|
+
const msg = errorMessage(res.json);
|
|
412
|
+
const code = errorCode(res.json);
|
|
413
|
+
const rid = requestIdOf(res);
|
|
414
|
+
const ref = rid ? `\nRequest id: ${rid} (quote it if you contact support)` : "";
|
|
415
|
+
if (res.status === 401) {
|
|
416
|
+
const lead = code && code !== "unauthorized" && msg ? `${msg}\n` : "";
|
|
417
|
+
die(`${lead}not authenticated. Run \`argorant login\` or set ARGORANT_API_KEY.${ref}`, EXIT.AUTH);
|
|
418
|
+
}
|
|
287
419
|
if (res.status === 402) {
|
|
288
420
|
// Distinct from a hard error: nothing is broken, the plan just doesn't
|
|
289
421
|
// include this. Agents branch on exit 5 to surface an upgrade, not a bug.
|
|
290
422
|
const d = detail && typeof detail === "object" && !Array.isArray(detail) ? detail : {};
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
423
|
+
const details = res.json && res.json.error && typeof res.json.error === "object" ? res.json.error.details || {} : {};
|
|
424
|
+
const text = msg || `${what} requires a paid Argorant plan.`;
|
|
425
|
+
const url = details.upgrade_url || d.upgrade_url || d.url || "https://argorant.com/pricing";
|
|
426
|
+
die(`${text}${text.includes(url) ? "" : `\nUpgrade: ${url}`}${ref}`, EXIT.UPGRADE);
|
|
427
|
+
}
|
|
428
|
+
if (res.status === 403) die(`${msg || `forbidden - your key lacks the scope for ${what}.`}${ref}`, EXIT.FORBIDDEN);
|
|
429
|
+
if (res.status === 429) {
|
|
430
|
+
const h = res.res && res.res.headers;
|
|
431
|
+
const ra = Number((h && h["retry-after"]) || 0);
|
|
432
|
+
const wait = Number.isFinite(ra) && ra > 0 ? ` Try again in ${ra === 1 ? "1 second" : fmtWait(Math.ceil(ra))}.` : "";
|
|
433
|
+
die(`${msg || "too many requests right now, or today's limit is used up."}${wait}${ref}`, EXIT.RATE_LIMIT);
|
|
294
434
|
}
|
|
295
|
-
|
|
296
|
-
if (res.status === 429) die(detailMsg(detail) || "rate limit / daily quota reached.", EXIT.RATE_LIMIT);
|
|
297
|
-
if (res.status >= 400) die(detailMsg(detail) || `${what} failed (HTTP ${res.status}).`);
|
|
298
|
-
return res.json || {};
|
|
435
|
+
die(`${msg || `${what} failed (HTTP ${res.status}).`}${ref}`);
|
|
299
436
|
}
|
|
300
437
|
|
|
301
438
|
// A paid job must never die on a path problem AFTER it was billed: check the
|
|
@@ -554,22 +691,107 @@ async function cmdSample(args) {
|
|
|
554
691
|
console.log(dim(`These 25 are the sample; the full pool contains ${Number(job.total_companies || 0).toLocaleString()} companies.`));
|
|
555
692
|
}
|
|
556
693
|
|
|
694
|
+
// ---- phone numbers (opt-in, --phones) ----
|
|
695
|
+
const PHONE_FIELDS = ["phone", "phone_1", "phone_2", "phones_all", "additional_phones", "phone_masked"];
|
|
696
|
+
function stripPhones(rec) {
|
|
697
|
+
if (rec && typeof rec === "object") for (const k of PHONE_FIELDS) delete rec[k];
|
|
698
|
+
return rec;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The price and whether the plan includes phone numbers, before anything is spent. An older server
|
|
702
|
+
// without the pricing route falls back to the published price.
|
|
703
|
+
async function phonePricing(args, key) {
|
|
704
|
+
try {
|
|
705
|
+
const res = await request("GET", args.base, "/api/v1/people/phones/pricing", { key });
|
|
706
|
+
if (res.status === 200 && res.json) return res.json;
|
|
707
|
+
} catch {
|
|
708
|
+
/* fall back below */
|
|
709
|
+
}
|
|
710
|
+
return { price_per_phone: 10, plan_includes_phones: null };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function phonePriceText(pricing) {
|
|
714
|
+
const price = Number((pricing && pricing.price_per_phone) || 10);
|
|
715
|
+
return `Phone numbers cost ${price} credits each, only when a number is found. Numbers you already have are free.`;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Phone numbers for people already revealed (POST /api/v1/people/phones). A refusal never undoes the
|
|
719
|
+
// emails that were just revealed: it comes back as {included:false, message}.
|
|
720
|
+
async function addPhones(args, key, records) {
|
|
721
|
+
const ids = [];
|
|
722
|
+
for (const r of records) {
|
|
723
|
+
const id = String(r.person_id || r.record_id || "").trim();
|
|
724
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
725
|
+
}
|
|
726
|
+
records.forEach(stripPhones);
|
|
727
|
+
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." };
|
|
728
|
+
const res = await request("POST", args.base, "/api/v1/people/phones", {
|
|
729
|
+
key,
|
|
730
|
+
body: { record_ids: ids.slice(0, 100), channel: "cli" },
|
|
731
|
+
headers: { "X-Argorant-Channel": "cli" },
|
|
732
|
+
});
|
|
733
|
+
if (res.status !== 200 || !res.json) {
|
|
734
|
+
const detail = res.json && res.json.detail;
|
|
735
|
+
// The error envelope's stable code first, then the older detail.error (servers before the envelope).
|
|
736
|
+
const code = errorCode(res.json);
|
|
737
|
+
let message = errorMessage(res.json) || detailMsg(detail) || `phone numbers failed (HTTP ${res.status}).`;
|
|
738
|
+
if (code === "phones_not_in_plan") message = "Your plan does not include phone numbers, so none were added and nothing was charged for them.";
|
|
739
|
+
else if (code === "insufficient_credits") message = "Not enough credits for a phone number, so none were added and nothing was charged for them.";
|
|
740
|
+
else if (code === "mcp_quota_exceeded" || code === "daily_limit_reached") message = "Today's reveal limit is used up, so no phone numbers were added. It resets at midnight UTC.";
|
|
741
|
+
for (const r of records) r.phone_status = "not_added";
|
|
742
|
+
return { included: false, status: res.status, error: code || "error", message, summary: message };
|
|
743
|
+
}
|
|
744
|
+
const byId = new Map((res.json.results || []).map((row) => [String(row.record_id), row]));
|
|
745
|
+
for (const r of records) {
|
|
746
|
+
const row = byId.get(String(r.person_id || r.record_id || "")) || {};
|
|
747
|
+
r.phone = row.phone || null;
|
|
748
|
+
if (row.additional_phones && row.additional_phones.length) r.additional_phones = row.additional_phones;
|
|
749
|
+
r.phone_status = row.phone_status || "not_found";
|
|
750
|
+
r.phone_credits_charged = Number(row.credits_charged || 0);
|
|
751
|
+
}
|
|
752
|
+
return { included: true, ...res.json, results: undefined };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function phoneLine(p) {
|
|
756
|
+
if (!p || !p.phone_status) return null;
|
|
757
|
+
if (p.phone) {
|
|
758
|
+
const extra = p.additional_phones && p.additional_phones.length ? dim(` (+${p.additional_phones.length} more)`) : "";
|
|
759
|
+
const cost = p.phone_credits_charged ? dim(` · ${p.phone_credits_charged} credits`) : p.phone_status === "already_unlocked" ? dim(" · already yours") : "";
|
|
760
|
+
return `${p.phone}${extra}${cost}`;
|
|
761
|
+
}
|
|
762
|
+
if (p.phone_status === "none_found" || p.phone_status === "not_found") return dim("no phone number on record · not charged");
|
|
763
|
+
if (p.phone_status === "not_enough_credits") return dim("phone not added · not enough credits");
|
|
764
|
+
if (p.phone_status === "not_in_plan") return dim("phone not added · not in your plan");
|
|
765
|
+
if (p.phone_status === "daily_limit_reached") return dim("phone not added · daily limit reached");
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
|
|
557
769
|
async function cmdReveal(args) {
|
|
558
770
|
const key = requireKey();
|
|
559
771
|
warnExcludeTitleGap(args.filters);
|
|
560
772
|
if (args.gradeExplicit) warnGradeGap("reveal");
|
|
561
773
|
const limit = args.limit || 10;
|
|
774
|
+
const pricing = args.phones ? await phonePricing(args, key) : null;
|
|
775
|
+
if (pricing && pricing.plan_includes_phones === false) {
|
|
776
|
+
warn("Your plan does not include phone numbers. Emails are revealed as usual; no phone numbers will be added or charged.");
|
|
777
|
+
}
|
|
562
778
|
// Confirmation is interactive-only by design: with --yes, --json, or a
|
|
563
779
|
// non-TTY stdin (CI, agents, pipes) this spends credits with no prompt.
|
|
564
780
|
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
565
|
-
const
|
|
781
|
+
const phonesText = args.phones ? ` ${phonePriceText(pricing)}` : "";
|
|
782
|
+
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
783
|
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
784
|
+
} else if (args.phones && !args.json) {
|
|
785
|
+
process.stderr.write(dim(phonePriceText(pricing) + "\n"));
|
|
567
786
|
}
|
|
568
787
|
// Sent for forward-compatibility: the platform has no per-request grade
|
|
569
788
|
// control on reveal yet (see GODMODE-PLAN.md), so this is a no-op today.
|
|
570
789
|
const query = { ...args.filters, limit, grade: args.grade === "valid-plus-catchall" ? "valid_plus_catchall" : "valid" };
|
|
571
790
|
const res = await request("GET", args.base, "/api/mcp/people/reveal", { key, query });
|
|
572
791
|
const r = need(res, "reveal");
|
|
792
|
+
const people = (r.results || []).filter((x) => x && typeof x === "object");
|
|
793
|
+
if (args.phones && people.length) r.phones = await addPhones(args, key, people);
|
|
794
|
+
else people.forEach(stripPhones);
|
|
573
795
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
574
796
|
console.log(dim(`${Number(r.total).toLocaleString()} total · revealed ${r.returned}`));
|
|
575
797
|
for (const p of r.results || []) {
|
|
@@ -578,8 +800,16 @@ async function cmdReveal(args) {
|
|
|
578
800
|
const name = p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") || p.name;
|
|
579
801
|
const who = [name, p.title].filter(Boolean).join(" · ");
|
|
580
802
|
console.log(` ${bold(who || "-")}`);
|
|
581
|
-
const bits = [p.email && cyan(p.email), p.
|
|
803
|
+
const bits = [p.email && cyan(p.email), p.linkedin_url, [p.company || p.company_name, p.country].filter(Boolean).join(", ")].filter(Boolean);
|
|
582
804
|
if (bits.length) console.log(" " + bits.join(dim(" · ")));
|
|
805
|
+
const phone = args.phones ? phoneLine(p) : null;
|
|
806
|
+
if (phone) console.log(" " + phone);
|
|
807
|
+
}
|
|
808
|
+
const charged = Number(r.charged || 0);
|
|
809
|
+
console.log(dim(`${charged} credit${charged === 1 ? "" : "s"} charged for emails`));
|
|
810
|
+
if (r.phones) {
|
|
811
|
+
if (r.phones.included) console.log(dim(r.phones.summary || `${r.phones.credits_charged || 0} credits charged for phone numbers`));
|
|
812
|
+
else warn(r.phones.message || "No phone numbers were added.");
|
|
583
813
|
}
|
|
584
814
|
}
|
|
585
815
|
|
|
@@ -622,16 +852,30 @@ async function cmdEnrich(args) {
|
|
|
622
852
|
else if (name) { body.name = name; body.domain = domain; }
|
|
623
853
|
else body.domain = domain;
|
|
624
854
|
const personMode = Boolean(body.email || body.name);
|
|
855
|
+
if (args.phones && !personMode) warn("Phone numbers are for people. A company lookup returns the company profile only.");
|
|
856
|
+
const wantPhones = args.phones && personMode;
|
|
857
|
+
const pricing = wantPhones ? await phonePricing(args, key) : null;
|
|
858
|
+
if (pricing && pricing.plan_includes_phones === false) {
|
|
859
|
+
warn("Your plan does not include phone numbers. The email lookup runs as usual; no phone number will be added or charged.");
|
|
860
|
+
}
|
|
625
861
|
// Company mode is free, so it never asks. Person mode is billed exactly like
|
|
626
862
|
// a reveal, so it gets the same interactive-only confirmation as `reveal`.
|
|
627
863
|
if (personMode && !args.yes && !args.json && process.stdin.isTTY) {
|
|
628
864
|
const ans = await prompt(
|
|
629
|
-
`Enrich this contact? A match costs 1 credit; a miss and a non-deliverable address are free
|
|
865
|
+
`Enrich this contact? A match costs 1 credit; a miss and a non-deliverable address are free.` +
|
|
866
|
+
(wantPhones ? ` ${phonePriceText(pricing)}` : "") + ` [y/N] `
|
|
630
867
|
);
|
|
631
868
|
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
869
|
+
} else if (wantPhones && !args.json) {
|
|
870
|
+
process.stderr.write(dim(phonePriceText(pricing) + "\n"));
|
|
632
871
|
}
|
|
633
872
|
const res = await request("POST", args.base, "/api/v1/enrich", { key, body });
|
|
634
873
|
const r = need(res, "enrich");
|
|
874
|
+
if (r && r.type === "person" && r.person && typeof r.person === "object") {
|
|
875
|
+
if (!r.person.person_id && r.person_id) r.person.person_id = r.person_id;
|
|
876
|
+
if (wantPhones && r.found) r.phones = await addPhones(args, key, [r.person]);
|
|
877
|
+
else stripPhones(r.person);
|
|
878
|
+
}
|
|
635
879
|
if (args.json) {
|
|
636
880
|
console.log(JSON.stringify(r, null, 2));
|
|
637
881
|
if (!r.found) process.exit(EXIT.ERROR);
|
|
@@ -656,11 +900,13 @@ async function cmdEnrich(args) {
|
|
|
656
900
|
console.log(` ${bold(who || "-")}`);
|
|
657
901
|
const bits = [
|
|
658
902
|
p.email && cyan(p.email),
|
|
659
|
-
p.phone,
|
|
660
903
|
p.linkedin_url,
|
|
661
904
|
[p.current_company_name, p.current_company_domain, p.country].filter(Boolean).join(", "),
|
|
662
905
|
].filter(Boolean);
|
|
663
906
|
if (bits.length) console.log(" " + bits.join(dim(" · ")));
|
|
907
|
+
const phone = wantPhones ? phoneLine(p) : null;
|
|
908
|
+
if (phone) console.log(" " + phone);
|
|
909
|
+
if (r.phones && !r.phones.included) warn(r.phones.message || "No phone number was added.");
|
|
664
910
|
if (r.deliverable === false) {
|
|
665
911
|
console.log(" " + dim(r.message || "This address did not pass live verification, so nothing was charged."));
|
|
666
912
|
}
|
|
@@ -707,7 +953,20 @@ async function pollExportBatch(args, key, statusPath, { quiet = false } = {}) {
|
|
|
707
953
|
}
|
|
708
954
|
}
|
|
709
955
|
|
|
710
|
-
|
|
956
|
+
// Paths of the export endpoints. `export` keeps the paths it always used;
|
|
957
|
+
// `exports` (0.13.0) uses the same operations under /api/v1.
|
|
958
|
+
const EXPORT_API_MCP = {
|
|
959
|
+
job: (id) => `/api/mcp/exports/${id}`,
|
|
960
|
+
batch: (id) => `/api/mcp/export-batches/${id}`,
|
|
961
|
+
download: (id) => `/api/mcp/exports/${id}/download`,
|
|
962
|
+
};
|
|
963
|
+
const EXPORT_API_V1 = {
|
|
964
|
+
job: (id) => `/api/v1/exports/${id}`,
|
|
965
|
+
batch: (id) => `/api/v1/export-batches/${id}`,
|
|
966
|
+
download: (id) => `/api/v1/exports/${id}/download`,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
async function downloadBatchChunks(args, key, batch, dest, api = EXPORT_API_MCP) {
|
|
711
970
|
const chunks = (batch.chunks || []).filter((c) => c.downloadable || EXPORT_TERMINAL_OK.includes(String(c.status || "").toLowerCase()));
|
|
712
971
|
if (!chunks.length) die("export batch reported done but returned no downloadable chunks.");
|
|
713
972
|
const files = [];
|
|
@@ -715,7 +974,7 @@ async function downloadBatchChunks(args, key, batch, dest) {
|
|
|
715
974
|
for (const chunk of chunks) {
|
|
716
975
|
i += 1;
|
|
717
976
|
const target = chunks.length === 1 ? dest : partPath(dest, chunk.chunk_index != null ? chunk.chunk_index + 1 : i);
|
|
718
|
-
const dl = chunk.download_api_path ||
|
|
977
|
+
const dl = chunk.download_url || chunk.download_api_path || api.download(chunk.job_id);
|
|
719
978
|
await downloadTo(args.base, dl, key, target);
|
|
720
979
|
files.push(target);
|
|
721
980
|
}
|
|
@@ -733,9 +992,9 @@ function countCsvRows(file) {
|
|
|
733
992
|
// `argorant export status <id>` / `argorant export download <id> -o file`:
|
|
734
993
|
// a paid export must never be unrecoverable because the CLI died after the
|
|
735
994
|
// job was created (bad path, ctrl-c, dropped connection).
|
|
736
|
-
async function exportStatusCmd(args, key, id) {
|
|
995
|
+
async function exportStatusCmd(args, key, id, api = EXPORT_API_MCP, cmdName = "export") {
|
|
737
996
|
const isBatch = args.batch;
|
|
738
|
-
const p = isBatch ?
|
|
997
|
+
const p = isBatch ? api.batch(id) : api.job(id);
|
|
739
998
|
const res = await request("GET", args.base, p, { key });
|
|
740
999
|
const s = need(res, "export status");
|
|
741
1000
|
if (args.json) return console.log(JSON.stringify(s, null, 2));
|
|
@@ -747,28 +1006,31 @@ async function exportStatusCmd(args, key, id) {
|
|
|
747
1006
|
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
1007
|
}
|
|
749
1008
|
if (s.error_message) console.log(red(" " + s.error_message));
|
|
750
|
-
if (s.downloadable) console.log(dim(` argorant
|
|
1009
|
+
if (s.downloadable) console.log(dim(` argorant ${cmdName} download ${id}${isBatch ? " --batch" : ""} -o leads.csv`));
|
|
751
1010
|
}
|
|
752
1011
|
|
|
753
|
-
async function exportDownloadCmd(args, key, id) {
|
|
1012
|
+
async function exportDownloadCmd(args, key, id, api = EXPORT_API_MCP, cmdName = "export") {
|
|
754
1013
|
const dest = ensureWritable(args.output || "argorant-leads.csv");
|
|
755
1014
|
if (args.batch) {
|
|
756
|
-
const res = await request("GET", args.base,
|
|
1015
|
+
const res = await request("GET", args.base, api.batch(id), { key });
|
|
757
1016
|
const b = need(res, "export batch status");
|
|
758
1017
|
if (!EXPORT_TERMINAL_OK.includes(String(b.status || "").toLowerCase())) {
|
|
759
|
-
die(`export batch #${id} is ${b.status || "not ready"} - run \`argorant
|
|
1018
|
+
die(`export batch #${id} is ${b.status || "not ready"} - run \`argorant ${cmdName} status ${id} --batch\`.`);
|
|
760
1019
|
}
|
|
761
|
-
const files = await downloadBatchChunks(args, key, b, dest);
|
|
1020
|
+
const files = await downloadBatchChunks(args, key, b, dest, api);
|
|
762
1021
|
if (args.json) return console.log(JSON.stringify({ ok: true, batch_id: id, files }, null, 2));
|
|
763
1022
|
for (const f of files) console.log(green("✓") + ` ${bold(String(countCsvRows(f) ?? "?"))} rows → ${bold(f)}`);
|
|
764
1023
|
return;
|
|
765
1024
|
}
|
|
766
|
-
const res = await request("GET", args.base,
|
|
1025
|
+
const res = await request("GET", args.base, api.job(id), { key });
|
|
767
1026
|
const s = need(res, "export status");
|
|
768
|
-
if (!s.downloadable && !s.download_api_path) {
|
|
769
|
-
|
|
1027
|
+
if (!s.downloadable && !s.download_api_path && !s.download_url) {
|
|
1028
|
+
if (EXPORT_TERMINAL_FAIL.includes(String(s.status || "").toLowerCase())) {
|
|
1029
|
+
die(`export #${id} ${String(s.status).toLowerCase() === "failed" ? "failed" : `was ${s.status}`}, so there is no file to download. Start a new export, or check it with \`argorant ${cmdName} status ${id}\`.`);
|
|
1030
|
+
}
|
|
1031
|
+
die(`export #${id} is ${s.status || "not ready"} - run \`argorant ${cmdName} status ${id}\`.`);
|
|
770
1032
|
}
|
|
771
|
-
await downloadTo(args.base, s.download_api_path ||
|
|
1033
|
+
await downloadTo(args.base, s.download_url || s.download_api_path || api.download(id), key, dest);
|
|
772
1034
|
if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: id }, null, 2));
|
|
773
1035
|
const rows = countCsvRows(dest);
|
|
774
1036
|
console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
|
|
@@ -791,9 +1053,18 @@ async function cmdExport(args) {
|
|
|
791
1053
|
const dest = ensureWritable(args.output || "argorant-leads.csv");
|
|
792
1054
|
// Confirmation is interactive-only by design: with --yes, --json, or a
|
|
793
1055
|
// non-TTY stdin (CI, agents, pipes) this spends credits with no prompt.
|
|
1056
|
+
const pricing = args.phones ? await phonePricing(args, key) : null;
|
|
1057
|
+
if (pricing && pricing.plan_includes_phones === false) {
|
|
1058
|
+
die("Your plan does not include phone numbers. Run the export without --phones.", EXIT.UPGRADE);
|
|
1059
|
+
}
|
|
794
1060
|
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
795
|
-
const
|
|
1061
|
+
const phonesText = args.phones
|
|
1062
|
+
? ` 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.`
|
|
1063
|
+
: "";
|
|
1064
|
+
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
1065
|
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
1066
|
+
} else if (args.phones && !args.json) {
|
|
1067
|
+
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
1068
|
}
|
|
798
1069
|
// Match the MCP/app defaults so the CLI yields the same rows: business email
|
|
799
1070
|
// present by default, and skip rows already exported (override with
|
|
@@ -808,7 +1079,7 @@ async function cmdExport(args) {
|
|
|
808
1079
|
const grades = args.grade === "valid-plus-catchall" ? ["valid", "catch_all"] : ["valid"];
|
|
809
1080
|
const create = await request("POST", args.base, "/api/mcp/exports/create", {
|
|
810
1081
|
key,
|
|
811
|
-
body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported, grades },
|
|
1082
|
+
body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported, grades, ...(args.phones ? { include_phones: true } : {}) },
|
|
812
1083
|
});
|
|
813
1084
|
const job = need(create, "export");
|
|
814
1085
|
// >EXPORT_MAX_ROWS (50k) → a multi-chunk batch, a different status endpoint
|
|
@@ -1071,9 +1342,10 @@ function readStdin() {
|
|
|
1071
1342
|
});
|
|
1072
1343
|
}
|
|
1073
1344
|
|
|
1345
|
+
// Every campaign, a page at a time (the API pages its lists; an older server
|
|
1346
|
+
// answers one page without next_cursor and the loop stops there).
|
|
1074
1347
|
async function fetchCampaigns(base, key) {
|
|
1075
|
-
const
|
|
1076
|
-
const r = need(res, "campaigns list");
|
|
1348
|
+
const r = await fetchPages(base, key, "/api/v1/campaigns", { limit: 100 }, "campaigns", "campaigns list", true);
|
|
1077
1349
|
return r.campaigns || [];
|
|
1078
1350
|
}
|
|
1079
1351
|
|
|
@@ -1483,10 +1755,11 @@ async function campaignsDelete(argv) {
|
|
|
1483
1755
|
}
|
|
1484
1756
|
|
|
1485
1757
|
async function campaignsLeadsList(args, campaignId, key) {
|
|
1486
|
-
const query = {
|
|
1758
|
+
const query = { offset: args.offset || 0 };
|
|
1487
1759
|
if (args.status) query.status = args.status;
|
|
1488
1760
|
if (args.q) query.q = args.q;
|
|
1489
|
-
const
|
|
1761
|
+
const want = args.limit ? parseLimit(args.limit, "-n") : 50;
|
|
1762
|
+
const r = await fetchUpTo(args.base, key, `/api/v1/campaigns/${campaignId}/leads`, query, "leads", "campaigns leads", want);
|
|
1490
1763
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1491
1764
|
console.log(dim(`${r.total} lead(s)`));
|
|
1492
1765
|
for (const l of r.leads || []) console.log(`${l.email} ${dim([l.first_name, l.last_name].filter(Boolean).join(" ") || "")} ${l.status}${l.current_step ? dim(` step ${l.current_step}`) : ""}${l.replied_at ? green(" replied") : ""}${l.bounced_at ? red(" bounced") : ""}`);
|
|
@@ -1506,7 +1779,8 @@ async function campaignsReplies(argv) {
|
|
|
1506
1779
|
const identifier = args._[0];
|
|
1507
1780
|
if (!identifier) die("usage: argorant campaigns replies <campaign> [-n 50]");
|
|
1508
1781
|
const campaignId = await resolveCampaign(args.base, key, identifier);
|
|
1509
|
-
const
|
|
1782
|
+
const want = args.limit ? parseLimit(args.limit, "-n") : 50;
|
|
1783
|
+
const r = await fetchUpTo(args.base, key, `/api/v1/campaigns/${campaignId}/replies`, {}, "replies", "campaigns replies", want);
|
|
1510
1784
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1511
1785
|
if (!(r.replies || []).length) return console.log(dim("No replies yet."));
|
|
1512
1786
|
for (const e of r.replies) {
|
|
@@ -1520,11 +1794,12 @@ async function cmdInboxThreads(argv) {
|
|
|
1520
1794
|
const key = requireKey();
|
|
1521
1795
|
if (sub === "list") {
|
|
1522
1796
|
const args = readFlags(argv.slice(1), { "--folder": "folder", "--campaign": "campaign", "--classification": "classification", "--q": "q", "-n": "limit", "--limit": "limit" });
|
|
1523
|
-
const query = { folder: args.folder || "replies"
|
|
1797
|
+
const query = { folder: args.folder || "replies" };
|
|
1524
1798
|
if (args.classification) query.classification = args.classification;
|
|
1525
1799
|
if (args.q) query.q = args.q;
|
|
1526
1800
|
if (args.campaign) query.campaign_id = await resolveCampaign(args.base, key, args.campaign);
|
|
1527
|
-
const
|
|
1801
|
+
const want = args.limit ? parseLimit(args.limit, "-n") : 50;
|
|
1802
|
+
const r = await fetchUpTo(args.base, key, "/api/v1/inbox", query, "events", "inbox", want);
|
|
1528
1803
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1529
1804
|
if (!(r.events || []).length) return console.log(dim(`No conversations in ${query.folder}.`));
|
|
1530
1805
|
for (const e of r.events) {
|
|
@@ -1598,7 +1873,7 @@ async function cmdBlocklist(argv) {
|
|
|
1598
1873
|
const key = requireKey();
|
|
1599
1874
|
if (sub === "list") {
|
|
1600
1875
|
const args = readFlags(argv.slice(1));
|
|
1601
|
-
const r =
|
|
1876
|
+
const r = await fetchPages(args.base, key, "/api/v1/blocklist", { limit: 100 }, "entries", "blocklist", true);
|
|
1602
1877
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1603
1878
|
if (!(r.entries || []).length) return console.log(dim("Blocklist is empty."));
|
|
1604
1879
|
for (const e of r.entries) console.log(`${e.type === "domain" ? "@" : " "}${e.value} ${dim(e.reason || "")}${e.yours ? "" : dim(" (Argorant-wide)")} ${dim(e.id)}`);
|
|
@@ -1722,7 +1997,7 @@ async function cmdInboxes(argv) {
|
|
|
1722
1997
|
const key = requireKey();
|
|
1723
1998
|
if (sub === "list") {
|
|
1724
1999
|
const args = readFlags(argv.slice(1));
|
|
1725
|
-
const r =
|
|
2000
|
+
const r = await fetchPages(args.base, key, "/api/v1/inboxes", { limit: 100 }, "inboxes", "inboxes list", true);
|
|
1726
2001
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1727
2002
|
const rows = r.inboxes || [];
|
|
1728
2003
|
if (!rows.length) return console.log(dim("No mailboxes connected yet. Connect Google Workspace with `argorant inboxes connect-google --admin ...` or Microsoft 365 in the app."));
|
|
@@ -2014,13 +2289,16 @@ function findNeed(res, what, { expect } = {}) {
|
|
|
2014
2289
|
const to = res.res && res.res.headers && res.res.headers.location;
|
|
2015
2290
|
die(`${what}: the server answered with a redirect (HTTP ${res.status}${to ? ` to ${to}` : ""}). Check --base / ARGORANT_API_BASE.`);
|
|
2016
2291
|
}
|
|
2017
|
-
|
|
2292
|
+
const envelope = j && typeof j === "object" && j.error && typeof j.error === "object" && !Array.isArray(j.error);
|
|
2293
|
+
if (res.status >= 400 && !envelope && j && typeof j === "object" && !Array.isArray(j) && j.detail === undefined && (j.error || j.message)) {
|
|
2018
2294
|
r = { ...res, json: { detail: j } };
|
|
2019
2295
|
}
|
|
2020
2296
|
if (r.status === 429) {
|
|
2021
2297
|
const ra = Number((res.res && res.res.headers && res.res.headers["retry-after"]) || 0);
|
|
2022
|
-
const msg =
|
|
2023
|
-
|
|
2298
|
+
const msg = errorMessage(r.json) || "the email finder limit was reached.";
|
|
2299
|
+
const rid = requestIdOf(res);
|
|
2300
|
+
const ref = rid ? `\nRequest id: ${rid} (quote it if you contact support)` : "";
|
|
2301
|
+
die((Number.isFinite(ra) && ra > 0 ? `${msg} Try again in ${fmtWait(Math.ceil(ra))}. Nothing was charged.` : `${msg} Nothing was charged.`) + ref, EXIT.RATE_LIMIT);
|
|
2024
2302
|
}
|
|
2025
2303
|
let out = need(r, what);
|
|
2026
2304
|
// Job endpoints answer {ok, job: {...}}; the commands work on the job itself.
|
|
@@ -2363,6 +2641,692 @@ async function cmdFind(argv) {
|
|
|
2363
2641
|
return findSingle(args, key);
|
|
2364
2642
|
}
|
|
2365
2643
|
|
|
2644
|
+
// =============================================================================
|
|
2645
|
+
// API v1 commands (0.13.0): account, lists, exports, credits, webhooks,
|
|
2646
|
+
// companies and lookup. They call /api/v1 only. Every write carries an
|
|
2647
|
+
// Idempotency-Key (see request()), so running a command again never does the
|
|
2648
|
+
// work twice. Lists come back a page at a time: -n sets the page size,
|
|
2649
|
+
// --cursor continues where the last page ended, --all walks to the end.
|
|
2650
|
+
// =============================================================================
|
|
2651
|
+
|
|
2652
|
+
const V1 = "/api/v1";
|
|
2653
|
+
const PAGE_FLAGS = { "-n": "limit", "--limit": "limit", "--cursor": "cursor", "--offset": "offset" };
|
|
2654
|
+
const COMPANY_FILTER_FLAGS = {
|
|
2655
|
+
"--query": "q",
|
|
2656
|
+
"--q": "q",
|
|
2657
|
+
"--industry": "industry",
|
|
2658
|
+
"--industries": "industries",
|
|
2659
|
+
"--country": "country",
|
|
2660
|
+
"--geography": "country",
|
|
2661
|
+
"--region": "country",
|
|
2662
|
+
"--state": "state",
|
|
2663
|
+
"--city": "city",
|
|
2664
|
+
"--company": "company_name",
|
|
2665
|
+
"--domain": "company_domain",
|
|
2666
|
+
"--employees": "employee_range",
|
|
2667
|
+
"--employee-range": "employee_range",
|
|
2668
|
+
"--keywords": "keywords",
|
|
2669
|
+
"--keyword": "keywords",
|
|
2670
|
+
"--technologies": "technologies",
|
|
2671
|
+
"--technology": "technologies",
|
|
2672
|
+
};
|
|
2673
|
+
const LOOKUP_KINDS = ["industries", "countries", "keywords", "technologies", "titles"];
|
|
2674
|
+
const LOOKUP_ALIASES = { industry: "industries", country: "countries", keyword: "keywords", technology: "technologies", tech: "technologies", title: "titles" };
|
|
2675
|
+
|
|
2676
|
+
// The page query every list command sends: limit plus cursor or offset.
|
|
2677
|
+
function pageQuery(args, def = 25) {
|
|
2678
|
+
const q = { limit: args.limit !== undefined ? parseLimit(args.limit, "--limit") : def };
|
|
2679
|
+
if (args.cursor !== undefined) q.cursor = String(args.cursor);
|
|
2680
|
+
if (args.offset !== undefined) {
|
|
2681
|
+
const n = Number(args.offset);
|
|
2682
|
+
if (!Number.isInteger(n) || n < 0) die(`--offset must be 0 or a positive whole number (got "${args.offset}").`);
|
|
2683
|
+
q.offset = n;
|
|
2684
|
+
}
|
|
2685
|
+
if (q.cursor !== undefined && q.offset !== undefined) die("pass --cursor or --offset, not both.");
|
|
2686
|
+
return q;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// One page, or with all=true every page (following next_cursor) merged into
|
|
2690
|
+
// one answer of the same shape.
|
|
2691
|
+
async function fetchPages(base, key, urlPath, query, itemsKey, what, all) {
|
|
2692
|
+
let page = need(await request("GET", base, urlPath, { key, query }), what);
|
|
2693
|
+
if (!all) return page;
|
|
2694
|
+
const items = [...pickItems(page, itemsKey)];
|
|
2695
|
+
let guard = 0;
|
|
2696
|
+
while (page.next_cursor && guard++ < 5000) {
|
|
2697
|
+
const q = { ...query, cursor: page.next_cursor };
|
|
2698
|
+
delete q.offset;
|
|
2699
|
+
page = need(await request("GET", base, urlPath, { key, query: q }), what);
|
|
2700
|
+
items.push(...pickItems(page, itemsKey));
|
|
2701
|
+
}
|
|
2702
|
+
return { ...page, [itemsKey]: items, offset: query.offset || 0, next_cursor: null, has_more: false };
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// Up to `want` rows, a page of at most 100 at a time, following next_cursor.
|
|
2706
|
+
async function fetchUpTo(base, key, urlPath, query, itemsKey, what, want) {
|
|
2707
|
+
let q = { ...query, limit: Math.min(100, want) };
|
|
2708
|
+
let page = need(await request("GET", base, urlPath, { key, query: q }), what);
|
|
2709
|
+
const rows = [...pickItems(page, itemsKey)];
|
|
2710
|
+
let guard = 0;
|
|
2711
|
+
while (rows.length < want && page.next_cursor && guard++ < 5000) {
|
|
2712
|
+
q = { ...query, limit: Math.min(100, want - rows.length), cursor: page.next_cursor };
|
|
2713
|
+
delete q.offset;
|
|
2714
|
+
page = need(await request("GET", base, urlPath, { key, query: q }), what);
|
|
2715
|
+
const more = pickItems(page, itemsKey);
|
|
2716
|
+
if (!more.length) break;
|
|
2717
|
+
rows.push(...more);
|
|
2718
|
+
}
|
|
2719
|
+
return { ...page, [itemsKey]: rows.slice(0, want) };
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// The array of a list answer, by its resource key (lists, exports, ...) with
|
|
2723
|
+
// the usual fallbacks.
|
|
2724
|
+
function pickItems(r, itemsKey) {
|
|
2725
|
+
if (!r || typeof r !== "object") return [];
|
|
2726
|
+
for (const k of [itemsKey, "data", "results", "items"]) if (Array.isArray(r[k])) return r[k];
|
|
2727
|
+
return [];
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function pageFooter(r, itemsKey, hint) {
|
|
2731
|
+
const n = pickItems(r, itemsKey).length;
|
|
2732
|
+
const parts = [`${n.toLocaleString()} shown`];
|
|
2733
|
+
if (r.total !== undefined && r.total !== null) parts.push(`${Number(r.total).toLocaleString()} in total`);
|
|
2734
|
+
console.log(dim(parts.join(" · ")));
|
|
2735
|
+
if (r.next_cursor) console.log(dim(`Next page: ${hint} --cursor ${r.next_cursor} (or --all for everything)`));
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function numericId(raw, what, usage) {
|
|
2739
|
+
const id = String(raw || "").trim();
|
|
2740
|
+
if (!id) die(usage);
|
|
2741
|
+
if (!/^\d+$/.test(id)) die(`${what} must be a number (got "${raw}").`);
|
|
2742
|
+
return id;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// Destructive and paid-on-confirm commands: ask at a terminal, refuse without
|
|
2746
|
+
// --yes when there is nobody to ask (an agent, CI, a pipe).
|
|
2747
|
+
async function confirmOrDie(args, question, noTtyHint) {
|
|
2748
|
+
if (args.yes) return true;
|
|
2749
|
+
if (!process.stdin.isTTY) die(`${noTtyHint} Add --yes to confirm.`);
|
|
2750
|
+
const ans = await prompt(`${question} [y/N] `);
|
|
2751
|
+
if (!/^y(es)?$/i.test(ans)) {
|
|
2752
|
+
console.log(dim("aborted."));
|
|
2753
|
+
return false;
|
|
2754
|
+
}
|
|
2755
|
+
return true;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function fmtDate(v) {
|
|
2759
|
+
if (!v) return "";
|
|
2760
|
+
return String(v).slice(0, 16).replace("T", " ");
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function fmtNum(v) {
|
|
2764
|
+
if (v === undefined || v === null || v === "") return "-";
|
|
2765
|
+
const n = Number(v);
|
|
2766
|
+
return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 2 }) : String(v);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// A price from cents (amount_cents, price_cents) or a decimal amount (price, amount).
|
|
2770
|
+
function fmtPrice(o, currency) {
|
|
2771
|
+
if (!o || typeof o !== "object") return "?";
|
|
2772
|
+
const cur = String(o.currency || currency || "usd").toLowerCase();
|
|
2773
|
+
const cents = o.price_cents ?? o.amount_cents ?? o.total_cents ?? o.due_today_cents ?? o.charged_cents;
|
|
2774
|
+
if (cents !== undefined && cents !== null) return money(cents, cur);
|
|
2775
|
+
const dec = o.price ?? o.amount ?? o.total;
|
|
2776
|
+
if (dec !== undefined && dec !== null && Number.isFinite(Number(dec))) return money(Math.round(Number(dec) * 100), cur);
|
|
2777
|
+
return "?";
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// ---- account ----
|
|
2781
|
+
async function cmdAccount(argv) {
|
|
2782
|
+
const args = readFlags(argv);
|
|
2783
|
+
const key = requireKey();
|
|
2784
|
+
const a = need(await request("GET", args.base, `${V1}/account`, { key }), "account");
|
|
2785
|
+
if (args.json) return console.log(JSON.stringify(a, null, 2));
|
|
2786
|
+
const ws = a.workspace && typeof a.workspace === "object" ? a.workspace : {};
|
|
2787
|
+
const role = a.workspace_role || ws.role;
|
|
2788
|
+
console.log(`${bold("Account")} ${a.email || (a.user && a.user.email) || "-"}${role ? dim(` (${role})`) : ""}`);
|
|
2789
|
+
if (ws.name || a.workspace_name) console.log(`${bold("Workspace")} ${ws.name || a.workspace_name}`);
|
|
2790
|
+
const plan = a.plan && typeof a.plan === "object" ? a.plan : { name: a.plan };
|
|
2791
|
+
if (plan.name) {
|
|
2792
|
+
const interval = plan.billing_interval || plan.interval;
|
|
2793
|
+
const bits = [plan.status, interval && `billed ${interval === "year" ? "yearly" : "monthly"}`, (plan.renews_at || plan.renewal_at) && `${plan.cancels_at_period_end ? "ends" : "renews"} ${fmtDate(plan.renews_at || plan.renewal_at).slice(0, 10)}`].filter(Boolean);
|
|
2794
|
+
console.log(`${bold("Plan")} ${plan.name}${bits.length ? dim(` ${bits.join(" · ")}`) : ""}`);
|
|
2795
|
+
}
|
|
2796
|
+
const cr = a.credits;
|
|
2797
|
+
if (cr !== undefined && cr !== null) {
|
|
2798
|
+
if (typeof cr === "object") {
|
|
2799
|
+
const avail = cr.available ?? cr.balance ?? cr.remaining ?? cr.total;
|
|
2800
|
+
const parts = [];
|
|
2801
|
+
for (const [k, label] of [["from_plan", "from your plan"], ["plan", "from your plan"], ["pay_as_you_go", "pay as you go"], ["free", "free"]]) {
|
|
2802
|
+
if (cr[k] !== undefined && cr[k] !== null && Number(cr[k]) > 0) parts.push(`${fmtNum(cr[k])} ${label}`);
|
|
2803
|
+
}
|
|
2804
|
+
console.log(`${bold("Credits")} ${bold(fmtNum(avail))} available${parts.length ? dim(` (${parts.join(" · ")})`) : ""}`);
|
|
2805
|
+
} else {
|
|
2806
|
+
console.log(`${bold("Credits")} ${bold(fmtNum(cr))} available`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
const lim = (a.limits && typeof a.limits === "object" && a.limits) || (a.rate_limit && typeof a.rate_limit === "object" && a.rate_limit) || {};
|
|
2810
|
+
const perMin = lim.requests_per_minute ?? lim.per_minute ?? a.rate_limit_per_minute;
|
|
2811
|
+
if (perMin !== undefined && perMin !== null) console.log(`${bold("Requests")} ${fmtNum(perMin)} per minute`);
|
|
2812
|
+
else if (lim.unlimited) console.log(`${bold("Requests")} no per-minute or daily limit`);
|
|
2813
|
+
const daily = lim.daily && typeof lim.daily === "object" ? Object.entries(lim.daily).filter(([, v]) => v && typeof v === "object" && v.limit !== undefined && v.limit !== null) : [];
|
|
2814
|
+
if (daily.length) {
|
|
2815
|
+
console.log(`${bold("Today")} ${daily.map(([name, v]) => `${name.replace(/_/g, " ")} ${fmtNum(v.used_today || 0)}/${fmtNum(v.limit)}`).join(" · ")}`);
|
|
2816
|
+
}
|
|
2817
|
+
const ce = a.campaign_emails;
|
|
2818
|
+
if (ce && typeof ce === "object") {
|
|
2819
|
+
if (ce.unlimited) console.log(`${bold("Campaign emails")} no monthly limit`);
|
|
2820
|
+
else if (ce.allowance !== undefined && ce.allowance !== null) {
|
|
2821
|
+
console.log(`${bold("Campaign emails")} ${fmtNum(ce.used)} of ${fmtNum(ce.allowance)} used${ce.resets_on ? dim(` resets ${String(ce.resets_on).slice(0, 10)}`) : ""}`);
|
|
2822
|
+
}
|
|
2823
|
+
if (ce.trial_active && ce.trial_days_left !== undefined && ce.trial_days_left !== null) console.log(dim(` free trial: ${ce.trial_days_left} day(s) left`));
|
|
2824
|
+
}
|
|
2825
|
+
if (Array.isArray(a.scopes)) console.log(`${bold("Scopes")} ${a.scopes.join(", ") || "-"}`);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// ---- lists ----
|
|
2829
|
+
function listsHelp() {
|
|
2830
|
+
const p = bold("argorant lists");
|
|
2831
|
+
console.log(`
|
|
2832
|
+
${bold("Argorant lists")} - saved lists of people (free to create and browse)
|
|
2833
|
+
|
|
2834
|
+
${p} [ls] [--kind records|filtered] [-n 25] [--cursor <c>] [--all]
|
|
2835
|
+
${p} create --name "<name>" [filters] ${dim("saves a search; the size is counted for you")}
|
|
2836
|
+
${p} show <id>
|
|
2837
|
+
${p} people <id> [-n 25] [--cursor <c>] [--all] ${dim("the people in it, contact details hidden")}
|
|
2838
|
+
${p} rename <id> --name "<new name>"
|
|
2839
|
+
${p} rm <id> [--yes]
|
|
2840
|
+
${p} export <id> [-n 1000] [-o leads.csv] [--include-exported] [--include-phones] [--no-wait] [--yes]
|
|
2841
|
+
${dim("1 credit per valid contact; with --include-phones 10 credits per phone number returned")}
|
|
2842
|
+
|
|
2843
|
+
filters for create: --title --seniority --department --industry --keywords --country --state --city --company --domain
|
|
2844
|
+
${dim("Add --json to any command for the raw answer.")}
|
|
2845
|
+
`);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
const LIST_FILTER_FLAGS = {
|
|
2849
|
+
"--query": "q",
|
|
2850
|
+
"--title": "title",
|
|
2851
|
+
"--exclude-title": "exclude_title",
|
|
2852
|
+
"--seniority": "seniority",
|
|
2853
|
+
"--department": "departments",
|
|
2854
|
+
"--departments": "departments",
|
|
2855
|
+
"--industry": "industry",
|
|
2856
|
+
"--keywords": "keywords",
|
|
2857
|
+
"--keyword": "keywords",
|
|
2858
|
+
"--country": "country",
|
|
2859
|
+
"--geography": "country",
|
|
2860
|
+
"--region": "country",
|
|
2861
|
+
"--state": "state",
|
|
2862
|
+
"--city": "city",
|
|
2863
|
+
"--company": "company_name",
|
|
2864
|
+
"--domain": "company_domain",
|
|
2865
|
+
};
|
|
2866
|
+
|
|
2867
|
+
function listLine(l) {
|
|
2868
|
+
const size = Number(l.snapshot_total || l.item_count || 0);
|
|
2869
|
+
const kind = l.selection_mode === "filtered" ? "saved search" : "list";
|
|
2870
|
+
return `${bold("#" + (l.list_id ?? l.id))} ${l.name || "-"} ${dim(`${size.toLocaleString()} people · ${kind}${l.updated_at ? ` · updated ${fmtDate(l.updated_at)}` : ""}`)}`;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
async function cmdLists(argv) {
|
|
2874
|
+
let sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "ls";
|
|
2875
|
+
let rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
2876
|
+
if (/^\d+$/.test(sub)) { rest = argv; sub = "show"; }
|
|
2877
|
+
if (sub === "help") return listsHelp();
|
|
2878
|
+
const key = requireKey();
|
|
2879
|
+
if (sub === "ls" || sub === "list") {
|
|
2880
|
+
const args = readFlags(rest, { ...PAGE_FLAGS, "--kind": "kind" }, { "--all": "all" });
|
|
2881
|
+
const query = pageQuery(args, 25);
|
|
2882
|
+
if (args.kind) {
|
|
2883
|
+
const kind = String(args.kind).toLowerCase();
|
|
2884
|
+
if (!["all", "records", "filtered"].includes(kind)) die("--kind is all, records (hand-picked lists) or filtered (saved searches)");
|
|
2885
|
+
query.kind = kind;
|
|
2886
|
+
}
|
|
2887
|
+
const r = await fetchPages(args.base, key, `${V1}/lists`, query, "lists", "lists", args.all);
|
|
2888
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2889
|
+
const rows = pickItems(r, "lists");
|
|
2890
|
+
if (!rows.length) return console.log(dim('No saved lists yet. Create one with `argorant lists create --name "..." --title CEO --country Germany`.'));
|
|
2891
|
+
for (const l of rows) console.log(listLine(l));
|
|
2892
|
+
pageFooter(r, "lists", "argorant lists");
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
if (sub === "create" || sub === "new") {
|
|
2896
|
+
const args = readFlags(rest, { ...LIST_FILTER_FLAGS, "--name": "name" }, { "--has-phone": "has_phone", "--has-linkedin": "has_linkedin", "--has-email": "has_email" });
|
|
2897
|
+
const name = String(args.name || "").trim();
|
|
2898
|
+
if (!name) die('usage: argorant lists create --name "My list" [filters] (e.g. --title CEO --country Germany)');
|
|
2899
|
+
const filters = {};
|
|
2900
|
+
for (const field of Object.values(LIST_FILTER_FLAGS)) if (args[field]) filters[field] = args[field];
|
|
2901
|
+
for (const field of ["has_phone", "has_linkedin", "has_email"]) if (args[field]) filters[field] = "true";
|
|
2902
|
+
if (args._.length) filters.q = args._.join(" ");
|
|
2903
|
+
if (!Object.keys(filters).length) die("give at least one filter, for example --title CEO --country Germany");
|
|
2904
|
+
const body = { name, filters };
|
|
2905
|
+
const r = need(await request("POST", args.base, `${V1}/lists`, { key, body }), "lists create");
|
|
2906
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2907
|
+
const l = r.list && typeof r.list === "object" ? r.list : r;
|
|
2908
|
+
console.log(green("✓") + ` Saved list ${bold("#" + (l.list_id ?? l.id))} ${dim("“" + (l.name || name) + "”")} · ${bold(Number(l.snapshot_total || 0).toLocaleString())} matching people`);
|
|
2909
|
+
console.log(dim(`Export it: argorant lists export ${l.list_id ?? l.id} -n 500 -o leads.csv`));
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
if (sub === "show" || sub === "get" || sub === "status") {
|
|
2913
|
+
const args = readFlags(rest);
|
|
2914
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists show <id>");
|
|
2915
|
+
const r = need(await request("GET", args.base, `${V1}/lists/${id}`, { key }), "list");
|
|
2916
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2917
|
+
const l = r.list && typeof r.list === "object" ? r.list : r;
|
|
2918
|
+
console.log(listLine(l));
|
|
2919
|
+
const f = l.filters && typeof l.filters === "object" ? Object.entries(l.filters).filter(([k, v]) => v && k !== "record_type") : [];
|
|
2920
|
+
if (f.length) console.log(dim(` filters: ${f.map(([k, v]) => `${k}=${v}`).join(", ")}`));
|
|
2921
|
+
console.log(dim(` argorant lists people ${id} · argorant lists export ${id} -o leads.csv`));
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
if (sub === "people" || sub === "items") {
|
|
2925
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
2926
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists people <id> [-n 25] [--cursor <c>] [--all]");
|
|
2927
|
+
const r = await fetchPages(args.base, key, `${V1}/lists/${id}/people`, pageQuery(args, 25), "people", "list people", args.all);
|
|
2928
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2929
|
+
const rows = pickItems(r, "people");
|
|
2930
|
+
if (!rows.length) return console.log(dim("This list has no people yet."));
|
|
2931
|
+
for (const p of rows) {
|
|
2932
|
+
const name = p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") || p.preview || p.name;
|
|
2933
|
+
const who = [name, p.title].filter(Boolean).join(" · ");
|
|
2934
|
+
const where = [p.company || p.company_name, p.country].filter(Boolean).join(", ");
|
|
2935
|
+
console.log(` ${bold(who || "-")}${where ? dim(" " + where) : ""}${p.email ? " " + cyan(p.email) : ""}`);
|
|
2936
|
+
}
|
|
2937
|
+
pageFooter(r, "people", `argorant lists people ${id}`);
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
if (sub === "rename") {
|
|
2941
|
+
const args = readFlags(rest, { "--name": "name" });
|
|
2942
|
+
const id = numericId(args._[0], "list id", 'usage: argorant lists rename <id> --name "<new name>"');
|
|
2943
|
+
const name = String(args.name || args._.slice(1).join(" ") || "").trim();
|
|
2944
|
+
if (!name) die('usage: argorant lists rename <id> --name "<new name>"');
|
|
2945
|
+
const r = need(await request("PATCH", args.base, `${V1}/lists/${id}`, { key, body: { name } }), "lists rename");
|
|
2946
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2947
|
+
const l = r.list && typeof r.list === "object" ? r.list : r;
|
|
2948
|
+
return console.log(green("✓") + ` List #${id} is now ${bold("“" + (l.name || name) + "”")}`);
|
|
2949
|
+
}
|
|
2950
|
+
if (sub === "rm" || sub === "delete" || sub === "remove") {
|
|
2951
|
+
const args = readFlags(rest);
|
|
2952
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists rm <id> [--yes]");
|
|
2953
|
+
if (!(await confirmOrDie(args, `Delete list #${id}? Campaigns that already use its people keep them.`, `Deleting list #${id} needs a confirmation.`))) return;
|
|
2954
|
+
const r = need(await request("DELETE", args.base, `${V1}/lists/${id}`, { key }), "lists rm");
|
|
2955
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2956
|
+
return console.log(green("✓") + ` Deleted list #${id}`);
|
|
2957
|
+
}
|
|
2958
|
+
if (sub === "export") return listsExport(rest, key);
|
|
2959
|
+
die(`unknown lists subcommand: ${sub} (ls, create, show, people, rename, rm, export)`);
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
async function listsExport(rest, key) {
|
|
2963
|
+
const args = readFlags(rest, { "-n": "limit", "--limit": "limit", "-o": "output", "--output": "output" }, {
|
|
2964
|
+
"--include-phones": "includePhones",
|
|
2965
|
+
"--include-exported": "includeExported",
|
|
2966
|
+
"--exclude-previously-exported": "excludeExported",
|
|
2967
|
+
"--no-wait": "noWait",
|
|
2968
|
+
});
|
|
2969
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists export <id> [-n 1000] [-o leads.csv] [--include-exported] [--include-phones] [--no-wait] [--yes]");
|
|
2970
|
+
if (args.includeExported && args.excludeExported) die("pass --include-exported or --exclude-previously-exported, not both.");
|
|
2971
|
+
const limit = args.limit !== undefined ? parseLimit(args.limit, "--limit") : 1000;
|
|
2972
|
+
const dest = args.noWait ? null : ensureWritable(args.output || "argorant-leads.csv");
|
|
2973
|
+
// Same rule as `export`: the question is for a person at a terminal; with
|
|
2974
|
+
// --yes, --json or no terminal it runs straight away.
|
|
2975
|
+
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
2976
|
+
const phones = args.includePhones ? " plus 10 credits per phone number returned" : "";
|
|
2977
|
+
const ans = await prompt(`Export up to ${bold(limit)} people from list #${id}? 1 credit per valid contact${phones}; invalid ones cost nothing. [y/N] `);
|
|
2978
|
+
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
2979
|
+
}
|
|
2980
|
+
const body = { limit, exclude_previously_exported: !args.includeExported };
|
|
2981
|
+
if (args.includePhones) body.include_phones = true;
|
|
2982
|
+
const job = need(await request("POST", args.base, `${V1}/lists/${id}/export`, { key, body }), "lists export");
|
|
2983
|
+
const isBatch = job.type === "batch" || (job.batch_id && !job.job_id);
|
|
2984
|
+
const jobId = isBatch ? job.batch_id : job.job_id;
|
|
2985
|
+
if (args.noWait || !jobId) {
|
|
2986
|
+
if (args.json) return console.log(JSON.stringify(job, null, 2));
|
|
2987
|
+
console.log(green("✓") + ` Export ${isBatch ? "batch " : ""}${bold("#" + (jobId ?? "?"))} started for list #${id}`);
|
|
2988
|
+
if (jobId) console.log(dim(`Check it: argorant exports status ${jobId}${isBatch ? " --batch" : ""} · download: argorant exports download ${jobId}${isBatch ? " --batch" : ""} -o leads.csv`));
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
if (isBatch) {
|
|
2992
|
+
if (!args.json) process.stdout.write(dim(`Large export queued as batch #${jobId}\n`));
|
|
2993
|
+
const batch = await pollExportBatch(args, key, EXPORT_API_V1.batch(jobId), { quiet: !!args.json });
|
|
2994
|
+
if (!args.json) process.stdout.write("\n");
|
|
2995
|
+
const files = await downloadBatchChunks(args, key, batch, dest, EXPORT_API_V1);
|
|
2996
|
+
if (args.json) return console.log(JSON.stringify({ ...job, files }, null, 2));
|
|
2997
|
+
for (const f of files) console.log(green("✓") + ` ${bold(String(countCsvRows(f) ?? "?"))} rows → ${bold(f)}`);
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
if (!args.json) process.stdout.write(dim(`Export #${jobId} queued - checking every email and building the file`));
|
|
3001
|
+
const started = Date.now();
|
|
3002
|
+
let status = {};
|
|
3003
|
+
for (;;) {
|
|
3004
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
3005
|
+
status = need(await request("GET", args.base, EXPORT_API_V1.job(jobId), { key }), "export status");
|
|
3006
|
+
const st = String(status.status || "").toLowerCase();
|
|
3007
|
+
if (!args.json) process.stdout.write(".");
|
|
3008
|
+
if (EXPORT_TERMINAL_OK.includes(st) || status.downloadable) break;
|
|
3009
|
+
if (EXPORT_TERMINAL_FAIL.includes(st)) die(`\nexport ${st}${status.error_message ? `: ${status.error_message}` : "."}`);
|
|
3010
|
+
if (Date.now() - started > 1000 * 60 * 20) die(`\nstill running after 20 minutes. Check later with \`argorant exports status ${jobId}\`.`);
|
|
3011
|
+
}
|
|
3012
|
+
if (!args.json) process.stdout.write("\n");
|
|
3013
|
+
try {
|
|
3014
|
+
await downloadTo(args.base, status.download_url || status.download_api_path || EXPORT_API_V1.download(jobId), key, dest);
|
|
3015
|
+
} catch (e) {
|
|
3016
|
+
die(`${e.message}\nThe export itself is done. Download it again with \`argorant exports download ${jobId} -o ${args.output || "argorant-leads.csv"}\`.`);
|
|
3017
|
+
}
|
|
3018
|
+
if (args.json) return console.log(JSON.stringify({ ...job, file: dest }, null, 2));
|
|
3019
|
+
const rows = countCsvRows(dest);
|
|
3020
|
+
console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
// ---- exports ----
|
|
3024
|
+
async function cmdExports(argv) {
|
|
3025
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "ls";
|
|
3026
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3027
|
+
if (sub === "help") {
|
|
3028
|
+
return console.log(`
|
|
3029
|
+
${bold("argorant exports")} [ls] [-n 25] [--cursor <c>] [--all] ${dim("your exports, newest first")}
|
|
3030
|
+
${bold("argorant exports")} status <id> [--batch]
|
|
3031
|
+
${bold("argorant exports")} download <id> [--batch] [-o leads.csv] ${dim("free; the file stays available for a while after it is built")}
|
|
3032
|
+
${dim("New exports: argorant export \"<query>\" -n 1000 -o leads.csv, or argorant lists export <list-id>")}
|
|
3033
|
+
`);
|
|
3034
|
+
}
|
|
3035
|
+
const key = requireKey();
|
|
3036
|
+
if (sub === "ls" || sub === "list") {
|
|
3037
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3038
|
+
const r = await fetchPages(args.base, key, `${V1}/exports`, pageQuery(args, 25), "exports", "exports", args.all);
|
|
3039
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3040
|
+
const rows = pickItems(r, "exports");
|
|
3041
|
+
if (!rows.length) return console.log(dim('No exports yet. Start one with `argorant export "<query>" -n 500 -o leads.csv`.'));
|
|
3042
|
+
for (const e of rows) {
|
|
3043
|
+
const id = e.job_id ?? e.id;
|
|
3044
|
+
console.log(`${bold("#" + id)} ${e.status || "-"} ${dim(`${Number(e.verified_rows || 0).toLocaleString()} valid of ${Number(e.total_rows || 0).toLocaleString()} rows · ${fmtDate(e.created_at)}`)}${e.downloadable ? green(" ready") : ""}`);
|
|
3045
|
+
}
|
|
3046
|
+
pageFooter(r, "exports", "argorant exports");
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
if (sub === "status" || sub === "download") {
|
|
3050
|
+
const args = readFlags(rest, { "-o": "output", "--output": "output" }, { "--batch": "batch" });
|
|
3051
|
+
const id = numericId(args._[0], "export id", `usage: argorant exports ${sub} <id> [--batch]${sub === "download" ? " [-o leads.csv]" : ""}`);
|
|
3052
|
+
return sub === "status" ? exportStatusCmd(args, key, id, EXPORT_API_V1, "exports") : exportDownloadCmd(args, key, id, EXPORT_API_V1, "exports");
|
|
3053
|
+
}
|
|
3054
|
+
die(`unknown exports subcommand: ${sub} (ls, status, download)`);
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// ---- credits ----
|
|
3058
|
+
function packLine(p) {
|
|
3059
|
+
const credits = Number(p.credits || 0);
|
|
3060
|
+
const cents = p.price_cents ?? p.amount_cents;
|
|
3061
|
+
const sign = String(p.currency || "usd").toLowerCase() === "eur" ? "€" : "$";
|
|
3062
|
+
const per = cents !== undefined && cents !== null && credits ? ` · ${sign}${(Number(cents) / 100 / credits).toFixed(3)} per credit` : "";
|
|
3063
|
+
return `${bold(credits.toLocaleString())} credits ${fmtPrice(p)}${dim(per + (p.valid_days ? ` · valid ${p.valid_days} days` : ""))}`;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function quoteLines(q) {
|
|
3067
|
+
const lines = [];
|
|
3068
|
+
const pack = q.pack && typeof q.pack === "object" ? q.pack : q;
|
|
3069
|
+
lines.push(`${bold(Number(pack.credits || q.credits || 0).toLocaleString())} credits for ${bold(fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : pack, q.currency))}`);
|
|
3070
|
+
const card = q.payment && q.payment.saved_card;
|
|
3071
|
+
if (card && card.usable) lines.push(`Paid with: the saved ${card.card || "card"}, charged right away when you confirm${card.charge_before ? ` (before ${fmtDate(card.charge_before)} UTC)` : ""}`);
|
|
3072
|
+
else if (card) lines.push(dim(`Paid with: a payment link you open yourself${card.reason ? ` (${card.reason})` : ""}`));
|
|
3073
|
+
else lines.push(dim("Paid with: a payment link you open yourself"));
|
|
3074
|
+
if (q.expires_at) lines.push(dim(`Quote ${q.quote_id} is valid until ${fmtDate(q.expires_at)} UTC`));
|
|
3075
|
+
return lines;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
async function cmdCredits(argv) {
|
|
3079
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "packs";
|
|
3080
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3081
|
+
if (sub === "help") {
|
|
3082
|
+
return console.log(`
|
|
3083
|
+
${bold("argorant credits")} packs ${dim("the credit packs and their prices")}
|
|
3084
|
+
${bold("argorant credits")} quote <credits> ${dim("a price quote for one pack; nothing is bought")}
|
|
3085
|
+
${bold("argorant credits")} buy <credits> [--pay-with link] [--yes]
|
|
3086
|
+
${dim("shows the quote, asks, then charges the saved card (when your workspace allows it) or gives you a payment link")}
|
|
3087
|
+
${bold("argorant credits")} purchases [<id>] ${dim("purchases made with this account")}
|
|
3088
|
+
${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits stay valid for 365 days.")}
|
|
3089
|
+
`);
|
|
3090
|
+
}
|
|
3091
|
+
const key = requireKey();
|
|
3092
|
+
if (sub === "packs" || sub === "prices") {
|
|
3093
|
+
const args = readFlags(rest);
|
|
3094
|
+
const r = need(await request("GET", args.base, `${V1}/credits/packs`, { key }), "credit packs");
|
|
3095
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3096
|
+
const packs = pickItems(r, "packs");
|
|
3097
|
+
if (!packs.length) return console.log(dim("No credit packs are on offer right now."));
|
|
3098
|
+
for (const p of packs) console.log(packLine(p));
|
|
3099
|
+
console.log(dim("Buy one: argorant credits buy <credits>"));
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
if (sub === "quote" || sub === "buy") {
|
|
3103
|
+
const args = readFlags(rest, { "--currency": "currency", "--pay-with": "payWith" });
|
|
3104
|
+
const credits = parseLimit(String(args._[0] || "").replace(/[,_]/g, "") || "x", "credits");
|
|
3105
|
+
const body = { credits };
|
|
3106
|
+
if (args.currency) body.currency = String(args.currency).toLowerCase();
|
|
3107
|
+
const q = need(await request("POST", args.base, `${V1}/credits/quote`, { key, body }), "credit quote");
|
|
3108
|
+
if (sub === "quote") {
|
|
3109
|
+
if (args.json) return console.log(JSON.stringify(q, null, 2));
|
|
3110
|
+
for (const l of quoteLines(q)) console.log(l);
|
|
3111
|
+
console.log(dim(`Buy it: argorant credits buy ${credits}`));
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
const payWith = String(args.payWith || "auto").toLowerCase();
|
|
3115
|
+
if (!["auto", "link"].includes(payWith)) die("--pay-with is link (always a payment link); leave it out to use the saved card when your workspace allows it");
|
|
3116
|
+
if (!args.json) for (const l of quoteLines(q)) console.log(l);
|
|
3117
|
+
const card = q.payment && q.payment.saved_card;
|
|
3118
|
+
const how = payWith !== "link" && card && card.usable ? `The saved ${card.card || "card"} is charged now.` : "You get a payment link; nothing is charged before you pay it.";
|
|
3119
|
+
if (!(await confirmOrDie(args, `Buy ${credits.toLocaleString()} credits for ${fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : q.pack || q, q.currency)}? ${how}`, "Buying credits needs a confirmation."))) return;
|
|
3120
|
+
const pbody = { quote_id: q.quote_id, confirmed: true };
|
|
3121
|
+
if (payWith !== "auto") pbody.pay_with = payWith;
|
|
3122
|
+
const r = need(await request("POST", args.base, `${V1}/credits/purchases`, { key, body: pbody }), "credit purchase");
|
|
3123
|
+
if (args.json) return console.log(JSON.stringify({ quote: q, purchase: r }, null, 2));
|
|
3124
|
+
const p = r.purchase && typeof r.purchase === "object" ? { ...r.purchase, ...r } : r;
|
|
3125
|
+
const url = p.checkout_url || p.payment_url || p.url;
|
|
3126
|
+
if (String(p.status || "").toLowerCase() === "paid") {
|
|
3127
|
+
console.log(green("✓") + ` Paid${p.card ? ` with the saved ${p.card}` : ""}: ${bold(Number(p.credits || credits).toLocaleString())} credits added${p.replayed ? dim(" (already done earlier)") : ""}`);
|
|
3128
|
+
if (p.message) console.log(dim(p.message));
|
|
3129
|
+
} else if (url) {
|
|
3130
|
+
console.log(green("✓") + ` Purchase ${bold(p.purchase_id || p.id || "")} is waiting for payment${p.replayed ? dim(" (same purchase as before)") : ""}`);
|
|
3131
|
+
if (p.saved_card && p.saved_card.reason) console.log(yellow(`Not charged to the saved card: ${p.saved_card.reason}`));
|
|
3132
|
+
console.log(`Pay here: ${cyan(url)}`);
|
|
3133
|
+
console.log(dim("The credits are added as soon as the payment goes through."));
|
|
3134
|
+
} else {
|
|
3135
|
+
console.log(`${p.status || "done"}. ${dim(p.message || "")}`);
|
|
3136
|
+
}
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
if (sub === "purchases" || sub === "purchase" || sub === "history") {
|
|
3140
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3141
|
+
const id = args._[0];
|
|
3142
|
+
if (id) {
|
|
3143
|
+
const r = need(await request("GET", args.base, `${V1}/credits/purchases/${encodeURIComponent(id)}`, { key }), "credit purchase");
|
|
3144
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3145
|
+
const p = r.purchase && typeof r.purchase === "object" ? r.purchase : r;
|
|
3146
|
+
console.log(`${bold(p.purchase_id || p.id || id)} ${p.status || "-"} ${fmtNum(p.credits)} credits · ${fmtPrice(p)} ${dim(fmtDate(p.created_at))}`);
|
|
3147
|
+
if (p.checkout_url && String(p.status || "") !== "paid") console.log(`Pay here: ${cyan(p.checkout_url)}`);
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
const r = await fetchPages(args.base, key, `${V1}/credits/purchases`, pageQuery(args, 25), "purchases", "credit purchases", args.all);
|
|
3151
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3152
|
+
const rows = pickItems(r, "purchases");
|
|
3153
|
+
if (!rows.length) return console.log(dim("No credit purchases yet. See the packs with `argorant credits packs`."));
|
|
3154
|
+
for (const p of rows) console.log(`${bold(p.purchase_id || p.id)} ${p.status || "-"} ${fmtNum(p.credits)} credits · ${fmtPrice(p)} ${dim(fmtDate(p.created_at))}`);
|
|
3155
|
+
pageFooter(r, "purchases", "argorant credits purchases");
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
die(`unknown credits subcommand: ${sub} (packs, quote, buy, purchases)`);
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// ---- webhooks ----
|
|
3162
|
+
async function cmdWebhooks(argv) {
|
|
3163
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "ls";
|
|
3164
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3165
|
+
if (sub === "help") {
|
|
3166
|
+
return console.log(`
|
|
3167
|
+
${bold("argorant webhooks")} [ls] ${dim("your webhooks and the events you can subscribe to")}
|
|
3168
|
+
${bold("argorant webhooks")} add <https-url> --events reply.received,export.ready
|
|
3169
|
+
${bold("argorant webhooks")} set <id> [--url <https-url>] [--events a,b] [--active|--inactive]
|
|
3170
|
+
${bold("argorant webhooks")} test <id> ${dim("sends a test event to the address")}
|
|
3171
|
+
${bold("argorant webhooks")} deliveries <id> [-n 25] ${dim("recent deliveries and their result")}
|
|
3172
|
+
${bold("argorant webhooks")} rm <id> [--yes]
|
|
3173
|
+
${dim("Webhooks are included from the Pro plan. Each delivery is signed. Check its X-Argorant-Signature header (a SHA-256 HMAC of the body) with the secret shown when you add the webhook.")}
|
|
3174
|
+
`);
|
|
3175
|
+
}
|
|
3176
|
+
const key = requireKey();
|
|
3177
|
+
if (sub === "ls" || sub === "list" || sub === "events") {
|
|
3178
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3179
|
+
const r = await fetchPages(args.base, key, `${V1}/webhooks`, pageQuery(args, 25), "webhooks", "webhooks", args.all);
|
|
3180
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3181
|
+
if (sub !== "events") {
|
|
3182
|
+
const rows = pickItems(r, "webhooks");
|
|
3183
|
+
if (!rows.length) console.log(dim("No webhooks yet. Add one with `argorant webhooks add https://example.com/hook --events reply.received`."));
|
|
3184
|
+
for (const w of rows) {
|
|
3185
|
+
const events = Array.isArray(w.events) ? w.events.join(", ") : String(w.events || "");
|
|
3186
|
+
console.log(`${bold("#" + w.id)} ${w.url} ${w.active === false ? yellow("paused") : green("active")}`);
|
|
3187
|
+
console.log(dim(` ${events}${w.last_error ? ` · last error: ${w.last_error}` : ""}`));
|
|
3188
|
+
}
|
|
3189
|
+
if (rows.length) pageFooter(r, "webhooks", "argorant webhooks");
|
|
3190
|
+
}
|
|
3191
|
+
if (Array.isArray(r.events) && r.events.length) console.log(dim(`Events: ${r.events.join(", ")}`));
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
if (sub === "add" || sub === "create") {
|
|
3195
|
+
const args = readFlags(rest, { "--events": "events", "--event": "events", "--url": "url" });
|
|
3196
|
+
const url = String(args.url || args._[0] || "").trim();
|
|
3197
|
+
const events = String(args.events || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
3198
|
+
if (!url || !events.length) die("usage: argorant webhooks add <https-url> --events reply.received,export.ready (argorant webhooks events lists them)");
|
|
3199
|
+
if (!/^https:\/\//i.test(url)) die("the webhook address must start with https://");
|
|
3200
|
+
const r = need(await request("POST", args.base, `${V1}/webhooks`, { key, body: { url, events } }), "webhooks add");
|
|
3201
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3202
|
+
const w = r.webhook && typeof r.webhook === "object" ? { ...r, ...r.webhook } : r;
|
|
3203
|
+
console.log(green("✓") + ` Webhook ${bold("#" + w.id)} sends ${(w.events || events).join ? (w.events || events).join(", ") : w.events} to ${w.url || url}`);
|
|
3204
|
+
if (w.secret) {
|
|
3205
|
+
console.log(`Signing secret: ${bold(w.secret)}`);
|
|
3206
|
+
console.log(yellow("Store it now. It is shown only once."));
|
|
3207
|
+
}
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
if (sub === "set" || sub === "update") {
|
|
3211
|
+
const args = readFlags(rest, { "--events": "events", "--url": "url" }, { "--active": "activeOn", "--inactive": "activeOff", "--pause": "activeOff", "--resume": "activeOn" });
|
|
3212
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks set <id> [--url <https-url>] [--events a,b] [--active|--inactive]");
|
|
3213
|
+
const body = {};
|
|
3214
|
+
if (args.url) {
|
|
3215
|
+
if (!/^https:\/\//i.test(args.url)) die("the webhook address must start with https://");
|
|
3216
|
+
body.url = String(args.url).trim();
|
|
3217
|
+
}
|
|
3218
|
+
if (args.events) body.events = String(args.events).split(",").map((x) => x.trim()).filter(Boolean);
|
|
3219
|
+
if (args.activeOn && args.activeOff) die("pass --active or --inactive, not both.");
|
|
3220
|
+
if (args.activeOn) body.active = true;
|
|
3221
|
+
if (args.activeOff) body.active = false;
|
|
3222
|
+
if (!Object.keys(body).length) die("nothing to change: give --url, --events, --active or --inactive");
|
|
3223
|
+
const r = need(await request("PATCH", args.base, `${V1}/webhooks/${id}`, { key, body }), "webhooks set");
|
|
3224
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3225
|
+
return console.log(green("✓") + ` Webhook #${id} updated`);
|
|
3226
|
+
}
|
|
3227
|
+
if (sub === "test") {
|
|
3228
|
+
const args = readFlags(rest);
|
|
3229
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks test <id>");
|
|
3230
|
+
const r = need(await request("POST", args.base, `${V1}/webhooks/${id}/test`, { key, body: {} }), "webhooks test");
|
|
3231
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3232
|
+
return console.log(green("✓") + ` Test event${r.event ? ` (${r.event})` : ""} queued for webhook #${id}. See the result with \`argorant webhooks deliveries ${id}\`.`);
|
|
3233
|
+
}
|
|
3234
|
+
if (sub === "deliveries") {
|
|
3235
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3236
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks deliveries <id> [-n 25]");
|
|
3237
|
+
const r = await fetchPages(args.base, key, `${V1}/webhooks/${id}/deliveries`, pageQuery(args, 25), "deliveries", "webhook deliveries", args.all);
|
|
3238
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3239
|
+
const rows = pickItems(r, "deliveries");
|
|
3240
|
+
if (!rows.length) return console.log(dim("No deliveries yet."));
|
|
3241
|
+
for (const d of rows) {
|
|
3242
|
+
const tag = d.ok || d.status === "delivered" ? green(d.status || "delivered") : yellow(d.status || "pending");
|
|
3243
|
+
console.log(`${dim(fmtDate(d.created_at))} ${d.event || "-"} ${tag}${d.attempts ? dim(` · ${d.attempts} attempt(s)`) : ""}${d.last_error ? dim(` · ${d.last_error}`) : ""}`);
|
|
3244
|
+
}
|
|
3245
|
+
pageFooter(r, "deliveries", `argorant webhooks deliveries ${id}`);
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
if (sub === "rm" || sub === "delete" || sub === "remove") {
|
|
3249
|
+
const args = readFlags(rest);
|
|
3250
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks rm <id> [--yes]");
|
|
3251
|
+
if (!(await confirmOrDie(args, `Delete webhook #${id}? Events stop going to its address.`, `Deleting webhook #${id} needs a confirmation.`))) return;
|
|
3252
|
+
const r = need(await request("DELETE", args.base, `${V1}/webhooks/${id}`, { key }), "webhooks rm");
|
|
3253
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3254
|
+
return console.log(green("✓") + ` Deleted webhook #${id}`);
|
|
3255
|
+
}
|
|
3256
|
+
die(`unknown webhooks subcommand: ${sub} (ls, add, set, test, deliveries, rm, events)`);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// ---- companies ----
|
|
3260
|
+
async function cmdCompanies(argv) {
|
|
3261
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "help";
|
|
3262
|
+
const rest = argv.slice(1);
|
|
3263
|
+
if (sub === "help") {
|
|
3264
|
+
return console.log(`
|
|
3265
|
+
${bold("argorant companies")} count [filters] ${dim("free")}
|
|
3266
|
+
${bold("argorant companies")} search [filters] [-n 25] [--cursor <c>] [--all] ${dim("free")}
|
|
3267
|
+
${bold("argorant companies")} people <company.com> [--title <role>] [-n 5] ${dim("free; same as argorant company")}
|
|
3268
|
+
filters: --industry --country --state --city --company --domain --keywords --technologies --employees 11-50,51-200 [free text]
|
|
3269
|
+
`);
|
|
3270
|
+
}
|
|
3271
|
+
const key = requireKey();
|
|
3272
|
+
if (sub === "count") {
|
|
3273
|
+
const args = readFlags(rest, COMPANY_FILTER_FLAGS);
|
|
3274
|
+
const query = companyFilters(args);
|
|
3275
|
+
const r = need(await request("GET", args.base, `${V1}/companies/count`, { key, query }), "companies count");
|
|
3276
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3277
|
+
return console.log(bold(Number(r.count || 0).toLocaleString()) + dim(" matching companies"));
|
|
3278
|
+
}
|
|
3279
|
+
if (sub === "search" || sub === "ls") {
|
|
3280
|
+
const args = readFlags(rest, { ...COMPANY_FILTER_FLAGS, ...PAGE_FLAGS }, { "--all": "all" });
|
|
3281
|
+
const query = { ...companyFilters(args), ...pageQuery(args, 10) };
|
|
3282
|
+
const r = await fetchPages(args.base, key, `${V1}/companies/search`, query, "companies", "companies search", args.all);
|
|
3283
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3284
|
+
const rows = pickItems(r, "companies");
|
|
3285
|
+
if (!rows.length) return console.log(dim("No matching companies."));
|
|
3286
|
+
for (const co of rows) {
|
|
3287
|
+
const name = co.company_name || co.company || co.name || co.company_domain || "-";
|
|
3288
|
+
const where = [co.city, co.country].filter(Boolean).join(", ");
|
|
3289
|
+
const size = co.employee_range || (co.employee_count ? `${Number(co.employee_count).toLocaleString()} people` : "");
|
|
3290
|
+
console.log(`${bold(name)} ${dim([co.company_domain || co.domain, co.industry, size, where].filter(Boolean).join(" · "))}`);
|
|
3291
|
+
}
|
|
3292
|
+
pageFooter(r, "companies", "argorant companies search ...");
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
if (sub === "people") {
|
|
3296
|
+
return cmdCompany(applySavedBase(parseArgs(rest)));
|
|
3297
|
+
}
|
|
3298
|
+
die(`unknown companies subcommand: ${sub} (count, search, people)`);
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
function companyFilters(args) {
|
|
3302
|
+
const q = {};
|
|
3303
|
+
for (const field of new Set(Object.values(COMPANY_FILTER_FLAGS))) if (args[field]) q[field] = args[field];
|
|
3304
|
+
if (args._.length) q.q = [q.q, ...args._].filter(Boolean).join(" ");
|
|
3305
|
+
return q;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// ---- lookup ----
|
|
3309
|
+
async function cmdLookup(argv) {
|
|
3310
|
+
const raw = String(argv[0] || "").toLowerCase();
|
|
3311
|
+
const kind = LOOKUP_ALIASES[raw] || raw;
|
|
3312
|
+
if (!LOOKUP_KINDS.includes(kind)) {
|
|
3313
|
+
die(`usage: argorant lookup ${LOOKUP_KINDS.join("|")} [text] [-n 20]\n Free. Use the exact labels it returns in your filters (for example --industry "Software Development").`);
|
|
3314
|
+
}
|
|
3315
|
+
const args = readFlags(argv.slice(1), { "-n": "limit", "--limit": "limit" });
|
|
3316
|
+
const key = requireKey();
|
|
3317
|
+
const query = { limit: args.limit !== undefined ? parseLimit(args.limit, "--limit") : 20 };
|
|
3318
|
+
if (args._.length) query.q = args._.join(" ");
|
|
3319
|
+
const r = need(await request("GET", args.base, `${V1}/lookups/${kind}`, { key, query }), `lookup ${kind}`);
|
|
3320
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3321
|
+
const rows = pickItems(r, "suggestions").length ? pickItems(r, "suggestions") : pickItems(r, kind);
|
|
3322
|
+
if (!rows.length) return console.log(dim(`No ${kind} match${query.q ? ` "${query.q}"` : ""}.`));
|
|
3323
|
+
for (const s of rows) {
|
|
3324
|
+
const label = typeof s === "string" ? s : s.label ?? s.value ?? s.name;
|
|
3325
|
+
const count = typeof s === "object" && s ? s.count : undefined;
|
|
3326
|
+
console.log(`${bold(label)}${count !== undefined && count !== null ? dim(` ${Number(count).toLocaleString()}`) : ""}`);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
|
|
2366
3330
|
function money(cents, currency) {
|
|
2367
3331
|
if (cents === null || cents === undefined) return "?";
|
|
2368
3332
|
const sign = String(currency || "usd").toLowerCase() === "eur" ? "€" : "$";
|
|
@@ -2386,10 +3350,13 @@ ${bold("COMMANDS")}
|
|
|
2386
3350
|
${cyan("search")} "<query>" -n 10 Preview matches, details redacted ${dim("(0 contact credits)")}
|
|
2387
3351
|
${cyan("sample")} <company.com> Build 25 distinct, live-valid company leads ${dim("(free sample)")}
|
|
2388
3352
|
${cyan("reveal")} "<query>" -n 25 Reveal full contact details ${dim("(uses credits; live-verified, pay only for deliverable)")}
|
|
3353
|
+
${cyan("reveal")} ... --phones Also add phone numbers ${dim("(10 credits per number found; none found = free)")}
|
|
2389
3354
|
${cyan("enrich")} --email <a@b.com> One address → the full person profile ${dim("(1 credit per match; miss = free)")}
|
|
2390
3355
|
${cyan("enrich")} --name "<n>" --domain <d> Find that person + reveal a verified email ${dim("(1 credit per match)")}
|
|
2391
3356
|
${cyan("enrich")} --domain <d> Company profile ${dim("(0 contact credits)")}
|
|
3357
|
+
${cyan("enrich")} ... --phones Also add the person's phone number ${dim("(10 credits if found)")}
|
|
2392
3358
|
${cyan("export")} "<query>" -n 1000 -o leads.csv Verified CSV export ${dim("(uses credits)")}
|
|
3359
|
+
${cyan("export")} ... --phones Include phone numbers in the file ${dim("(10 credits per number in the file)")}
|
|
2393
3360
|
${cyan("export status")} <job_id> Status of an existing export ${dim("(free; add --batch for >50k)")}
|
|
2394
3361
|
${cyan("export download")} <job_id> -o leads.csv Re-download a finished export ${dim("(free)")}
|
|
2395
3362
|
${cyan("list create")} --name "<n>" [filters] Save a reusable list ${dim("(free)")}
|
|
@@ -2406,6 +3373,13 @@ ${bold("COMMANDS")}
|
|
|
2406
3373
|
${cyan("inboxes")} quote | order | status | renewal New mailboxes on new domains ${dim("(saved card or payment link)")}
|
|
2407
3374
|
${cyan("inbox")} list | read | reply | forward | classify Replies across campaigns; answer in-thread or forward
|
|
2408
3375
|
${cyan("blocklist")} list | add | remove Addresses and domains never contacted
|
|
3376
|
+
${cyan("account")} Plan, credits, request limit, campaign emails ${dim("(free)")}
|
|
3377
|
+
${cyan("lists")} ls | create | show | people | rename | rm | export Saved lists ${dim("(argorant lists help)")}
|
|
3378
|
+
${cyan("exports")} ls | status | download Your exports, a page at a time ${dim("(free)")}
|
|
3379
|
+
${cyan("companies")} count | search | people Companies that match your filters ${dim("(free)")}
|
|
3380
|
+
${cyan("lookup")} industries | countries | keywords | technologies | titles [text] Exact filter labels ${dim("(free)")}
|
|
3381
|
+
${cyan("credits")} packs | quote | buy | purchases Buy credit packs ${dim("(asks first; saved card or payment link)")}
|
|
3382
|
+
${cyan("webhooks")} ls | add | set | test | deliveries | rm Get events sent to your server ${dim("(Pro plan and up)")}
|
|
2409
3383
|
|
|
2410
3384
|
${bold("FILTERS")}
|
|
2411
3385
|
--keywords <k> Comma = OR. The widest, most reliable filter - prefer it
|
|
@@ -2424,17 +3398,27 @@ ${bold("OPTIONS")}
|
|
|
2424
3398
|
--json Raw JSON output -y, --yes Skip confirmations
|
|
2425
3399
|
--base <url> Override API base (or ARGORANT_API_BASE)
|
|
2426
3400
|
--batch Treat the id in \`export status/download\` as a batch id
|
|
3401
|
+
--phones Include phone numbers (reveal, enrich, export). Off by default.
|
|
3402
|
+
10 credits per phone number returned, nothing if none is found;
|
|
3403
|
+
numbers you already unlocked are free. From the Pro plan.
|
|
2427
3404
|
--max-credits <n> Credit cap for a \`find --file\` job (it pauses at the cap)
|
|
3405
|
+
--cursor <c> Next page of a list (printed under each page); --all reads every page
|
|
3406
|
+
--idempotency-key <k> Your own key for this command's writes, so running it
|
|
3407
|
+
again replays the first answer instead of doing the work twice
|
|
3408
|
+
(by default every write gets a fresh key)
|
|
2428
3409
|
--grade <g> valid (default) or valid-plus-catchall - which deliverable
|
|
2429
3410
|
grade to include on reveal/export. You only ever pay for
|
|
2430
3411
|
deliverable contacts; this is the one grade distinction
|
|
2431
3412
|
exposed anywhere. ${dim("(coming soon - currently a no-op; see docs)")}
|
|
2432
3413
|
|
|
2433
3414
|
${bold("NON-INTERACTIVE USE")} ${dim("(agents, CI, pipes)")}
|
|
2434
|
-
${red("reveal, export, verify --file, and find SPEND CREDITS WITHOUT A PROMPT")} whenever
|
|
3415
|
+
${red("reveal, export, lists export, verify --file, and find SPEND CREDITS WITHOUT A PROMPT")} whenever
|
|
2435
3416
|
stdin is not a TTY, or when --yes / --json is passed. The confirmation is a
|
|
2436
3417
|
convenience for humans at a terminal, never a safety net. Check your -n.
|
|
2437
3418
|
A single \`find\` never asks: each lookup costs at most 1 credit.
|
|
3419
|
+
\`credits buy\`, \`lists rm\` and \`webhooks rm\` never run without a yes: at a
|
|
3420
|
+
terminal they ask, anywhere else they need --yes.
|
|
3421
|
+
Errors print a request id. Quote it if you contact support.
|
|
2438
3422
|
|
|
2439
3423
|
${bold("EXIT CODES")}
|
|
2440
3424
|
0 ok · 1 error · 2 not authenticated · 3 forbidden (missing scope)
|
|
@@ -2451,30 +3435,57 @@ ${bold("EXAMPLES")}
|
|
|
2451
3435
|
${p} enrich --email patrick@stripe.com --json
|
|
2452
3436
|
${p} enrich --name "Patrick Collison" --domain stripe.com
|
|
2453
3437
|
${p} enrich --domain stripe.com
|
|
3438
|
+
${p} reveal --title CFO --domain stripe.com -n 5 --phones
|
|
3439
|
+
${p} export --title CFO --country Germany -n 200 --phones -o cfos.csv
|
|
2454
3440
|
${p} verify ceo@stripe.com
|
|
2455
3441
|
${p} verify --file my-list.csv -o verified.csv
|
|
2456
3442
|
${p} find "Patrick Collison" --domain stripe.com
|
|
2457
3443
|
${p} find --file people.csv -o found.csv --max-credits 200
|
|
3444
|
+
${p} lookup industries soft
|
|
3445
|
+
${p} lists create --name "DACH CFOs" --title CFO --country Germany,Austria,Switzerland
|
|
3446
|
+
${p} lists export 1240 -n 500 -o cfos.csv
|
|
3447
|
+
${p} credits buy 5000
|
|
3448
|
+
${p} webhooks add https://example.com/hooks/argorant --events reply.received,reply.positive
|
|
2458
3449
|
|
|
2459
3450
|
Docs: ${cyan("https://argorant.com/docs/cli")}
|
|
2460
3451
|
`);
|
|
2461
3452
|
}
|
|
2462
3453
|
|
|
3454
|
+
// Commands with their own flag vocabulary (readFlags), never the generic
|
|
3455
|
+
// filter parser. `list` with a 0.13.0 subcommand goes to `lists`.
|
|
3456
|
+
const OWN_FLAG_COMMANDS = {
|
|
3457
|
+
campaigns: (a) => cmdCampaigns(a),
|
|
3458
|
+
campaign: (a) => cmdCampaigns(a),
|
|
3459
|
+
inboxes: (a) => cmdInboxes(a),
|
|
3460
|
+
mailboxes: (a) => cmdInboxes(a),
|
|
3461
|
+
inbox: (a) => cmdInboxThreads(a),
|
|
3462
|
+
unibox: (a) => cmdInboxThreads(a),
|
|
3463
|
+
blocklist: (a) => cmdBlocklist(a),
|
|
3464
|
+
find: (a) => cmdFind(a),
|
|
3465
|
+
account: (a) => cmdAccount(a),
|
|
3466
|
+
lists: (a) => cmdLists(a),
|
|
3467
|
+
exports: (a) => cmdExports(a),
|
|
3468
|
+
credits: (a) => cmdCredits(a),
|
|
3469
|
+
webhooks: (a) => cmdWebhooks(a),
|
|
3470
|
+
webhook: (a) => cmdWebhooks(a),
|
|
3471
|
+
companies: (a) => cmdCompanies(a),
|
|
3472
|
+
lookup: (a) => cmdLookup(a),
|
|
3473
|
+
lookups: (a) => cmdLookup(a),
|
|
3474
|
+
};
|
|
3475
|
+
const LISTS_SUBCOMMANDS = new Set(["ls", "people", "items", "rename", "rm", "delete", "remove", "export", "show", "get", "help"]);
|
|
3476
|
+
|
|
2463
3477
|
async function main() {
|
|
2464
|
-
const argv = process.argv.slice(2);
|
|
3478
|
+
const argv = takeIdempotencyFlag(process.argv.slice(2));
|
|
2465
3479
|
const cmd = argv[0];
|
|
2466
3480
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help();
|
|
2467
3481
|
if (cmd === "version" || cmd === "--version" || cmd === "-v") return console.log(VERSION);
|
|
2468
3482
|
// `campaigns` and `find` have their own flag vocabulary (--step, --count, --first, --max-credits, ...)
|
|
2469
3483
|
// handled by readFlags - it never goes through the generic filter parser
|
|
2470
3484
|
// below, which would reject those flags as unknown.
|
|
2471
|
-
|
|
3485
|
+
const own = OWN_FLAG_COMMANDS[cmd] || (cmd === "list" && LISTS_SUBCOMMANDS.has(String(argv[1] || "").toLowerCase()) ? OWN_FLAG_COMMANDS.lists : null);
|
|
3486
|
+
if (own) {
|
|
2472
3487
|
try {
|
|
2473
|
-
|
|
2474
|
-
else if (cmd === "find") await cmdFind(argv.slice(1));
|
|
2475
|
-
else if (cmd === "blocklist") await cmdBlocklist(argv.slice(1));
|
|
2476
|
-
else if (cmd === "inbox" || cmd === "unibox") await cmdInboxThreads(argv.slice(1));
|
|
2477
|
-
else await cmdInboxes(argv.slice(1));
|
|
3488
|
+
await own(argv.slice(1));
|
|
2478
3489
|
} catch (e) {
|
|
2479
3490
|
die(e && e.message ? e.message : String(e));
|
|
2480
3491
|
}
|
|
@@ -2504,4 +3515,14 @@ async function main() {
|
|
|
2504
3515
|
}
|
|
2505
3516
|
}
|
|
2506
3517
|
|
|
2507
|
-
main
|
|
3518
|
+
if (require.main === module) {
|
|
3519
|
+
main();
|
|
3520
|
+
} else {
|
|
3521
|
+
// Loaded by the offline tests (scripts/test-unit.mjs): nothing runs, the
|
|
3522
|
+
// helpers are handed out.
|
|
3523
|
+
module.exports = {
|
|
3524
|
+
parseArgs, readFlags, flagValue, parseLimit, takeIdempotencyFlag, nextIdempotencyKey, isV1Write, IDEM,
|
|
3525
|
+
errorMessage, errorCode, requestIdOf, detailMsg, need, EXIT, request, pageQuery, pickItems, fetchPages, retryWaitSeconds, RATE_RETRY,
|
|
3526
|
+
companyFilters, numericId, fmtPrice, packLine, quoteLines, LOOKUP_KINDS, OWN_FLAG_COMMANDS, VERSION,
|
|
3527
|
+
};
|
|
3528
|
+
}
|