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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ 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.51.0] — 2026-07-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Native Clay Public API authentication and people search for governed lead
|
|
15
|
+
acquisition, including ICP-to-Clay filter translation and resumable search
|
|
16
|
+
checkpoints.
|
|
17
|
+
- A provider-neutral, fill-only contact waterfall contract for work email,
|
|
18
|
+
mobile, and direct-dial enrichment, with Pipe0 as the first registered
|
|
19
|
+
provider and compatibility for existing Pipe0 email resolution.
|
|
20
|
+
- Clay acquisition presets, initialization, doctor diagnostics, public API
|
|
21
|
+
exports, strategy documentation, and live-safe credential validation.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `enrich acquire` now uses the shared decision-first presentation contract:
|
|
26
|
+
compact cards by default, full evidence with `--verbose`, and a stable JSON
|
|
27
|
+
envelope with counts, cost, meter, and persistence state under `--json`.
|
|
28
|
+
- Pre-enrichment deduplication and avoided-spend reporting are provider-neutral
|
|
29
|
+
so the same acquisition pipeline can support additional contact sources.
|
|
30
|
+
|
|
10
31
|
## [0.50.1] — 2026-07-10
|
|
11
32
|
|
|
12
33
|
### 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
|
/**
|
|
@@ -351,6 +352,22 @@ export async function login(args) {
|
|
|
351
352
|
console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
|
|
352
353
|
return;
|
|
353
354
|
}
|
|
355
|
+
if (provider === "clay") {
|
|
356
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
357
|
+
const key = await readSecret("Clay Public API key");
|
|
358
|
+
if (!key)
|
|
359
|
+
throw new Error("No Clay Public API key provided.");
|
|
360
|
+
if (!args.includes("--no-validate")) {
|
|
361
|
+
const validation = await validateClayApiKey(key);
|
|
362
|
+
if (!validation.ok)
|
|
363
|
+
throw new Error(validation.detail);
|
|
364
|
+
console.log(validation.detail);
|
|
365
|
+
}
|
|
366
|
+
const stamp = new Date().toISOString();
|
|
367
|
+
storeCredential("clay", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
368
|
+
console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
354
371
|
if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
|
|
355
372
|
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
356
373
|
const key = await readSecret(`${provider} API key`);
|
|
@@ -365,7 +382,7 @@ export async function login(args) {
|
|
|
365
382
|
return;
|
|
366
383
|
}
|
|
367
384
|
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");
|
|
385
|
+
throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, clay, pipe0, explorium, heyreach, theirstack, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
369
386
|
}
|
|
370
387
|
const now = new Date().toISOString();
|
|
371
388
|
rejectArgvSecret(args, "--token");
|
|
@@ -450,6 +467,9 @@ export function doctorReport(env = process.env) {
|
|
|
450
467
|
stripe: env.STRIPE_SECRET_KEY
|
|
451
468
|
? { source: "env", detail: "STRIPE_SECRET_KEY" }
|
|
452
469
|
: providerStatus("stripe", broker),
|
|
470
|
+
clay: env.CLAY_API_KEY
|
|
471
|
+
? { source: "env", detail: "CLAY_API_KEY" }
|
|
472
|
+
: providerStatus("clay", null),
|
|
453
473
|
};
|
|
454
474
|
const llm = resolveLlmCredential(env);
|
|
455
475
|
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
@@ -461,13 +481,13 @@ export function doctorReport(env = process.env) {
|
|
|
461
481
|
return true;
|
|
462
482
|
}
|
|
463
483
|
});
|
|
464
|
-
const
|
|
465
|
-
const nextSteps =
|
|
484
|
+
const connectedCrm = ["hubspot", "salesforce"].filter((provider) => providers[provider].source !== "none");
|
|
485
|
+
const nextSteps = connectedCrm.length === 0
|
|
466
486
|
? [
|
|
467
487
|
"fullstackgtm audit --demo # no credentials needed",
|
|
468
488
|
"fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
|
|
469
489
|
]
|
|
470
|
-
: [`fullstackgtm audit --provider ${
|
|
490
|
+
: [`fullstackgtm audit --provider ${connectedCrm[0]}`];
|
|
471
491
|
return {
|
|
472
492
|
package: packageInfo,
|
|
473
493
|
node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
|
package/dist/cli/enrich.js
CHANGED
|
@@ -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-
|
|
197
|
-
`(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)}
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
*
|
|
505
|
-
*
|
|
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
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if (
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
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: {
|
package/dist/cli/init.js
CHANGED
|
@@ -13,7 +13,7 @@ import { option } from "./shared.js";
|
|
|
13
13
|
export function initCommand(args) {
|
|
14
14
|
if (args.includes("--help") || args.includes("-h")) {
|
|
15
15
|
console.log(`Usage:
|
|
16
|
-
fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
16
|
+
fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
17
17
|
|
|
18
18
|
Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
|
|
19
19
|
icp.json starter ICP (edit, or rebuild with \`icp interview\`)
|
|
@@ -25,8 +25,8 @@ docs/recipes.md for the full play set. Existing files are kept unless --force.`)
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
const source = (option(args, "--source") ?? "pipe0");
|
|
28
|
-
if (!["pipe0", "explorium", "linkedin"].includes(source)) {
|
|
29
|
-
throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
|
|
28
|
+
if (!["pipe0", "explorium", "clay", "linkedin"].includes(source)) {
|
|
29
|
+
throw new Error(`init: --source must be pipe0|explorium|clay|linkedin (got "${source}")`);
|
|
30
30
|
}
|
|
31
31
|
const provider = (option(args, "--provider") ?? "hubspot");
|
|
32
32
|
if (!["hubspot", "salesforce"].includes(provider)) {
|
package/dist/cli/tam.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
2
2
|
/** Provider API key: env override first, then the credential store (`login`). */
|
|
3
|
-
export declare function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string;
|
|
3
|
+
export declare function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string;
|
|
4
4
|
/**
|
|
5
5
|
* `tam` — Total Addressable Market mapping. Estimate a defensible universe from
|
|
6
6
|
* the ICP, then iteratively populate it via scheduled governed acquire runs and
|
package/dist/cli/tam.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getCredential } from "../credentials.js";
|
|
|
5
5
|
import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown } from "../tam.js";
|
|
6
6
|
import { probeExploriumBusinessCount } from "../connectors/prospectSources.js";
|
|
7
7
|
import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.js";
|
|
8
|
+
import { clayApiKey } from "../connectors/clay.js";
|
|
8
9
|
import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.js";
|
|
9
10
|
import { scheduleCommand } from "./schedule.js";
|
|
10
11
|
import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
|
|
@@ -12,6 +13,8 @@ import { unknownSubcommandError } from "./suggest.js";
|
|
|
12
13
|
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
13
14
|
/** Provider API key: env override first, then the credential store (`login`). */
|
|
14
15
|
export function providerKey(provider) {
|
|
16
|
+
if (provider === "clay")
|
|
17
|
+
return clayApiKey();
|
|
15
18
|
const envName = provider === "explorium"
|
|
16
19
|
? "EXPLORIUM_API_KEY"
|
|
17
20
|
: provider === "pipe0"
|
|
@@ -41,7 +44,7 @@ export async function tamCommand(args) {
|
|
|
41
44
|
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]
|
|
42
45
|
fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
|
|
43
46
|
fullstackgtm tam report [--name <n>] [--out <path>]
|
|
44
|
-
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
47
|
+
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
45
48
|
|
|
46
49
|
Estimate the reachable market FROM your ICP: a real account count × a confirmed
|
|
47
50
|
ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Prospect } from "./prospectSources.ts";
|
|
2
|
+
export declare const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
|
|
3
|
+
export type ClayKeyValidation = {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
detail: string;
|
|
6
|
+
workspaceId?: string;
|
|
7
|
+
};
|
|
8
|
+
export type ClaySearchSourceType = "people" | "companies";
|
|
9
|
+
export type ClayPeopleSearchPage = {
|
|
10
|
+
prospects: Prospect[];
|
|
11
|
+
hasMore: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Create a forward-only Clay search iterator. This call does not fetch a page. */
|
|
14
|
+
export declare function createClaySearch(opts: {
|
|
15
|
+
apiKey: string;
|
|
16
|
+
sourceType: ClaySearchSourceType;
|
|
17
|
+
filters: Record<string, unknown>;
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
apiBaseUrl?: string;
|
|
20
|
+
}): Promise<string>;
|
|
21
|
+
/** Consume the next people page from a Clay search iterator. */
|
|
22
|
+
export declare function runClayPeopleSearchPage(opts: {
|
|
23
|
+
apiKey: string;
|
|
24
|
+
searchId: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
apiBaseUrl?: string;
|
|
28
|
+
}): Promise<ClayPeopleSearchPage>;
|
|
29
|
+
export declare function normalizeClayPerson(value: unknown): Prospect;
|
|
30
|
+
/** Validate a Clay Public API key without creating a search or spending enrichment credits. */
|
|
31
|
+
export declare function validateClayApiKey(apiKey: string, fetchImpl?: typeof fetch, apiBaseUrl?: string): Promise<ClayKeyValidation>;
|
|
32
|
+
/** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
|
|
33
|
+
export declare function clayApiKey(env?: Record<string, string | undefined>): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { getCredential } from "../credentials.js";
|
|
2
|
+
import { ProviderHttpError } from "../providerError.js";
|
|
3
|
+
export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
|
|
4
|
+
function clayHeaders(apiKey) {
|
|
5
|
+
return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
|
|
6
|
+
}
|
|
7
|
+
/** Create a forward-only Clay search iterator. This call does not fetch a page. */
|
|
8
|
+
export async function createClaySearch(opts) {
|
|
9
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
10
|
+
const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
|
|
11
|
+
const response = await fetchImpl(`${base}/search/filters-mode`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: clayHeaders(opts.apiKey),
|
|
14
|
+
body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
throw new ProviderHttpError("Clay", "create search", response.status);
|
|
18
|
+
const body = await response.json();
|
|
19
|
+
const searchId = body.search_id ?? body.searchId;
|
|
20
|
+
if (typeof searchId !== "string" || !searchId)
|
|
21
|
+
throw new Error("Clay create search returned no search_id.");
|
|
22
|
+
return searchId;
|
|
23
|
+
}
|
|
24
|
+
/** Consume the next people page from a Clay search iterator. */
|
|
25
|
+
export async function runClayPeopleSearchPage(opts) {
|
|
26
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
27
|
+
const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
|
|
28
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
|
|
29
|
+
const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: clayHeaders(opts.apiKey),
|
|
32
|
+
body: JSON.stringify({ limit }),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
throw new ProviderHttpError("Clay", "run people search", response.status);
|
|
36
|
+
const body = await response.json();
|
|
37
|
+
if (!Array.isArray(body.data))
|
|
38
|
+
throw new Error("Clay people search returned an invalid data array.");
|
|
39
|
+
const hasMore = body.has_more ?? body.hasMore;
|
|
40
|
+
if (typeof hasMore !== "boolean")
|
|
41
|
+
throw new Error("Clay people search returned no has_more flag.");
|
|
42
|
+
return { prospects: body.data.map(normalizeClayPerson), hasMore };
|
|
43
|
+
}
|
|
44
|
+
export function normalizeClayPerson(value) {
|
|
45
|
+
const row = value && typeof value === "object" ? value : {};
|
|
46
|
+
const location = row.structured_location && typeof row.structured_location === "object"
|
|
47
|
+
? row.structured_location
|
|
48
|
+
: {};
|
|
49
|
+
const fullName = stringValue(row.name);
|
|
50
|
+
return {
|
|
51
|
+
firstName: stringValue(row.first_name),
|
|
52
|
+
lastName: stringValue(row.last_name),
|
|
53
|
+
fullName,
|
|
54
|
+
jobTitle: stringValue(row.latest_experience_title),
|
|
55
|
+
companyName: stringValue(row.latest_experience_company),
|
|
56
|
+
companyDomain: bareDomain(stringValue(row.domain)),
|
|
57
|
+
linkedin: normalizeLinkedin(stringValue(row.url)),
|
|
58
|
+
headline: undefined,
|
|
59
|
+
sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
|
|
60
|
+
location: {
|
|
61
|
+
city: stringValue(location.city),
|
|
62
|
+
state: stringValue(location.state),
|
|
63
|
+
region: stringValue(location.region),
|
|
64
|
+
country: stringValue(location.country),
|
|
65
|
+
countryCode: stringValue(location.country_iso),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function stringValue(value) {
|
|
70
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
71
|
+
}
|
|
72
|
+
function bareDomain(value) {
|
|
73
|
+
return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
|
|
74
|
+
}
|
|
75
|
+
function normalizeLinkedin(value) {
|
|
76
|
+
if (!value)
|
|
77
|
+
return undefined;
|
|
78
|
+
const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
|
|
79
|
+
return normalized.replace(/\/$/, "");
|
|
80
|
+
}
|
|
81
|
+
/** Validate a Clay Public API key without creating a search or spending enrichment credits. */
|
|
82
|
+
export async function validateClayApiKey(apiKey, fetchImpl = fetch, apiBaseUrl = CLAY_PUBLIC_API_BASE) {
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
|
|
86
|
+
headers: { "clay-api-key": apiKey, Accept: "application/json" },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
91
|
+
return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
|
|
95
|
+
let body;
|
|
96
|
+
try {
|
|
97
|
+
body = await response.json();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
|
|
101
|
+
}
|
|
102
|
+
const record = body && typeof body === "object" ? body : {};
|
|
103
|
+
const workspace = record.workspace && typeof record.workspace === "object"
|
|
104
|
+
? record.workspace
|
|
105
|
+
: undefined;
|
|
106
|
+
const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
|
|
107
|
+
const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
|
|
108
|
+
? String(rawWorkspaceId)
|
|
109
|
+
: undefined;
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
|
|
113
|
+
workspaceId,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
|
|
117
|
+
export function clayApiKey(env = process.env) {
|
|
118
|
+
const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
|
|
119
|
+
if (key)
|
|
120
|
+
return key;
|
|
121
|
+
throw new Error('No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
|
|
122
|
+
'`fullstackgtm login clay`, or set CLAY_API_KEY.');
|
|
123
|
+
}
|
|
@@ -32,6 +32,18 @@ export type Prospect = {
|
|
|
32
32
|
linkedin?: string;
|
|
33
33
|
/** real work email once resolved (pipe0); never the hashed value */
|
|
34
34
|
email?: string;
|
|
35
|
+
/** Validated mobile number when a contact provider explicitly returns one. */
|
|
36
|
+
mobile?: string;
|
|
37
|
+
/** Validated business direct dial when distinct from mobile. */
|
|
38
|
+
directDial?: string;
|
|
39
|
+
/** Person geography returned by identity-search providers. */
|
|
40
|
+
location?: {
|
|
41
|
+
city?: string;
|
|
42
|
+
state?: string;
|
|
43
|
+
region?: string;
|
|
44
|
+
country?: string;
|
|
45
|
+
countryCode?: string;
|
|
46
|
+
};
|
|
35
47
|
/** ICP fit score 0..1, set by the acquire scorer */
|
|
36
48
|
fitScore?: number;
|
|
37
49
|
/** provider-native id for traceability */
|