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/src/cli/auth.ts CHANGED
@@ -8,6 +8,7 @@ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getC
8
8
  import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.ts";
9
9
  import { resolveLlmCredential, validateLlmKey } from "../llm.ts";
10
10
  import { createFilePlanStore, type StoredPlan } from "../planStore.ts";
11
+ import { validateClayApiKey } from "../connectors/clay.ts";
11
12
  import { isOptionValue, numericOption, option, readPackageInfo, readSecret } from "./shared.ts";
12
13
  import { box, colorEnabled, paint, scoreColor, sparkline } from "./ui.ts";
13
14
 
@@ -346,18 +347,24 @@ export async function login(args: string[]) {
346
347
  console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
347
348
  return;
348
349
  }
349
- if (provider === "anthropic" || provider === "openai") {
350
+ if (provider === "anthropic" || provider === "openai" || provider === "openrouter") {
350
351
  rejectArgvSecret(args, "--token", "--key", "--api-key");
351
- const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
352
+ const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : provider === "openrouter" ? "sk-or-..." : "sk-..."})`);
352
353
  if (!key) throw new Error(`No ${provider} key provided.`);
353
354
  if (!args.includes("--no-validate")) {
354
- const validation = await validateLlmKey(provider, key);
355
- if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
356
- console.log(validation.detail);
355
+ if (provider === "openrouter") {
356
+ const response = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` } });
357
+ if (!response.ok) throw new Error(`openrouter rejected the key: ${safeStatus(response)}`);
358
+ console.log("Key accepted by OpenRouter.");
359
+ } else {
360
+ const validation = await validateLlmKey(provider, key);
361
+ if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
362
+ console.log(validation.detail);
363
+ }
357
364
  }
358
365
  const stamp = new Date().toISOString();
359
366
  storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
360
- console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
367
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm icp derive\` uses it automatically${provider === "openrouter" ? "." : "; call parse/score use it too."}`);
361
368
  return;
362
369
  }
363
370
  if (provider === "apollo") {
@@ -378,6 +385,20 @@ export async function login(args: string[]) {
378
385
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
379
386
  return;
380
387
  }
388
+ if (provider === "clay") {
389
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
390
+ const key = await readSecret("Clay Public API key");
391
+ if (!key) throw new Error("No Clay Public API key provided.");
392
+ if (!args.includes("--no-validate")) {
393
+ const validation = await validateClayApiKey(key);
394
+ if (!validation.ok) throw new Error(validation.detail);
395
+ console.log(validation.detail);
396
+ }
397
+ const stamp = new Date().toISOString();
398
+ storeCredential("clay", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
399
+ console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
400
+ return;
401
+ }
381
402
  if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
382
403
  rejectArgvSecret(args, "--token", "--key", "--api-key");
383
404
  const key = await readSecret(`${provider} API key`);
@@ -393,7 +414,7 @@ export async function login(args: string[]) {
393
414
  }
394
415
  if (provider !== "hubspot") {
395
416
  throw new Error(
396
- "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",
417
+ "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",
397
418
  );
398
419
  }
399
420
  const now = new Date().toISOString();
@@ -491,6 +512,12 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
491
512
  stripe: env.STRIPE_SECRET_KEY
492
513
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
493
514
  : providerStatus("stripe", broker),
515
+ clay: env.CLAY_API_KEY
516
+ ? { source: "env", detail: "CLAY_API_KEY" }
517
+ : providerStatus("clay", null),
518
+ openrouter: env.OPENROUTER_API_KEY
519
+ ? { source: "env", detail: "OPENROUTER_API_KEY" }
520
+ : providerStatus("openrouter", null),
494
521
  };
495
522
 
496
523
  const llm = resolveLlmCredential(env);
@@ -503,14 +530,14 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
503
530
  }
504
531
  });
505
532
 
506
- const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
533
+ const connectedCrm = ["hubspot", "salesforce"].filter((provider) => providers[provider].source !== "none");
507
534
  const nextSteps =
508
- connected.length === 0
535
+ connectedCrm.length === 0
509
536
  ? [
510
537
  "fullstackgtm audit --demo # no credentials needed",
511
538
  "fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
512
539
  ]
513
- : [`fullstackgtm audit --provider ${connected[0][0]}`];
540
+ : [`fullstackgtm audit --provider ${connectedCrm[0]}`];
514
541
 
515
542
  return {
516
543
  package: packageInfo,
package/src/cli/enrich.ts CHANGED
@@ -9,6 +9,8 @@ import { createFilePlanStore } from "../planStore.ts";
9
9
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, type EnrichConfig, type EnrichCounts, type EnrichObjectType, type EnrichRun, type EnrichRunStore, type EnrichSourceRecord } from "../enrich.ts";
10
10
  import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
11
11
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
12
+ import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.ts";
13
+ import { runContactWaterfall, type ContactProviderAdapter, type ContactWaterfallStep } from "../contactProviders.ts";
12
14
  import { loadSeen, recordSeen } from "../acquireSeen.ts";
13
15
  import { acquireCheckpointId, createFileAcquireCheckpointStore, type AcquireCheckpointKey } from "../acquireCheckpoint.ts";
14
16
  import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.ts";
@@ -16,7 +18,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
16
18
  import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
17
19
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
18
20
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
19
- import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
21
+ import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
20
22
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
21
23
  import type { CanonicalGtmSnapshot } from "../types.ts";
22
24
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
@@ -217,8 +219,8 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
217
219
  // additional per-run ceiling, never a way to exceed the budget.
218
220
  if (apiSkippedCrm || apiSkippedSeen) {
219
221
  console.error(
220
- `Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
221
- `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`,
222
+ `Pre-enrichment dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} previously seen ` +
223
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} downstream spend avoided).`,
222
224
  );
223
225
  }
224
226
  // `cap` is the desired create count bounded by the persistent meter. Raw
@@ -301,20 +303,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
301
303
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
302
304
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
303
305
  if (!save) {
304
- if (rest.includes("--json")) {
305
- console.log(
306
- JSON.stringify(
307
- { plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom },
308
- null,
309
- 2,
310
- ),
311
- );
312
- } else {
313
- console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
314
- console.log(meterLine);
315
- if (gaugeLine) console.log(gaugeLine);
316
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
317
- }
306
+ printAcquireOutput({
307
+ args: rest,
308
+ result,
309
+ meter: headroom,
310
+ meterLine,
311
+ gaugeLine,
312
+ saved: false,
313
+ planSaved: false,
314
+ hostedPlanUrl: null,
315
+ });
318
316
  return;
319
317
  }
320
318
 
@@ -363,24 +361,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
363
361
  acquireCheckpointSync.hostedVersion,
364
362
  );
365
363
  }
366
- if (planIds.length > 0) {
367
- const hostedRuns = hostedRunsUrl();
368
- console.log(`\nSaved plan ${result.plan.id}`);
369
- console.log(` Leads ${result.counts.created} net-new`);
370
- console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
371
- console.log(` Budget ${gaugeLine ?? meterLine}`);
372
- if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
373
- if (hostedRuns) console.log(` Observe ${hostedRuns}`);
374
- console.log("\nNext steps");
375
- console.log(` Review fullstackgtm plans show ${result.plan.id}`);
376
- console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
377
- console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
378
- console.log("\nThe meter is charged only when a create lands at apply.");
379
- } else {
380
- console.log(meterLine);
381
- if (gaugeLine) console.log(gaugeLine);
382
- console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
383
- }
364
+ printAcquireOutput({
365
+ args: rest,
366
+ result,
367
+ meter: headroom,
368
+ meterLine,
369
+ gaugeLine,
370
+ saved: true,
371
+ planSaved: planIds.length > 0,
372
+ hostedPlanUrl,
373
+ });
384
374
  return;
385
375
  }
386
376
 
@@ -561,8 +551,9 @@ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
561
551
  /**
562
552
  * Pull net-new prospects from an API acquire source into source records the
563
553
  * acquire builder dedupes + turns into create_record ops. Explorium discovers;
564
- * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
565
- * carry the dedupe key (email) survive you cannot resolve-first without it.
554
+ * A field-level provider waterfall fills missing contact data. Existing
555
+ * email-keyed and resolveEmailsWith=pipe0 configs translate to an implicit
556
+ * Pipe0 work-email step. Only rows that carry the configured dedupe key survive.
566
557
  */
567
558
  async function acquireFromApi(
568
559
  config: EnrichConfig,
@@ -602,7 +593,9 @@ async function acquireFromApi(
602
593
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
603
594
  : disc.provider === "pipe0"
604
595
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
605
- : {};
596
+ : disc.provider === "clay"
597
+ ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
598
+ : {};
606
599
  const queryFingerprint = createHash("sha256")
607
600
  .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
608
601
  .digest("hex");
@@ -677,6 +670,19 @@ async function acquireFromApi(
677
670
  page = result.prospects;
678
671
  cursor = result.nextCursor;
679
672
  exhausted = result.nextCursor === null;
673
+ } else if (disc.provider === "clay") {
674
+ if (!cursor) {
675
+ progress.note("creating Clay people-search iterator");
676
+ cursor = await createClaySearch({
677
+ apiKey: providerKey("clay"), sourceType: "people", filters,
678
+ });
679
+ }
680
+ const result = await runClayPeopleSearchPage({
681
+ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
682
+ });
683
+ page = result.prospects;
684
+ offset += page.length;
685
+ exhausted = !result.hasMore;
680
686
  } else if (disc.provider === "explorium") {
681
687
  const pageNumber = offset + 1;
682
688
  exploriumPage = pageNumber;
@@ -695,7 +701,7 @@ async function acquireFromApi(
695
701
  offset += page.length;
696
702
  exhausted = page.length < requestSize;
697
703
  } else {
698
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
704
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (clay | explorium | pipe0 | linkedin).`);
699
705
  }
700
706
  discovered += page.length;
701
707
  progress.items(discovered, scanLimit);
@@ -747,23 +753,37 @@ async function acquireFromApi(
747
753
  progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
748
754
  progress.note("checking work-email requirements");
749
755
 
750
- // 3. Resolve real work emails. Triggered either when email IS the dedupe key
751
- // (Explorium's email is hashed, Crustdata returns none) or when a source
752
- // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
753
- // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
754
- // pipe0 waterfall, chunked; resolves from name + company domain/name.
755
- if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
756
- // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
757
- // names. Resolve domains first (pipe0 company:identity) so resolution can
758
- // actually land — without it, name-only resolution fails for every lead.
759
- if (prospects.some((p) => !p.companyDomain && p.companyName)) {
760
- progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
761
- const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
762
- prospects.splice(0, prospects.length, ...resolved);
756
+ // 3. Resolve contact fields through the ordered, fill-only waterfall. Keep
757
+ // legacy behavior: email-keyed acquisition and resolveEmailsWith=pipe0 imply
758
+ // a Pipe0 work-email step when no explicit waterfall is configured.
759
+ const implicitPipe0 = matchKey === "email" || disc.resolveEmailsWith === "pipe0";
760
+ const contactWaterfall: ContactWaterfallStep[] = disc.contactWaterfall ?? (
761
+ implicitPipe0 ? [{ provider: "pipe0", fields: ["work_email"] }] : []
762
+ );
763
+ if (contactWaterfall.length > 0) {
764
+ const adapters: Record<string, ContactProviderAdapter> = {
765
+ pipe0: {
766
+ provider: "pipe0",
767
+ resolve: async (candidates, fields) => {
768
+ let resolved = candidates;
769
+ if (fields.includes("work_email") && resolved.some((p) => !p.companyDomain && p.companyName)) {
770
+ progress.note(`resolving company domains for ${resolved.length} candidate(s)`);
771
+ resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects: resolved });
772
+ }
773
+ if (fields.includes("work_email")) {
774
+ progress.note(`resolving work emails with pipe0 for ${resolved.length} candidate(s)`);
775
+ resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects: resolved });
776
+ }
777
+ return resolved;
778
+ },
779
+ },
780
+ };
781
+ const result = await runContactWaterfall({ prospects, steps: contactWaterfall, adapters });
782
+ prospects.splice(0, prospects.length, ...result.prospects);
783
+ for (const attempt of result.attempts) {
784
+ const added = Object.entries(attempt.added).map(([field, count]) => `${count} ${field}`).join(", ") || "0 fields";
785
+ progress.note(`${attempt.provider}: ${attempt.attempted} attempted · ${added} added`);
763
786
  }
764
- progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
765
- const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
766
- prospects.splice(0, prospects.length, ...resolved);
767
787
  }
768
788
  progress.items(prospects.length, prospects.length);
769
789
  progress.note(`${prospects.length} candidate(s) ready for planning`);
@@ -876,6 +896,61 @@ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number):
876
896
  );
877
897
  }
878
898
 
899
+ function printAcquireOutput(options: {
900
+ args: readonly string[];
901
+ result: ReturnType<typeof buildAcquirePlan>;
902
+ meter: AcquireRemaining;
903
+ meterLine: string;
904
+ gaugeLine: string | null;
905
+ /** The acquisition run/checkpoint was persisted. */
906
+ saved: boolean;
907
+ /** A non-empty plan was persisted to the plan store. */
908
+ planSaved: boolean;
909
+ hostedPlanUrl: string | null;
910
+ }): void {
911
+ const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
912
+ if (args.includes("--json")) {
913
+ console.log(JSON.stringify({
914
+ plan: result.plan,
915
+ counts: result.counts,
916
+ estCostUsd: result.estCostUsd,
917
+ meter,
918
+ persistence: {
919
+ runSaved: saved,
920
+ planSaved,
921
+ planId: planSaved ? result.plan.id : null,
922
+ hostedPlanUrl,
923
+ },
924
+ }, null, 2));
925
+ return;
926
+ }
927
+
928
+ if (verbosePlanRequested(args)) {
929
+ console.log(patchPlanToMarkdown(result.plan));
930
+ console.log(`\nAcquisition`);
931
+ console.log(` Leads ${result.counts.created} net-new · ${result.counts.unassigned} unassigned`);
932
+ console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
933
+ console.log(` Budget ${gaugeLine ?? meterLine}`);
934
+ if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
935
+ } else if (planSaved || !saved) {
936
+ console.log(compactPlan(result.plan, { saved: planSaved }));
937
+ console.log(meterLine);
938
+ if (gaugeLine) console.log(gaugeLine);
939
+ } else {
940
+ console.log(meterLine);
941
+ if (gaugeLine) console.log(gaugeLine);
942
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
943
+ }
944
+
945
+ if (planSaved) {
946
+ console.log(`\nApprove fullstackgtm plans approve ${result.plan.id} --operations all`);
947
+ console.log(`Apply fullstackgtm apply --plan-id ${result.plan.id} --provider <hubspot|salesforce>`);
948
+ const hostedRuns = hostedRunsUrl();
949
+ if (hostedRuns) console.log(`Observe ${hostedRuns}`);
950
+ console.log("The meter is charged only when a create lands at apply.");
951
+ }
952
+ }
953
+
879
954
  function hostedRunsUrl(): string | null {
880
955
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
881
956
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/src/cli/help.ts CHANGED
@@ -20,13 +20,16 @@ Usage:
20
20
  fullstackgtm login salesforce --instance-url <url> [--no-validate] advanced: paste access token
21
21
  fullstackgtm login stripe [--no-validate]
22
22
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
23
- 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>
23
+ fullstackgtm login apollo | clay store a data-provider Public API key
24
+ fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)
25
+ fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin
26
+ fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|heyreach|broker>
24
27
 
25
28
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
26
29
  the process list and shell history. Pipe them on stdin or enter them at the
27
30
  interactive prompt:
28
31
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
29
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
32
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
30
33
  cold start: scaffold icp.json + enrich.config.json + a
31
34
  PLAYBOOK wired for this workspace (the CLI ships primitives,
32
35
  your agent is the orchestrator — see docs/recipes.md)
@@ -76,7 +79,7 @@ Usage:
76
79
  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]
77
80
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
78
81
  fullstackgtm tam report [--name <n>] [--out <path>]
79
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
82
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
80
83
  size the reachable market FROM the ICP (a real account count ×
81
84
  ACV; buyers/account = the contact population target), then fill
82
85
  it: populate schedules plan-only enrich acquire --save (apply
@@ -298,7 +301,7 @@ export const HELP: Record<string, HelpEntry> = {
298
301
  summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
299
302
  phase: "Setup",
300
303
  synopsis: [
301
- "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
304
+ "fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
302
305
  ],
303
306
  detail:
304
307
  "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).",
@@ -312,7 +315,7 @@ export const HELP: Record<string, HelpEntry> = {
312
315
  "fullstackgtm login hubspot | salesforce hosted browser OAuth (default)",
313
316
  "fullstackgtm login --via <hosted url> pair with a team deployment",
314
317
  "fullstackgtm login stripe",
315
- "fullstackgtm login anthropic | openai | apollo",
318
+ "fullstackgtm login anthropic | openai | apollo | clay",
316
319
  ],
317
320
  detail:
318
321
  "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`.",
@@ -321,7 +324,7 @@ export const HELP: Record<string, HelpEntry> = {
321
324
  logout: {
322
325
  summary: "remove stored credentials for a provider",
323
326
  phase: "Setup",
324
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
327
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|broker>"],
325
328
  seeAlso: ["login", "doctor"],
326
329
  },
327
330
  doctor: {
@@ -695,7 +698,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
695
698
  merge: ["--input", "--out", "--json"],
696
699
  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"],
697
700
  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"],
698
- icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
701
+ 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"],
699
702
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
700
703
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
701
704
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
package/src/cli/icp.ts CHANGED
@@ -1,15 +1,20 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
2
 
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
+ import { emitKeypressEvents } from "node:readline";
5
+ import { createInterface } from "node:readline/promises";
4
6
  import { resolve } from "node:path";
5
- import { resolveLlmCredential } from "../llm.ts";
7
+ import { resolveLlmCredential, type LlmCallOptions } from "../llm.ts";
6
8
  import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, INTERVIEW_SPEC } from "../icp.ts";
7
9
  import { createFileSignalStore, DEFAULT_SIGNALS_CONFIG, type SignalsConfig } from "../signals.ts";
8
10
  import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals } from "../judge.ts";
9
11
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.ts";
10
- import type { LlmCallOptions } from "../llm.ts";
11
- import { loadIcp, numericOption, option, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
12
+ import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
12
13
  import { createStatusLine } from "./ui.ts";
14
+ import { box, colorEnabled, paint } from "./ui.ts";
15
+ import { getCredential, storeCredential } from "../credentials.ts";
16
+ import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE, type WebsiteIcpDerivation } from "../icpDerive.ts";
17
+ import type { Icp } from "../icp.ts";
13
18
  import { unknownSubcommandError } from "./suggest.ts";
14
19
 
15
20
  function renderJudgeDecisions(decisions: Awaited<ReturnType<typeof judgeSignals>>): string {
@@ -24,6 +29,110 @@ function renderJudgeDecisions(decisions: Awaited<ReturnType<typeof judgeSignals>
24
29
  return lines.join("\n");
25
30
  }
26
31
 
32
+ function updateReviewSegment(icp: Icp, id: string, raw: string): Icp {
33
+ const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
34
+ if (id === "threshold") return { ...icp, scoring: { ...icp.scoring, threshold: Number(raw) } };
35
+ if (["industries", "employeeBands", "geos", "technologies"].includes(id)) {
36
+ return { ...icp, firmographics: { ...icp.firmographics, [id]: values } };
37
+ }
38
+ if (["titleKeywords", "jobLevels", "departments"].includes(id)) {
39
+ return { ...icp, persona: { ...icp.persona, [id]: values } };
40
+ }
41
+ return { ...icp, signals: { ...icp.signals, intentTopics: values } };
42
+ }
43
+
44
+ function renderDerivedIcp(result: WebsiteIcpDerivation, selected = -1): string {
45
+ const p = paint(colorEnabled());
46
+ const segments = icpReviewSegments(result.icp);
47
+ const lines = [
48
+ `${result.company.name} ${result.company.domain}`,
49
+ `${Math.round(result.confidence * 100)}% confidence · ${result.derivation.model}`,
50
+ "",
51
+ ...segments.map((segment, index) => `${index === selected ? "›" : " "} ${segment.label.padEnd(15)} ${segment.value}`),
52
+ "",
53
+ selected >= 0 ? "↑/↓ select · enter edit · s save · q cancel" : `${result.evidence.length} website-verifiable evidence quote(s)`,
54
+ ];
55
+ return box(lines, p, "ICP preview").join("\n");
56
+ }
57
+
58
+ async function resolveIcpDeriveLlm(args: string[]): Promise<LlmCallOptions> {
59
+ const requested = option(args, "--provider")?.toLowerCase();
60
+ if (requested && !["openrouter", "openai", "anthropic"].includes(requested)) {
61
+ throw new Error(`icp derive --provider must be openrouter, openai, or anthropic; got "${requested}".`);
62
+ }
63
+ const openRouterKey = process.env.OPENROUTER_API_KEY ?? getCredential("openrouter")?.accessToken;
64
+ const normal = resolveLlmCredential();
65
+ if ((!requested || requested === "openrouter") && openRouterKey) {
66
+ return { provider: "openai", apiKey: openRouterKey, openaiBaseUrl: OPENROUTER_API_BASE };
67
+ }
68
+ if (requested === "openai" || requested === "anthropic") {
69
+ const envKey = requested === "openai" ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY;
70
+ const key = envKey ?? getCredential(requested)?.accessToken;
71
+ if (key) return { provider: requested, apiKey: key, ...resolveLlmBaseUrls() };
72
+ }
73
+ if (normal && (!requested || requested === normal.provider)) return { ...normal, ...resolveLlmBaseUrls() };
74
+ const provider = requested as "openrouter" | "openai" | "anthropic" | undefined;
75
+ if (!process.stdin.isTTY || process.env.CI) {
76
+ throw new Error(
77
+ "ICP derivation needs an LLM key. Run one of:\n" +
78
+ " fullstackgtm login openrouter\n fullstackgtm login openai\n fullstackgtm login anthropic\n" +
79
+ "Then rerun the same icp derive command.",
80
+ );
81
+ }
82
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
83
+ const answer = provider ?? ((await rl.question("LLM provider [openrouter/openai/anthropic] (openrouter): ")).trim().toLowerCase() || "openrouter");
84
+ rl.close();
85
+ if (!['openrouter', 'openai', 'anthropic'].includes(answer)) throw new Error(`Unknown LLM provider "${answer}".`);
86
+ const selectedProvider = answer as "openrouter" | "openai" | "anthropic";
87
+ const key = await readSecret(`${selectedProvider} API key`);
88
+ if (!key) throw new Error(`No ${answer} API key provided.`);
89
+ const now = new Date().toISOString();
90
+ storeCredential(selectedProvider, { kind: "api_key", accessToken: key, createdAt: now, updatedAt: now });
91
+ console.error(`Stored ${selectedProvider} key in the active FullStackGTM profile.`);
92
+ return selectedProvider === "openrouter"
93
+ ? { provider: "openai", apiKey: key, openaiBaseUrl: OPENROUTER_API_BASE }
94
+ : { provider: selectedProvider, apiKey: key };
95
+ }
96
+
97
+ async function reviewDerivedIcp(result: WebsiteIcpDerivation): Promise<WebsiteIcpDerivation | null> {
98
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return result;
99
+ let current = result;
100
+ let selected = 0;
101
+ const render = () => process.stdout.write(`\u001b[2J\u001b[H${renderDerivedIcp(current, selected)}\n`);
102
+ emitKeypressEvents(process.stdin);
103
+ process.stdin.setRawMode?.(true);
104
+ process.stdin.resume();
105
+ render();
106
+ return new Promise((resolveReview) => {
107
+ const finish = (value: WebsiteIcpDerivation | null) => {
108
+ process.stdin.off("keypress", onKey);
109
+ process.stdin.setRawMode?.(false);
110
+ process.stdin.pause();
111
+ resolveReview(value);
112
+ };
113
+ const onKey = async (_input: string, key: { name?: string; ctrl?: boolean }) => {
114
+ const segments = icpReviewSegments(current.icp);
115
+ if (key.ctrl && key.name === "c" || key.name === "q") return finish(null);
116
+ if (key.name === "up") { selected = (selected - 1 + segments.length) % segments.length; return render(); }
117
+ if (key.name === "down") { selected = (selected + 1) % segments.length; return render(); }
118
+ if (key.name === "s") return finish(current);
119
+ if (key.name !== "return") return;
120
+ process.stdin.off("keypress", onKey);
121
+ process.stdin.setRawMode?.(false);
122
+ const segment = segments[selected];
123
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
124
+ const answer = await rl.question(`\n${segment.label} [${segment.value}]: `);
125
+ rl.close();
126
+ if (answer.trim()) current = { ...current, icp: updateReviewSegment(current.icp, segment.id, answer) };
127
+ emitKeypressEvents(process.stdin);
128
+ process.stdin.setRawMode?.(true);
129
+ process.stdin.on("keypress", onKey);
130
+ render();
131
+ };
132
+ process.stdin.on("keypress", onKey);
133
+ });
134
+ }
135
+
27
136
 
28
137
  /**
29
138
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
@@ -38,6 +147,8 @@ export async function icpCommand(args: string[]) {
38
147
  if (!sub || args.includes("--help") || args.includes("-h")) {
39
148
  console.log(`Usage:
40
149
  fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
150
+ fullstackgtm icp derive --domain <site> [--model <id>] [--out icp.json] [--json]
151
+ derive an evidence-backed ICP from a public website (OpenRouter/OpenAI/Anthropic)
41
152
  fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
42
153
  fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
43
154
  fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
@@ -65,6 +176,36 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
65
176
  );
66
177
  return;
67
178
  }
179
+ if (sub === "derive") {
180
+ const domain = option(rest, "--domain");
181
+ if (!domain) throw new Error("Usage: fullstackgtm icp derive --domain <company.com> [--out icp.json] [--json]");
182
+ const llm = await resolveIcpDeriveLlm(rest);
183
+ const status = createStatusLine();
184
+ let derived: WebsiteIcpDerivation;
185
+ try {
186
+ derived = await deriveWebsiteIcp({
187
+ domain,
188
+ llm,
189
+ model: option(rest, "--model") ?? undefined,
190
+ onProgress: (event) => status.set(event.message),
191
+ });
192
+ } finally {
193
+ status.done();
194
+ }
195
+ if (!rest.includes("--json") && !rest.includes("--no-interactive")) {
196
+ const reviewed = await reviewDerivedIcp(derived);
197
+ if (!reviewed) throw new Error("ICP review cancelled; no file was written.");
198
+ derived = reviewed;
199
+ }
200
+ const out = option(rest, "--out");
201
+ if (out) {
202
+ const path = resolve(process.cwd(), out);
203
+ writeFileSync(path, `${JSON.stringify(derived.icp, null, 2)}\n`);
204
+ console.error(`Wrote reviewed ICP to ${path}. Next: fullstackgtm enrich acquire --source clay --icp ${path}`);
205
+ }
206
+ console.log(rest.includes("--json") ? JSON.stringify(derived, null, 2) : renderDerivedIcp(derived));
207
+ return;
208
+ }
68
209
  if (sub === "set") {
69
210
  const file = rest.find((a) => !a.startsWith("--"));
70
211
  if (!file) throw new Error("Usage: fullstackgtm icp set <answers.json> [--name <n>] [--out <path>]");
package/src/cli/init.ts CHANGED
@@ -16,7 +16,7 @@ import { option } from "./shared.ts";
16
16
  export function initCommand(args: string[]) {
17
17
  if (args.includes("--help") || args.includes("-h")) {
18
18
  console.log(`Usage:
19
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
19
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
20
20
 
21
21
  Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
22
22
  icp.json starter ICP (edit, or rebuild with \`icp interview\`)
@@ -28,8 +28,8 @@ docs/recipes.md for the full play set. Existing files are kept unless --force.`)
28
28
  return;
29
29
  }
30
30
  const source = (option(args, "--source") ?? "pipe0") as InitSource;
31
- if (!["pipe0", "explorium", "linkedin"].includes(source)) {
32
- throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
31
+ if (!["pipe0", "explorium", "clay", "linkedin"].includes(source)) {
32
+ throw new Error(`init: --source must be pipe0|explorium|clay|linkedin (got "${source}")`);
33
33
  }
34
34
  const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
35
35
  if (!["hubspot", "salesforce"].includes(provider)) {