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.
@@ -313,7 +313,7 @@ function fieldValue(field) {
313
313
  return typeof v === "string" && v.trim() ? v : undefined;
314
314
  }
315
315
  // ---------------------------------------------------------------------------
316
- // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
316
+ // Pre-enrichment dedup: identity keys shared between prospects and CRM contacts, so
317
317
  // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
318
318
  function normName(value) {
319
319
  return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
@@ -0,0 +1,44 @@
1
+ import type { Prospect } from "./connectors/prospectSources.ts";
2
+ export type ContactField = "work_email" | "mobile" | "direct_dial";
3
+ export type ContactInputShape = "linkedin" | "name_domain" | "email" | "phone" | "company_domain";
4
+ export type ContactProviderExecution = "sync" | "poll" | "webhook" | "batch";
5
+ export type ContactProviderBilling = "per_attempt" | "per_match" | "per_field" | "subscription" | "unknown";
6
+ export type ContactProviderCapability = {
7
+ provider: string;
8
+ operation: string;
9
+ inputShapes: ContactInputShape[];
10
+ outputFields: ContactField[];
11
+ execution: ContactProviderExecution;
12
+ billing: ContactProviderBilling;
13
+ chargesOn: Array<"request" | "match" | "valid" | "accept_all" | "unknown">;
14
+ supportsBalance: boolean;
15
+ supportsIdempotency: boolean;
16
+ maxBatchSize?: number;
17
+ };
18
+ /** Only implemented adapters belong here; planned providers stay in the strategy document. */
19
+ export declare const CONTACT_PROVIDER_CAPABILITIES: Readonly<Record<string, readonly ContactProviderCapability[]>>;
20
+ export type ContactWaterfallStep = {
21
+ provider: string;
22
+ fields: ContactField[];
23
+ };
24
+ export type ContactProviderAdapter = {
25
+ provider: string;
26
+ resolve(prospects: Prospect[], fields: ContactField[]): Promise<Prospect[]>;
27
+ };
28
+ export type ContactWaterfallAttempt = {
29
+ provider: string;
30
+ fields: ContactField[];
31
+ attempted: number;
32
+ added: Partial<Record<ContactField, number>>;
33
+ };
34
+ export type ContactWaterfallResult = {
35
+ prospects: Prospect[];
36
+ attempts: ContactWaterfallAttempt[];
37
+ };
38
+ export declare function validateContactWaterfall(steps: ContactWaterfallStep[]): ContactWaterfallStep[];
39
+ /** Run providers in order. Later steps receive only records still missing a requested field. */
40
+ export declare function runContactWaterfall(opts: {
41
+ prospects: Prospect[];
42
+ steps: ContactWaterfallStep[];
43
+ adapters: Readonly<Record<string, ContactProviderAdapter>>;
44
+ }): Promise<ContactWaterfallResult>;
@@ -0,0 +1,100 @@
1
+ /** Only implemented adapters belong here; planned providers stay in the strategy document. */
2
+ export const CONTACT_PROVIDER_CAPABILITIES = {
3
+ pipe0: [{
4
+ provider: "pipe0",
5
+ operation: "person:workemail:waterfall@1",
6
+ inputShapes: ["name_domain"],
7
+ outputFields: ["work_email"],
8
+ execution: "batch",
9
+ billing: "per_attempt",
10
+ chargesOn: ["request"],
11
+ supportsBalance: false,
12
+ supportsIdempotency: false,
13
+ maxBatchSize: 100,
14
+ }],
15
+ };
16
+ export function validateContactWaterfall(steps) {
17
+ if (!Array.isArray(steps) || steps.length === 0) {
18
+ throw new Error("contact waterfall must contain at least one provider step");
19
+ }
20
+ return steps.map((step, index) => {
21
+ if (!step || typeof step.provider !== "string" || !step.provider.trim()) {
22
+ throw new Error(`contact waterfall step ${index + 1}: provider must be a non-empty string`);
23
+ }
24
+ const capabilities = CONTACT_PROVIDER_CAPABILITIES[step.provider];
25
+ if (!capabilities) {
26
+ throw new Error(`contact waterfall step ${index + 1}: unsupported provider "${step.provider}" ` +
27
+ `(implemented: ${Object.keys(CONTACT_PROVIDER_CAPABILITIES).join(", ")})`);
28
+ }
29
+ if (!Array.isArray(step.fields) || step.fields.length === 0) {
30
+ throw new Error(`contact waterfall step ${index + 1}: fields must be a non-empty array`);
31
+ }
32
+ const fields = [...new Set(step.fields)];
33
+ for (const field of fields) {
34
+ if (field !== "work_email" && field !== "mobile" && field !== "direct_dial") {
35
+ throw new Error(`contact waterfall step ${index + 1}: unsupported field "${String(field)}"`);
36
+ }
37
+ if (!capabilities.some((capability) => capability.outputFields.includes(field))) {
38
+ const available = [...new Set(capabilities.flatMap((capability) => capability.outputFields))];
39
+ throw new Error(`contact waterfall step ${index + 1}: ${step.provider} does not implement "${field}" ` +
40
+ `(available: ${available.join(", ")})`);
41
+ }
42
+ }
43
+ return { provider: step.provider, fields };
44
+ });
45
+ }
46
+ /** Run providers in order. Later steps receive only records still missing a requested field. */
47
+ export async function runContactWaterfall(opts) {
48
+ const steps = validateContactWaterfall(opts.steps);
49
+ const prospects = opts.prospects.map((prospect) => ({ ...prospect }));
50
+ const attempts = [];
51
+ for (const step of steps) {
52
+ const adapter = opts.adapters[step.provider];
53
+ if (!adapter)
54
+ throw new Error(`contact waterfall: adapter "${step.provider}" is not configured`);
55
+ const indexes = prospects
56
+ .map((prospect, index) => step.fields.some((field) => !fieldValue(prospect, field)) ? index : -1)
57
+ .filter((index) => index >= 0);
58
+ if (indexes.length === 0) {
59
+ attempts.push({ provider: step.provider, fields: step.fields, attempted: 0, added: {} });
60
+ continue;
61
+ }
62
+ const inputs = indexes.map((index) => prospects[index]);
63
+ const outputs = await adapter.resolve(inputs, step.fields);
64
+ if (outputs.length !== inputs.length) {
65
+ throw new Error(`contact waterfall: ${step.provider} returned ${outputs.length} result(s) for ${inputs.length} input(s)`);
66
+ }
67
+ const added = {};
68
+ outputs.forEach((output, outputIndex) => {
69
+ const prospectIndex = indexes[outputIndex];
70
+ const current = prospects[prospectIndex];
71
+ let merged = current;
72
+ for (const field of step.fields) {
73
+ if (fieldValue(current, field))
74
+ continue;
75
+ const value = fieldValue(output, field);
76
+ if (!value)
77
+ continue;
78
+ merged = setFieldValue(merged, field, value);
79
+ added[field] = (added[field] ?? 0) + 1;
80
+ }
81
+ prospects[prospectIndex] = merged;
82
+ });
83
+ attempts.push({ provider: step.provider, fields: step.fields, attempted: inputs.length, added });
84
+ }
85
+ return { prospects, attempts };
86
+ }
87
+ function fieldValue(prospect, field) {
88
+ if (field === "work_email")
89
+ return prospect.email;
90
+ if (field === "mobile")
91
+ return prospect.mobile;
92
+ return prospect.directDial;
93
+ }
94
+ function setFieldValue(prospect, field, value) {
95
+ if (field === "work_email")
96
+ return { ...prospect, email: value };
97
+ if (field === "mobile")
98
+ return { ...prospect, mobile: value };
99
+ return { ...prospect, directDial: value };
100
+ }
package/dist/enrich.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AcquireBudget } from "./acquireMeter.ts";
2
2
  import type { ProgressEmitter } from "./progress.ts";
3
3
  import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
4
+ import { type ContactWaterfallStep } from "./contactProviders.ts";
4
5
  import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
5
6
  /**
6
7
  * The enrich layer: governed append/refresh of third-party data into the CRM.
@@ -83,11 +84,16 @@ export type AcquireCreateMap = {
83
84
  * real emails (e.g. explorium discovers, pipe0 resolves the work email).
84
85
  */
85
86
  export type AcquireDiscoveryConfig = {
86
- provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
87
+ provider: "explorium" | "pipe0" | "clay" | "linkedin" | "heyreach";
88
+ /** Clay search collection. Phase 1 supports people; companies follows separately. */
89
+ sourceType?: "people" | "companies";
87
90
  filters?: Record<string, unknown>;
88
91
  size?: number;
89
92
  /** Maximum raw provider candidates scanned per run while seeking fresh leads. */
90
93
  scanLimit?: number;
94
+ /** Ordered fill-only contact providers. Later steps receive only unresolved fields. */
95
+ contactWaterfall?: ContactWaterfallStep[];
96
+ /** @deprecated Use contactWaterfall. Retained for existing configurations. */
91
97
  resolveEmailsWith?: "pipe0";
92
98
  /** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
93
99
  listId?: string;
package/dist/enrich.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
4
  import { HUBSPOT_DEFAULT_FIELD_MAPPINGS } from "./mappings.js";
5
5
  import { parseAssignmentPolicy, resolveAssignment } from "./assign.js";
6
+ import { validateContactWaterfall } from "./contactProviders.js";
6
7
  export const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
7
8
  export const DEFAULT_STALE_DAYS = 90;
8
9
  const OBJECT_TYPES = ["company", "contact"];
@@ -12,7 +13,7 @@ const MATCH_KEYS = {
12
13
  contact: ["email", "name", "linkedin"],
13
14
  };
14
15
  /** API source ids the MVP can pull from. */
15
- export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "linkedin"];
16
+ export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "clay", "linkedin"];
16
17
  /**
17
18
  * Canonical fields enrich may target, plus the HubSpot property spellings the
18
19
  * config may use for them (so `"crm": "numberofemployees"` and
@@ -189,6 +190,22 @@ export function parseEnrichConfig(raw) {
189
190
  fail(error instanceof Error ? error.message : String(error));
190
191
  }
191
192
  }
193
+ for (const [sourceId, discovery] of Object.entries(config.acquire?.discovery ?? {})) {
194
+ if (discovery.provider === "clay" && (discovery.sourceType ?? "people") !== "people") {
195
+ fail(`acquire.discovery.${sourceId}.sourceType currently supports only "people" for Clay`);
196
+ }
197
+ if (discovery.contactWaterfall !== undefined) {
198
+ try {
199
+ discovery.contactWaterfall = validateContactWaterfall(discovery.contactWaterfall);
200
+ }
201
+ catch (error) {
202
+ fail(`acquire.discovery.${sourceId}.${error instanceof Error ? error.message : String(error)}`);
203
+ }
204
+ }
205
+ if (discovery.resolveEmailsWith !== undefined && discovery.resolveEmailsWith !== "pipe0") {
206
+ fail(`acquire.discovery.${sourceId}.resolveEmailsWith must be "pipe0"`);
207
+ }
208
+ }
192
209
  return config;
193
210
  }
194
211
  /**
@@ -254,6 +271,34 @@ export function builtinAcquirePreset(source) {
254
271
  },
255
272
  };
256
273
  }
274
+ if (provider === "clay") {
275
+ return {
276
+ sources: { clay: { kind: "api" } },
277
+ match: { contact: { keys: ["linkedin", "email"], onAmbiguous: "skip" } },
278
+ fields: {},
279
+ policy: { overwrite: "never" },
280
+ acquire: {
281
+ budget: { records: { perDay: 50, perMonth: 500 } },
282
+ costPerRecord: { clay: 0 },
283
+ discovery: { clay: { provider: "clay", sourceType: "people", size: 25 } },
284
+ create: {
285
+ contact: {
286
+ matchKey: "linkedin",
287
+ properties: {
288
+ hs_linkedin_url: "linkedin",
289
+ firstname: "firstName",
290
+ lastname: "lastName",
291
+ jobtitle: "jobTitle",
292
+ company: "companyName",
293
+ email: "email",
294
+ },
295
+ associateCompanyFrom: "companyName",
296
+ associateCompanyDomainFrom: "companyDomain",
297
+ },
298
+ },
299
+ },
300
+ };
301
+ }
257
302
  if (provider !== "pipe0" && provider !== "explorium")
258
303
  return undefined;
259
304
  return {
package/dist/icp.d.ts CHANGED
@@ -104,6 +104,8 @@ export declare function icpToTheirStackFilters(icp: Icp): {
104
104
  * PERSONA precision but NOT industry, so the industry filter is load-bearing.
105
105
  */
106
106
  export declare function icpToCrustdataFilters(icp: Icp): Record<string, unknown>;
107
+ /** Clay people-search filters using only fields confirmed in the live catalog. */
108
+ export declare function icpToClayPeopleFilters(icp: Icp): Record<string, unknown>;
107
109
  export type IcpFit = {
108
110
  score: number;
109
111
  reasons: string[];
package/dist/icp.js CHANGED
@@ -205,6 +205,55 @@ export function icpToCrustdataFilters(icp) {
205
205
  }
206
206
  return f;
207
207
  }
208
+ const CLAY_INDUSTRY = {
209
+ software: ["Software Development"],
210
+ saas: ["Software Development"],
211
+ internet: ["Technology, Information and Internet"],
212
+ fintech: ["Financial Services"],
213
+ "financial services": ["Financial Services"],
214
+ "information technology & services": ["IT Services and IT Consulting"],
215
+ "information technology and services": ["IT Services and IT Consulting"],
216
+ };
217
+ const CLAY_EMPLOYEE_BAND = {
218
+ "1-10": "2-10",
219
+ "11-50": "11-50",
220
+ "51-200": "51-200",
221
+ "201-500": "201-500",
222
+ "501-1000": "501-1,000",
223
+ "1001-5000": "1,001-5,000",
224
+ "5001-10000": "5,001-10,000",
225
+ "10001+": "10,001+",
226
+ };
227
+ const CLAY_SENIORITY = {
228
+ cxo: "c-suite",
229
+ "c-suite": "c-suite",
230
+ vp: "vp",
231
+ director: "director",
232
+ head: "head",
233
+ manager: "manager",
234
+ owner: "owner",
235
+ founder: "founder",
236
+ senior: "senior",
237
+ };
238
+ /** Clay people-search filters using only fields confirmed in the live catalog. */
239
+ export function icpToClayPeopleFilters(icp) {
240
+ const filters = {};
241
+ if (icp.persona.titleKeywords?.length)
242
+ filters.job_title_keywords = icp.persona.titleKeywords;
243
+ const seniorities = [...new Set((icp.persona.jobLevels ?? []).map((level) => CLAY_SENIORITY[level.toLowerCase()]).filter(Boolean))];
244
+ if (seniorities.length)
245
+ filters.job_title_seniority_levels_v2 = seniorities;
246
+ const sizes = [...new Set((icp.firmographics.employeeBands ?? []).map((band) => CLAY_EMPLOYEE_BAND[band.replace(/,/g, "")]).filter(Boolean))];
247
+ if (sizes.length)
248
+ filters.company_sizes = sizes;
249
+ if (icp.firmographics.geos?.length) {
250
+ filters.location_countries_include = icp.firmographics.geos.map((geo) => COUNTRY_NAMES[geo.toLowerCase()] ?? geo);
251
+ }
252
+ const industries = [...new Set((icp.firmographics.industries ?? []).flatMap((industry) => CLAY_INDUSTRY[industry.toLowerCase()] ?? [titleCase(industry)]))];
253
+ if (industries.length)
254
+ filters.company_industries_include = industries;
255
+ return filters;
256
+ }
208
257
  const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
209
258
  function titleCase(value) {
210
259
  return value
package/dist/index.d.ts CHANGED
@@ -9,8 +9,10 @@ export { accountHierarchyToMarkdown, buildAccountHierarchy, type AccountHierarch
9
9
  export { buildRelationshipMap, relationshipMapToMarkdown, type RelationshipMap, type StakeholderNode, type StakeholderRole, type StakeholderSentiment, } from "./relationships.ts";
10
10
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli, type FullstackgtmConfig, type LoadedConfig, type RulePackageTrust, } from "./config.ts";
11
11
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
12
+ export { CONTACT_PROVIDER_CAPABILITIES, runContactWaterfall, validateContactWaterfall, type ContactField, type ContactInputShape, type ContactProviderAdapter, type ContactProviderBilling, type ContactProviderCapability, type ContactProviderExecution, type ContactWaterfallAttempt, type ContactWaterfallResult, type ContactWaterfallStep, } from "./contactProviders.ts";
12
13
  export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, type ProgressEmitter, type ProgressEvent, type ProgressListener, type ProgressSnapshot, } from "./progress.ts";
13
14
  export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
15
+ export { CLAY_PUBLIC_API_BASE, clayApiKey, createClaySearch, normalizeClayPerson, runClayPeopleSearchPage, validateClayApiKey, type ClayPeopleSearchPage, type ClaySearchSourceType, type ClayKeyValidation, } from "./connectors/clay.ts";
14
16
  export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refreshHubspotToken, runHubspotLoopbackLogin, validateHubspotToken, type HubspotTokenSet, type LoopbackLoginOptions, } from "./connectors/hubspotAuth.ts";
15
17
  export { createSalesforceConnector, type SalesforceConnection, type SalesforceConnectorOptions, } from "./connectors/salesforce.ts";
16
18
  export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken, type SalesforceDeviceAuthorization, type SalesforceTokenSet, } from "./connectors/salesforceAuth.ts";
package/dist/index.js CHANGED
@@ -9,8 +9,10 @@ export { accountHierarchyToMarkdown, buildAccountHierarchy, } from "./hierarchy.
9
9
  export { buildRelationshipMap, relationshipMapToMarkdown, } from "./relationships.js";
10
10
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli, } from "./config.js";
11
11
  export { applyPatchPlan } from "./connector.js";
12
+ export { CONTACT_PROVIDER_CAPABILITIES, runContactWaterfall, validateContactWaterfall, } from "./contactProviders.js";
12
13
  export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, } from "./progress.js";
13
14
  export { createHubspotConnector } from "./connectors/hubspot.js";
15
+ export { CLAY_PUBLIC_API_BASE, clayApiKey, createClaySearch, normalizeClayPerson, runClayPeopleSearchPage, validateClayApiKey, } from "./connectors/clay.js";
14
16
  export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refreshHubspotToken, runHubspotLoopbackLogin, validateHubspotToken, } from "./connectors/hubspotAuth.js";
15
17
  export { createSalesforceConnector, } from "./connectors/salesforce.js";
16
18
  export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
package/dist/init.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import { type EnrichConfig } from "./enrich.ts";
18
18
  import { type Icp } from "./icp.ts";
19
19
  export type InitProvider = "hubspot" | "salesforce";
20
- export type InitSource = "pipe0" | "explorium" | "linkedin";
20
+ export type InitSource = "pipe0" | "explorium" | "clay" | "linkedin";
21
21
  export type ScaffoldOptions = {
22
22
  /** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
23
23
  source?: InitSource;
package/dist/init.js CHANGED
@@ -45,7 +45,7 @@ export function starterIcp() {
45
45
  export function starterEnrichConfig(source) {
46
46
  const preset = builtinAcquirePreset(source);
47
47
  if (!preset?.acquire) {
48
- // builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
48
+ // builtinAcquirePreset covers pipe0/explorium/clay/linkedin, so this is unreachable
49
49
  // for the typed InitSource set — guard anyway rather than emit a broken file.
50
50
  throw new Error(`init: no acquire preset for source "${source}"`);
51
51
  }
package/docs/api.md CHANGED
@@ -261,6 +261,22 @@ domain stamped — so the acquired account is immediately signal-watchable
261
261
  waterfall, chunked, surfaces upstream errors), `fetchPipe0CrustdataProspects`
262
262
  (people search). `prospectIdentityKeys` / `crmContactKeys` /
263
263
  `partitionFreshProspects` power the pre-email dedup.
264
+ - **Clay Search** (`connectors/clay.ts`): `createClaySearch` creates Clay's
265
+ forward-only server iterator and `runClayPeopleSearchPage` advances it.
266
+ `enrich acquire --source clay` uses the stored `login clay` credential,
267
+ translates the common ICP title/seniority/industry/size/country constraints,
268
+ normalizes people to `Prospect`, checkpoints the search id on saved runs, and
269
+ keys proposed contacts by LinkedIn URL. It performs no paid contact routine
270
+ by default; add an explicit `contactWaterfall` when contact fields are needed.
271
+ - **Contact provider router** (`contactProviders.ts`):
272
+ `CONTACT_PROVIDER_CAPABILITIES`, `validateContactWaterfall`, and
273
+ `runContactWaterfall` provide an ordered, fill-only field router. Configure
274
+ `acquire.discovery.<source>.contactWaterfall` as steps such as
275
+ `[{ "provider": "pipe0", "fields": ["work_email"] }]`. Later steps receive
276
+ only records still missing a requested field and cannot overwrite an earlier
277
+ accepted value. The registry advertises implemented operations only; the
278
+ broader candidate backlog does not become valid configuration until its
279
+ adapter lands.
264
280
  - **LinkedIn source** (`connectors/linkedin.ts`, `acquireLinkedIn.ts`): the
265
281
  injectable `LinkedInProvider` interface with a default HeyReach adapter
266
282
  (`createHeyReachProvider`, `HEYREACH_BASE`, `X-API-KEY`, `normalizeHeyReachLead`)
@@ -271,7 +287,8 @@ domain stamped — so the acquired account is immediately signal-watchable
271
287
  apply are reused unchanged. Phase 1 is discovery-only — read-only, never sends.
272
288
  `linkedin` is a `SUPPORTED_API_SOURCES` source, so an explicit config can set
273
289
  discovery `size` (read a whole list, not the preset's 25). A LinkedIn list
274
- carries no emails; opt into resolution with
290
+ carries no emails; opt into resolution with `contactWaterfall`, or use the
291
+ backward-compatible
275
292
  `acquire.discovery.linkedin.resolveEmailsWith: "pipe0"` — email resolution is
276
293
  no longer gated on the dedupe key being `email`, so a profile-URL-keyed source
277
294
  still creates outreach-ready, emailed contacts. ICP scoring matches title
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.50.1",
3
+ "version": "0.51.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
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
 
@@ -378,6 +379,20 @@ export async function login(args: string[]) {
378
379
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
379
380
  return;
380
381
  }
382
+ if (provider === "clay") {
383
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
384
+ const key = await readSecret("Clay Public API key");
385
+ if (!key) throw new Error("No Clay Public API key provided.");
386
+ if (!args.includes("--no-validate")) {
387
+ const validation = await validateClayApiKey(key);
388
+ if (!validation.ok) throw new Error(validation.detail);
389
+ console.log(validation.detail);
390
+ }
391
+ const stamp = new Date().toISOString();
392
+ storeCredential("clay", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
393
+ console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
394
+ return;
395
+ }
381
396
  if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
382
397
  rejectArgvSecret(args, "--token", "--key", "--api-key");
383
398
  const key = await readSecret(`${provider} API key`);
@@ -393,7 +408,7 @@ export async function login(args: string[]) {
393
408
  }
394
409
  if (provider !== "hubspot") {
395
410
  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",
411
+ "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",
397
412
  );
398
413
  }
399
414
  const now = new Date().toISOString();
@@ -491,6 +506,9 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
491
506
  stripe: env.STRIPE_SECRET_KEY
492
507
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
493
508
  : providerStatus("stripe", broker),
509
+ clay: env.CLAY_API_KEY
510
+ ? { source: "env", detail: "CLAY_API_KEY" }
511
+ : providerStatus("clay", null),
494
512
  };
495
513
 
496
514
  const llm = resolveLlmCredential(env);
@@ -503,14 +521,14 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
503
521
  }
504
522
  });
505
523
 
506
- const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
524
+ const connectedCrm = ["hubspot", "salesforce"].filter((provider) => providers[provider].source !== "none");
507
525
  const nextSteps =
508
- connected.length === 0
526
+ connectedCrm.length === 0
509
527
  ? [
510
528
  "fullstackgtm audit --demo # no credentials needed",
511
529
  "fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
512
530
  ]
513
- : [`fullstackgtm audit --provider ${connected[0][0]}`];
531
+ : [`fullstackgtm audit --provider ${connectedCrm[0]}`];
514
532
 
515
533
  return {
516
534
  package: packageInfo,