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