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.
Files changed (3) hide show
  1. package/README.md +126 -10
  2. package/bin/argorant.js +963 -51
  3. 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 = parseInt(argv[++i], 10);
87
- else if (a === "-o" || a === "--output") out.output = argv[++i];
88
- else if (a === "-f" || a === "--file") out.file = argv[++i];
89
- else if (a === "--column") out.column = argv[++i];
90
- else if (a === "--base") out.base = argv[++i];
91
- else if (a === "--name") out.name = argv[++i];
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 in VALUE_FLAGS) out.filters[VALUE_FLAGS[a]] = argv[++i];
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
- if (res.status === 401) die("not authenticated. Run `argorant login` or set ARGORANT_API_KEY.", 2);
183
- if (res.status === 403) die((res.json && res.json.detail) || `forbidden your key lacks the scope for ${what}.`, 3);
184
- if (res.status === 429) die((res.json && res.json.detail) || "rate limit / daily quota reached.", 4);
185
- if (res.status >= 400) die((res.json && res.json.detail) || `${what} failed (HTTP ${res.status}).`);
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
- const line = (label, k) => {
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
- console.log(` ${label.padEnd(8)} ${x.used ?? 0}/${lim} today`);
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 (!u.unlimited) {
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
- const who = [p.name, p.title].filter(Boolean).join(" · ");
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
- const query = { ...args.filters, limit };
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
- const who = [p.name, p.title].filter(Boolean).join(" · ");
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
- const dest = args.output || "argorant-leads.csv";
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 only deliverable
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 (["failed", "error", "cancelled", "canceled"].includes(status)) die(`\nexport ${status}.`);
343
- if (Date.now() - started > 1000 * 60 * 20) die("\nexport timed out after 20 minutes.");
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("Export ready but no download path returned yet. Re-run with --json to inspect the job, or check the Exports page in the app.");
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 csv = ["email,status,deliverable", ...all.map((r) => `${r.email},${r.status},${r.deliverable}`)].join("\n") + "\n";
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
- const res = await request("GET", args.base, `/api/mcp/lists/${encodeURIComponent(id)}`, { key });
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("(free)")}
471
- ${cyan("search")} "<query>" -n 10 Preview matches, details redacted ${dim("(free)")}
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
- const args = parseArgs(argv.slice(1));
510
- // Allow a saved non-default base from login.
511
- if (args.base === DEFAULT_BASE) {
512
- const saved = loadConfig().base;
513
- if (saved && !process.env.ARGORANT_API_BASE) args.base = saved;
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,