argorant 0.14.0 → 0.15.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/bin/argorant.js CHANGED
@@ -54,28 +54,138 @@ function resolveKey() {
54
54
  }
55
55
 
56
56
  // ---- arg parsing ----
57
- // Flags that map to API filter params. Value flags take the next token.
58
- const VALUE_FLAGS = {
59
- "--title": "title",
60
- "--exclude-title": "exclude_title",
61
- "--seniority": "seniority",
62
- "--department": "departments",
63
- "--departments": "departments",
64
- "--industry": "industry",
65
- // --keywords is the highest-recall door in the index (matches source keyword
66
- // tags + derived company tags, comma = OR). Measured against real segments it
67
- // beats --industry by 2-4x, so it leads the docs and examples below.
68
- "--keywords": "keywords",
57
+ // ---- one filter vocabulary (0.15.0) ----
58
+ // Every filter-taking command (count, search, reveal, export, list create, lists create, lists add,
59
+ // companies, company people, campaigns leads add) reads its filters from ONE map, built from the
60
+ // filter schema the API publishes (bin/search-filters.json, the same file the API, the app and the
61
+ // connector use). Each list filter has an include flag and an exclude flag, usable together.
62
+ const FILTER_SCHEMA = (() => {
63
+ try {
64
+ return require("./search-filters.json");
65
+ } catch {
66
+ return { filters: [], modes: {}, cli_flags: {} };
67
+ }
68
+ })();
69
+ // Older spellings that keep working.
70
+ const FILTER_ALIASES = {
69
71
  "--keyword": "keywords",
70
- "--country": "country",
71
- // --geography is an alias for --country; the API expands regions like
72
- // "Europe", "EMEA", "DACH", "APAC" into their member countries.
72
+ "--exclude-keyword": "exclude_keywords",
73
+ "--departments": "departments",
74
+ "--exclude-departments": "exclude_departments",
73
75
  "--geography": "country",
74
76
  "--region": "country",
75
- "--state": "state",
76
- "--city": "city",
77
+ "--exclude-geography": "exclude_country",
78
+ "--exclude-region": "exclude_country",
77
79
  "--company": "company_name",
78
- "--domain": "company_domain",
80
+ "--company-domain": "company_domain",
81
+ "--exclude-company-domain": "exclude_company_domain",
82
+ "--employee-range": "employee_range",
83
+ "--technologies": "technologies",
84
+ "--technology": "technologies",
85
+ "--near-place": "near_place",
86
+ };
87
+ const BOOL_PARAMS = new Set(FILTER_SCHEMA.filters.filter((f) => f.kind === "boolean").map((f) => f.include));
88
+ // flag -> API parameter, value flags only (booleans below). --near takes a place (a city or postal code such as
89
+ // "Denver, Colorado", or a place id) or "lat,lon".
90
+ const FILTER_VALUE_FLAGS = (() => {
91
+ const out = {};
92
+ for (const [param, flag] of Object.entries(FILTER_SCHEMA.cli_flags || {})) {
93
+ if (!BOOL_PARAMS.has(param)) out[flag] = param;
94
+ }
95
+ out["--near"] = "near";
96
+ return { ...out, ...FILTER_ALIASES };
97
+ })();
98
+ const FILTER_BOOL_FLAGS = (() => {
99
+ const out = {};
100
+ for (const [param, flag] of Object.entries(FILTER_SCHEMA.cli_flags || {})) if (BOOL_PARAMS.has(param)) out[flag] = param;
101
+ return out;
102
+ })();
103
+ // Finite values (checked here, before any request): parameter -> [{value, label}].
104
+ const FILTER_ENUMS = (() => {
105
+ const out = {};
106
+ for (const f of FILTER_SCHEMA.filters || []) {
107
+ if (f.kind === "enum_list" && Array.isArray(f.values)) {
108
+ for (const p of [f.include, f.exclude]) if (p) out[p] = f.values;
109
+ }
110
+ if (f.kind === "radius") {
111
+ if (Array.isArray(f.units)) out.radius_unit = f.units;
112
+ if (Array.isArray(f.targets)) out.radius_target = f.targets;
113
+ }
114
+ }
115
+ for (const [name, m] of Object.entries(FILTER_SCHEMA.modes || {})) if (Array.isArray(m.values)) out[name] = m.values;
116
+ return out;
117
+ })();
118
+ const SINGLE_VALUE = new Set(["title_match", "exclude_title_match", "keyword_scope", "radius_unit", "radius_target"]);
119
+ // Filters for company records (people-only ones are refused by the API on company searches).
120
+ const COMPANY_PARAMS = new Set([
121
+ "q", "industry", "exclude_industry", "industries", "exclude_industries", "country", "exclude_country", "state",
122
+ "exclude_state", "city", "exclude_city", "company_name", "company_domain", "exclude_company_domain", "employee_range",
123
+ "employees_min", "employees_max", "revenue_band", "revenue_min", "revenue_max", "keywords", "exclude_keywords",
124
+ "keyword_scope", "technologies", "near", "near_place", "near_lat", "near_lon", "radius", "radius_unit", "radius_target",
125
+ ]);
126
+ // What `company <domain>` passes on (the people at one company).
127
+ const COMPANY_PEOPLE_PARAMS = new Set([
128
+ "title", "exclude_title", "title_match", "exclude_title_match", "seniority", "exclude_seniority", "departments",
129
+ "exclude_departments", "country", "exclude_country", "exclude_state", "exclude_city", "gender", "income_band",
130
+ ]);
131
+ function flagOfParam(param) {
132
+ return (FILTER_SCHEMA.cli_flags || {})[param] || `--${String(param).replace(/_/g, "-")}`;
133
+ }
134
+ function fold(v) {
135
+ return String(v).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
136
+ }
137
+ // Check and spell finite values the canonical way; plain error listing what is allowed.
138
+ function checkEnum(param, raw) {
139
+ const allowed = FILTER_ENUMS[param];
140
+ if (!allowed) return raw;
141
+ const match = (v) => allowed.find((a) => fold(a.value) === fold(v) || fold(a.label) === fold(v) || fold(a.value).replace(/[\s,]/g, "") === fold(v).replace(/[\s,]/g, ""));
142
+ const whole = match(String(raw).trim());
143
+ if (whole) return whole.value; // a label with a comma in it, such as 501-1,000
144
+ const parts = SINGLE_VALUE.has(param) ? [String(raw)] : String(raw).split(",");
145
+ const out = [];
146
+ for (const part of parts) {
147
+ const v = part.trim();
148
+ if (!v) continue;
149
+ const hit = match(v);
150
+ if (!hit) die(`${flagOfParam(param)} takes: ${allowed.map((a) => a.value).join(", ")} (got "${v}").`);
151
+ if (!out.includes(hit.value)) out.push(hit.value);
152
+ }
153
+ return out.join(",");
154
+ }
155
+ // The filters of one command, ready to send: values checked, --near split.
156
+ function finishFilters(filters) {
157
+ const out = {};
158
+ for (const [k, v] of Object.entries(filters || {})) {
159
+ if (v === undefined || v === null || v === "") continue;
160
+ out[k] = typeof v === "string" ? checkEnum(k, v) : v;
161
+ }
162
+ if (out.near !== undefined) {
163
+ const near = String(out.near).trim();
164
+ delete out.near;
165
+ const m = near.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
166
+ if (m) {
167
+ out.near_lat = m[1];
168
+ out.near_lon = m[2];
169
+ } else {
170
+ out.near_place = near;
171
+ }
172
+ }
173
+ const hasPlace = out.near_place || (out.near_lat !== undefined && out.near_lon !== undefined);
174
+ if (out.radius !== undefined && !hasPlace) die("--radius needs a place: --near \"Denver, Colorado\" or --near <lat,lon>.");
175
+ if (hasPlace && out.radius === undefined) die("--near needs a distance: add --radius, for example --radius 25.");
176
+ return out;
177
+ }
178
+ // Collect the filters a readFlags() result holds for a flag map.
179
+ function collectFilters(args, valueFlags, boolFlags = FILTER_BOOL_FLAGS) {
180
+ const out = {};
181
+ for (const field of new Set(Object.values(valueFlags))) if (args[field] !== undefined && args[field] !== "") out[field] = args[field];
182
+ for (const field of new Set(Object.values(boolFlags))) if (args[field]) out[field] = "true";
183
+ return finishFilters(out);
184
+ }
185
+
186
+ // Flags that map to API filter params for the generic parser. Value flags take the next token.
187
+ const VALUE_FLAGS = {
188
+ ...FILTER_VALUE_FLAGS,
79
189
  "--website": "website",
80
190
  // Used by `enrich` (email → full profile). Harmless on the other commands,
81
191
  // which simply ignore an unknown query field.
@@ -85,9 +195,7 @@ const VALUE_FLAGS = {
85
195
  // only (deliverable contacts); there is deliberately NO flag to query invalid or
86
196
  // any raw verification status - that is never exposed on any surface.
87
197
  const BOOL_FLAGS = {
88
- "--has-phone": "has_phone",
89
- "--has-linkedin": "has_linkedin",
90
- "--has-email": "has_email",
198
+ ...FILTER_BOOL_FLAGS,
91
199
  "--verified-only": "verified_only",
92
200
  };
93
201
 
@@ -105,16 +213,6 @@ function setGrade(out, v) {
105
213
  out.gradeExplicit = true;
106
214
  }
107
215
 
108
- // --exclude-title is fully applied by `export` and `list create` today (the
109
- // platform forwards it into the title-exclusion query on those two paths).
110
- // `count`, `search`, and `reveal` go through a separate read path that does
111
- // not yet apply it (see GODMODE-PLAN.md). Warn instead of silently dropping
112
- // a filter the user asked for.
113
- function warnExcludeTitleGap(filters) {
114
- if (filters.exclude_title) {
115
- warn(`--exclude-title is not applied by this command yet (platform-side gap) - it works with \`export\` and \`list create\`.`);
116
- }
117
- }
118
216
  function warnGradeGap(scope) {
119
217
  if (scope === "browse") warn(`--grade has no effect on count/search - grading only applies at reveal/export time.`);
120
218
  else if (scope === "reveal") warn(`--grade is coming soon for reveal - it currently always returns the platform's standard deliverable set.`);
@@ -178,6 +276,7 @@ function parseArgs(argv) {
178
276
  }
179
277
  // Free-text positional → q
180
278
  if (out._.length) out.filters.q = out._.join(" ");
279
+ out.filters = finishFilters(out.filters);
181
280
  return out;
182
281
  }
183
282
 
@@ -432,6 +531,8 @@ function need(res, what) {
432
531
  const wait = Number.isFinite(ra) && ra > 0 ? ` Try again in ${ra === 1 ? "1 second" : fmtWait(Math.ceil(ra))}.` : "";
433
532
  die(`${msg || "too many requests right now, or today's limit is used up."}${wait}${ref}`, EXIT.RATE_LIMIT);
434
533
  }
534
+ const notReady = res.res && res.res.headers && res.res.headers["x-argorant-filter-not-ready"];
535
+ if (notReady) die(`${msg || "This filter is not ready yet."}\nRun \`argorant filters\` to see which filters can be used now.${ref}`);
435
536
  die(`${msg || `${what} failed (HTTP ${res.status}).`}${ref}`);
436
537
  }
437
538
 
@@ -561,7 +662,6 @@ async function cmdWhoami(args) {
561
662
 
562
663
  async function cmdCount(args) {
563
664
  const key = requireKey();
564
- warnExcludeTitleGap(args.filters);
565
665
  if (args.gradeExplicit) warnGradeGap("browse");
566
666
  const res = await request("GET", args.base, "/api/mcp/people/count", { key, query: args.filters });
567
667
  const r = need(res, "count");
@@ -580,13 +680,13 @@ async function cmdCompany(args) {
580
680
  if (!domain || !domain.includes(".")) {
581
681
  die("usage: argorant company <company.com> [--title <role>] [-n 5] [--json]");
582
682
  }
583
- const query = {
584
- title: args.filters.title,
585
- seniority: args.filters.seniority,
586
- departments: args.filters.departments,
587
- country: args.filters.country,
588
- limit: args.limit || 5,
589
- };
683
+ const query = { limit: args.limit || 5 };
684
+ const ignored = [];
685
+ for (const [k, v] of Object.entries(args.filters)) {
686
+ if (COMPANY_PEOPLE_PARAMS.has(k)) query[k] = v;
687
+ else if (!["company_domain", "q", "website"].includes(k)) ignored.push(flagOfParam(k));
688
+ }
689
+ if (ignored.length) die(`\`company\` looks at one company's people; it does not take ${ignored.join(", ")}. Use \`count\` or \`search\` with --domain instead.`);
590
690
  const res = await request(
591
691
  "GET",
592
692
  args.base,
@@ -615,7 +715,6 @@ async function cmdCompany(args) {
615
715
 
616
716
  async function cmdSearch(args) {
617
717
  const key = requireKey();
618
- warnExcludeTitleGap(args.filters);
619
718
  if (args.gradeExplicit) warnGradeGap("browse");
620
719
  const query = { ...args.filters, limit: args.limit || 5 };
621
720
  const res = await request("GET", args.base, "/api/mcp/people/preview", { key, query });
@@ -768,7 +867,6 @@ function phoneLine(p) {
768
867
 
769
868
  async function cmdReveal(args) {
770
869
  const key = requireKey();
771
- warnExcludeTitleGap(args.filters);
772
870
  if (args.gradeExplicit) warnGradeGap("reveal");
773
871
  const limit = args.limit || 10;
774
872
  const pricing = args.phones ? await phonePricing(args, key) : null;
@@ -1222,9 +1320,16 @@ async function cmdList(args) {
1222
1320
  const res = await request("POST", args.base, "/api/mcp/lists/create", { key, body });
1223
1321
  const r = need(res, "list create");
1224
1322
  if (args.json) return console.log(JSON.stringify(r, null, 2));
1323
+ const fill = await listFill(args.base, key, r.list_id, r);
1324
+ if (fill && fill.filling) {
1325
+ console.log(green("✓") + ` Created list ${bold("#" + r.list_id)} ${dim("“" + r.name + "”")}`);
1326
+ console.log(` ${fillLine(fill)}`);
1327
+ console.log(dim(`Check it: argorant lists show ${r.list_id}`));
1328
+ return;
1329
+ }
1225
1330
  const total = Number(r.snapshot_total || 0);
1226
1331
  console.log(green("✓") + ` Created list ${bold("#" + r.list_id)} ${dim("“" + r.name + "”")} - ${bold(total.toLocaleString())} matching contacts`);
1227
- console.log(dim(`Export it with: argorant export ${Object.entries(args.filters).filter(([, v]) => v).map(([k, v]) => `--${k.replace(/_/g, "-")} ${/\s/.test(String(v)) ? `"${v}"` : v}`).join(" ")} -o leads.csv`));
1332
+ console.log(dim(`Export it with: argorant export ${Object.entries(args.filters).filter(([, v]) => v).map(([k, v]) => (k === "q" ? (/\s/.test(String(v)) ? `"${v}"` : String(v)) : FILTER_BOOL_FLAGS[flagOfParam(k)] ? flagOfParam(k) : `${flagOfParam(k)} ${/\s/.test(String(v)) ? `"${v}"` : v}`)).join(" ")} -o leads.csv`));
1228
1333
  return;
1229
1334
  }
1230
1335
  if (sub === "status" || sub === "show" || sub === "get") {
@@ -1239,7 +1344,9 @@ async function cmdList(args) {
1239
1344
  if (args.json) return console.log(JSON.stringify(r, null, 2));
1240
1345
  const total = Number(r.snapshot_total ?? r.item_count ?? 0);
1241
1346
  console.log(`${bold("List #" + (r.list_id ?? id))} ${dim("“" + (r.name || "-") + "”")}`);
1242
- console.log(` ${bold(total.toLocaleString())} contacts · ${dim((r.selection_mode || "filtered") + " · " + (r.record_type || "person"))}`);
1347
+ const fill = await listFill(args.base, key, String(id).trim(), null);
1348
+ if (fill && (fill.filling || (fill.fill && fill.fill.status === "failed"))) console.log(` ${fillLine(fill)}`);
1349
+ else console.log(` ${bold(total.toLocaleString())} contacts · ${dim((r.selection_mode || "filtered") + " · " + (r.record_type || "person"))}`);
1243
1350
  return;
1244
1351
  }
1245
1352
  die("usage: argorant list create --name \"…\" [filters] | argorant list status <id>");
@@ -1270,29 +1377,8 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
1270
1377
  // filter-enroll endpoint doesn't accept, e.g. --verified-only), plus --query
1271
1378
  // as an explicit alias for free-text `q` (clearer than a bare positional in a
1272
1379
  // command that already takes a campaign name/id as its first positional).
1273
- const CAMPAIGN_FILTER_VALUE_FLAGS = {
1274
- "--query": "q",
1275
- "--title": "title",
1276
- "--exclude-title": "exclude_title",
1277
- "--seniority": "seniority",
1278
- "--department": "departments",
1279
- "--departments": "departments",
1280
- "--industry": "industry",
1281
- "--keywords": "keywords",
1282
- "--keyword": "keywords",
1283
- "--country": "country",
1284
- "--geography": "country",
1285
- "--region": "country",
1286
- "--state": "state",
1287
- "--city": "city",
1288
- "--company": "company_name",
1289
- "--domain": "company_domain",
1290
- };
1291
- const CAMPAIGN_FILTER_BOOL_FLAGS = {
1292
- "--has-phone": "has_phone",
1293
- "--has-linkedin": "has_linkedin",
1294
- "--has-email": "has_email",
1295
- };
1380
+ const CAMPAIGN_FILTER_VALUE_FLAGS = { "--query": "q", ...FILTER_VALUE_FLAGS };
1381
+ const CAMPAIGN_FILTER_BOOL_FLAGS = { ...FILTER_BOOL_FLAGS };
1296
1382
 
1297
1383
  // Minimal flag reader shared by every `campaigns` subcommand: pulls out the
1298
1384
  // universal --json/--yes/--base plus whatever value/bool flags the caller
@@ -1721,9 +1807,7 @@ async function campaignsLeads(argv) {
1721
1807
  }
1722
1808
 
1723
1809
  // Filter enrollment straight from the database: operator keys.
1724
- const filters = {};
1725
- for (const field of Object.values(CAMPAIGN_FILTER_VALUE_FLAGS)) if (args[field]) filters[field] = args[field];
1726
- for (const field of Object.values(CAMPAIGN_FILTER_BOOL_FLAGS)) if (args[field]) filters[field] = "true";
1810
+ const filters = collectFilters(args, CAMPAIGN_FILTER_VALUE_FLAGS, CAMPAIGN_FILTER_BOOL_FLAGS);
1727
1811
  if (!Object.keys(filters).length) die(usage);
1728
1812
  const body = { filters };
1729
1813
  if (args.limit) body.limit = parseLimit(args.limit, "-n");
@@ -3003,25 +3087,11 @@ async function cmdFind(argv) {
3003
3087
 
3004
3088
  const V1 = "/api/v1";
3005
3089
  const PAGE_FLAGS = { "-n": "limit", "--limit": "limit", "--cursor": "cursor", "--offset": "offset" };
3006
- const COMPANY_FILTER_FLAGS = {
3007
- "--query": "q",
3008
- "--q": "q",
3009
- "--industry": "industry",
3010
- "--industries": "industries",
3011
- "--country": "country",
3012
- "--geography": "country",
3013
- "--region": "country",
3014
- "--state": "state",
3015
- "--city": "city",
3016
- "--company": "company_name",
3017
- "--domain": "company_domain",
3018
- "--employees": "employee_range",
3019
- "--employee-range": "employee_range",
3020
- "--keywords": "keywords",
3021
- "--keyword": "keywords",
3022
- "--technologies": "technologies",
3023
- "--technology": "technologies",
3024
- };
3090
+ const COMPANY_FILTER_FLAGS = (() => {
3091
+ const out = { "--query": "q", "--q": "q" };
3092
+ for (const [flag, param] of Object.entries(FILTER_VALUE_FLAGS)) if (COMPANY_PARAMS.has(param)) out[flag] = param;
3093
+ return out;
3094
+ })();
3025
3095
  const LOOKUP_KINDS = ["industries", "countries", "keywords", "technologies", "titles"];
3026
3096
  const LOOKUP_ALIASES = { industry: "industries", country: "countries", keyword: "keywords", technology: "technologies", tech: "technologies", title: "titles" };
3027
3097
 
@@ -3192,29 +3262,14 @@ ${bold("Argorant lists")} - saved lists of people (free to create and browse)
3192
3262
  ${p} export <id> [-n 1000] [-o leads.csv] [--include-exported] [--include-phones] [--no-wait] [--yes]
3193
3263
  ${dim("1 credit per valid contact; with --include-phones 10 credits per phone number returned")}
3194
3264
 
3195
- filters for create: --title --seniority --department --industry --keywords --country --state --city --company --domain
3265
+ ${p} add <id> [filters] ${dim("adds every match of a search; people already in it are skipped")}
3266
+
3267
+ filters for create and add: the same as everywhere (argorant help, FILTERS), include and exclude
3196
3268
  ${dim("Add --json to any command for the raw answer.")}
3197
3269
  `);
3198
3270
  }
3199
3271
 
3200
- const LIST_FILTER_FLAGS = {
3201
- "--query": "q",
3202
- "--title": "title",
3203
- "--exclude-title": "exclude_title",
3204
- "--seniority": "seniority",
3205
- "--department": "departments",
3206
- "--departments": "departments",
3207
- "--industry": "industry",
3208
- "--keywords": "keywords",
3209
- "--keyword": "keywords",
3210
- "--country": "country",
3211
- "--geography": "country",
3212
- "--region": "country",
3213
- "--state": "state",
3214
- "--city": "city",
3215
- "--company": "company_name",
3216
- "--domain": "company_domain",
3217
- };
3272
+ const LIST_FILTER_FLAGS = { "--query": "q", ...FILTER_VALUE_FLAGS };
3218
3273
 
3219
3274
  function listLine(l) {
3220
3275
  const size = Number(l.snapshot_total || l.item_count || 0);
@@ -3245,20 +3300,40 @@ async function cmdLists(argv) {
3245
3300
  return;
3246
3301
  }
3247
3302
  if (sub === "create" || sub === "new") {
3248
- const args = readFlags(rest, { ...LIST_FILTER_FLAGS, "--name": "name" }, { "--has-phone": "has_phone", "--has-linkedin": "has_linkedin", "--has-email": "has_email" });
3303
+ const args = readFlags(rest, { ...LIST_FILTER_FLAGS, "--name": "name" }, FILTER_BOOL_FLAGS);
3249
3304
  const name = String(args.name || "").trim();
3250
3305
  if (!name) die('usage: argorant lists create --name "My list" [filters] (e.g. --title CEO --country Germany)');
3251
- const filters = {};
3252
- for (const field of Object.values(LIST_FILTER_FLAGS)) if (args[field]) filters[field] = args[field];
3253
- for (const field of ["has_phone", "has_linkedin", "has_email"]) if (args[field]) filters[field] = "true";
3306
+ const filters = collectFilters(args, LIST_FILTER_FLAGS);
3254
3307
  if (args._.length) filters.q = args._.join(" ");
3255
3308
  if (!Object.keys(filters).length) die("give at least one filter, for example --title CEO --country Germany");
3256
3309
  const body = { name, filters };
3257
3310
  const r = need(await request("POST", args.base, `${V1}/lists`, { key, body }), "lists create");
3258
3311
  if (args.json) return console.log(JSON.stringify(r, null, 2));
3259
3312
  const l = r.list && typeof r.list === "object" ? r.list : r;
3260
- console.log(green("✓") + ` Saved list ${bold("#" + (l.list_id ?? l.id))} ${dim("“" + (l.name || name) + "”")} · ${bold(Number(l.snapshot_total || 0).toLocaleString())} matching people`);
3261
- console.log(dim(`Export it: argorant lists export ${l.list_id ?? l.id} -n 500 -o leads.csv`));
3313
+ const lid = l.list_id ?? l.id;
3314
+ const fill = await listFill(args.base, key, lid, r);
3315
+ if (fill && fill.filling) {
3316
+ console.log(green("✓") + ` Saved list ${bold("#" + lid)} ${dim("“" + (l.name || name) + "”")}`);
3317
+ console.log(` ${fillLine(fill)}`);
3318
+ console.log(dim(`Check it: argorant lists show ${lid}`));
3319
+ return;
3320
+ }
3321
+ console.log(green("✓") + ` Saved list ${bold("#" + lid)} ${dim("“" + (l.name || name) + "”")} · ${bold(Number(l.snapshot_total || 0).toLocaleString())} matching people`);
3322
+ console.log(dim(`Export it: argorant lists export ${lid} -n 500 -o leads.csv`));
3323
+ return;
3324
+ }
3325
+ if (sub === "add") {
3326
+ const args = readFlags(rest, LIST_FILTER_FLAGS, FILTER_BOOL_FLAGS);
3327
+ const id = numericId(args._[0], "list id", "usage: argorant lists add <id> [filters] (adds every match; people already in the list are skipped)");
3328
+ const filters = collectFilters(args, LIST_FILTER_FLAGS);
3329
+ if (args._.length > 1) filters.q = args._.slice(1).join(" ");
3330
+ if (!Object.keys(filters).length) die("give at least one filter, for example --title CEO --country Germany");
3331
+ const r = need(await request("POST", args.base, `${V1}/lists/${id}/items`, { key, body: { filters } }), "lists add");
3332
+ if (args.json) return console.log(JSON.stringify(r, null, 2));
3333
+ if (r.summary) console.log(green("✓") + " " + r.summary);
3334
+ else console.log(green("✓") + ` Added ${Number(r.added || 0).toLocaleString()} people to list #${id}`);
3335
+ const fill = await listFill(args.base, key, id, r);
3336
+ if (fill && fill.filling) console.log(` ${fillLine(fill)}`);
3262
3337
  return;
3263
3338
  }
3264
3339
  if (sub === "show" || sub === "get" || sub === "status") {
@@ -3267,7 +3342,14 @@ async function cmdLists(argv) {
3267
3342
  const r = need(await request("GET", args.base, `${V1}/lists/${id}`, { key }), "list");
3268
3343
  if (args.json) return console.log(JSON.stringify(r, null, 2));
3269
3344
  const l = r.list && typeof r.list === "object" ? r.list : r;
3270
- console.log(listLine(l));
3345
+ const fill = await listFill(args.base, key, id, null);
3346
+ if (fill && fill.filling) {
3347
+ console.log(`${bold("#" + (l.list_id ?? l.id ?? id))} ${l.name || "-"}`);
3348
+ console.log(` ${fillLine(fill)}`);
3349
+ } else {
3350
+ console.log(listLine(l));
3351
+ if (fill && fill.fill && fill.fill.status === "failed") console.log(` ${fillLine(fill)}`);
3352
+ }
3271
3353
  const f = l.filters && typeof l.filters === "object" ? Object.entries(l.filters).filter(([k, v]) => v && k !== "record_type") : [];
3272
3354
  if (f.length) console.log(dim(` filters: ${f.map(([k, v]) => `${k}=${v}`).join(", ")}`));
3273
3355
  console.log(dim(` argorant lists people ${id} · argorant lists export ${id} -o leads.csv`));
@@ -3308,7 +3390,34 @@ async function cmdLists(argv) {
3308
3390
  return console.log(green("✓") + ` Deleted list #${id}`);
3309
3391
  }
3310
3392
  if (sub === "export") return listsExport(rest, key);
3311
- die(`unknown lists subcommand: ${sub} (ls, create, show, people, rename, rm, export)`);
3393
+ die(`unknown lists subcommand: ${sub} (ls, create, add, show, people, rename, rm, export)`);
3394
+ }
3395
+
3396
+ // A list that is still filling (saved from a search, or grown with `lists add`) is never reported as
3397
+ // complete: GET /api/v1/lists/{id}/fill says how far it is. Null when the API has no fill status.
3398
+ async function listFill(base, key, id, created) {
3399
+ // After a save or an add: only when the answer says it is still filling (queued or processing).
3400
+ if (created && !created.async && !(created.materialize && ["queued", "processing"].includes(created.materialize.status))) return null;
3401
+ let res;
3402
+ try {
3403
+ res = await request("GET", base, `${V1}/lists/${encodeURIComponent(String(id))}/fill`, { key });
3404
+ } catch {
3405
+ return null;
3406
+ }
3407
+ if (res.status >= 400 || !res.json) return null;
3408
+ return res.json;
3409
+ }
3410
+ function fillLine(fill) {
3411
+ const job = (fill && fill.fill) || {};
3412
+ const list = (fill && fill.list) || {};
3413
+ const sofar = Math.max(Number(job.inserted_rows || 0), Number(list.item_count || 0));
3414
+ if (job.status === "processing") return `filling: ${sofar.toLocaleString()} people so far${job.target_rows ? ` of about ${Number(job.target_rows).toLocaleString()}` : ""}`;
3415
+ if (job.status === "queued") {
3416
+ const ahead = Number(job.queue_position || 0);
3417
+ return `waiting for its turn${ahead ? ` (${ahead} before it)` : ""}; ${Number(list.item_count || 0).toLocaleString()} people in it now`;
3418
+ }
3419
+ if (job.status === "failed") return `the last fill did not finish; ${Number(list.item_count || 0).toLocaleString()} people in it. Save or add the search again.`;
3420
+ return `ready with ${Number(list.item_count || list.snapshot_total || 0).toLocaleString()} people`;
3312
3421
  }
3313
3422
 
3314
3423
  async function listsExport(rest, key) {
@@ -3434,8 +3543,9 @@ async function cmdCredits(argv) {
3434
3543
  return console.log(`
3435
3544
  ${bold("argorant credits")} packs ${dim("the credit packs and their prices")}
3436
3545
  ${bold("argorant credits")} quote <credits> ${dim("a price quote for one pack; nothing is bought")}
3437
- ${bold("argorant credits")} buy <credits> [--pay-with link] [--yes]
3438
- ${dim("shows the quote, asks, then charges the saved card (when your workspace allows it) or gives you a payment link")}
3546
+ ${bold("argorant credits")} buy <credits> [--pay-with link] [--yes] [--confirmation "<the user's yes>"]
3547
+ ${dim("shows the quote, asks, then charges the saved card (when the account owner allowed it, up to a monthly limit) or gives you a payment link")}
3548
+ ${dim("every purchase records its confirmation: your answer at the prompt, or the words passed with --confirmation")}
3439
3549
  ${bold("argorant credits")} purchases [<id>] ${dim("purchases made with this account")}
3440
3550
  ${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits stay valid for 365 days.")}
3441
3551
  `);
@@ -3452,7 +3562,7 @@ ${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits st
3452
3562
  return;
3453
3563
  }
3454
3564
  if (sub === "quote" || sub === "buy") {
3455
- const args = readFlags(rest, { "--currency": "currency", "--pay-with": "payWith" });
3565
+ const args = readFlags(rest, { "--currency": "currency", "--pay-with": "payWith", "--confirmation": "confirmation" });
3456
3566
  const credits = parseLimit(String(args._[0] || "").replace(/[,_]/g, "") || "x", "credits");
3457
3567
  const body = { credits };
3458
3568
  if (args.currency) body.currency = String(args.currency).toLowerCase();
@@ -3468,9 +3578,14 @@ ${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits st
3468
3578
  if (!args.json) for (const l of quoteLines(q)) console.log(l);
3469
3579
  const card = q.payment && q.payment.saved_card;
3470
3580
  const how = payWith !== "link" && card && card.usable ? `The saved ${card.card || "card"} is charged now.` : "You get a payment link; nothing is charged before you pay it.";
3471
- if (!(await confirmOrDie(args, `Buy ${credits.toLocaleString()} credits for ${fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : q.pack || q, q.currency)}? ${how}`, "Buying credits needs a confirmation."))) return;
3581
+ const question = `Buy ${credits.toLocaleString()} credits for ${fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : q.pack || q, q.currency)}? ${how}`;
3582
+ if (!(await confirmOrDie(args, question, "Buying credits needs a confirmation."))) return;
3472
3583
  const pbody = { quote_id: q.quote_id, confirmed: true };
3473
3584
  if (payWith !== "auto") pbody.pay_with = payWith;
3585
+ // The purchase records how it was confirmed (2026-10-07): the user's own words when an agent passes them,
3586
+ // the answer at the prompt, or nothing extra with --yes (the record then says confirmed=true).
3587
+ const said = args.confirmation ? String(args.confirmation) : !args.yes ? `Answered yes at the terminal to: ${question}` : "";
3588
+ if (said.trim()) pbody.user_confirmation = said.replace(/\s+/g, " ").trim().slice(0, 1000);
3474
3589
  const r = need(await request("POST", args.base, `${V1}/credits/purchases`, { key, body: pbody }), "credit purchase");
3475
3590
  if (args.json) return console.log(JSON.stringify({ quote: q, purchase: r }, null, 2));
3476
3591
  const p = r.purchase && typeof r.purchase === "object" ? { ...r.purchase, ...r } : r;
@@ -3617,7 +3732,9 @@ async function cmdCompanies(argv) {
3617
3732
  ${bold("argorant companies")} count [filters] ${dim("free")}
3618
3733
  ${bold("argorant companies")} search [filters] [-n 25] [--cursor <c>] [--all] ${dim("free")}
3619
3734
  ${bold("argorant companies")} people <company.com> [--title <role>] [-n 5] ${dim("free; same as argorant company")}
3620
- filters: --industry --country --state --city --company --domain --keywords --technologies --employees 11-50,51-200 [free text]
3735
+ filters: --industry --country --state --city --domain (each with --exclude-...) --company --keywords
3736
+ --exclude-keywords --keyword-scope --technologies --employees 11-50,51-200 --employees-min/max
3737
+ --revenue --revenue-min/max --near <place> --radius <n> [free text]
3621
3738
  `);
3622
3739
  }
3623
3740
  const key = requireKey();
@@ -3651,12 +3768,48 @@ ${bold("argorant companies")} people <company.com> [--title <role>] [-n 5]
3651
3768
  }
3652
3769
 
3653
3770
  function companyFilters(args) {
3654
- const q = {};
3655
- for (const field of new Set(Object.values(COMPANY_FILTER_FLAGS))) if (args[field]) q[field] = args[field];
3771
+ const q = collectFilters(args, COMPANY_FILTER_FLAGS, {});
3656
3772
  if (args._.length) q.q = [q.q, ...args._].filter(Boolean).join(" ");
3773
+ if (q.radius !== undefined && !q.radius_target) q.radius_target = "company";
3657
3774
  return q;
3658
3775
  }
3659
3776
 
3777
+ // ---- filters: every filter, its values, and whether it can be used now ----
3778
+ async function cmdFilters(args) {
3779
+ const key = resolveKey();
3780
+ let schema = null;
3781
+ let live = false;
3782
+ if (key) {
3783
+ try {
3784
+ const res = await request("GET", args.base, "/api/v1/people/filters", { key });
3785
+ if (res.status < 400 && res.json && Array.isArray(res.json.filters)) {
3786
+ schema = res.json;
3787
+ live = true;
3788
+ } else if (res.status === 401) need(res, "filters");
3789
+ } catch {
3790
+ /* offline: the bundled list below */
3791
+ }
3792
+ }
3793
+ if (!schema) schema = FILTER_SCHEMA;
3794
+ if (args.json) return console.log(JSON.stringify(schema, null, 2));
3795
+ const flags = schema.cli_flags || FILTER_SCHEMA.cli_flags || {};
3796
+ console.log(bold("Filters") + dim(live ? " (ready now, from your account)" : " (bundled list; log in to see what is ready now)"));
3797
+ for (const f of schema.filters || []) {
3798
+ const names = [f.include, f.exclude, ...(f.kind === "range" || f.kind === "radius" ? f.params : [])]
3799
+ .filter(Boolean)
3800
+ .filter((p, i, a) => a.indexOf(p) === i)
3801
+ .map((p) => flags[p] || `--${p.replace(/_/g, "-")}`);
3802
+ const state = !live ? "" : f.ready ? green(" ready") : red(" not ready yet") + dim(f.reason ? ` ${f.reason}` : "");
3803
+ console.log(` ${bold(f.label)} ${dim(names.join(" "))}${state}`);
3804
+ if (Array.isArray(f.values) && f.values.length) console.log(dim(` values: ${f.values.map((v) => v.value).join(", ")}`));
3805
+ }
3806
+ for (const [name, m] of Object.entries(schema.modes || {})) {
3807
+ console.log(` ${bold(m.label)} ${dim(flags[name] || "--" + name.replace(/_/g, "-"))}`);
3808
+ console.log(dim(` values: ${(m.values || []).map((v) => v.value).join(", ")}${m.default ? ` (default ${m.default})` : ""}`));
3809
+ }
3810
+ console.log(dim(" Every list filter takes several values; a comma means any of them. Include and exclude work together."));
3811
+ }
3812
+
3660
3813
  // ---- lookup ----
3661
3814
  async function cmdLookup(argv) {
3662
3815
  const raw = String(argv[0] || "").toLowerCase();
@@ -3712,7 +3865,8 @@ ${bold("COMMANDS")}
3712
3865
  ${cyan("export status")} <job_id> Status of an existing export ${dim("(free; add --batch for >50k)")}
3713
3866
  ${cyan("export download")} <job_id> -o leads.csv Re-download a finished export ${dim("(free)")}
3714
3867
  ${cyan("list create")} --name "<n>" [filters] Save a reusable list ${dim("(free)")}
3715
- ${cyan("list status")} <id> Show a saved list's size ${dim("(free)")}
3868
+ ${cyan("list status")} <id> Show a saved list's size, or how far it has filled ${dim("(free)")}
3869
+ ${cyan("filters")} Every filter, its values, and which are ready now ${dim("(free)")}
3716
3870
  ${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
3717
3871
  ${cyan("verify")} --file emails.csv -o out.csv Bulk-verify your own list ${dim("(recent re-checks free)")}
3718
3872
  ${cyan("find")} "<first last>" --domain <d> Find a person's work email ${dim("(1 credit found, 0.25 credit nothing found)")}
@@ -3726,24 +3880,35 @@ ${bold("COMMANDS")}
3726
3880
  ${cyan("inbox")} list | read | reply | forward | classify Replies across campaigns; answer in-thread or forward
3727
3881
  ${cyan("blocklist")} list | add | remove Addresses and domains never contacted
3728
3882
  ${cyan("account")} Plan, credits, request limit, campaign emails ${dim("(free)")}
3729
- ${cyan("lists")} ls | create | show | people | rename | rm | export Saved lists ${dim("(argorant lists help)")}
3883
+ ${cyan("lists")} ls | create | add | show | people | rename | rm | export Saved lists ${dim("(argorant lists help)")}
3730
3884
  ${cyan("exports")} ls | status | download Your exports, a page at a time ${dim("(free)")}
3731
3885
  ${cyan("companies")} count | search | people Companies that match your filters ${dim("(free)")}
3732
3886
  ${cyan("lookup")} industries | countries | keywords | technologies | titles [text] Exact filter labels ${dim("(free)")}
3733
3887
  ${cyan("credits")} packs | quote | buy | purchases Buy credit packs ${dim("(asks first; saved card or payment link)")}
3734
3888
  ${cyan("webhooks")} ls | add | set | test | deliveries | rm Get events sent to your server ${dim("(Pro plan and up)")}
3735
3889
 
3736
- ${bold("FILTERS")}
3737
- --keywords <k> Comma = OR. The widest, most reliable filter - prefer it
3738
- over --industry (matches tags most records carry).
3739
- --title <t> --exclude-title <t> --seniority <s> --department <d>
3740
- --industry <i> --country <c> --geography <r> --state <s>
3741
- --city <c> --company <name> --domain <domain>
3742
- --has-phone --has-linkedin --has-email --verified-only
3743
- ${dim("--title is abbreviation-aware (CFO ↔ Chief Financial Officer).")}
3744
- ${dim("--verified-only keeps deliverable contacts; export verifies live & bills only valid.")}
3745
- ${dim("--country / --geography accept regions: Europe, EMEA, DACH, Nordics, APAC, LATAM, GCC…")}
3746
- ${dim("--exclude-title works fully with `export` and `list create`; `count`/`search`/`reveal` don't apply it yet (CLI warns).")}
3890
+ ${bold("FILTERS")} ${dim("(the same on every command: count, search, reveal, export, lists, companies, campaigns)")}
3891
+ Each list filter has an include flag and an exclude flag; use both together.
3892
+ A comma means any of them.
3893
+ --title <t> --exclude-title <t> --title-match words|exact|contains
3894
+ --keywords <k> --exclude-keywords <k> --keyword-scope company|people|any
3895
+ --industry <i> --exclude-industry <i>
3896
+ --country <c> --exclude-country <c> ${dim("(regions too: Europe, EMEA, DACH, Nordics, APAC, LATAM, GCC)")}
3897
+ --state <s> --exclude-state <s> --city <c> --exclude-city <c>
3898
+ --seniority <s> --exclude-seniority <s>
3899
+ --department <d> --exclude-department <d>
3900
+ --domain <d> --exclude-domain <d> --company <name>
3901
+ --employees <bands> --employees-min <n> --employees-max <n>
3902
+ --revenue <bands> --revenue-min <usd> --revenue-max <usd>
3903
+ --near <place or lat,lon> --radius <n> [--radius-unit mi|km] [--radius-target person|company]
3904
+ --gender female|male --income <bands> ${dim("(estimates; not for lending audiences)")}
3905
+ --has-phone --has-linkedin --has-email --verified-only
3906
+ ${dim("--title-match words (default): whole words, so Data does not match Database. exact: the whole")}
3907
+ ${dim("job title, so CEO does not match Founder & CEO. contains: anywhere in the title.")}
3908
+ ${dim("--keyword-scope company (default): company tags, industry and description. people: skills and")}
3909
+ ${dim("profile text. any: both, plus job title and company name.")}
3910
+ ${dim("--title is abbreviation-aware (CFO and Chief Financial Officer find each other).")}
3911
+ ${dim("Run `argorant filters` for every value and to see which filters are ready now.")}
3747
3912
 
3748
3913
  ${bold("OPTIONS")}
3749
3914
  -n, --limit <n> Max rows -o, --output <file> CSV path (export)
@@ -3824,7 +3989,7 @@ const OWN_FLAG_COMMANDS = {
3824
3989
  lookup: (a) => cmdLookup(a),
3825
3990
  lookups: (a) => cmdLookup(a),
3826
3991
  };
3827
- const LISTS_SUBCOMMANDS = new Set(["ls", "people", "items", "rename", "rm", "delete", "remove", "export", "show", "get", "help"]);
3992
+ const LISTS_SUBCOMMANDS = new Set(["ls", "add", "people", "items", "rename", "rm", "delete", "remove", "export", "show", "get", "help"]);
3828
3993
 
3829
3994
  async function main() {
3830
3995
  const argv = takeIdempotencyFlag(process.argv.slice(2));
@@ -3857,6 +4022,7 @@ async function main() {
3857
4022
  export: cmdExport,
3858
4023
  list: cmdList,
3859
4024
  verify: cmdVerify,
4025
+ filters: cmdFilters,
3860
4026
  };
3861
4027
  const fn = table[cmd];
3862
4028
  if (!fn) die(`unknown command: ${cmd}\nRun \`argorant help\` for usage.`);
@@ -3877,5 +4043,7 @@ if (require.main === module) {
3877
4043
  errorMessage, errorCode, requestIdOf, detailMsg, need, EXIT, request, pageQuery, pickItems, fetchPages, retryWaitSeconds, RATE_RETRY,
3878
4044
  companyFilters, numericId, fmtPrice, packLine, quoteLines, LOOKUP_KINDS, OWN_FLAG_COMMANDS, VERSION,
3879
4045
  parseCsv, mailboxesFromCsv, mailboxBody, normalizeSecurity, SMTP_BULK_CHUNK,
4046
+ FILTER_SCHEMA, FILTER_VALUE_FLAGS, FILTER_BOOL_FLAGS, FILTER_ENUMS, VALUE_FLAGS, BOOL_FLAGS, CAMPAIGN_FILTER_VALUE_FLAGS,
4047
+ LIST_FILTER_FLAGS, COMPANY_FILTER_FLAGS, COMPANY_PARAMS, finishFilters, collectFilters, checkEnum, fillLine,
3880
4048
  };
3881
4049
  }