salesprompter-cli 0.1.1 → 0.1.3

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/dist/cli.js CHANGED
@@ -1,65 +1,391 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Command } from "commander";
3
4
  import { z } from "zod";
4
- import { EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
5
- import { DryRunSyncProvider, HeuristicEnrichmentProvider, HeuristicLeadProvider, HeuristicScoringProvider } from "./engine.js";
6
- import { readJsonFile, splitCsv, writeJsonFile } from "./io.js";
5
+ import { clearAuthSession, loginWithDeviceFlow, loginWithToken, requireAuthSession, shouldBypassAuth, verifySession } from "./auth.js";
6
+ import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
7
+ import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
8
+ import { auditDomainDecisions, buildDomainfinderBacklogQueries, buildDomainfinderCandidatesSql, buildDomainfinderInputSql, buildDomainfinderWritebackSql, buildExistingDomainRepairSql, buildExistingDomainAuditQueries, compareDomainSelectionStrategies, selectBestDomains } from "./domainfinder.js";
9
+ import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
10
+ import { analyzeHistoricalQueries } from "./historical-queries.js";
11
+ import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
12
+ import { InstantlySyncProvider } from "./instantly.js";
13
+ import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
14
+ import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
15
+ const require = createRequire(import.meta.url);
16
+ const { version: packageVersion } = require("../package.json");
7
17
  const program = new Command();
8
- const leadProvider = new HeuristicLeadProvider();
18
+ const leadProvider = new AccountLeadProvider(new HeuristicCompanyProvider(), new HeuristicPeopleSearchProvider());
9
19
  const enrichmentProvider = new HeuristicEnrichmentProvider();
10
20
  const scoringProvider = new HeuristicScoringProvider();
11
- const syncProvider = new DryRunSyncProvider();
21
+ const syncProvider = new RoutedSyncProvider(new DryRunSyncProvider(), new InstantlySyncProvider());
22
+ const runtimeOutputOptions = {
23
+ json: false,
24
+ quiet: false
25
+ };
12
26
  function printOutput(value) {
13
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
27
+ if (runtimeOutputOptions.quiet) {
28
+ return;
29
+ }
30
+ const space = runtimeOutputOptions.json ? undefined : 2;
31
+ process.stdout.write(`${JSON.stringify(value, null, space)}\n`);
32
+ }
33
+ function applyGlobalOutputOptions(actionCommand) {
34
+ const globalOptions = actionCommand.optsWithGlobals();
35
+ runtimeOutputOptions.json = Boolean(globalOptions.json);
36
+ runtimeOutputOptions.quiet = Boolean(globalOptions.quiet);
37
+ }
38
+ function buildCliError(error) {
39
+ if (error instanceof z.ZodError) {
40
+ return {
41
+ status: "error",
42
+ code: "invalid_arguments",
43
+ message: "Invalid arguments",
44
+ details: error.issues.map((issue) => ({
45
+ path: issue.path.join("."),
46
+ message: issue.message
47
+ }))
48
+ };
49
+ }
50
+ const message = error instanceof Error ? error.message : "Unknown error";
51
+ if (message.includes("not logged in")) {
52
+ return {
53
+ status: "error",
54
+ code: "auth_required",
55
+ message
56
+ };
57
+ }
58
+ if (message.includes("session expired")) {
59
+ return {
60
+ status: "error",
61
+ code: "session_expired",
62
+ message
63
+ };
64
+ }
65
+ return {
66
+ status: "error",
67
+ code: "runtime_error",
68
+ message
69
+ };
70
+ }
71
+ function exitCodeForError(code) {
72
+ switch (code) {
73
+ case "invalid_arguments":
74
+ return 2;
75
+ case "auth_required":
76
+ case "session_expired":
77
+ return 3;
78
+ case "runtime_error":
79
+ return 1;
80
+ }
81
+ }
82
+ const nullableInt = z.union([z.number().int(), z.string(), z.null()]).transform((value, ctx) => {
83
+ if (value === null) {
84
+ return null;
85
+ }
86
+ if (typeof value === "number") {
87
+ if (!Number.isInteger(value)) {
88
+ ctx.addIssue({
89
+ code: z.ZodIssueCode.custom,
90
+ message: "Expected an integer-compatible value"
91
+ });
92
+ return z.NEVER;
93
+ }
94
+ return value;
95
+ }
96
+ const trimmed = value.trim();
97
+ if (trimmed.length === 0) {
98
+ return null;
99
+ }
100
+ const parsed = Number(trimmed);
101
+ if (!Number.isInteger(parsed)) {
102
+ ctx.addIssue({
103
+ code: z.ZodIssueCode.custom,
104
+ message: "Expected an integer-compatible value"
105
+ });
106
+ return z.NEVER;
107
+ }
108
+ return parsed;
109
+ });
110
+ const domainCandidateArraySchema = z.array(z.object({
111
+ companyId: nullableInt,
112
+ crmCompanyId: nullableInt,
113
+ companyName: z.string(),
114
+ domain: z.string().nullable(),
115
+ source: z.string(),
116
+ type: z.string().nullable(),
117
+ hunterEmailCount: nullableInt,
118
+ linkedinDomain: z.string().nullable(),
119
+ linkedinWebsite: z.string().nullable()
120
+ }));
121
+ const domainDecisionArraySchema = z.array(z.object({
122
+ companyKey: z.string(),
123
+ selected: z
124
+ .object({
125
+ companyId: nullableInt,
126
+ crmCompanyId: nullableInt,
127
+ companyName: z.string(),
128
+ domain: z.string().nullable(),
129
+ source: z.string(),
130
+ type: z.string().nullable(),
131
+ hunterEmailCount: nullableInt,
132
+ linkedinDomain: z.string().nullable(),
133
+ linkedinWebsite: z.string().nullable()
134
+ })
135
+ .nullable(),
136
+ reason: z.enum([
137
+ "linkedin-domain",
138
+ "linkedin-website",
139
+ "highest-hunter-count",
140
+ "fallback-first-non-null",
141
+ "no-domain"
142
+ ]),
143
+ candidates: domainCandidateArraySchema
144
+ }));
145
+ const existingAuditReportSchema = z.object({
146
+ market: z.string(),
147
+ countries: z.array(z.string()),
148
+ stages: z.array(z.object({
149
+ key: z.string(),
150
+ description: z.string(),
151
+ rows: z.array(z.record(z.string(), z.unknown()))
152
+ }))
153
+ });
154
+ async function fetchHistoricalQueryRows(tables) {
155
+ const rows = [];
156
+ for (const table of tables) {
157
+ const sql = `SELECT CAST(query AS STRING) AS query, COUNT(*) AS freq FROM \`icpidentifier.SalesPrompter.${table}\` WHERE query IS NOT NULL GROUP BY CAST(query AS STRING)`;
158
+ const resultRows = await runBigQueryRows(sql);
159
+ for (const row of resultRows) {
160
+ rows.push({
161
+ sourceTable: table,
162
+ query: String(row.query ?? ""),
163
+ freq: Number(row.freq ?? 0)
164
+ });
165
+ }
166
+ }
167
+ return rows;
14
168
  }
15
169
  program
16
170
  .name("salesprompter")
17
171
  .description("Sales workflow CLI for ICP definition, lead generation, enrichment, scoring, and sync.")
18
- .version("0.1.0");
172
+ .version(packageVersion)
173
+ .option("--json", "Emit compact machine-readable JSON output", false)
174
+ .option("--quiet", "Suppress successful stdout output", false);
175
+ program
176
+ .command("auth:login")
177
+ .description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
178
+ .option("--token <token>", "App-issued bearer token for direct login")
179
+ .option("--api-url <url>", "Salesprompter API base URL, defaults to SALESPROMPTER_API_BASE_URL or salesprompter.ai")
180
+ .option("--timeout-seconds <number>", "Device flow wait timeout in seconds", "180")
181
+ .action(async (options) => {
182
+ const timeoutSeconds = z.coerce.number().int().min(30).max(1800).parse(options.timeoutSeconds);
183
+ const token = typeof options.token === "string" ? options.token.trim() : "";
184
+ if (token.length > 0) {
185
+ const session = await loginWithToken(token, options.apiUrl);
186
+ printOutput({
187
+ status: "ok",
188
+ method: "token",
189
+ apiBaseUrl: session.apiBaseUrl,
190
+ user: session.user,
191
+ expiresAt: session.expiresAt ?? null
192
+ });
193
+ return;
194
+ }
195
+ const startedAt = new Date().toISOString();
196
+ if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
197
+ process.stderr.write("Starting device login flow. Complete login in the browser.\n");
198
+ }
199
+ const result = await loginWithDeviceFlow({
200
+ apiBaseUrl: options.apiUrl,
201
+ timeoutSeconds
202
+ });
203
+ printOutput({
204
+ status: "ok",
205
+ method: "device",
206
+ startedAt,
207
+ verificationUrl: result.verificationUrl,
208
+ userCode: result.userCode,
209
+ apiBaseUrl: result.session.apiBaseUrl,
210
+ user: result.session.user,
211
+ expiresAt: result.session.expiresAt ?? null
212
+ });
213
+ });
214
+ program
215
+ .command("auth:whoami")
216
+ .description("Show current authenticated user and session status.")
217
+ .option("--verify", "Verify token with API before printing", false)
218
+ .action(async (options) => {
219
+ const session = await requireAuthSession();
220
+ const verifiedSession = options.verify ? await verifySession(session) : session;
221
+ printOutput({
222
+ status: "ok",
223
+ apiBaseUrl: verifiedSession.apiBaseUrl,
224
+ user: verifiedSession.user,
225
+ expiresAt: verifiedSession.expiresAt ?? null,
226
+ createdAt: verifiedSession.createdAt
227
+ });
228
+ });
229
+ program
230
+ .command("auth:logout")
231
+ .description("Remove local CLI auth session.")
232
+ .action(async () => {
233
+ await clearAuthSession();
234
+ printOutput({ status: "ok", loggedOut: true });
235
+ });
236
+ program.hook("preAction", async (_thisCommand, actionCommand) => {
237
+ applyGlobalOutputOptions(actionCommand);
238
+ const commandName = actionCommand.name();
239
+ if (commandName.startsWith("auth:")) {
240
+ return;
241
+ }
242
+ if (shouldBypassAuth()) {
243
+ return;
244
+ }
245
+ const session = await requireAuthSession();
246
+ if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
247
+ throw new Error("session expired. Run `salesprompter auth:login`.");
248
+ }
249
+ });
250
+ program
251
+ .command("account:resolve")
252
+ .description("Resolve a target company into a normalized account profile.")
253
+ .requiredOption("--domain <domain>", "Company domain like deel.com")
254
+ .option("--company-name <name>", "Optional company name override")
255
+ .option("--icp <path>", "Optional path to ICP JSON for industry/region/title hints")
256
+ .requiredOption("--out <path>", "Output file path")
257
+ .action(async (options) => {
258
+ const icp = options.icp
259
+ ? await readJsonFile(options.icp, IcpSchema)
260
+ : IcpSchema.parse({ name: "ad-hoc account resolve" });
261
+ const result = await leadProvider.generateLeads(icp, 1, {
262
+ companyDomain: options.domain,
263
+ companyName: options.companyName
264
+ });
265
+ await writeJsonFile(options.out, result.account);
266
+ printOutput({
267
+ status: "ok",
268
+ provider: result.provider,
269
+ mode: result.mode,
270
+ account: AccountProfileSchema.parse(result.account),
271
+ out: options.out,
272
+ warnings: result.warnings
273
+ });
274
+ });
19
275
  program
20
276
  .command("icp:define")
21
277
  .description("Define an ideal customer profile and write it to a JSON file.")
22
278
  .requiredOption("--name <name>", "Human-readable ICP name")
279
+ .option("--description <text>", "Plain-language ICP description", "")
23
280
  .option("--industries <items>", "Comma-separated industries", "")
24
281
  .option("--company-sizes <items>", "Comma-separated buckets like 1-49,50-199,200-499,500+", "")
25
282
  .option("--regions <items>", "Comma-separated regions", "")
283
+ .option("--countries <items>", "Comma-separated country codes like DE,AT,CH", "")
26
284
  .option("--titles <items>", "Comma-separated titles", "")
27
285
  .option("--pains <items>", "Comma-separated pain points", "")
28
286
  .option("--required-signals <items>", "Comma-separated required signals", "")
29
287
  .option("--excluded-signals <items>", "Comma-separated excluded signals", "")
288
+ .option("--keywords <items>", "Comma-separated keywords for search matching", "")
289
+ .option("--excluded-keywords <items>", "Comma-separated keywords to filter out", "")
30
290
  .requiredOption("--out <path>", "Output file path")
31
291
  .action(async (options) => {
32
292
  const icp = IcpSchema.parse({
33
293
  name: options.name,
294
+ description: options.description,
34
295
  industries: splitCsv(options.industries),
35
296
  companySizes: splitCsv(options.companySizes),
36
297
  regions: splitCsv(options.regions),
298
+ countries: splitCsv(options.countries),
37
299
  titles: splitCsv(options.titles),
38
300
  pains: splitCsv(options.pains),
39
301
  requiredSignals: splitCsv(options.requiredSignals),
40
- excludedSignals: splitCsv(options.excludedSignals)
302
+ excludedSignals: splitCsv(options.excludedSignals),
303
+ keywords: splitCsv(options.keywords),
304
+ excludedKeywords: splitCsv(options.excludedKeywords)
41
305
  });
42
306
  await writeJsonFile(options.out, icp);
43
307
  printOutput({ status: "ok", icp });
44
308
  });
309
+ program
310
+ .command("icp:vendor")
311
+ .description("Create a vendor-specific ICP template.")
312
+ .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
313
+ .option("--market <market>", "global|europe|dach", "dach")
314
+ .requiredOption("--out <path>", "Output file path")
315
+ .action(async (options) => {
316
+ const vendor = z.enum(["deel"]).parse(options.vendor);
317
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
318
+ const icp = buildVendorIcp(vendor, market);
319
+ await writeJsonFile(options.out, icp);
320
+ printOutput({ status: "ok", icp });
321
+ });
322
+ program
323
+ .command("icp:from-historical-queries:bq")
324
+ .description("Build a vendor ICP from historical BigQuery query patterns.")
325
+ .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
326
+ .option("--market <market>", "global|europe|dach", "dach")
327
+ .option("--tables <items>", "Comma-separated BigQuery tables with a query column", "leadLists_raw,leadLists_unique,linkedinSearchExport_people_unique,salesNavigatorSearchExport_companies_unique,snse_containers_input")
328
+ .option("--search-kind <kind>", "all|people|sales-people|sales-company", "sales-people")
329
+ .option("--include-function <items>", "Comma-separated included function texts to keep", "Human Resources")
330
+ .requiredOption("--out <path>", "Output ICP file path")
331
+ .option("--report-out <path>", "Optional output path for the historical report")
332
+ .action(async (options) => {
333
+ const vendor = z.enum(["deel"]).parse(options.vendor);
334
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
335
+ const searchKind = z.enum(["all", "people", "sales-people", "sales-company"]).parse(options.searchKind);
336
+ const tables = splitCsv(options.tables);
337
+ if (tables.length === 0) {
338
+ throw new Error("at least one table is required");
339
+ }
340
+ const rows = await fetchHistoricalQueryRows(tables);
341
+ const report = analyzeHistoricalQueries(rows, {
342
+ searchKind,
343
+ includeFunctionText: splitCsv(options.includeFunction)
344
+ });
345
+ const icp = buildHistoricalVendorIcp(vendor, market, report);
346
+ await writeJsonFile(options.out, icp);
347
+ if (options.reportOut) {
348
+ await writeJsonFile(options.reportOut, report);
349
+ }
350
+ printOutput({
351
+ status: "ok",
352
+ icp,
353
+ out: options.out,
354
+ reportOut: options.reportOut ?? null,
355
+ distinctQueryRowsAnalyzed: report.distinctQueryRowsAnalyzed,
356
+ weightedQueryVolume: report.weightedQueryVolume,
357
+ sourceTables: report.sourceTables
358
+ });
359
+ });
45
360
  program
46
361
  .command("leads:generate")
47
- .description("Generate seed leads against an ICP.")
362
+ .description("Generate leads for a target account or from fallback seeds.")
48
363
  .requiredOption("--icp <path>", "Path to ICP JSON")
49
364
  .option("--count <number>", "Number of leads to generate", "10")
50
- .option("--company-domain <domain>", "Target a specific company domain like deel.com")
365
+ .option("--domain <domain>", "Target a specific company domain like deel.com")
366
+ .option("--company-domain <domain>", "Deprecated alias for --domain")
51
367
  .option("--company-name <name>", "Optional company name override for a targeted domain")
52
368
  .requiredOption("--out <path>", "Output file path")
53
369
  .action(async (options) => {
54
370
  const icp = await readJsonFile(options.icp, IcpSchema);
55
371
  const count = z.coerce.number().int().min(1).max(1000).parse(options.count);
372
+ const domain = options.domain ?? options.companyDomain;
56
373
  const target = {
57
- companyDomain: options.companyDomain,
374
+ companyDomain: domain,
58
375
  companyName: options.companyName
59
376
  };
60
- const leads = await leadProvider.generateLeads(icp, count, target);
61
- await writeJsonFile(options.out, leads);
62
- printOutput({ status: "ok", generated: leads.length, out: options.out, target: target.companyDomain ?? null });
377
+ const result = await leadProvider.generateLeads(icp, count, target);
378
+ await writeJsonFile(options.out, result.leads);
379
+ printOutput({
380
+ status: "ok",
381
+ generated: result.leads.length,
382
+ out: options.out,
383
+ target: result.account.domain,
384
+ provider: result.provider,
385
+ mode: result.mode,
386
+ account: result.account,
387
+ warnings: result.warnings
388
+ });
63
389
  });
64
390
  program
65
391
  .command("leads:enrich")
@@ -98,17 +424,491 @@ program
98
424
  });
99
425
  program
100
426
  .command("sync:outreach")
101
- .description("Dry-run sync scored leads into an outreach platform.")
427
+ .description("Sync scored leads into an outreach platform. Instantly supports real writes and defaults to dry-run.")
102
428
  .requiredOption("--target <target>", "apollo|instantly|outreach")
103
429
  .requiredOption("--in <path>", "Path to scored lead JSON array")
430
+ .option("--campaign-id <id>", "Instantly campaign id, or set INSTANTLY_CAMPAIGN_ID")
431
+ .option("--apply", "Create leads in Instantly instead of dry-run", false)
432
+ .option("--allow-duplicates", "Do not skip emails already present in the Instantly campaign", false)
104
433
  .action(async (options) => {
105
434
  const target = SyncTargetSchema.parse(options.target);
106
435
  const leads = await readJsonFile(options.in, z.array(ScoredLeadSchema));
107
- const result = await syncProvider.sync(target, leads);
436
+ const result = await syncProvider.sync(target, leads, {
437
+ apply: Boolean(options.apply),
438
+ instantlyCampaignId: options.campaignId,
439
+ allowDuplicates: Boolean(options.allowDuplicates)
440
+ });
108
441
  printOutput({ status: "ok", ...result });
109
442
  });
443
+ program
444
+ .command("leads:lookup:bq")
445
+ .description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
446
+ .requiredOption("--icp <path>", "Path to ICP JSON")
447
+ .option("--table <table>", "Fully qualified BigQuery table or view", "icpidentifier.SalesGPT.leadPool_new")
448
+ .option("--company-field <field>", "Company name field", "companyName")
449
+ .option("--domain-field <field>", "Company domain field", "domain")
450
+ .option("--region-field <field>", "Region field if your table has one")
451
+ .option("--keyword-fields <items>", "Comma-separated text fields used for keyword matching", "companyName,industry,description,tagline,specialties")
452
+ .option("--title-field <field>", "Contact title field", "jobTitle")
453
+ .option("--industry-field <field>", "Industry field", "industry")
454
+ .option("--company-size-field <field>", "Company size field", "companySize")
455
+ .option("--country-field <field>", "Country or region field", "company_countryCode")
456
+ .option("--first-name-field <field>", "First name field", "firstName")
457
+ .option("--last-name-field <field>", "Last name field", "lastName")
458
+ .option("--email-field <field>", "Email field", "email")
459
+ .option("--additional-where <sql>", "Extra WHERE predicate to append")
460
+ .option("--limit <number>", "Max rows to return", "200")
461
+ .option("--sql-out <path>", "Optional file path for generated SQL")
462
+ .option("--out <path>", "Optional file path for query results JSON")
463
+ .option("--lead-out <path>", "Optional file path for normalized lead JSON")
464
+ .option("--execute", "Run the generated query via bq")
465
+ .option("--no-salesprompter-guards", "Disable default leadPool_new quality guards")
466
+ .action(async (options) => {
467
+ const icp = await readJsonFile(options.icp, IcpSchema);
468
+ const limit = z.coerce.number().int().min(1).max(5000).parse(options.limit);
469
+ const sql = buildBigQueryLeadLookupSql(icp, {
470
+ table: options.table,
471
+ companyField: options.companyField,
472
+ domainField: options.domainField,
473
+ regionField: options.regionField,
474
+ keywordFields: splitCsv(options.keywordFields),
475
+ titleField: options.titleField,
476
+ industryField: options.industryField,
477
+ companySizeField: options.companySizeField,
478
+ countryField: options.countryField,
479
+ firstNameField: options.firstNameField,
480
+ lastNameField: options.lastNameField,
481
+ emailField: options.emailField,
482
+ limit,
483
+ additionalWhere: options.additionalWhere,
484
+ useSalesprompterGuards: Boolean(options.salesprompterGuards)
485
+ });
486
+ if (options.sqlOut) {
487
+ await writeTextFile(options.sqlOut, `${sql}\n`);
488
+ }
489
+ if (!options.execute) {
490
+ printOutput({
491
+ status: "ok",
492
+ mode: "sql-only",
493
+ table: options.table,
494
+ sql,
495
+ sqlOut: options.sqlOut ?? null
496
+ });
497
+ return;
498
+ }
499
+ const rows = await runBigQueryQuery(sql, { maxRows: limit });
500
+ if (options.out) {
501
+ await writeJsonFile(options.out, rows);
502
+ }
503
+ if (options.leadOut) {
504
+ const normalizedLeads = normalizeBigQueryLeadRows(z.array(z.record(z.string(), z.unknown())).parse(rows));
505
+ await writeJsonFile(options.leadOut, normalizedLeads);
506
+ }
507
+ printOutput({
508
+ status: "ok",
509
+ mode: "executed",
510
+ table: options.table,
511
+ rowCount: Array.isArray(rows) ? rows.length : null,
512
+ out: options.out ?? null,
513
+ leadOut: options.leadOut ?? null,
514
+ sqlOut: options.sqlOut ?? null
515
+ });
516
+ });
517
+ program
518
+ .command("queries:analyze:bq")
519
+ .description("Analyze historical query parameters from BigQuery tables.")
520
+ .option("--tables <items>", "Comma-separated BigQuery tables with a query column", "leadLists_raw,leadLists_unique,linkedinSearchExport_people_unique,salesNavigatorSearchExport_companies_unique,snse_containers_input")
521
+ .option("--search-kind <kind>", "all|people|sales-people|sales-company", "all")
522
+ .option("--include-function <items>", "Comma-separated included function texts to keep, e.g. Human Resources", "")
523
+ .requiredOption("--out <path>", "Output report path")
524
+ .action(async (options) => {
525
+ const searchKind = z.enum(["all", "people", "sales-people", "sales-company"]).parse(options.searchKind);
526
+ const tables = splitCsv(options.tables);
527
+ if (tables.length === 0) {
528
+ throw new Error("at least one table is required");
529
+ }
530
+ const rows = await fetchHistoricalQueryRows(tables);
531
+ const report = analyzeHistoricalQueries(rows, {
532
+ searchKind,
533
+ includeFunctionText: splitCsv(options.includeFunction)
534
+ });
535
+ await writeJsonFile(options.out, report);
536
+ printOutput({
537
+ status: "ok",
538
+ distinctQueryRowsAnalyzed: report.distinctQueryRowsAnalyzed,
539
+ weightedQueryVolume: report.weightedQueryVolume,
540
+ out: options.out,
541
+ sourceTables: report.sourceTables
542
+ });
543
+ });
544
+ program
545
+ .command("leadlists:funnel:bq")
546
+ .description("Build an upstream lead-list funnel report for a vendor/market.")
547
+ .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
548
+ .option("--market <market>", "global|europe|dach", "dach")
549
+ .requiredOption("--out <path>", "Output report path")
550
+ .action(async (options) => {
551
+ const vendor = z.enum(["deel"]).parse(options.vendor);
552
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
553
+ const definition = buildLeadlistsFunnelQueries(vendor, market);
554
+ const stages = [];
555
+ for (const stage of definition.stages) {
556
+ const rows = await runBigQueryRows(stage.sql, { maxRows: 1000 });
557
+ const result = rows[0] ?? {};
558
+ stages.push({
559
+ key: stage.key,
560
+ description: stage.description,
561
+ result
562
+ });
563
+ }
564
+ const report = {
565
+ vendor,
566
+ market,
567
+ queryTerms: definition.queryTerms,
568
+ stages,
569
+ notes: definition.notes,
570
+ requiredFields: {
571
+ upstreamPeople: ["profileUrl", "companyUrl", "firstName", "lastName", "jobTitle"],
572
+ upstreamCompany: ["companyUrl", "handle", "domain", "countryCode", "companySize"],
573
+ downstreamContact: ["firstName_cleaned", "lastName_cleaned", "domain", "email", "email_invalid"]
574
+ }
575
+ };
576
+ await writeJsonFile(options.out, report);
577
+ printOutput({
578
+ status: "ok",
579
+ vendor,
580
+ market,
581
+ out: options.out,
582
+ stages: stages.map((stage) => ({ key: stage.key, result: stage.result }))
583
+ });
584
+ });
585
+ program
586
+ .command("domainfinder:backlog:bq")
587
+ .description("Analyze the domain finder backlog and its input bottleneck.")
588
+ .option("--market <market>", "global|europe|dach", "dach")
589
+ .requiredOption("--out <path>", "Output report path")
590
+ .action(async (options) => {
591
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
592
+ const definition = buildDomainfinderBacklogQueries(market);
593
+ const stages = [];
594
+ for (const stage of definition.stages) {
595
+ const rows = await runBigQueryRows(stage.sql, { maxRows: 1000 });
596
+ stages.push({
597
+ key: stage.key,
598
+ description: stage.description,
599
+ result: rows[0] ?? {}
600
+ });
601
+ }
602
+ const report = {
603
+ market,
604
+ countries: definition.countries,
605
+ stages,
606
+ notes: definition.notes
607
+ };
608
+ await writeJsonFile(options.out, report);
609
+ printOutput({
610
+ status: "ok",
611
+ market,
612
+ out: options.out,
613
+ stages: stages.map((stage) => ({ key: stage.key, result: stage.result }))
614
+ });
615
+ });
616
+ program
617
+ .command("domainfinder:candidates:bq")
618
+ .description("Fetch real domain candidates from BigQuery for backlog companies.")
619
+ .option("--market <market>", "global|europe|dach", "dach")
620
+ .option("--limit <number>", "Max candidate rows to fetch", "500")
621
+ .requiredOption("--out <path>", "Output candidate JSON path")
622
+ .option("--sql-out <path>", "Optional SQL output path")
623
+ .action(async (options) => {
624
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
625
+ const limit = z.coerce.number().int().min(1).max(50000).parse(options.limit);
626
+ const sql = buildDomainfinderCandidatesSql(market, limit);
627
+ if (options.sqlOut) {
628
+ await writeTextFile(options.sqlOut, `${sql}\n`);
629
+ }
630
+ const rows = await runBigQueryRows(sql, { maxRows: limit });
631
+ await writeJsonFile(options.out, rows);
632
+ printOutput({
633
+ status: "ok",
634
+ market,
635
+ candidates: rows.length,
636
+ out: options.out,
637
+ sqlOut: options.sqlOut ?? null
638
+ });
639
+ });
640
+ program
641
+ .command("domainfinder:input-sql")
642
+ .description("Generate improved SQL for the domainFinder input view.")
643
+ .option("--market <market>", "global|europe|dach", "dach")
644
+ .requiredOption("--out <path>", "Output SQL file path")
645
+ .action(async (options) => {
646
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
647
+ const sql = buildDomainfinderInputSql(market);
648
+ await writeTextFile(options.out, `${sql}\n`);
649
+ printOutput({ status: "ok", market, out: options.out });
650
+ });
651
+ program
652
+ .command("domainfinder:select")
653
+ .description("Select the best domain per company from candidate rows using improved logic.")
654
+ .requiredOption("--in <path>", "Path to candidate JSON array")
655
+ .requiredOption("--out <path>", "Output file path")
656
+ .action(async (options) => {
657
+ const candidates = await readJsonFile(options.in, domainCandidateArraySchema);
658
+ const decisions = selectBestDomains(candidates);
659
+ await writeJsonFile(options.out, decisions);
660
+ printOutput({
661
+ status: "ok",
662
+ companies: decisions.length,
663
+ out: options.out,
664
+ reasons: decisions.reduce((acc, decision) => {
665
+ acc[decision.reason] = (acc[decision.reason] ?? 0) + 1;
666
+ return acc;
667
+ }, {})
668
+ });
669
+ });
670
+ program
671
+ .command("domainfinder:audit")
672
+ .description("Audit selected domain decisions and surface risky writeback cases.")
673
+ .requiredOption("--in <path>", "Path to decision JSON array")
674
+ .requiredOption("--out <path>", "Output audit JSON path")
675
+ .action(async (options) => {
676
+ const decisions = await readJsonFile(options.in, domainDecisionArraySchema);
677
+ const report = auditDomainDecisions(decisions);
678
+ await writeJsonFile(options.out, report);
679
+ printOutput({
680
+ status: "ok",
681
+ out: options.out,
682
+ summary: report.summary
683
+ });
684
+ });
685
+ program
686
+ .command("domainfinder:compare-pipedream")
687
+ .description("Compare the legacy Pipedream selector against the improved CLI selector.")
688
+ .requiredOption("--in <path>", "Path to candidate JSON array")
689
+ .requiredOption("--out <path>", "Output comparison JSON path")
690
+ .action(async (options) => {
691
+ const candidates = await readJsonFile(options.in, domainCandidateArraySchema);
692
+ const report = compareDomainSelectionStrategies(candidates);
693
+ await writeJsonFile(options.out, report);
694
+ printOutput({
695
+ status: "ok",
696
+ out: options.out,
697
+ summary: report.summary
698
+ });
699
+ });
700
+ program
701
+ .command("domainfinder:audit-existing:bq")
702
+ .description("Audit currently exposed linkedin_companies domains against LinkedIn references in BigQuery.")
703
+ .option("--market <market>", "global|europe|dach", "dach")
704
+ .requiredOption("--out <path>", "Output audit JSON path")
705
+ .action(async (options) => {
706
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
707
+ const definition = buildExistingDomainAuditQueries(market);
708
+ const stages = [];
709
+ for (const stage of definition.stages) {
710
+ const rows = await runBigQueryRows(stage.sql, { maxRows: stage.key === "samples" ? 100 : 1000 });
711
+ stages.push({
712
+ key: stage.key,
713
+ description: stage.description,
714
+ rows
715
+ });
716
+ }
717
+ const report = {
718
+ market,
719
+ countries: definition.countries,
720
+ stages
721
+ };
722
+ await writeJsonFile(options.out, report);
723
+ printOutput({
724
+ status: "ok",
725
+ out: options.out,
726
+ market,
727
+ summary: stages.find((stage) => stage.key === "summary")?.rows[0] ?? {}
728
+ });
729
+ });
730
+ program
731
+ .command("domainfinder:audit-delta")
732
+ .description("Compare two domainfinder audit-existing reports and compute metric deltas.")
733
+ .requiredOption("--before <path>", "Path to previous audit-existing report")
734
+ .requiredOption("--after <path>", "Path to newer audit-existing report")
735
+ .requiredOption("--out <path>", "Output delta JSON path")
736
+ .action(async (options) => {
737
+ const before = await readJsonFile(options.before, existingAuditReportSchema);
738
+ const after = await readJsonFile(options.after, existingAuditReportSchema);
739
+ const beforeSummary = before.stages.find((stage) => stage.key === "summary")?.rows[0] ?? {};
740
+ const afterSummary = after.stages.find((stage) => stage.key === "summary")?.rows[0] ?? {};
741
+ const keys = [
742
+ "companies",
743
+ "with_chosen_domain",
744
+ "with_linkedin_domain",
745
+ "with_linkedin_website",
746
+ "chosen_blacklisted",
747
+ "chosen_starts_with_www",
748
+ "chosen_differs_from_linkedin_domain",
749
+ "chosen_differs_from_linkedin_website"
750
+ ];
751
+ const delta = {};
752
+ for (const key of keys) {
753
+ const beforeValue = Number(beforeSummary[key] ?? 0);
754
+ const afterValue = Number(afterSummary[key] ?? 0);
755
+ delta[key] = {
756
+ before: beforeValue,
757
+ after: afterValue,
758
+ delta: afterValue - beforeValue
759
+ };
760
+ }
761
+ const report = {
762
+ before: {
763
+ path: options.before,
764
+ market: before.market
765
+ },
766
+ after: {
767
+ path: options.after,
768
+ market: after.market
769
+ },
770
+ delta
771
+ };
772
+ await writeJsonFile(options.out, report);
773
+ printOutput({
774
+ status: "ok",
775
+ out: options.out,
776
+ delta
777
+ });
778
+ });
779
+ program
780
+ .command("domainfinder:repair-existing:bq")
781
+ .description("Generate targeted repair SQL for existing chosen domains and optionally execute it in BigQuery.")
782
+ .option("--market <market>", "global|europe|dach", "dach")
783
+ .option("--limit <number>", "Max companies to repair in this batch", "5000")
784
+ .option("--mode <mode>", "conservative|aggressive|mismatch-only", "conservative")
785
+ .requiredOption("--out <path>", "Output SQL file path")
786
+ .option("--trace-id <traceId>", "Trace id for this repair batch")
787
+ .option("--execute", "Execute the generated SQL in BigQuery", false)
788
+ .action(async (options) => {
789
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
790
+ const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
791
+ const mode = z.enum(["conservative", "aggressive", "mismatch-only"]).parse(options.mode);
792
+ const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-repair-${market}-${Date.now()}`);
793
+ const sql = buildExistingDomainRepairSql(market, traceId, limit, mode);
794
+ await writeTextFile(options.out, `${sql}\n`);
795
+ let execution = null;
796
+ if (options.execute) {
797
+ const stdout = await executeBigQuerySql(sql);
798
+ execution = { executed: true, stdout };
799
+ }
800
+ printOutput({
801
+ status: "ok",
802
+ market,
803
+ limit,
804
+ mode,
805
+ traceId,
806
+ out: options.out,
807
+ execution
808
+ });
809
+ });
810
+ program
811
+ .command("domainfinder:writeback-sql")
812
+ .description("Generate conservative writeback SQL for domainFinder_output from audited decisions.")
813
+ .requiredOption("--in <path>", "Path to decision JSON array")
814
+ .requiredOption("--out <path>", "Output SQL file path")
815
+ .option("--trace-id <traceId>", "Trace id for this writeback batch")
816
+ .action(async (options) => {
817
+ const decisions = await readJsonFile(options.in, domainDecisionArraySchema);
818
+ const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-${Date.now()}`);
819
+ const sql = buildDomainfinderWritebackSql(decisions, traceId);
820
+ await writeTextFile(options.out, `${sql}\n`);
821
+ const report = auditDomainDecisions(decisions);
822
+ printOutput({
823
+ status: "ok",
824
+ out: options.out,
825
+ traceId,
826
+ acceptedForWriteback: report.summary.acceptedForWriteback,
827
+ rejectedForWriteback: report.summary.rejectedForWriteback
828
+ });
829
+ });
830
+ program
831
+ .command("domainfinder:writeback:bq")
832
+ .description("Generate writeback SQL and optionally execute it against domainFinder_output.")
833
+ .requiredOption("--in <path>", "Path to decision JSON array")
834
+ .requiredOption("--out <path>", "Output SQL file path")
835
+ .option("--trace-id <traceId>", "Trace id for this writeback batch")
836
+ .option("--execute", "Execute the generated SQL in BigQuery", false)
837
+ .action(async (options) => {
838
+ const decisions = await readJsonFile(options.in, domainDecisionArraySchema);
839
+ const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-${Date.now()}`);
840
+ const sql = buildDomainfinderWritebackSql(decisions, traceId);
841
+ await writeTextFile(options.out, `${sql}\n`);
842
+ const report = auditDomainDecisions(decisions);
843
+ let execution = null;
844
+ if (options.execute) {
845
+ const stdout = await executeBigQuerySql(sql);
846
+ execution = { executed: true, stdout };
847
+ }
848
+ printOutput({
849
+ status: "ok",
850
+ out: options.out,
851
+ traceId,
852
+ acceptedForWriteback: report.summary.acceptedForWriteback,
853
+ rejectedForWriteback: report.summary.rejectedForWriteback,
854
+ execution
855
+ });
856
+ });
857
+ program
858
+ .command("domainfinder:run:bq")
859
+ .description("Run the full domainfinder pipeline: candidates, select, audit, writeback SQL, optional BigQuery execution.")
860
+ .option("--market <market>", "global|europe|dach", "dach")
861
+ .option("--limit <number>", "Max candidate rows to fetch", "500")
862
+ .requiredOption("--out-dir <path>", "Output directory for artifacts")
863
+ .option("--trace-id <traceId>", "Trace id for this pipeline batch")
864
+ .option("--execute-writeback", "Execute the generated writeback SQL in BigQuery", false)
865
+ .action(async (options) => {
866
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
867
+ const limit = z.coerce.number().int().min(1).max(50000).parse(options.limit);
868
+ const outDir = z.string().min(1).parse(options.outDir);
869
+ const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-${market}-${Date.now()}`);
870
+ const candidatesPath = `${outDir}/domain-candidates-${market}.json`;
871
+ const candidatesSqlPath = `${outDir}/domain-candidates-${market}.sql`;
872
+ const decisionsPath = `${outDir}/domain-decisions-${market}.json`;
873
+ const auditPath = `${outDir}/domain-audit-${market}.json`;
874
+ const writebackSqlPath = `${outDir}/domain-writeback-${market}.sql`;
875
+ const candidateSql = buildDomainfinderCandidatesSql(market, limit);
876
+ await writeTextFile(candidatesSqlPath, `${candidateSql}\n`);
877
+ const candidateRows = await runBigQueryRows(candidateSql, { maxRows: limit });
878
+ await writeJsonFile(candidatesPath, candidateRows);
879
+ const candidates = domainCandidateArraySchema.parse(candidateRows);
880
+ const decisions = selectBestDomains(candidates);
881
+ await writeJsonFile(decisionsPath, decisions);
882
+ const audit = auditDomainDecisions(decisions);
883
+ await writeJsonFile(auditPath, audit);
884
+ const writebackSql = buildDomainfinderWritebackSql(decisions, traceId);
885
+ await writeTextFile(writebackSqlPath, `${writebackSql}\n`);
886
+ let execution = null;
887
+ if (options.executeWriteback) {
888
+ const stdout = await executeBigQuerySql(writebackSql);
889
+ execution = { executed: true, stdout };
890
+ }
891
+ printOutput({
892
+ status: "ok",
893
+ market,
894
+ limit,
895
+ traceId,
896
+ candidatesPath,
897
+ decisionsPath,
898
+ auditPath,
899
+ writebackSqlPath,
900
+ summary: audit.summary,
901
+ execution
902
+ });
903
+ });
110
904
  program.parseAsync(process.argv).catch((error) => {
111
- const message = error instanceof Error ? error.message : "Unknown error";
112
- process.stderr.write(`${message}\n`);
113
- process.exitCode = 1;
905
+ const cliError = buildCliError(error);
906
+ const space = runtimeOutputOptions.json ? undefined : 2;
907
+ if (runtimeOutputOptions.json) {
908
+ process.stderr.write(`${JSON.stringify(cliError, null, space)}\n`);
909
+ }
910
+ else {
911
+ process.stderr.write(`${cliError.message}\n`);
912
+ }
913
+ process.exitCode = exitCodeForError(cliError.code);
114
914
  });