argorant 0.14.1 → 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) {
@@ -3623,7 +3732,9 @@ async function cmdCompanies(argv) {
3623
3732
  ${bold("argorant companies")} count [filters] ${dim("free")}
3624
3733
  ${bold("argorant companies")} search [filters] [-n 25] [--cursor <c>] [--all] ${dim("free")}
3625
3734
  ${bold("argorant companies")} people <company.com> [--title <role>] [-n 5] ${dim("free; same as argorant company")}
3626
- 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]
3627
3738
  `);
3628
3739
  }
3629
3740
  const key = requireKey();
@@ -3657,12 +3768,48 @@ ${bold("argorant companies")} people <company.com> [--title <role>] [-n 5]
3657
3768
  }
3658
3769
 
3659
3770
  function companyFilters(args) {
3660
- const q = {};
3661
- 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, {});
3662
3772
  if (args._.length) q.q = [q.q, ...args._].filter(Boolean).join(" ");
3773
+ if (q.radius !== undefined && !q.radius_target) q.radius_target = "company";
3663
3774
  return q;
3664
3775
  }
3665
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
+
3666
3813
  // ---- lookup ----
3667
3814
  async function cmdLookup(argv) {
3668
3815
  const raw = String(argv[0] || "").toLowerCase();
@@ -3718,7 +3865,8 @@ ${bold("COMMANDS")}
3718
3865
  ${cyan("export status")} <job_id> Status of an existing export ${dim("(free; add --batch for >50k)")}
3719
3866
  ${cyan("export download")} <job_id> -o leads.csv Re-download a finished export ${dim("(free)")}
3720
3867
  ${cyan("list create")} --name "<n>" [filters] Save a reusable list ${dim("(free)")}
3721
- ${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)")}
3722
3870
  ${cyan("verify")} <email> Verify one of your own emails ${dim("(verification pool)")}
3723
3871
  ${cyan("verify")} --file emails.csv -o out.csv Bulk-verify your own list ${dim("(recent re-checks free)")}
3724
3872
  ${cyan("find")} "<first last>" --domain <d> Find a person's work email ${dim("(1 credit found, 0.25 credit nothing found)")}
@@ -3732,24 +3880,35 @@ ${bold("COMMANDS")}
3732
3880
  ${cyan("inbox")} list | read | reply | forward | classify Replies across campaigns; answer in-thread or forward
3733
3881
  ${cyan("blocklist")} list | add | remove Addresses and domains never contacted
3734
3882
  ${cyan("account")} Plan, credits, request limit, campaign emails ${dim("(free)")}
3735
- ${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)")}
3736
3884
  ${cyan("exports")} ls | status | download Your exports, a page at a time ${dim("(free)")}
3737
3885
  ${cyan("companies")} count | search | people Companies that match your filters ${dim("(free)")}
3738
3886
  ${cyan("lookup")} industries | countries | keywords | technologies | titles [text] Exact filter labels ${dim("(free)")}
3739
3887
  ${cyan("credits")} packs | quote | buy | purchases Buy credit packs ${dim("(asks first; saved card or payment link)")}
3740
3888
  ${cyan("webhooks")} ls | add | set | test | deliveries | rm Get events sent to your server ${dim("(Pro plan and up)")}
3741
3889
 
3742
- ${bold("FILTERS")}
3743
- --keywords <k> Comma = OR. The widest, most reliable filter - prefer it
3744
- over --industry (matches tags most records carry).
3745
- --title <t> --exclude-title <t> --seniority <s> --department <d>
3746
- --industry <i> --country <c> --geography <r> --state <s>
3747
- --city <c> --company <name> --domain <domain>
3748
- --has-phone --has-linkedin --has-email --verified-only
3749
- ${dim("--title is abbreviation-aware (CFO ↔ Chief Financial Officer).")}
3750
- ${dim("--verified-only keeps deliverable contacts; export verifies live & bills only valid.")}
3751
- ${dim("--country / --geography accept regions: Europe, EMEA, DACH, Nordics, APAC, LATAM, GCC…")}
3752
- ${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.")}
3753
3912
 
3754
3913
  ${bold("OPTIONS")}
3755
3914
  -n, --limit <n> Max rows -o, --output <file> CSV path (export)
@@ -3830,7 +3989,7 @@ const OWN_FLAG_COMMANDS = {
3830
3989
  lookup: (a) => cmdLookup(a),
3831
3990
  lookups: (a) => cmdLookup(a),
3832
3991
  };
3833
- 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"]);
3834
3993
 
3835
3994
  async function main() {
3836
3995
  const argv = takeIdempotencyFlag(process.argv.slice(2));
@@ -3863,6 +4022,7 @@ async function main() {
3863
4022
  export: cmdExport,
3864
4023
  list: cmdList,
3865
4024
  verify: cmdVerify,
4025
+ filters: cmdFilters,
3866
4026
  };
3867
4027
  const fn = table[cmd];
3868
4028
  if (!fn) die(`unknown command: ${cmd}\nRun \`argorant help\` for usage.`);
@@ -3883,5 +4043,7 @@ if (require.main === module) {
3883
4043
  errorMessage, errorCode, requestIdOf, detailMsg, need, EXIT, request, pageQuery, pickItems, fetchPages, retryWaitSeconds, RATE_RETRY,
3884
4044
  companyFilters, numericId, fmtPrice, packLine, quoteLines, LOOKUP_KINDS, OWN_FLAG_COMMANDS, VERSION,
3885
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,
3886
4048
  };
3887
4049
  }