argorant 0.4.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 +963 -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,7 @@ const VALUE_FLAGS = {
|
|
|
66
74
|
"--city": "city",
|
|
67
75
|
"--company": "company_name",
|
|
68
76
|
"--domain": "company_domain",
|
|
77
|
+
"--website": "website",
|
|
69
78
|
};
|
|
70
79
|
// Boolean filter flags (presence => "true"). --verified-only is positive intent
|
|
71
80
|
// only (deliverable contacts); there is deliberately NO flag to query invalid or
|
|
@@ -77,24 +86,81 @@ const BOOL_FLAGS = {
|
|
|
77
86
|
"--verified-only": "verified_only",
|
|
78
87
|
};
|
|
79
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
|
+
|
|
80
143
|
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 };
|
|
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 };
|
|
82
145
|
for (let i = 0; i < argv.length; i++) {
|
|
83
146
|
const a = argv[i];
|
|
84
147
|
if (a === "--json") out.json = true;
|
|
85
148
|
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
|
|
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);
|
|
92
155
|
else if (a === "--include-exported") out.includeExported = true;
|
|
93
|
-
else if (a
|
|
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);
|
|
94
159
|
else if (a in BOOL_FLAGS) out.filters[BOOL_FLAGS[a]] = "true";
|
|
95
160
|
else if (a.startsWith("--") && a.includes("=")) {
|
|
96
161
|
const [k, v] = [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)];
|
|
97
162
|
if (k in VALUE_FLAGS) out.filters[VALUE_FLAGS[k]] = v;
|
|
163
|
+
else if (k === "--grade") setGrade(out, v);
|
|
98
164
|
else die(`unknown flag: ${k}`);
|
|
99
165
|
} else if (a.startsWith("-") && a !== "-") {
|
|
100
166
|
die(`unknown flag: ${a}`);
|
|
@@ -178,14 +244,71 @@ function downloadTo(base, urlPath, key, dest) {
|
|
|
178
244
|
});
|
|
179
245
|
}
|
|
180
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
|
+
|
|
181
279
|
function need(res, what) {
|
|
182
|
-
|
|
183
|
-
if (res.status ===
|
|
184
|
-
if (res.status ===
|
|
185
|
-
|
|
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}).`);
|
|
186
293
|
return res.json || {};
|
|
187
294
|
}
|
|
188
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
|
+
|
|
189
312
|
function requireKey() {
|
|
190
313
|
const k = resolveKey();
|
|
191
314
|
if (!k) die("no API key. Run `argorant login` or set ARGORANT_API_KEY.", 2);
|
|
@@ -227,11 +350,27 @@ async function cmdLogin(args) {
|
|
|
227
350
|
const res = await request("GET", args.base, "/api/mcp/account", { key });
|
|
228
351
|
if (res.status === 401) die("that key was rejected (401). Double-check you copied the whole ag_live_ key.", 2);
|
|
229
352
|
const acct = need(res, "login");
|
|
230
|
-
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 });
|
|
231
354
|
console.log(green("✓") + ` Logged in as ${bold(acct.email || "your account")} ${dim("(" + (acct.role || "member") + ")")}`);
|
|
232
355
|
console.log(dim(`Key saved to ${CONFIG_PATH}`));
|
|
233
356
|
}
|
|
234
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
|
+
|
|
235
374
|
async function cmdWhoami(args) {
|
|
236
375
|
const key = requireKey();
|
|
237
376
|
const res = await request("GET", args.base, "/api/mcp/account", { key });
|
|
@@ -240,84 +379,352 @@ async function cmdWhoami(args) {
|
|
|
240
379
|
console.log(`${bold("Account")} ${a.email || "—"} ${dim("(" + (a.role || "member") + ")")}`);
|
|
241
380
|
console.log(`${bold("Scopes")} ${(a.scopes || []).join(", ") || "—"}`);
|
|
242
381
|
const u = a.usage || {};
|
|
243
|
-
|
|
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) => {
|
|
244
394
|
const x = u[k];
|
|
245
|
-
if (!x) return;
|
|
246
|
-
const lim = x.daily_limit == null ? "unlimited" : x.daily_limit;
|
|
247
|
-
|
|
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`;
|
|
248
399
|
};
|
|
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 {
|
|
400
|
+
if (u.unlimited) {
|
|
256
401
|
console.log(dim("Quota: unlimited"));
|
|
402
|
+
return;
|
|
257
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);
|
|
258
410
|
}
|
|
259
411
|
|
|
260
412
|
async function cmdCount(args) {
|
|
261
413
|
const key = requireKey();
|
|
414
|
+
warnExcludeTitleGap(args.filters);
|
|
415
|
+
if (args.gradeExplicit) warnGradeGap("browse");
|
|
262
416
|
const res = await request("GET", args.base, "/api/mcp/people/count", { key, query: args.filters });
|
|
263
417
|
const r = need(res, "count");
|
|
264
418
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
265
419
|
console.log(bold(Number(r.count).toLocaleString()) + dim(" matching contacts"));
|
|
266
420
|
}
|
|
267
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
|
+
|
|
268
466
|
async function cmdSearch(args) {
|
|
269
467
|
const key = requireKey();
|
|
468
|
+
warnExcludeTitleGap(args.filters);
|
|
469
|
+
if (args.gradeExplicit) warnGradeGap("browse");
|
|
270
470
|
const query = { ...args.filters, limit: args.limit || 5 };
|
|
271
471
|
const res = await request("GET", args.base, "/api/mcp/people/preview", { key, query });
|
|
272
472
|
const r = need(res, "search");
|
|
273
473
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
274
474
|
console.log(dim(`${Number(r.total).toLocaleString()} total · showing ${r.returned} (details redacted — use \`reveal\` or \`export\`)`));
|
|
275
475
|
for (const p of r.results || []) {
|
|
276
|
-
|
|
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(" · ");
|
|
277
479
|
const where = [p.company || p.company_name, p.country].filter(Boolean).join(", ");
|
|
278
480
|
console.log(` ${bold(who || "—")}${where ? dim(" " + where) : ""}`);
|
|
279
481
|
}
|
|
280
482
|
}
|
|
281
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
|
+
|
|
282
544
|
async function cmdReveal(args) {
|
|
283
545
|
const key = requireKey();
|
|
546
|
+
warnExcludeTitleGap(args.filters);
|
|
547
|
+
if (args.gradeExplicit) warnGradeGap("reveal");
|
|
284
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.
|
|
285
551
|
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
286
552
|
const ans = await prompt(`Reveal up to ${bold(limit)} contacts? This uses your quota/credits. [y/N] `);
|
|
287
553
|
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
288
554
|
}
|
|
289
|
-
|
|
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" };
|
|
290
558
|
const res = await request("GET", args.base, "/api/mcp/people/reveal", { key, query });
|
|
291
559
|
const r = need(res, "reveal");
|
|
292
560
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
293
561
|
console.log(dim(`${Number(r.total).toLocaleString()} total · revealed ${r.returned}`));
|
|
294
562
|
for (const p of r.results || []) {
|
|
295
|
-
|
|
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(" · ");
|
|
296
567
|
console.log(` ${bold(who || "—")}`);
|
|
297
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);
|
|
298
569
|
if (bits.length) console.log(" " + bits.join(dim(" · ")));
|
|
299
570
|
}
|
|
300
571
|
}
|
|
301
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
|
+
|
|
302
674
|
async function cmdExport(args) {
|
|
303
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");
|
|
304
685
|
const limit = args.limit || 1000;
|
|
305
|
-
|
|
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.
|
|
306
691
|
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
307
692
|
const ans = await prompt(`Export up to ${bold(limit)} verified contacts to ${bold(dest)}? Uses quota/credits. [y/N] `);
|
|
308
693
|
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
309
694
|
}
|
|
310
695
|
// Match the MCP/app defaults so the CLI yields the same rows: business email
|
|
311
696
|
// present by default, and skip rows already exported (override with
|
|
312
|
-
// --include-exported). Verification stays live at export time
|
|
697
|
+
// --include-exported). Verification stays live at export time - only deliverable
|
|
313
698
|
// rows are billed; no verification-status filter is exposed.
|
|
314
699
|
const exportFilters = { ...args.filters };
|
|
315
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"];
|
|
316
706
|
const create = await request("POST", args.base, "/api/mcp/exports/create", {
|
|
317
707
|
key,
|
|
318
|
-
body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported },
|
|
708
|
+
body: { limit, filters: exportFilters, exclude_previously_exported: !args.includeExported, grades },
|
|
319
709
|
});
|
|
320
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
|
+
}
|
|
321
728
|
const statusPath = job.status_api_path || (job.job_id ? `/api/mcp/exports/${job.job_id}` : null);
|
|
322
729
|
if (!statusPath) {
|
|
323
730
|
if (args.json) return console.log(JSON.stringify(job, null, 2));
|
|
@@ -339,23 +746,24 @@ async function cmdExport(args) {
|
|
|
339
746
|
downloadPath = downloadPath || `/api/mcp/exports/${job.job_id}/download`;
|
|
340
747
|
break;
|
|
341
748
|
}
|
|
342
|
-
if (
|
|
343
|
-
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
|
+
}
|
|
344
753
|
}
|
|
345
754
|
if (!args.json) process.stdout.write("\n");
|
|
346
755
|
if (!downloadPath) {
|
|
347
756
|
if (args.json) return console.log(JSON.stringify(job, null, 2));
|
|
348
|
-
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}\`.`);
|
|
349
764
|
}
|
|
350
|
-
await downloadTo(args.base, downloadPath, key, dest);
|
|
351
765
|
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
|
-
})();
|
|
766
|
+
const rows = countCsvRows(dest);
|
|
359
767
|
console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
|
|
360
768
|
}
|
|
361
769
|
|
|
@@ -399,7 +807,8 @@ async function cmdVerifyFile(args, key) {
|
|
|
399
807
|
}
|
|
400
808
|
emails = [...new Set(emails)];
|
|
401
809
|
if (!emails.length) die("no email addresses found in file (try --column <name>)");
|
|
402
|
-
const out = args.output || "argorant-verified.csv";
|
|
810
|
+
const out = ensureWritable(args.output || "argorant-verified.csv");
|
|
811
|
+
// Interactive-only by design — see reveal/export.
|
|
403
812
|
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
404
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] `);
|
|
405
814
|
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
@@ -417,7 +826,8 @@ async function cmdVerifyFile(args, key) {
|
|
|
417
826
|
}
|
|
418
827
|
if (!args.json) process.stdout.write("\n");
|
|
419
828
|
if (args.json) return console.log(JSON.stringify({ ok: true, total: all.length, checks_charged: charged, cached, results: all }, null, 2));
|
|
420
|
-
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";
|
|
421
831
|
fs.writeFileSync(out, csv);
|
|
422
832
|
const deliverable = all.filter((r) => r.deliverable).length;
|
|
423
833
|
console.log(green("✓") + ` ${bold(all.length.toLocaleString())} verified → ${bold(out)} ${dim(`(${deliverable.toLocaleString()} deliverable · ${charged.toLocaleString()} checks billed · ${cached.toLocaleString()} free)`)}`);
|
|
@@ -445,7 +855,11 @@ async function cmdList(args) {
|
|
|
445
855
|
if (sub === "status" || sub === "show" || sub === "get") {
|
|
446
856
|
const id = args._[1] || args.name;
|
|
447
857
|
if (!id) die("usage: argorant list status <list_id>");
|
|
448
|
-
|
|
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 });
|
|
449
863
|
const r = need(res, "list status");
|
|
450
864
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
451
865
|
const total = Number(r.snapshot_total ?? r.item_count ?? 0);
|
|
@@ -456,6 +870,470 @@ async function cmdList(args) {
|
|
|
456
870
|
die("usage: argorant list create --name \"…\" [filters] | argorant list status <id>");
|
|
457
871
|
}
|
|
458
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);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
459
1337
|
function help() {
|
|
460
1338
|
const p = bold("argorant");
|
|
461
1339
|
console.log(`
|
|
@@ -466,17 +1344,25 @@ ${bold("USAGE")}
|
|
|
466
1344
|
|
|
467
1345
|
${bold("COMMANDS")}
|
|
468
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)
|
|
469
1348
|
${cyan("whoami")} Account, scopes, and daily quota
|
|
470
|
-
${cyan("count")} "<query>" Count matching contacts ${dim("(
|
|
471
|
-
${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)")}
|
|
472
1353
|
${cyan("reveal")} "<query>" -n 25 Reveal full contact details ${dim("(uses credits; live-verified, pay only for deliverable)")}
|
|
473
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)")}
|
|
474
1357
|
${cyan("list create")} --name "<n>" [filters] Save a reusable list ${dim("(free)")}
|
|
475
1358
|
${cyan("list status")} <id> Show a saved list's size ${dim("(free)")}
|
|
476
1359
|
${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
|
|
477
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)")}
|
|
478
1362
|
|
|
479
1363
|
${bold("FILTERS")}
|
|
1364
|
+
--keywords <k> Comma = OR. The widest, most reliable filter - prefer it
|
|
1365
|
+
over --industry (matches tags most records carry).
|
|
480
1366
|
--title <t> --exclude-title <t> --seniority <s> --department <d>
|
|
481
1367
|
--industry <i> --country <c> --geography <r> --state <s>
|
|
482
1368
|
--city <c> --company <name> --domain <domain>
|
|
@@ -484,15 +1370,32 @@ ${bold("FILTERS")}
|
|
|
484
1370
|
${dim("--title is abbreviation-aware (CFO ↔ Chief Financial Officer).")}
|
|
485
1371
|
${dim("--verified-only keeps deliverable contacts; export verifies live & bills only valid.")}
|
|
486
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).")}
|
|
487
1374
|
|
|
488
1375
|
${bold("OPTIONS")}
|
|
489
1376
|
-n, --limit <n> Max rows -o, --output <file> CSV path (export)
|
|
490
1377
|
--json Raw JSON output -y, --yes Skip confirmations
|
|
491
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
|
|
492
1393
|
|
|
493
1394
|
${bold("EXAMPLES")}
|
|
494
1395
|
${p} count "fintech CFOs in germany"
|
|
1396
|
+
${p} company stripe.com
|
|
495
1397
|
${p} search "heads of procurement" --country Germany -n 10
|
|
1398
|
+
${p} sample recruitcrm.io -o sample.csv
|
|
496
1399
|
${p} export --industry fintech --title CFO --country Germany -n 500 -o cfos.csv
|
|
497
1400
|
${p} verify ceo@stripe.com
|
|
498
1401
|
${p} verify --file my-list.csv -o verified.csv
|
|
@@ -506,17 +1409,26 @@ async function main() {
|
|
|
506
1409
|
const cmd = argv[0];
|
|
507
1410
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help();
|
|
508
1411
|
if (cmd === "version" || cmd === "--version" || cmd === "-v") return console.log(VERSION);
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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;
|
|
514
1422
|
}
|
|
1423
|
+
const args = applySavedBase(parseArgs(argv.slice(1)));
|
|
515
1424
|
const table = {
|
|
516
1425
|
login: cmdLogin,
|
|
1426
|
+
logout: cmdLogout,
|
|
517
1427
|
whoami: cmdWhoami,
|
|
518
1428
|
count: cmdCount,
|
|
1429
|
+
company: cmdCompany,
|
|
519
1430
|
search: cmdSearch,
|
|
1431
|
+
sample: cmdSample,
|
|
520
1432
|
reveal: cmdReveal,
|
|
521
1433
|
export: cmdExport,
|
|
522
1434
|
list: cmdList,
|