fullstackgtm 0.44.0 → 0.45.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 +185 -1
- package/README.md +19 -7
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +100 -5130
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.js +7 -59
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +73 -7
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +15 -6
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +99 -5829
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +7 -56
- package/src/icp.ts +19 -35
- package/src/judge.ts +7 -0
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +12 -0
package/dist/cli/tam.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { getCredential } from "../credentials.js";
|
|
5
|
+
import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown } from "../tam.js";
|
|
6
|
+
import { probeExploriumBusinessCount } from "../connectors/prospectSources.js";
|
|
7
|
+
import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.js";
|
|
8
|
+
import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.js";
|
|
9
|
+
import { scheduleCommand } from "./schedule.js";
|
|
10
|
+
import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
|
|
11
|
+
import { unknownSubcommandError } from "./suggest.js";
|
|
12
|
+
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
13
|
+
/** Provider API key: env override first, then the credential store (`login`). */
|
|
14
|
+
export function providerKey(provider) {
|
|
15
|
+
const envName = provider === "explorium"
|
|
16
|
+
? "EXPLORIUM_API_KEY"
|
|
17
|
+
: provider === "pipe0"
|
|
18
|
+
? "PIPE0_API_KEY"
|
|
19
|
+
: provider === "theirstack"
|
|
20
|
+
? "THEIRSTACK_API_KEY"
|
|
21
|
+
: "HEYREACH_API_KEY";
|
|
22
|
+
if (process.env[envName])
|
|
23
|
+
return process.env[envName];
|
|
24
|
+
const stored = getCredential(provider);
|
|
25
|
+
if (stored)
|
|
26
|
+
return stored.accessToken;
|
|
27
|
+
throw new Error(`No ${provider} credentials. Run \`echo "$KEY" | fullstackgtm login ${provider}\`, or set ${envName}.`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `tam` — Total Addressable Market mapping. Estimate a defensible universe from
|
|
31
|
+
* the ICP, then iteratively populate it via scheduled governed acquire runs and
|
|
32
|
+
* track coverage over time. Subcommands: estimate, status, report, populate.
|
|
33
|
+
*/
|
|
34
|
+
export async function tamCommand(args) {
|
|
35
|
+
const [sub, ...rest] = args;
|
|
36
|
+
if (!sub || args.includes("--help") || args.includes("-h")) {
|
|
37
|
+
console.log(`Usage:
|
|
38
|
+
fullstackgtm tam estimate [--name <n>] [--icp <path>] (--accounts <n> | --source theirstack|explorium)
|
|
39
|
+
(--acv <annual-usd> | --acv-from-crm --deal-period monthly|quarterly|annual) [--acv-basis account|buyer]
|
|
40
|
+
[--buyers-per-account <n>] [--cross-checks <file.json>] [source options for a baseline] [--json]
|
|
41
|
+
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
|
+
fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
|
|
43
|
+
fullstackgtm tam report [--name <n>] [--out <path>]
|
|
44
|
+
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
45
|
+
|
|
46
|
+
Estimate the reachable market FROM your ICP: a real account count × a confirmed
|
|
47
|
+
ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
|
|
48
|
+
annual to derive+annualize from closed-won — no band defaults, a bare --provider
|
|
49
|
+
does NOT set ACV, refuses without one); buyers/account (explicit or CRM-derived).
|
|
50
|
+
Then populate it with scheduled \`enrich acquire --save\` runs (plan-only — apply
|
|
51
|
+
stays a separate human gate) and watch coverage close. \`status --save\` stamps the
|
|
52
|
+
timeline so \`report\`/ETA can project how long full coverage will take.
|
|
53
|
+
|
|
54
|
+
--source theirstack counts (and \`tam accounts\` LISTS) companies that actually USE a
|
|
55
|
+
CRM/MAP (set firmographics.technologies, e.g. ["salesforce","hubspot"]) — the real
|
|
56
|
+
RevOps universe, with real names. --source explorium is a firmographic count only
|
|
57
|
+
(NAICS/size/geo, no list). See docs/tam.md.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const name = option(rest, "--name") ?? "default";
|
|
61
|
+
if (sub === "estimate") {
|
|
62
|
+
const icp = loadIcp(rest);
|
|
63
|
+
if (!icp) {
|
|
64
|
+
throw new Error("tam estimate derives the universe from your ICP — none found (icp.json in cwd, or --icp <path>). " +
|
|
65
|
+
"Build one: `fullstackgtm icp interview`, or scaffold a workspace with `fullstackgtm init`.");
|
|
66
|
+
}
|
|
67
|
+
// The account universe: an explicit --accounts assumption, or a live count of
|
|
68
|
+
// matching COMPANIES. `theirstack` is technographic — it counts companies that
|
|
69
|
+
// actually USE a CRM/MAP (the real RevOps buying signal) and can return the
|
|
70
|
+
// list. `explorium` is a firmographic count only (NAICS/size/geo, no list).
|
|
71
|
+
let accounts = numericOption(rest, "--accounts");
|
|
72
|
+
let accountsSource = "assumption";
|
|
73
|
+
let capped = false;
|
|
74
|
+
const probeSource = option(rest, "--source");
|
|
75
|
+
if (accounts === undefined && probeSource) {
|
|
76
|
+
if (probeSource === "theirstack") {
|
|
77
|
+
const total = await theirStackCountCompanies({
|
|
78
|
+
apiKey: providerKey("theirstack"),
|
|
79
|
+
filters: icpToTheirStackFilters(icp),
|
|
80
|
+
});
|
|
81
|
+
if (total === null) {
|
|
82
|
+
throw new Error("TheirStack returned no total for the ICP — check firmographics.technologies (e.g. [\"salesforce\",\"hubspot\"]) " +
|
|
83
|
+
"and geos/employeeBands, or pass --accounts <n>.");
|
|
84
|
+
}
|
|
85
|
+
accounts = total;
|
|
86
|
+
accountsSource = "provider:theirstack (uses-CRM)";
|
|
87
|
+
}
|
|
88
|
+
else if (probeSource === "explorium") {
|
|
89
|
+
const probe = await probeExploriumBusinessCount({
|
|
90
|
+
apiKey: providerKey("explorium"),
|
|
91
|
+
filters: icpToExploriumBusinessFilters(icp),
|
|
92
|
+
});
|
|
93
|
+
if (probe === null) {
|
|
94
|
+
throw new Error("Explorium /v1/businesses returned no total for the ICP firmographic — pass --accounts <n>. See docs/tam.md.");
|
|
95
|
+
}
|
|
96
|
+
accounts = probe.total;
|
|
97
|
+
capped = probe.capped;
|
|
98
|
+
accountsSource = capped ? "provider:explorium (≥60k cap — floor)" : "provider:explorium";
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
throw new Error(`tam estimate --source must be theirstack (technographic, uses-CRM + a real list) or explorium ` +
|
|
102
|
+
`(firmographic count only) — got "${probeSource}". pipe0 is a population source, not a count.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (accounts === undefined) {
|
|
106
|
+
throw new Error("tam estimate needs the account universe size: --source theirstack (count companies that use a CRM), " +
|
|
107
|
+
"--source explorium (firmographic count), or --accounts <n>.");
|
|
108
|
+
}
|
|
109
|
+
const basis = (option(rest, "--acv-basis") ?? "account");
|
|
110
|
+
if (basis !== "account" && basis !== "buyer") {
|
|
111
|
+
throw new Error(`tam: --acv-basis must be account|buyer (got "${basis}")`);
|
|
112
|
+
}
|
|
113
|
+
const ccPath = option(rest, "--cross-checks");
|
|
114
|
+
const crossChecks = ccPath
|
|
115
|
+
? JSON.parse(readFileSync(resolve(process.cwd(), ccPath), "utf8"))
|
|
116
|
+
: [];
|
|
117
|
+
// Load the CRM snapshot ONCE if a source is given — reused for the coverage
|
|
118
|
+
// baseline AND for deriving real ACV / buyers-per-account from the CRM.
|
|
119
|
+
const hasSource = Boolean(option(rest, "--provider")) ||
|
|
120
|
+
Boolean(option(rest, "--input")) ||
|
|
121
|
+
rest.includes("--demo") ||
|
|
122
|
+
rest.includes("--sample");
|
|
123
|
+
const snapshot = hasSource ? await readSnapshot(rest) : undefined;
|
|
124
|
+
const baseline = snapshot ? coverageCountsFromSnapshot(snapshot) : { accounts: 0, contacts: 0 };
|
|
125
|
+
// ACV must be a REAL, ANNUAL figure that the operator confirms — never a band
|
|
126
|
+
// default, and never silently lifted from the CRM. `--provider` is the
|
|
127
|
+
// COVERAGE source, not the ACV: the TAM's economics aren't HubSpot's job.
|
|
128
|
+
// Two confirmed paths: an explicit annual `--acv`, or an opt-in
|
|
129
|
+
// `--acv-from-crm` that derives the median closed-won amount AND annualizes it
|
|
130
|
+
// per a REQUIRED `--deal-period` (a monthly MRR deal × 12 = annual ACV).
|
|
131
|
+
const acvExplicit = numericOption(rest, "--acv");
|
|
132
|
+
const acvFromCrm = rest.includes("--acv-from-crm");
|
|
133
|
+
let acvValue;
|
|
134
|
+
let acvSource;
|
|
135
|
+
if (acvExplicit !== undefined) {
|
|
136
|
+
acvValue = acvExplicit;
|
|
137
|
+
acvSource = "explicit (annual)";
|
|
138
|
+
}
|
|
139
|
+
else if (acvFromCrm) {
|
|
140
|
+
if (!snapshot) {
|
|
141
|
+
throw new Error("--acv-from-crm needs a CRM source — add --provider <crm> or --input <snapshot>.");
|
|
142
|
+
}
|
|
143
|
+
const period = option(rest, "--deal-period");
|
|
144
|
+
const factor = period === "monthly" ? 12 : period === "quarterly" ? 4 : period === "annual" ? 1 : undefined;
|
|
145
|
+
if (factor === undefined) {
|
|
146
|
+
throw new Error("--acv-from-crm requires --deal-period monthly|quarterly|annual — deal amounts can be MRR or annual " +
|
|
147
|
+
"or one-time, and we won't guess (guessing the period is a 4–12× error). e.g. a $15k/mo deal needs " +
|
|
148
|
+
"`--deal-period monthly` → $180k annual ACV.");
|
|
149
|
+
}
|
|
150
|
+
const derived = deriveAcvFromClosedWon(snapshot);
|
|
151
|
+
if (!derived) {
|
|
152
|
+
throw new Error("--acv-from-crm found no closed-won deals with amounts in the snapshot — pass --acv <annual-usd> instead.");
|
|
153
|
+
}
|
|
154
|
+
acvValue = derived.valueUsd * factor;
|
|
155
|
+
acvSource =
|
|
156
|
+
`crm:closed-won (${derived.dealCount} deal${derived.dealCount === 1 ? "" : "s"}, ` +
|
|
157
|
+
`median $${derived.valueUsd.toLocaleString()}/${period}${factor > 1 ? ` ×${factor}` : ""} = annual)`;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
throw new Error("tam estimate needs a real ANNUAL ACV — and it won't guess one. Pass --acv <annual-usd> (your " +
|
|
161
|
+
"confirmed annualized ACV), or --acv-from-crm --deal-period monthly|quarterly|annual to derive it " +
|
|
162
|
+
"from closed-won deals. (--provider is your coverage source, not your ACV — it no longer auto-sets it.)");
|
|
163
|
+
}
|
|
164
|
+
// Buyers/account — real signal (avg CRM contacts/account) or an explicit
|
|
165
|
+
// value; never a silent guess. With neither, a clearly-labeled minimal
|
|
166
|
+
// assumption of 1 (so the contact target = accounts, not an inflated number).
|
|
167
|
+
let buyersPerAccount = numericOption(rest, "--buyers-per-account");
|
|
168
|
+
let buyersSource = "explicit";
|
|
169
|
+
if (buyersPerAccount === undefined) {
|
|
170
|
+
const db = snapshot ? deriveBuyersPerAccount(snapshot) : null;
|
|
171
|
+
if (db) {
|
|
172
|
+
buyersPerAccount = db.value;
|
|
173
|
+
buyersSource = `crm:avg-contacts/account (${db.accountsSampled} accounts)`;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
buyersPerAccount = 1;
|
|
177
|
+
buyersSource = "assumption:1-buyer (no CRM signal)";
|
|
178
|
+
console.error("tam: buyers/account not given and no CRM contacts to derive from — assuming 1 buyer/account. " +
|
|
179
|
+
"Set --buyers-per-account <n> or pass --provider for a real contacts-per-account average.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const createdAt = option(rest, "--today") ?? new Date().toISOString();
|
|
183
|
+
const model = estimateTam({
|
|
184
|
+
name,
|
|
185
|
+
icpName: icp.name,
|
|
186
|
+
accounts,
|
|
187
|
+
accountsSource,
|
|
188
|
+
buyersPerAccount,
|
|
189
|
+
buyersSource,
|
|
190
|
+
// Capture the ICP filter so `tam status` can classify CRM accounts as
|
|
191
|
+
// in/out of THIS TAM (not just count everything).
|
|
192
|
+
targeting: {
|
|
193
|
+
geos: icp.firmographics?.geos,
|
|
194
|
+
employeeBands: icp.firmographics?.employeeBands,
|
|
195
|
+
industries: icp.firmographics?.industries,
|
|
196
|
+
technologies: icp.firmographics?.technologies,
|
|
197
|
+
},
|
|
198
|
+
acv: { basis, valueUsd: acvValue, source: acvSource },
|
|
199
|
+
crossChecks,
|
|
200
|
+
baseline,
|
|
201
|
+
createdAt,
|
|
202
|
+
});
|
|
203
|
+
saveTamModel(model);
|
|
204
|
+
if (rest.includes("--json")) {
|
|
205
|
+
console.log(JSON.stringify(model, null, 2));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const coverage = computeCoverage(model, baseline, createdAt);
|
|
209
|
+
console.log(coverageToText(model, coverage, null));
|
|
210
|
+
if (capped) {
|
|
211
|
+
console.log(`\n⚠ The account count hit Explorium's 60,000 ceiling — the true universe is LARGER. ` +
|
|
212
|
+
"Treat this TAM as a floor, or narrow the ICP (tighter industry/geo/size) for an exact count.");
|
|
213
|
+
}
|
|
214
|
+
console.log(`\nSaved TAM "${name}". Next: schedule population with ` +
|
|
215
|
+
`\`fullstackgtm tam populate --name ${name} --cron "0 7 * * 1-5"\`, ` +
|
|
216
|
+
`then track with \`fullstackgtm tam status --name ${name} --provider hubspot --save\`.`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (sub === "status") {
|
|
220
|
+
const model = loadTamModel(name);
|
|
221
|
+
if (!model)
|
|
222
|
+
throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
|
|
223
|
+
const snapshot = await readSnapshot(rest);
|
|
224
|
+
const at = option(rest, "--today") ?? new Date().toISOString();
|
|
225
|
+
// Classify CRM accounts against THIS TAM's ICP — only in-TAM accounts count
|
|
226
|
+
// toward coverage; off-ICP junk lands in out-of-TAM/unknown. Reconciles
|
|
227
|
+
// bottom-up vs top-down (the in-TAM count is a floor on the real universe).
|
|
228
|
+
const coverage = classifyCoverage(model, snapshot, at);
|
|
229
|
+
const save = saveRequested(rest);
|
|
230
|
+
if (save)
|
|
231
|
+
appendCoverage(name, coverage);
|
|
232
|
+
// Include the current reading in the ETA basis whether or not we persisted it.
|
|
233
|
+
const timeline = save ? readCoverageTimeline(name) : [...readCoverageTimeline(name), coverage];
|
|
234
|
+
const eta = projectEta(model, timeline);
|
|
235
|
+
if (rest.includes("--json")) {
|
|
236
|
+
console.log(JSON.stringify({ model, coverage, eta, saved: save }, null, 2));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
console.log(coverageToText(model, coverage, eta));
|
|
240
|
+
if (!save)
|
|
241
|
+
console.log(`\n(reading not saved — add --save to stamp the timeline for ETA tracking)`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (sub === "report") {
|
|
245
|
+
const model = loadTamModel(name);
|
|
246
|
+
if (!model)
|
|
247
|
+
throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
|
|
248
|
+
const timeline = readCoverageTimeline(name);
|
|
249
|
+
const eta = projectEta(model, timeline);
|
|
250
|
+
const md = tamReportToMarkdown(model, timeline, eta);
|
|
251
|
+
const out = option(rest, "--out");
|
|
252
|
+
if (out) {
|
|
253
|
+
writeFileSync(resolve(process.cwd(), out), md);
|
|
254
|
+
console.log(`Wrote ${out}.`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
console.log(md);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (sub === "populate") {
|
|
262
|
+
await tamPopulate(name, rest);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (sub === "accounts") {
|
|
266
|
+
await tamAccounts(rest);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
throw unknownSubcommandError("tam", sub, ["estimate", "status", "report", "populate", "accounts"]);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* `tam accounts` — pull the REAL target-account list from a technographic source
|
|
273
|
+
* (companies that actually use a CRM, matched to the ICP). This is the
|
|
274
|
+
* materialized list the count can't give you: real names + domains you can
|
|
275
|
+
* review, save, and acquire into the CRM. Read-only (no CRM write); each pulled
|
|
276
|
+
* company costs provider credits, so `--max` caps the page.
|
|
277
|
+
*/
|
|
278
|
+
async function tamAccounts(rest) {
|
|
279
|
+
const source = option(rest, "--source") ?? "theirstack";
|
|
280
|
+
if (source !== "theirstack") {
|
|
281
|
+
throw new Error(`tam accounts --source supports theirstack (only technographic list source wired) — got "${source}".`);
|
|
282
|
+
}
|
|
283
|
+
const icp = loadIcp(rest);
|
|
284
|
+
if (!icp) {
|
|
285
|
+
throw new Error("tam accounts needs an ICP (icp.json in cwd, or --icp <path>) — its technologies/firmographics are the filter.");
|
|
286
|
+
}
|
|
287
|
+
const max = Math.min(numericOption(rest, "--max") ?? 25, 100);
|
|
288
|
+
// Pulling the list costs TheirStack credits (~3/company) — never spend by
|
|
289
|
+
// surprise. Show the cost up front; --dry-run prices it without spending; a
|
|
290
|
+
// pull above --max-credits (default 150 ≈ 50 companies) needs --confirm.
|
|
291
|
+
const usdPerCredit = numericOption(rest, "--usd-per-credit") ?? undefined;
|
|
292
|
+
const thisCost = theirStackPullCost(max, usdPerCredit);
|
|
293
|
+
const usdStr = (c) => (c.usd !== undefined ? ` (~$${c.usd.toLocaleString()})` : "");
|
|
294
|
+
const model = loadTamModel(option(rest, "--name") ?? "default");
|
|
295
|
+
const fullUniverse = model?.universe.accounts;
|
|
296
|
+
const fullCost = fullUniverse ? theirStackPullCost(fullUniverse, usdPerCredit) : undefined;
|
|
297
|
+
const costLine = `Pull cost: up to ${max} companies ≈ ${thisCost.credits.toLocaleString()} TheirStack credits${usdStr(thisCost)}` +
|
|
298
|
+
(fullCost
|
|
299
|
+
? `. Full TAM (~${fullUniverse.toLocaleString()} accounts) ≈ ${fullCost.credits.toLocaleString()} credits${usdStr(fullCost)} to materialize.`
|
|
300
|
+
: ".");
|
|
301
|
+
if (rest.includes("--dry-run")) {
|
|
302
|
+
console.log(`${costLine}\n(dry run — nothing pulled, 0 credits spent. Add --usd-per-credit <rate> for a $ estimate.)`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const maxCredits = numericOption(rest, "--max-credits") ?? 150;
|
|
306
|
+
if (thisCost.credits > maxCredits && !rest.includes("--confirm")) {
|
|
307
|
+
console.error(`${costLine}\nThis pull (${thisCost.credits.toLocaleString()} credits) exceeds the --max-credits guard (${maxCredits}). ` +
|
|
308
|
+
"Re-run with --confirm to spend it, lower --max, or --dry-run to just price it.");
|
|
309
|
+
process.exitCode = 2;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
console.error(costLine);
|
|
313
|
+
const { companies: raw, total } = await theirStackSearchCompanies({
|
|
314
|
+
apiKey: providerKey("theirstack"),
|
|
315
|
+
filters: icpToTheirStackFilters(icp),
|
|
316
|
+
limit: max,
|
|
317
|
+
});
|
|
318
|
+
// A company carries its WHOLE tech stack (often 200+ slugs) — noise. Keep only
|
|
319
|
+
// the ICP-targeted technologies (the CRMs/MAPs that put it in the list).
|
|
320
|
+
const targeted = new Set((icp.firmographics.technologies ?? []).map((t) => t.trim().toLowerCase()));
|
|
321
|
+
const companies = raw.map((c) => ({
|
|
322
|
+
...c,
|
|
323
|
+
technologies: (c.technologies ?? []).filter((t) => targeted.has(t.toLowerCase())),
|
|
324
|
+
}));
|
|
325
|
+
const out = option(rest, "--out");
|
|
326
|
+
if (rest.includes("--json")) {
|
|
327
|
+
console.log(JSON.stringify({ total, returned: companies.length, companies }, null, 2));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (out) {
|
|
331
|
+
const header = "name,domain,employee_count,country,linkedin_url,matched_technologies";
|
|
332
|
+
const esc = (v) => {
|
|
333
|
+
const s = v === undefined ? "" : String(v);
|
|
334
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
335
|
+
};
|
|
336
|
+
const rows = companies.map((c) => [c.name, c.domain, c.employeeCount, c.countryCode, c.linkedinUrl, (c.technologies ?? []).join("|")]
|
|
337
|
+
.map(esc)
|
|
338
|
+
.join(","));
|
|
339
|
+
writeFileSync(resolve(process.cwd(), out), `${[header, ...rows].join("\n")}\n`);
|
|
340
|
+
console.log(`Wrote ${companies.length} companies to ${out}${total !== null ? ` (of ${total.toLocaleString()} matching)` : ""}.`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
console.log(`${companies.length} companies${total !== null ? ` of ${total.toLocaleString()} matching the ICP` : ""} ` +
|
|
344
|
+
`(uses ${[...targeted].join("/")}):\n`);
|
|
345
|
+
for (const c of companies) {
|
|
346
|
+
console.log(` ${(c.name ?? "?").padEnd(32)} ${(c.domain ?? "").padEnd(28)} ${String(c.employeeCount ?? "?").padStart(6)} emp ` +
|
|
347
|
+
`[${(c.technologies ?? []).join(",")}]`);
|
|
348
|
+
}
|
|
349
|
+
console.log(`\nNext: stage them with \`fullstackgtm enrich ingest <file.csv> --source clay --objects companies\` ` +
|
|
350
|
+
"→ `enrich acquire` to create governed account records (or re-run with --out <file.csv>).");
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* `tam populate` — wire the scheduled population of a TAM: a recurring
|
|
354
|
+
* `enrich acquire --source <s> --save` that queues a needs_approval lead plan
|
|
355
|
+
* each firing (apply stays a separate human gate; the meter is charged only at
|
|
356
|
+
* apply). Delegates to the scheduler so the cron + allowlist (incl. the --save
|
|
357
|
+
* guard) are validated the same way as any `schedule add`.
|
|
358
|
+
*/
|
|
359
|
+
async function tamPopulate(name, rest) {
|
|
360
|
+
const model = loadTamModel(name);
|
|
361
|
+
if (!model)
|
|
362
|
+
throw new Error(`No TAM "${name}" — run \`fullstackgtm tam estimate\` first.`);
|
|
363
|
+
const cron = option(rest, "--cron");
|
|
364
|
+
if (!cron) {
|
|
365
|
+
throw new Error('tam populate requires --cron "<expr>" (e.g. "0 7 * * 1-5" = 7am on weekdays)');
|
|
366
|
+
}
|
|
367
|
+
const source = option(rest, "--source") ?? "pipe0";
|
|
368
|
+
const provider = option(rest, "--provider") ?? "hubspot";
|
|
369
|
+
const label = option(rest, "--label") ?? `tam-${name}`;
|
|
370
|
+
const acquireCmd = `enrich acquire --source ${source} --provider ${provider} --save`;
|
|
371
|
+
// Reuse the scheduler: validates the cron, the allowlist, and the --save guard.
|
|
372
|
+
await scheduleCommand(["add", acquireCmd, "--cron", cron, "--label", label]);
|
|
373
|
+
const remainingAccounts = Math.max(0, model.universe.accounts - model.baseline.accounts);
|
|
374
|
+
console.log(`\nPopulating TAM "${name}": each firing queues a needs_approval lead plan — apply stays gated, the ` +
|
|
375
|
+
`acquire meter is charged only at apply. ~${remainingAccounts.toLocaleString()} of ` +
|
|
376
|
+
`${model.universe.accounts.toLocaleString()} accounts remain to reach the universe ` +
|
|
377
|
+
`($${model.tamUsd.toLocaleString()} TAM).`);
|
|
378
|
+
console.log("Next: `fullstackgtm schedule install` to activate, then each cycle " +
|
|
379
|
+
"`fullstackgtm plans list` → `plans approve` → `apply`. Track progress with " +
|
|
380
|
+
`\`fullstackgtm tam status --name ${name} --provider ${provider} --save\`.`);
|
|
381
|
+
// Cost lives in two separate places — be explicit so neither surprises.
|
|
382
|
+
const ts = theirStackPullCost(model.universe.accounts);
|
|
383
|
+
console.log(`\nCost: population spend is the acquire provider's (${source}), governed by the per-profile acquire meter ` +
|
|
384
|
+
"(records + $/day caps in enrich.config.json) — not TheirStack. Separately, materializing the account LIST " +
|
|
385
|
+
`from TheirStack (\`tam accounts\`) is ~${ts.credits.toLocaleString()} credits for the full ${model.universe.accounts.toLocaleString()}-account ` +
|
|
386
|
+
"universe; `tam accounts --dry-run [--usd-per-credit <r>]` prices any pull without spending.");
|
|
387
|
+
}
|
package/dist/cli/ui.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
type StreamLike = {
|
|
2
|
+
isTTY?: boolean;
|
|
3
|
+
columns?: number;
|
|
4
|
+
write(chunk: string): boolean;
|
|
5
|
+
};
|
|
6
|
+
export type UiEnv = Record<string, string | undefined>;
|
|
7
|
+
/** Should `stream` receive color/ANSI styling at all? */
|
|
8
|
+
export declare function colorEnabled(stream?: StreamLike | undefined, env?: UiEnv): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Should `stream` receive live single-line repaints (spinners, bars)?
|
|
11
|
+
* Stricter than color: FORCE_COLOR opts into color codes, not into cursor
|
|
12
|
+
* animation — repaint sequences in a captured log are never what anyone wants.
|
|
13
|
+
*/
|
|
14
|
+
export declare function animationEnabled(stream?: StreamLike | undefined, env?: UiEnv): boolean;
|
|
15
|
+
declare const CODES: {
|
|
16
|
+
readonly reset: "\u001B[0m";
|
|
17
|
+
readonly bold: "\u001B[1m";
|
|
18
|
+
readonly dim: "\u001B[2m";
|
|
19
|
+
readonly red: "\u001B[31m";
|
|
20
|
+
readonly green: "\u001B[32m";
|
|
21
|
+
readonly yellow: "\u001B[33m";
|
|
22
|
+
readonly blue: "\u001B[34m";
|
|
23
|
+
readonly magenta: "\u001B[35m";
|
|
24
|
+
readonly cyan: "\u001B[36m";
|
|
25
|
+
};
|
|
26
|
+
export type StyleName = keyof Omit<typeof CODES, "reset">;
|
|
27
|
+
export type Paint = Record<StyleName, (text: string) => string> & {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Style functions for one stream: identity functions when styling is off, so
|
|
32
|
+
* call sites never branch. `paint(colorEnabled(process.stdout))`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function paint(enabled: boolean): Paint;
|
|
35
|
+
export declare const GLYPH: {
|
|
36
|
+
readonly ok: "✓";
|
|
37
|
+
readonly warn: "!";
|
|
38
|
+
readonly fail: "✗";
|
|
39
|
+
readonly pending: "○";
|
|
40
|
+
readonly bullet: "●";
|
|
41
|
+
};
|
|
42
|
+
/** `✓` in green / `!` in yellow / `✗` in red — or the bare glyph unstyled. */
|
|
43
|
+
export declare function statusGlyph(state: "ok" | "warn" | "fail" | "pending", p: Paint): string;
|
|
44
|
+
/** Color banding for 0–100 scores: ≥80 green, ≥60 yellow, else red. */
|
|
45
|
+
export declare function scoreColor(score: number, p: Paint): string;
|
|
46
|
+
/** Min–max normalized sparkline: [42, 51, 68, 81] → "▁▃▅█". */
|
|
47
|
+
export declare function sparkline(values: number[]): string;
|
|
48
|
+
/** `formatBar(0.67)` → "▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱" (clamped to [0,1]). */
|
|
49
|
+
export declare function formatBar(fraction: number, width?: number): string;
|
|
50
|
+
/** 950 → "950ms"; 12_400 → "12s"; 84_000 → "1m 24s". */
|
|
51
|
+
export declare function formatDuration(ms: number): string;
|
|
52
|
+
/** 12345 → "12,345" (fixed en-US grouping — output must not vary by host locale). */
|
|
53
|
+
export declare function formatCount(value: number): string;
|
|
54
|
+
/**
|
|
55
|
+
* Rounded-corner frame around pre-rendered lines. `width` is measured on the
|
|
56
|
+
* raw text, so style the CONTENT only after framing (or pass unstyled lines) —
|
|
57
|
+
* ANSI codes inside `lines` would inflate the measured width.
|
|
58
|
+
*/
|
|
59
|
+
export declare function box(lines: string[], p: Paint, title?: string): string[];
|
|
60
|
+
/**
|
|
61
|
+
* Left-aligned columns with two-space gutters. Rows may be ragged; widths are
|
|
62
|
+
* measured on raw cell text, so pass unstyled cells and style whole lines, or
|
|
63
|
+
* style via the optional per-column painters (applied AFTER padding).
|
|
64
|
+
*/
|
|
65
|
+
export declare function table(rows: string[][], painters?: Array<((cell: string) => string) | null>): string[];
|
|
66
|
+
export type StatusLine = {
|
|
67
|
+
/** Replace the line's text (next frame repaints it). */
|
|
68
|
+
set(text: string): void;
|
|
69
|
+
/** Stop animating; optionally leave a final static line (else clear). */
|
|
70
|
+
done(finalText?: string): void;
|
|
71
|
+
readonly active: boolean;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* One live spinner line on stderr, repainted in place. Returns an inert no-op
|
|
75
|
+
* outside an interactive TTY, so call sites never branch. `done()` always
|
|
76
|
+
* clears the animation; pass `finalText` to leave a plain summary line behind.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createStatusLine(stream?: StreamLike, env?: UiEnv): StatusLine;
|
|
79
|
+
/**
|
|
80
|
+
* A status line that re-renders itself every second with the elapsed time —
|
|
81
|
+
* for single long waits (an LLM call) where nothing else ticks. `label`
|
|
82
|
+
* receives the formatted elapsed time. Inert outside an interactive TTY.
|
|
83
|
+
*/
|
|
84
|
+
export declare function startElapsedStatus(label: (elapsed: string) => string, stream?: StreamLike, env?: UiEnv): {
|
|
85
|
+
done(finalText?: string): void;
|
|
86
|
+
active: boolean;
|
|
87
|
+
};
|
|
88
|
+
export type Checklist = {
|
|
89
|
+
update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
|
|
90
|
+
/** Stop animating and erase the board (callers print their own summary). */
|
|
91
|
+
done(): void;
|
|
92
|
+
readonly active: boolean;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
96
|
+
* progresses (the audit rule registry renders through this). Repaints with
|
|
97
|
+
* cursor-up; erases itself on done() so the verb's real output owns stdout.
|
|
98
|
+
*/
|
|
99
|
+
export declare function createChecklist(items: Array<{
|
|
100
|
+
id: string;
|
|
101
|
+
label: string;
|
|
102
|
+
}>, stream?: StreamLike, env?: UiEnv): Checklist;
|
|
103
|
+
/** Truncate to `max` display characters, ending in "…" when cut. */
|
|
104
|
+
export declare function truncateToWidth(text: string, max: number): string;
|
|
105
|
+
/** critical → red, warning → yellow, info → dim. */
|
|
106
|
+
export declare function severityWord(word: string, p: Paint): string;
|
|
107
|
+
/** needs_approval → yellow, approved/applied → green, rejected → red. */
|
|
108
|
+
export declare function planStatusWord(status: string, p: Paint): string;
|
|
109
|
+
/**
|
|
110
|
+
* Interactive-terminal styling for the patch-plan markdown that `audit` and
|
|
111
|
+
* `plans show` print: headings bold, severities banded, and the core diff —
|
|
112
|
+
* current value red, proposed value green (placeholders yellow: they need a
|
|
113
|
+
* human before they can be written). Line-level and additive — with paint
|
|
114
|
+
* disabled the text passes through untouched, so piped output is unchanged.
|
|
115
|
+
* One deliberate text change in rich mode: finding headlines render
|
|
116
|
+
* terminal-bold instead of markdown-bold (the ** markers drop).
|
|
117
|
+
*/
|
|
118
|
+
export declare function stylizePlanMarkdown(text: string, p: Paint): string;
|
|
119
|
+
/** Strip ANSI escape sequences (test helper + safety net for width math). */
|
|
120
|
+
export declare function stripAnsi(text: string): string;
|
|
121
|
+
export {};
|