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