salesprompter-cli 0.1.0 → 0.1.2

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