fullstackgtm 0.50.1 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/cli/auth.js +24 -4
- package/dist/cli/enrich.js +122 -56
- package/dist/cli/help.js +9 -6
- package/dist/cli/init.js +3 -3
- package/dist/cli/tam.d.ts +1 -1
- package/dist/cli/tam.js +4 -1
- package/dist/connectors/clay.d.ts +33 -0
- package/dist/connectors/clay.js +123 -0
- package/dist/connectors/prospectSources.d.ts +12 -0
- package/dist/connectors/prospectSources.js +1 -1
- package/dist/contactProviders.d.ts +44 -0
- package/dist/contactProviders.js +100 -0
- package/dist/enrich.d.ts +7 -1
- package/dist/enrich.js +46 -1
- package/dist/icp.d.ts +2 -0
- package/dist/icp.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +1 -1
- package/dist/init.js +1 -1
- package/docs/api.md +18 -1
- package/package.json +1 -1
- package/src/cli/auth.ts +22 -4
- package/src/cli/enrich.ts +130 -55
- package/src/cli/help.ts +9 -6
- package/src/cli/init.ts +3 -3
- package/src/cli/tam.ts +4 -2
- package/src/connectors/clay.ts +155 -0
- package/src/connectors/prospectSources.ts +7 -1
- package/src/contactProviders.ts +141 -0
- package/src/enrich.ts +51 -2
- package/src/icp.ts +51 -0
- package/src/index.ts +25 -0
- package/src/init.ts +2 -2
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-
|
|
221
|
-
`(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)}
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
*
|
|
565
|
-
*
|
|
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
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
|
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: {
|
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)) {
|
package/src/cli/tam.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getCredential } from "../credentials.ts";
|
|
|
6
6
|
import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown, type AcvBasis, type TamCrossCheck } from "../tam.ts";
|
|
7
7
|
import { probeExploriumBusinessCount } from "../connectors/prospectSources.ts";
|
|
8
8
|
import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.ts";
|
|
9
|
+
import { clayApiKey } from "../connectors/clay.ts";
|
|
9
10
|
import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.ts";
|
|
10
11
|
import { scheduleCommand } from "./schedule.ts";
|
|
11
12
|
import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
|
|
@@ -14,7 +15,8 @@ import { unknownSubcommandError } from "./suggest.ts";
|
|
|
14
15
|
|
|
15
16
|
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
16
17
|
/** Provider API key: env override first, then the credential store (`login`). */
|
|
17
|
-
export function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string {
|
|
18
|
+
export function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string {
|
|
19
|
+
if (provider === "clay") return clayApiKey();
|
|
18
20
|
const envName =
|
|
19
21
|
provider === "explorium"
|
|
20
22
|
? "EXPLORIUM_API_KEY"
|
|
@@ -44,7 +46,7 @@ export async function tamCommand(args: string[]) {
|
|
|
44
46
|
fullstackgtm tam accounts [--name <n>] [--icp <path>] --source theirstack [--max <n>] [--dry-run | --confirm] [--max-credits <n>] [--usd-per-credit <r>] [--out <file.csv> | --json]
|
|
45
47
|
fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
|
|
46
48
|
fullstackgtm tam report [--name <n>] [--out <path>]
|
|
47
|
-
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
49
|
+
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
48
50
|
|
|
49
51
|
Estimate the reachable market FROM your ICP: a real account count × a confirmed
|
|
50
52
|
ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getCredential } from "../credentials.ts";
|
|
2
|
+
import { ProviderHttpError } from "../providerError.ts";
|
|
3
|
+
import type { Prospect } from "./prospectSources.ts";
|
|
4
|
+
|
|
5
|
+
export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
|
|
6
|
+
|
|
7
|
+
export type ClayKeyValidation = {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
detail: string;
|
|
10
|
+
workspaceId?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ClaySearchSourceType = "people" | "companies";
|
|
14
|
+
|
|
15
|
+
export type ClayPeopleSearchPage = {
|
|
16
|
+
prospects: Prospect[];
|
|
17
|
+
hasMore: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function clayHeaders(apiKey: string): Record<string, string> {
|
|
21
|
+
return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create a forward-only Clay search iterator. This call does not fetch a page. */
|
|
25
|
+
export async function createClaySearch(opts: {
|
|
26
|
+
apiKey: string;
|
|
27
|
+
sourceType: ClaySearchSourceType;
|
|
28
|
+
filters: Record<string, unknown>;
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
apiBaseUrl?: string;
|
|
31
|
+
}): Promise<string> {
|
|
32
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
33
|
+
const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
|
|
34
|
+
const response = await fetchImpl(`${base}/search/filters-mode`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: clayHeaders(opts.apiKey),
|
|
37
|
+
body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) throw new ProviderHttpError("Clay", "create search", response.status);
|
|
40
|
+
const body = await response.json() as { search_id?: unknown; searchId?: unknown };
|
|
41
|
+
const searchId = body.search_id ?? body.searchId;
|
|
42
|
+
if (typeof searchId !== "string" || !searchId) throw new Error("Clay create search returned no search_id.");
|
|
43
|
+
return searchId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Consume the next people page from a Clay search iterator. */
|
|
47
|
+
export async function runClayPeopleSearchPage(opts: {
|
|
48
|
+
apiKey: string;
|
|
49
|
+
searchId: string;
|
|
50
|
+
limit?: number;
|
|
51
|
+
fetchImpl?: typeof fetch;
|
|
52
|
+
apiBaseUrl?: string;
|
|
53
|
+
}): Promise<ClayPeopleSearchPage> {
|
|
54
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
55
|
+
const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
|
|
56
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
|
|
57
|
+
const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: clayHeaders(opts.apiKey),
|
|
60
|
+
body: JSON.stringify({ limit }),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) throw new ProviderHttpError("Clay", "run people search", response.status);
|
|
63
|
+
const body = await response.json() as { data?: unknown; has_more?: unknown; hasMore?: unknown };
|
|
64
|
+
if (!Array.isArray(body.data)) throw new Error("Clay people search returned an invalid data array.");
|
|
65
|
+
const hasMore = body.has_more ?? body.hasMore;
|
|
66
|
+
if (typeof hasMore !== "boolean") throw new Error("Clay people search returned no has_more flag.");
|
|
67
|
+
return { prospects: body.data.map(normalizeClayPerson), hasMore };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeClayPerson(value: unknown): Prospect {
|
|
71
|
+
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
72
|
+
const location = row.structured_location && typeof row.structured_location === "object"
|
|
73
|
+
? row.structured_location as Record<string, unknown>
|
|
74
|
+
: {};
|
|
75
|
+
const fullName = stringValue(row.name);
|
|
76
|
+
return {
|
|
77
|
+
firstName: stringValue(row.first_name),
|
|
78
|
+
lastName: stringValue(row.last_name),
|
|
79
|
+
fullName,
|
|
80
|
+
jobTitle: stringValue(row.latest_experience_title),
|
|
81
|
+
companyName: stringValue(row.latest_experience_company),
|
|
82
|
+
companyDomain: bareDomain(stringValue(row.domain)),
|
|
83
|
+
linkedin: normalizeLinkedin(stringValue(row.url)),
|
|
84
|
+
headline: undefined,
|
|
85
|
+
sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
|
|
86
|
+
location: {
|
|
87
|
+
city: stringValue(location.city),
|
|
88
|
+
state: stringValue(location.state),
|
|
89
|
+
region: stringValue(location.region),
|
|
90
|
+
country: stringValue(location.country),
|
|
91
|
+
countryCode: stringValue(location.country_iso),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stringValue(value: unknown): string | undefined {
|
|
97
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function bareDomain(value: string | undefined): string | undefined {
|
|
101
|
+
return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeLinkedin(value: string | undefined): string | undefined {
|
|
105
|
+
if (!value) return undefined;
|
|
106
|
+
const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
|
|
107
|
+
return normalized.replace(/\/$/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validate a Clay Public API key without creating a search or spending enrichment credits. */
|
|
111
|
+
export async function validateClayApiKey(
|
|
112
|
+
apiKey: string,
|
|
113
|
+
fetchImpl: typeof fetch = fetch,
|
|
114
|
+
apiBaseUrl = CLAY_PUBLIC_API_BASE,
|
|
115
|
+
): Promise<ClayKeyValidation> {
|
|
116
|
+
let response: Response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
|
|
119
|
+
headers: { "clay-api-key": apiKey, Accept: "application/json" },
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok) return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
|
|
126
|
+
let body: unknown;
|
|
127
|
+
try {
|
|
128
|
+
body = await response.json();
|
|
129
|
+
} catch {
|
|
130
|
+
return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
|
|
131
|
+
}
|
|
132
|
+
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
|
133
|
+
const workspace = record.workspace && typeof record.workspace === "object"
|
|
134
|
+
? record.workspace as Record<string, unknown>
|
|
135
|
+
: undefined;
|
|
136
|
+
const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
|
|
137
|
+
const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
|
|
138
|
+
? String(rawWorkspaceId)
|
|
139
|
+
: undefined;
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
|
|
143
|
+
workspaceId,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
|
|
148
|
+
export function clayApiKey(env: Record<string, string | undefined> = process.env): string {
|
|
149
|
+
const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
|
|
150
|
+
if (key) return key;
|
|
151
|
+
throw new Error(
|
|
152
|
+
'No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
|
|
153
|
+
'`fullstackgtm login clay`, or set CLAY_API_KEY.',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -34,6 +34,12 @@ export type Prospect = {
|
|
|
34
34
|
linkedin?: string;
|
|
35
35
|
/** real work email once resolved (pipe0); never the hashed value */
|
|
36
36
|
email?: string;
|
|
37
|
+
/** Validated mobile number when a contact provider explicitly returns one. */
|
|
38
|
+
mobile?: string;
|
|
39
|
+
/** Validated business direct dial when distinct from mobile. */
|
|
40
|
+
directDial?: string;
|
|
41
|
+
/** Person geography returned by identity-search providers. */
|
|
42
|
+
location?: { city?: string; state?: string; region?: string; country?: string; countryCode?: string };
|
|
37
43
|
/** ICP fit score 0..1, set by the acquire scorer */
|
|
38
44
|
fitScore?: number;
|
|
39
45
|
/** provider-native id for traceability */
|
|
@@ -471,7 +477,7 @@ function fieldValue(field: Pipe0Field | undefined): string | undefined {
|
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
// ---------------------------------------------------------------------------
|
|
474
|
-
// Pre-
|
|
480
|
+
// Pre-enrichment dedup: identity keys shared between prospects and CRM contacts, so
|
|
475
481
|
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|
|
476
482
|
|
|
477
483
|
function normName(value: string | undefined): string {
|