fullstackgtm 0.50.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli/auth.js +42 -11
  3. package/dist/cli/enrich.js +122 -56
  4. package/dist/cli/help.js +10 -7
  5. package/dist/cli/icp.js +155 -1
  6. package/dist/cli/init.js +3 -3
  7. package/dist/cli/tam.d.ts +1 -1
  8. package/dist/cli/tam.js +4 -1
  9. package/dist/connectors/clay.d.ts +33 -0
  10. package/dist/connectors/clay.js +123 -0
  11. package/dist/connectors/prospectSources.d.ts +12 -0
  12. package/dist/connectors/prospectSources.js +1 -1
  13. package/dist/contactProviders.d.ts +44 -0
  14. package/dist/contactProviders.js +100 -0
  15. package/dist/enrich.d.ts +7 -1
  16. package/dist/enrich.js +46 -1
  17. package/dist/icp.d.ts +2 -0
  18. package/dist/icp.js +53 -0
  19. package/dist/icpDerive.d.ts +51 -0
  20. package/dist/icpDerive.js +146 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +3 -0
  23. package/dist/init.d.ts +1 -1
  24. package/dist/init.js +1 -1
  25. package/dist/llm.d.ts +4 -0
  26. package/dist/llm.js +77 -6
  27. package/dist/publicHttp.js +6 -0
  28. package/docs/api.md +18 -1
  29. package/package.json +1 -1
  30. package/src/cli/auth.ts +37 -10
  31. package/src/cli/enrich.ts +130 -55
  32. package/src/cli/help.ts +10 -7
  33. package/src/cli/icp.ts +144 -3
  34. package/src/cli/init.ts +3 -3
  35. package/src/cli/tam.ts +4 -2
  36. package/src/connectors/clay.ts +155 -0
  37. package/src/connectors/prospectSources.ts +7 -1
  38. package/src/contactProviders.ts +141 -0
  39. package/src/enrich.ts +51 -2
  40. package/src/icp.ts +55 -0
  41. package/src/icpDerive.ts +158 -0
  42. package/src/index.ts +38 -0
  43. package/src/init.ts +2 -2
  44. package/src/llm.ts +71 -6
  45. package/src/publicHttp.ts +7 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.52.0] — 2026-07-11
11
+
12
+ ### Added
13
+
14
+ - `fullstackgtm icp derive --domain <site>` derives an evidence-backed ICP
15
+ from a public website with OpenRouter, OpenAI, or Anthropic, then offers an
16
+ interactive arrow-key review before writing `icp.json`.
17
+ - `fullstackgtm login openrouter` stores and validates a profile-scoped API key
18
+ for website-to-ICP derivation.
19
+ - OpenRouter derivation streams reasoning activity and provider-authored
20
+ reasoning summaries into the CLI status line during the model's longest wait.
21
+
22
+ ### Changed
23
+
24
+ - Public website fetching retains DNS pinning while disabling Node's automatic
25
+ address-family selection, avoiding custom-lookup failures on current Node.
26
+ - ICP-to-Clay filters map only supported catalog industries rather than
27
+ inventing unsupported provider labels.
28
+
29
+ ## [0.51.0] — 2026-07-11
30
+
31
+ ### Added
32
+
33
+ - Native Clay Public API authentication and people search for governed lead
34
+ acquisition, including ICP-to-Clay filter translation and resumable search
35
+ checkpoints.
36
+ - A provider-neutral, fill-only contact waterfall contract for work email,
37
+ mobile, and direct-dial enrichment, with Pipe0 as the first registered
38
+ provider and compatibility for existing Pipe0 email resolution.
39
+ - Clay acquisition presets, initialization, doctor diagnostics, public API
40
+ exports, strategy documentation, and live-safe credential validation.
41
+
42
+ ### Changed
43
+
44
+ - `enrich acquire` now uses the shared decision-first presentation contract:
45
+ compact cards by default, full evidence with `--verbose`, and a stable JSON
46
+ envelope with counts, cost, meter, and persistence state under `--json`.
47
+ - Pre-enrichment deduplication and avoided-spend reporting are provider-neutral
48
+ so the same acquisition pipeline can support additional contact sources.
49
+
10
50
  ## [0.50.1] — 2026-07-10
11
51
 
12
52
  ### Added
package/dist/cli/auth.js CHANGED
@@ -7,6 +7,7 @@ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getC
7
7
  import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.js";
8
8
  import { resolveLlmCredential, validateLlmKey } from "../llm.js";
9
9
  import { createFilePlanStore } from "../planStore.js";
10
+ import { validateClayApiKey } from "../connectors/clay.js";
10
11
  import { isOptionValue, numericOption, option, readPackageInfo, readSecret } from "./shared.js";
11
12
  import { box, colorEnabled, paint, scoreColor, sparkline } from "./ui.js";
12
13
  /**
@@ -316,20 +317,28 @@ export async function login(args) {
316
317
  console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
317
318
  return;
318
319
  }
319
- if (provider === "anthropic" || provider === "openai") {
320
+ if (provider === "anthropic" || provider === "openai" || provider === "openrouter") {
320
321
  rejectArgvSecret(args, "--token", "--key", "--api-key");
321
- const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
322
+ const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : provider === "openrouter" ? "sk-or-..." : "sk-..."})`);
322
323
  if (!key)
323
324
  throw new Error(`No ${provider} key provided.`);
324
325
  if (!args.includes("--no-validate")) {
325
- const validation = await validateLlmKey(provider, key);
326
- if (!validation.ok)
327
- throw new Error(`${provider} rejected the key: ${validation.detail}`);
328
- console.log(validation.detail);
326
+ if (provider === "openrouter") {
327
+ const response = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` } });
328
+ if (!response.ok)
329
+ throw new Error(`openrouter rejected the key: ${safeStatus(response)}`);
330
+ console.log("Key accepted by OpenRouter.");
331
+ }
332
+ else {
333
+ const validation = await validateLlmKey(provider, key);
334
+ if (!validation.ok)
335
+ throw new Error(`${provider} rejected the key: ${validation.detail}`);
336
+ console.log(validation.detail);
337
+ }
329
338
  }
330
339
  const stamp = new Date().toISOString();
331
340
  storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
332
- console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
341
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm icp derive\` uses it automatically${provider === "openrouter" ? "." : "; call parse/score use it too."}`);
333
342
  return;
334
343
  }
335
344
  if (provider === "apollo") {
@@ -351,6 +360,22 @@ export async function login(args) {
351
360
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
352
361
  return;
353
362
  }
363
+ if (provider === "clay") {
364
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
365
+ const key = await readSecret("Clay Public API key");
366
+ if (!key)
367
+ throw new Error("No Clay Public API key provided.");
368
+ if (!args.includes("--no-validate")) {
369
+ const validation = await validateClayApiKey(key);
370
+ if (!validation.ok)
371
+ throw new Error(validation.detail);
372
+ console.log(validation.detail);
373
+ }
374
+ const stamp = new Date().toISOString();
375
+ storeCredential("clay", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
376
+ console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
377
+ return;
378
+ }
354
379
  if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
355
380
  rejectArgvSecret(args, "--token", "--key", "--api-key");
356
381
  const key = await readSecret(`${provider} API key`);
@@ -365,7 +390,7 @@ export async function login(args) {
365
390
  return;
366
391
  }
367
392
  if (provider !== "hubspot") {
368
- throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
393
+ throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, openrouter, apollo, clay, pipe0, explorium, heyreach, theirstack, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
369
394
  }
370
395
  const now = new Date().toISOString();
371
396
  rejectArgvSecret(args, "--token");
@@ -450,6 +475,12 @@ export function doctorReport(env = process.env) {
450
475
  stripe: env.STRIPE_SECRET_KEY
451
476
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
452
477
  : providerStatus("stripe", broker),
478
+ clay: env.CLAY_API_KEY
479
+ ? { source: "env", detail: "CLAY_API_KEY" }
480
+ : providerStatus("clay", null),
481
+ openrouter: env.OPENROUTER_API_KEY
482
+ ? { source: "env", detail: "OPENROUTER_API_KEY" }
483
+ : providerStatus("openrouter", null),
453
484
  };
454
485
  const llm = resolveLlmCredential(env);
455
486
  const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
@@ -461,13 +492,13 @@ export function doctorReport(env = process.env) {
461
492
  return true;
462
493
  }
463
494
  });
464
- const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
465
- const nextSteps = connected.length === 0
495
+ const connectedCrm = ["hubspot", "salesforce"].filter((provider) => providers[provider].source !== "none");
496
+ const nextSteps = connectedCrm.length === 0
466
497
  ? [
467
498
  "fullstackgtm audit --demo # no credentials needed",
468
499
  "fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
469
500
  ]
470
- : [`fullstackgtm audit --provider ${connected[0][0]}`];
501
+ : [`fullstackgtm audit --provider ${connectedCrm[0]}`];
471
502
  return {
472
503
  package: packageInfo,
473
504
  node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
@@ -8,6 +8,8 @@ import { createFilePlanStore } from "../planStore.js";
8
8
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor } from "../enrich.js";
9
9
  import { loadMeter, remaining } from "../acquireMeter.js";
10
10
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
11
+ import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.js";
12
+ import { runContactWaterfall } from "../contactProviders.js";
11
13
  import { loadSeen, recordSeen } from "../acquireSeen.js";
12
14
  import { acquireCheckpointId, createFileAcquireCheckpointStore } from "../acquireCheckpoint.js";
13
15
  import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.js";
@@ -15,7 +17,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.js";
15
17
  import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
16
18
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
17
19
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
18
- import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
20
+ import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
19
21
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
20
22
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
21
23
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
@@ -193,8 +195,8 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
193
195
  // Meter: how many MORE leads may we create right now? --max is an
194
196
  // additional per-run ceiling, never a way to exceed the budget.
195
197
  if (apiSkippedCrm || apiSkippedSeen) {
196
- console.error(`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
197
- `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`);
198
+ console.error(`Pre-enrichment dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} previously seen ` +
199
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} downstream spend avoided).`);
198
200
  }
199
201
  // `cap` is the desired create count bounded by the persistent meter. Raw
200
202
  // discovery scanning has its own --scan-limit and may cross many duplicate-
@@ -264,16 +266,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
264
266
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
265
267
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
266
268
  if (!save) {
267
- if (rest.includes("--json")) {
268
- console.log(JSON.stringify({ plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom }, null, 2));
269
- }
270
- else {
271
- console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
272
- console.log(meterLine);
273
- if (gaugeLine)
274
- console.log(gaugeLine);
275
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
276
- }
269
+ printAcquireOutput({
270
+ args: rest,
271
+ result,
272
+ meter: headroom,
273
+ meterLine,
274
+ gaugeLine,
275
+ saved: false,
276
+ planSaved: false,
277
+ hostedPlanUrl: null,
278
+ });
277
279
  return;
278
280
  }
279
281
  const run = await openEnrichRun(store, source, "acquire", option(rest, "--run-label"), today);
@@ -319,28 +321,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
319
321
  if (acquireTelemetry && acquireCheckpointSync) {
320
322
  await persistAcquireCheckpoint(acquireCheckpointSync.key, acquireTelemetry.discovery, acquireCheckpointSync.hostedVersion);
321
323
  }
322
- if (planIds.length > 0) {
323
- const hostedRuns = hostedRunsUrl();
324
- console.log(`\nSaved plan ${result.plan.id}`);
325
- console.log(` Leads ${result.counts.created} net-new`);
326
- console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
327
- console.log(` Budget ${gaugeLine ?? meterLine}`);
328
- if (hostedPlanUrl)
329
- console.log(` Plan ${hostedPlanUrl}`);
330
- if (hostedRuns)
331
- console.log(` Observe ${hostedRuns}`);
332
- console.log("\nNext steps");
333
- console.log(` Review fullstackgtm plans show ${result.plan.id}`);
334
- console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
335
- console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
336
- console.log("\nThe meter is charged only when a create lands at apply.");
337
- }
338
- else {
339
- console.log(meterLine);
340
- if (gaugeLine)
341
- console.log(gaugeLine);
342
- console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
343
- }
324
+ printAcquireOutput({
325
+ args: rest,
326
+ result,
327
+ meter: headroom,
328
+ meterLine,
329
+ gaugeLine,
330
+ saved: true,
331
+ planSaved: planIds.length > 0,
332
+ hostedPlanUrl,
333
+ });
344
334
  return;
345
335
  }
346
336
  const mode = subcommand === "refresh" ? "refresh" : "append";
@@ -501,8 +491,9 @@ function formatEnrichCounts(counts, ambiguities) {
501
491
  /**
502
492
  * Pull net-new prospects from an API acquire source into source records the
503
493
  * acquire builder dedupes + turns into create_record ops. Explorium discovers;
504
- * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
505
- * carry the dedupe key (email) survive you cannot resolve-first without it.
494
+ * A field-level provider waterfall fills missing contact data. Existing
495
+ * email-keyed and resolveEmailsWith=pipe0 configs translate to an implicit
496
+ * Pipe0 work-email step. Only rows that carry the configured dedupe key survive.
506
497
  */
507
498
  async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRun, targetFresh, persistCheckpoint, progress) {
508
499
  const acquire = config.acquire;
@@ -521,7 +512,9 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
521
512
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
522
513
  : disc.provider === "pipe0"
523
514
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
524
- : {};
515
+ : disc.provider === "clay"
516
+ ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
517
+ : {};
525
518
  const queryFingerprint = createHash("sha256")
526
519
  .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
527
520
  .digest("hex");
@@ -584,6 +577,20 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
584
577
  cursor = result.nextCursor;
585
578
  exhausted = result.nextCursor === null;
586
579
  }
580
+ else if (disc.provider === "clay") {
581
+ if (!cursor) {
582
+ progress.note("creating Clay people-search iterator");
583
+ cursor = await createClaySearch({
584
+ apiKey: providerKey("clay"), sourceType: "people", filters,
585
+ });
586
+ }
587
+ const result = await runClayPeopleSearchPage({
588
+ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
589
+ });
590
+ page = result.prospects;
591
+ offset += page.length;
592
+ exhausted = !result.hasMore;
593
+ }
587
594
  else if (disc.provider === "explorium") {
588
595
  const pageNumber = offset + 1;
589
596
  exploriumPage = pageNumber;
@@ -604,7 +611,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
604
611
  exhausted = page.length < requestSize;
605
612
  }
606
613
  else {
607
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
614
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (clay | explorium | pipe0 | linkedin).`);
608
615
  }
609
616
  discovered += page.length;
610
617
  progress.items(discovered, scanLimit);
@@ -650,23 +657,35 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
650
657
  }
651
658
  progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
652
659
  progress.note("checking work-email requirements");
653
- // 3. Resolve real work emails. Triggered either when email IS the dedupe key
654
- // (Explorium's email is hashed, Crustdata returns none) or when a source
655
- // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
656
- // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
657
- // pipe0 waterfall, chunked; resolves from name + company domain/name.
658
- if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
659
- // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
660
- // names. Resolve domains first (pipe0 company:identity) so resolution can
661
- // actually land — without it, name-only resolution fails for every lead.
662
- if (prospects.some((p) => !p.companyDomain && p.companyName)) {
663
- progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
664
- const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
665
- prospects.splice(0, prospects.length, ...resolved);
666
- }
667
- progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
668
- const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
669
- prospects.splice(0, prospects.length, ...resolved);
660
+ // 3. Resolve contact fields through the ordered, fill-only waterfall. Keep
661
+ // legacy behavior: email-keyed acquisition and resolveEmailsWith=pipe0 imply
662
+ // a Pipe0 work-email step when no explicit waterfall is configured.
663
+ const implicitPipe0 = matchKey === "email" || disc.resolveEmailsWith === "pipe0";
664
+ const contactWaterfall = disc.contactWaterfall ?? (implicitPipe0 ? [{ provider: "pipe0", fields: ["work_email"] }] : []);
665
+ if (contactWaterfall.length > 0) {
666
+ const adapters = {
667
+ pipe0: {
668
+ provider: "pipe0",
669
+ resolve: async (candidates, fields) => {
670
+ let resolved = candidates;
671
+ if (fields.includes("work_email") && resolved.some((p) => !p.companyDomain && p.companyName)) {
672
+ progress.note(`resolving company domains for ${resolved.length} candidate(s)`);
673
+ resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects: resolved });
674
+ }
675
+ if (fields.includes("work_email")) {
676
+ progress.note(`resolving work emails with pipe0 for ${resolved.length} candidate(s)`);
677
+ resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects: resolved });
678
+ }
679
+ return resolved;
680
+ },
681
+ },
682
+ };
683
+ const result = await runContactWaterfall({ prospects, steps: contactWaterfall, adapters });
684
+ prospects.splice(0, prospects.length, ...result.prospects);
685
+ for (const attempt of result.attempts) {
686
+ const added = Object.entries(attempt.added).map(([field, count]) => `${count} ${field}`).join(", ") || "0 fields";
687
+ progress.note(`${attempt.provider}: ${attempt.attempted} attempted · ${added} added`);
688
+ }
670
689
  }
671
690
  progress.items(prospects.length, prospects.length);
672
691
  progress.note(`${prospects.length} candidate(s) ready for planning`);
@@ -756,6 +775,53 @@ function formatAcquireMeter(headroom, costPerRecord) {
756
775
  `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
757
776
  `(≈$${costPerRecord.toFixed(2)}/lead).`);
758
777
  }
778
+ function printAcquireOutput(options) {
779
+ const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
780
+ if (args.includes("--json")) {
781
+ console.log(JSON.stringify({
782
+ plan: result.plan,
783
+ counts: result.counts,
784
+ estCostUsd: result.estCostUsd,
785
+ meter,
786
+ persistence: {
787
+ runSaved: saved,
788
+ planSaved,
789
+ planId: planSaved ? result.plan.id : null,
790
+ hostedPlanUrl,
791
+ },
792
+ }, null, 2));
793
+ return;
794
+ }
795
+ if (verbosePlanRequested(args)) {
796
+ console.log(patchPlanToMarkdown(result.plan));
797
+ console.log(`\nAcquisition`);
798
+ console.log(` Leads ${result.counts.created} net-new · ${result.counts.unassigned} unassigned`);
799
+ console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
800
+ console.log(` Budget ${gaugeLine ?? meterLine}`);
801
+ if (hostedPlanUrl)
802
+ console.log(` Plan ${hostedPlanUrl}`);
803
+ }
804
+ else if (planSaved || !saved) {
805
+ console.log(compactPlan(result.plan, { saved: planSaved }));
806
+ console.log(meterLine);
807
+ if (gaugeLine)
808
+ console.log(gaugeLine);
809
+ }
810
+ else {
811
+ console.log(meterLine);
812
+ if (gaugeLine)
813
+ console.log(gaugeLine);
814
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
815
+ }
816
+ if (planSaved) {
817
+ console.log(`\nApprove fullstackgtm plans approve ${result.plan.id} --operations all`);
818
+ console.log(`Apply fullstackgtm apply --plan-id ${result.plan.id} --provider <hubspot|salesforce>`);
819
+ const hostedRuns = hostedRunsUrl();
820
+ if (hostedRuns)
821
+ console.log(`Observe ${hostedRuns}`);
822
+ console.log("The meter is charged only when a create lands at apply.");
823
+ }
824
+ }
759
825
  function hostedRunsUrl() {
760
826
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
761
827
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/dist/cli/help.js CHANGED
@@ -16,13 +16,16 @@ Usage:
16
16
  fullstackgtm login salesforce --instance-url <url> [--no-validate] advanced: paste access token
17
17
  fullstackgtm login stripe [--no-validate]
18
18
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
19
- fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
19
+ fullstackgtm login apollo | clay store a data-provider Public API key
20
+ fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)
21
+ fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin
22
+ fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|heyreach|broker>
20
23
 
21
24
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
22
25
  the process list and shell history. Pipe them on stdin or enter them at the
23
26
  interactive prompt:
24
27
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
25
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
28
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
26
29
  cold start: scaffold icp.json + enrich.config.json + a
27
30
  PLAYBOOK wired for this workspace (the CLI ships primitives,
28
31
  your agent is the orchestrator — see docs/recipes.md)
@@ -72,7 +75,7 @@ Usage:
72
75
  fullstackgtm tam estimate [--name <n>] [--icp <path>] (--accounts <n> | --source theirstack|explorium) (--acv <annual-usd> | --acv-from-crm --deal-period monthly|quarterly|annual) [--acv-basis account|buyer] [--buyers-per-account <n>] [--cross-checks <file.json>] [source options] [--json]
73
76
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
74
77
  fullstackgtm tam report [--name <n>] [--out <path>]
75
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
78
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
76
79
  size the reachable market FROM the ICP (a real account count ×
77
80
  ACV; buyers/account = the contact population target), then fill
78
81
  it: populate schedules plan-only enrich acquire --save (apply
@@ -276,7 +279,7 @@ export const HELP = {
276
279
  summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
277
280
  phase: "Setup",
278
281
  synopsis: [
279
- "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
282
+ "fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
280
283
  ],
281
284
  detail: "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
282
285
  seeAlso: ["icp", "enrich", "signals"],
@@ -289,7 +292,7 @@ export const HELP = {
289
292
  "fullstackgtm login hubspot | salesforce hosted browser OAuth (default)",
290
293
  "fullstackgtm login --via <hosted url> pair with a team deployment",
291
294
  "fullstackgtm login stripe",
292
- "fullstackgtm login anthropic | openai | apollo",
295
+ "fullstackgtm login anthropic | openai | apollo | clay",
293
296
  ],
294
297
  detail: "HubSpot/Salesforce use hosted browser OAuth by default; BYO app/token paths are advanced. Secrets are NEVER passed as flags — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot --private-token`.",
295
298
  seeAlso: ["doctor", "logout", "profiles"],
@@ -297,7 +300,7 @@ export const HELP = {
297
300
  logout: {
298
301
  summary: "remove stored credentials for a provider",
299
302
  phase: "Setup",
300
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
303
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|broker>"],
301
304
  seeAlso: ["login", "doctor"],
302
305
  },
303
306
  doctor: {
@@ -633,7 +636,7 @@ export const COMMAND_FLAGS = {
633
636
  merge: ["--input", "--out", "--json"],
634
637
  market: ["--category", "--config", "--vendor", "--snapshot", "--task-account", "--task-deal", "--capture-run", "--run", "--prior-run", "--diff", "--from", "--calls", "--anchor", "--min-mentions", "--max-claims", "--promote-lift", "--model", "--format", "--auto", "--unverified", "--save", "--dry-run", "--verbose", "--json", "--out"],
635
638
  tam: [...SOURCE_FLAGS, "--source", "--name", "--icp", "--accounts", "--acv", "--acv-basis", "--acv-from-crm", "--deal-period", "--buyers-per-account", "--cross-checks", "--max", "--max-credits", "--usd-per-credit", "--dry-run", "--confirm", "--cron", "--label", "--save", "--verbose", "--json", "--out"],
636
- icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
639
+ icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
637
640
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
638
641
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
639
642
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],