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.
Files changed (99) hide show
  1. package/CHANGELOG.md +185 -1
  2. package/README.md +19 -7
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +100 -5130
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/salesforce.d.ts +3 -1
  48. package/dist/connectors/salesforce.js +12 -5
  49. package/dist/connectors/signalSources.js +7 -59
  50. package/dist/icp.d.ts +15 -11
  51. package/dist/icp.js +19 -36
  52. package/dist/judge.d.ts +2 -0
  53. package/dist/judge.js +6 -0
  54. package/dist/marketClassify.d.ts +2 -0
  55. package/dist/marketClassify.js +7 -1
  56. package/dist/mcp-bin.js +2 -2
  57. package/dist/mcp.js +259 -166
  58. package/dist/schedule.d.ts +80 -2
  59. package/dist/schedule.js +254 -1
  60. package/dist/spoolFiles.d.ts +44 -0
  61. package/dist/spoolFiles.js +114 -0
  62. package/dist/types.d.ts +11 -0
  63. package/docs/api.md +73 -7
  64. package/docs/signal-spool-format.md +13 -0
  65. package/llms.txt +15 -6
  66. package/package.json +1 -1
  67. package/skills/fullstackgtm/SKILL.md +1 -1
  68. package/src/audit.ts +27 -1
  69. package/src/cli/audit.ts +447 -0
  70. package/src/cli/auth.ts +549 -0
  71. package/src/cli/call.ts +398 -0
  72. package/src/cli/capabilities.ts +215 -0
  73. package/src/cli/draft.ts +101 -0
  74. package/src/cli/enrich.ts +885 -0
  75. package/src/cli/fix.ts +372 -0
  76. package/src/cli/help.ts +664 -0
  77. package/src/cli/icp.ts +265 -0
  78. package/src/cli/init.ts +65 -0
  79. package/src/cli/market.ts +423 -0
  80. package/src/cli/plans.ts +523 -0
  81. package/src/cli/schedule.ts +526 -0
  82. package/src/cli/shared.ts +375 -0
  83. package/src/cli/signals.ts +434 -0
  84. package/src/cli/suggest.ts +151 -0
  85. package/src/cli/tam.ts +435 -0
  86. package/src/cli/ui.ts +426 -0
  87. package/src/cli.ts +99 -5829
  88. package/src/connector.ts +46 -0
  89. package/src/connectors/hubspot.ts +14 -2
  90. package/src/connectors/salesforce.ts +18 -2
  91. package/src/connectors/signalSources.ts +7 -56
  92. package/src/icp.ts +19 -35
  93. package/src/judge.ts +7 -0
  94. package/src/marketClassify.ts +8 -1
  95. package/src/mcp-bin.ts +2 -2
  96. package/src/mcp.ts +130 -57
  97. package/src/schedule.ts +310 -3
  98. package/src/spoolFiles.ts +116 -0
  99. package/src/types.ts +12 -0
@@ -0,0 +1,549 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken } from "../connectors/hubspotAuth.ts";
6
+ import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken } from "../connectors/salesforceAuth.ts";
7
+ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, storeCredential, type StoredCredential } from "../credentials.ts";
8
+ import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.ts";
9
+ import { resolveLlmCredential, validateLlmKey } from "../llm.ts";
10
+ import { createFilePlanStore, type StoredPlan } from "../planStore.ts";
11
+ import { isOptionValue, numericOption, option, readPackageInfo, readSecret } from "./shared.ts";
12
+ import { box, colorEnabled, paint, scoreColor, sparkline } from "./ui.ts";
13
+
14
+
15
+ /**
16
+ * Provider error bodies can echo request parameters (including secrets) and
17
+ * land in logs. Surface only the status and, when present, a known-safe error
18
+ * description field — never the raw body.
19
+ */
20
+ function safeStatus(response: Response): string {
21
+ return `HTTP ${response.status} ${response.statusText}`.trim();
22
+ }
23
+
24
+ function rejectArgvSecret(args: string[], ...flags: string[]) {
25
+ for (const flag of flags) {
26
+ if (args.includes(flag)) {
27
+ throw new Error(
28
+ `${flag} no longer accepts a value on the command line (argv secrets leak via \`ps\` and ` +
29
+ `shell history). Pipe the secret on stdin instead, e.g. \`echo "$SECRET" | fullstackgtm login ...\`.`,
30
+ );
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The broker channel carries a long-lived pairing bearer and receives freshly
37
+ * minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
38
+ * dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
39
+ * shell escaping does nothing for a token sent in cleartext.
40
+ */
41
+ export function assertSecureBrokerUrl(raw: string): URL {
42
+ let url: URL;
43
+ try {
44
+ url = new URL(raw);
45
+ } catch {
46
+ throw new Error(`--via must be a full URL (e.g. https://gtm.yourco.com), got "${raw}".`);
47
+ }
48
+ const isLocalhost =
49
+ url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
50
+ if (url.protocol === "https:") return url;
51
+ if (url.protocol === "http:" && isLocalhost) return url; // local dev only
52
+ throw new Error(
53
+ `Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
54
+ "credentials, so it must use https (http is allowed only for localhost dev).",
55
+ );
56
+ }
57
+
58
+ async function brokerLogin(baseUrl: string) {
59
+ const viaUrl = assertSecureBrokerUrl(baseUrl);
60
+ const base = baseUrl.replace(/\/$/, "");
61
+ const os = await import("node:os");
62
+ // Self-reported, shown to the approver so they can recognize this request
63
+ // and refuse one they didn't initiate.
64
+ const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
65
+ let startResponse: Response;
66
+ try {
67
+ startResponse = await fetch(`${base}/api/cli/auth/start`, {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ requesterLabel }),
71
+ });
72
+ } catch (error) {
73
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
74
+ throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
75
+ }
76
+ if (!startResponse.ok) {
77
+ throw new Error(
78
+ `Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`,
79
+ );
80
+ }
81
+ const start = await startResponse.json();
82
+ // Only auto-open a verification URL that belongs to the --via origin the user
83
+ // typed — a malicious/typo'd deployment cannot redirect the browser elsewhere.
84
+ let sameOrigin = false;
85
+ try {
86
+ sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
87
+ } catch {
88
+ sameOrigin = false;
89
+ }
90
+ console.error(
91
+ `\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`,
92
+ );
93
+ if (sameOrigin) {
94
+ void openInBrowser(start.verificationUrl);
95
+ } else {
96
+ console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
97
+ }
98
+
99
+ const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
100
+ const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
101
+ while (Date.now() < deadline) {
102
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
103
+ const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
107
+ });
108
+ if (!pollResponse.ok) {
109
+ throw new Error(`Pairing poll failed (${pollResponse.status}).`);
110
+ }
111
+ const poll = await pollResponse.json();
112
+ if (poll.status === "pending") continue;
113
+ if (poll.status === "approved" && poll.cliToken) {
114
+ const now = new Date().toISOString();
115
+ storeCredential("broker", {
116
+ kind: "broker",
117
+ accessToken: poll.cliToken,
118
+ baseUrl: base,
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ });
122
+ console.log(`Paired with ${base}. Credentials stored in ${credentialsPath()}.`);
123
+ console.log(
124
+ "Provider commands now use the organization's stored sync credentials via the deployment.",
125
+ );
126
+ return;
127
+ }
128
+ throw new Error(`Pairing was ${poll.status}.`);
129
+ }
130
+ throw new Error("Pairing timed out before it was approved.");
131
+ }
132
+
133
+ async function salesforceLogin(args: string[]) {
134
+ const now = new Date().toISOString();
135
+
136
+ if (args.includes("--device")) {
137
+ const clientId = option(args, "--client-id");
138
+ if (!clientId) {
139
+ throw new Error(
140
+ "--device requires --client-id (the consumer key of a Connected App with device flow enabled).",
141
+ );
142
+ }
143
+ const loginUrl = option(args, "--login-url") ?? undefined;
144
+ const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
145
+ console.error(
146
+ `\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`,
147
+ );
148
+ void openInBrowser(authorization.verificationUri);
149
+ const tokens = await pollSalesforceDeviceLogin({
150
+ clientId,
151
+ deviceCode: authorization.deviceCode,
152
+ intervalSeconds: authorization.intervalSeconds,
153
+ loginUrl,
154
+ });
155
+ storeCredential("salesforce", {
156
+ kind: "oauth",
157
+ accessToken: tokens.accessToken,
158
+ refreshToken: tokens.refreshToken,
159
+ instanceUrl: tokens.instanceUrl,
160
+ expiresAt: tokens.expiresAt,
161
+ clientId,
162
+ loginUrl,
163
+ createdAt: now,
164
+ updatedAt: now,
165
+ });
166
+ console.log(
167
+ `Logged in to Salesforce (${tokens.instanceUrl}). Credentials stored in ${credentialsPath()}.`,
168
+ );
169
+ console.log("Tokens refresh silently; no further browser interaction is needed.");
170
+ return;
171
+ }
172
+
173
+ rejectArgvSecret(args, "--token");
174
+ const instanceUrl = option(args, "--instance-url");
175
+ if (!instanceUrl) {
176
+ throw new Error(
177
+ "Salesforce login needs --device --client-id <consumer key>, or --instance-url " +
178
+ "<https://yourorg.my.salesforce.com> with the access token piped on stdin.",
179
+ );
180
+ }
181
+ const token = await readSecret("Salesforce access token");
182
+ if (!token) throw new Error("No access token provided.");
183
+ if (!args.includes("--no-validate")) {
184
+ const result = await validateSalesforceToken(token, instanceUrl);
185
+ if (!result.ok) throw new Error(result.detail);
186
+ console.log(result.detail);
187
+ }
188
+ storeCredential("salesforce", {
189
+ kind: "private_app",
190
+ accessToken: token,
191
+ instanceUrl,
192
+ createdAt: now,
193
+ updatedAt: now,
194
+ });
195
+ console.log(`Logged in to Salesforce. Credentials stored in ${credentialsPath()}.`);
196
+ }
197
+
198
+ export async function login(args: string[]) {
199
+ const via = option(args, "--via");
200
+ if (via) {
201
+ await brokerLogin(via);
202
+ return;
203
+ }
204
+ const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
205
+ if (provider === "salesforce") {
206
+ await salesforceLogin(args);
207
+ return;
208
+ }
209
+ if (provider === "stripe") {
210
+ rejectArgvSecret(args, "--token");
211
+ const key = await readSecret("Stripe secret key (sk_...)");
212
+ if (!key) throw new Error("No Stripe key provided.");
213
+ if (!args.includes("--no-validate")) {
214
+ const response = await fetch("https://api.stripe.com/v1/customers?limit=1", {
215
+ headers: { Authorization: `Bearer ${key}` },
216
+ });
217
+ if (!response.ok) {
218
+ throw new Error(`Stripe rejected the key (${response.status}): ${safeStatus(response)}`);
219
+ }
220
+ console.log("Key accepted by the Stripe API.");
221
+ }
222
+ const stamp = new Date().toISOString();
223
+ storeCredential("stripe", {
224
+ kind: "private_app",
225
+ accessToken: key,
226
+ createdAt: stamp,
227
+ updatedAt: stamp,
228
+ });
229
+ console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
230
+ return;
231
+ }
232
+ if (provider === "anthropic" || provider === "openai") {
233
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
234
+ const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
235
+ if (!key) throw new Error(`No ${provider} key provided.`);
236
+ if (!args.includes("--no-validate")) {
237
+ const validation = await validateLlmKey(provider, key);
238
+ if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
239
+ console.log(validation.detail);
240
+ }
241
+ const stamp = new Date().toISOString();
242
+ storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
243
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
244
+ return;
245
+ }
246
+ if (provider === "apollo") {
247
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
248
+ const key = await readSecret("Apollo API key");
249
+ if (!key) throw new Error("No Apollo key provided.");
250
+ if (!args.includes("--no-validate")) {
251
+ const response = await fetch("https://api.apollo.io/api/v1/auth/health", {
252
+ headers: { "X-Api-Key": key, Accept: "application/json" },
253
+ });
254
+ if (!response.ok) {
255
+ throw new Error(`Apollo rejected the key: ${safeStatus(response)}`);
256
+ }
257
+ console.log("Key accepted by the Apollo API.");
258
+ }
259
+ const stamp = new Date().toISOString();
260
+ storeCredential("apollo", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
261
+ console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
262
+ return;
263
+ }
264
+ if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
265
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
266
+ const key = await readSecret(`${provider} API key`);
267
+ if (!key) throw new Error(`No ${provider} key provided.`);
268
+ // No free auth-health endpoint; validating would spend credits, so the key
269
+ // is stored as-is and validated on the first pull.
270
+ const stamp = new Date().toISOString();
271
+ storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
272
+ const usedBy =
273
+ provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" : "`fullstackgtm enrich acquire`";
274
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. ${usedBy} uses it automatically (validated on first pull).`);
275
+ return;
276
+ }
277
+ if (provider !== "hubspot") {
278
+ throw new Error(
279
+ "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",
280
+ );
281
+ }
282
+ const now = new Date().toISOString();
283
+
284
+ if (args.includes("--oauth")) {
285
+ rejectArgvSecret(args, "--client-secret");
286
+ const clientId = option(args, "--client-id");
287
+ if (!clientId) {
288
+ throw new Error(
289
+ "--oauth requires --client-id from your own HubSpot app " +
290
+ `(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL). ` +
291
+ "The client secret is read from stdin or an interactive prompt.",
292
+ );
293
+ }
294
+ const clientSecret = await readSecret("HubSpot app client secret");
295
+ if (!clientSecret) throw new Error("No client secret provided.");
296
+ const scopes = option(args, "--scopes")?.split(",").map((scope) => scope.trim());
297
+ const tokens = await runHubspotLoopbackLogin({
298
+ clientId,
299
+ clientSecret,
300
+ port: numericOption(args, "--port"),
301
+ scopes,
302
+ });
303
+ storeCredential("hubspot", {
304
+ kind: "oauth",
305
+ accessToken: tokens.accessToken,
306
+ refreshToken: tokens.refreshToken,
307
+ expiresAt: tokens.expiresAt,
308
+ clientId,
309
+ clientSecret,
310
+ scopes,
311
+ createdAt: now,
312
+ updatedAt: now,
313
+ });
314
+ console.log(`Logged in to HubSpot via OAuth. Credentials stored in ${credentialsPath()}.`);
315
+ console.log("Tokens refresh silently; no further browser interaction is needed.");
316
+ return;
317
+ }
318
+
319
+ rejectArgvSecret(args, "--token");
320
+ const token = await readSecret("HubSpot private app token");
321
+ if (!token) throw new Error("No token provided.");
322
+ if (!args.includes("--no-validate")) {
323
+ const result = await validateHubspotToken(token);
324
+ if (!result.ok) throw new Error(result.detail);
325
+ console.log(result.detail);
326
+ }
327
+ storeCredential("hubspot", {
328
+ kind: "private_app",
329
+ accessToken: token,
330
+ createdAt: now,
331
+ updatedAt: now,
332
+ });
333
+ console.log(`Logged in to HubSpot. Credentials stored in ${credentialsPath()}.`);
334
+ }
335
+
336
+ export function logout(args: string[]) {
337
+ const provider = args.find((arg) => !arg.startsWith("--"));
338
+ if (!provider) throw new Error("Usage: fullstackgtm logout hubspot");
339
+ if (!getCredential(provider)) {
340
+ console.log(`No stored credentials for ${provider}.`);
341
+ return;
342
+ }
343
+ deleteCredential(provider);
344
+ console.log(`Removed stored ${provider} credentials.`);
345
+ }
346
+
347
+ type ProviderDoctorStatus = {
348
+ source: "env" | "stored" | "broker" | "none";
349
+ detail: string;
350
+ };
351
+
352
+ export function doctorReport(env: Record<string, string | undefined> = process.env) {
353
+ const packageInfo = readPackageInfo();
354
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
355
+ const storePath = credentialsPath();
356
+ const configPath = resolve("fullstackgtm.config.json");
357
+ const broker = getCredential("broker");
358
+
359
+ const providers: Record<string, ProviderDoctorStatus> = {
360
+ hubspot: env.HUBSPOT_ACCESS_TOKEN
361
+ ? { source: "env", detail: "HUBSPOT_ACCESS_TOKEN" }
362
+ : providerStatus("hubspot", broker),
363
+ salesforce:
364
+ env.SALESFORCE_ACCESS_TOKEN && env.SALESFORCE_INSTANCE_URL
365
+ ? { source: "env", detail: "SALESFORCE_ACCESS_TOKEN + SALESFORCE_INSTANCE_URL" }
366
+ : providerStatus("salesforce", broker),
367
+ stripe: env.STRIPE_SECRET_KEY
368
+ ? { source: "env", detail: "STRIPE_SECRET_KEY" }
369
+ : providerStatus("stripe", broker),
370
+ };
371
+
372
+ const llm = resolveLlmCredential(env);
373
+ const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
374
+ try {
375
+ import.meta.resolve(name);
376
+ return false;
377
+ } catch {
378
+ return true;
379
+ }
380
+ });
381
+
382
+ const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
383
+ const nextSteps =
384
+ connected.length === 0
385
+ ? [
386
+ "fullstackgtm audit --demo # no credentials needed",
387
+ "fullstackgtm login hubspot # connect your CRM (or: login --via <hosted url>)",
388
+ ]
389
+ : [`fullstackgtm audit --provider ${connected[0][0]}`];
390
+
391
+ return {
392
+ package: packageInfo,
393
+ node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
394
+ profile: activeProfile(),
395
+ credentialStore: { path: storePath, exists: existsSync(storePath) },
396
+ config: { path: configPath, exists: existsSync(configPath) },
397
+ providers,
398
+ broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
399
+ llm: llm
400
+ ? { configured: true, provider: llm.provider, source: llm.source }
401
+ : { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
402
+ mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
403
+ nextSteps,
404
+ };
405
+ }
406
+
407
+ function providerStatus(provider: string, broker: StoredCredential | null): ProviderDoctorStatus {
408
+ const stored = getCredential(provider);
409
+ if (stored) {
410
+ return { source: "stored", detail: `${stored.kind} login, updated ${stored.updatedAt}` };
411
+ }
412
+ if (broker) {
413
+ return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
414
+ }
415
+ return { source: "none", detail: `fullstackgtm login ${provider}` };
416
+ }
417
+
418
+ export type WorkspaceDoctor = {
419
+ profile: string;
420
+ healthScore: number | null;
421
+ scoreDelta: number | null;
422
+ lastAuditAt: string | null;
423
+ auditCount: number;
424
+ pendingPlans: Array<{ id: string; summary: string; operations: number; approved: number }>;
425
+ };
426
+
427
+ /**
428
+ * The workspace slice of doctor: current health + plans awaiting approval.
429
+ * Folded into doctor (rather than adding a separate triage verb) so a single
430
+ * call returns environment + workspace state + copy-pasteable next commands —
431
+ * previously an agent needed three round-trips (doctor, health, plans list)
432
+ * to orient itself.
433
+ */
434
+ export async function workspaceDoctor(): Promise<WorkspaceDoctor> {
435
+ const profile = activeWorkspaceProfile();
436
+ const rollup = summarizeHealth(readHealthTimeline(), profile);
437
+ let pending: StoredPlan[] = [];
438
+ try {
439
+ pending = await createFilePlanStore().list("needs_approval");
440
+ } catch {
441
+ // A malformed plans dir must not take doctor down — doctor is the verb
442
+ // that diagnoses broken installs. The plans slice just comes back empty.
443
+ }
444
+ return {
445
+ profile,
446
+ healthScore: rollup?.current.score ?? null,
447
+ scoreDelta: rollup?.scoreDelta ?? null,
448
+ lastAuditAt: rollup?.latest ?? null,
449
+ auditCount: rollup?.auditCount ?? 0,
450
+ pendingPlans: pending
451
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.plan.id.localeCompare(b.plan.id))
452
+ .slice(0, 10)
453
+ .map((stored) => ({
454
+ id: stored.plan.id,
455
+ summary: stored.plan.summary,
456
+ operations: stored.plan.operations.length,
457
+ approved: stored.approvedOperationIds.length,
458
+ })),
459
+ };
460
+ }
461
+
462
+ /**
463
+ * State-aware next steps: a plan awaiting approval outranks the generic
464
+ * "run an audit" advice — the exact show → approve → apply chain, ready to
465
+ * paste, with the connected provider filled in when there is one.
466
+ */
467
+ /**
468
+ * Rich-only sparkline of the profile's score history, e.g. " ▂▄▅▇". Empty
469
+ * string in plain mode or with fewer than two readings, so the plain doctor
470
+ * line is byte-identical to the pre-TUI CLI.
471
+ */
472
+ function healthSparkline(profile: string, rich: boolean): string {
473
+ if (!rich) return "";
474
+ const history = summarizeHealth(readHealthTimeline(), profile)?.history ?? [];
475
+ if (history.length < 2) return "";
476
+ return ` ${sparkline(history.map((point) => point.score))}`;
477
+ }
478
+
479
+ function nextStepsWithWorkspace(report: ReturnType<typeof doctorReport>, workspace: WorkspaceDoctor): string[] {
480
+ if (workspace.pendingPlans.length === 0) return report.nextSteps;
481
+ const [first] = workspace.pendingPlans;
482
+ const connected = Object.entries(report.providers).find(([, status]) => status.source !== "none");
483
+ const providerHint = connected ? connected[0] : "<hubspot|salesforce|stripe>";
484
+ return [
485
+ `fullstackgtm plans show ${first.id}`,
486
+ `fullstackgtm plans approve ${first.id} --operations <ids|all>`,
487
+ `fullstackgtm apply --plan-id ${first.id} --provider ${providerHint}`,
488
+ ];
489
+ }
490
+
491
+ export async function doctorCommand(args: string[]) {
492
+ const report = doctorReport();
493
+ const workspace = await workspaceDoctor();
494
+ const nextSteps = nextStepsWithWorkspace(report, workspace);
495
+ if (args.includes("--json")) {
496
+ console.log(JSON.stringify({ ...report, workspace, nextSteps }, null, 2));
497
+ return;
498
+ }
499
+
500
+ // Styling contract: with color off (piped/NO_COLOR/CI) every painter is the
501
+ // identity function and the rich-only extras are skipped, so the plain
502
+ // output stays byte-identical to the unstyled CLI.
503
+ const p = paint(colorEnabled(process.stdout));
504
+ const mark = (ok: boolean) => (ok ? p.green("ok") : p.red("MISSING"));
505
+ const delta =
506
+ workspace.scoreDelta === null
507
+ ? ""
508
+ : ` (Δ ${
509
+ workspace.scoreDelta >= 0
510
+ ? p.green(`+${workspace.scoreDelta}`)
511
+ : p.red(`${workspace.scoreDelta}`)
512
+ })`;
513
+ const healthLine =
514
+ workspace.auditCount === 0
515
+ ? p.dim("no saved audits yet (fullstackgtm audit --provider <name> --save starts the timeline)")
516
+ : `${scoreColor(workspace.healthScore ?? 0, p)}/100${delta} — ${workspace.auditCount} audit(s) saved, last ${workspace.lastAuditAt}${healthSparkline(workspace.profile, p.enabled)}`;
517
+ const lines = [
518
+ `Package: ${p.bold(`${report.package.name} ${report.package.version}`)}`,
519
+ `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
520
+ `Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
521
+ `Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
522
+ `Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
523
+ "",
524
+ "Providers:",
525
+ ...Object.entries(report.providers).map(([provider, status]) =>
526
+ ` ${provider.padEnd(11)} ${status.source === "none" ? p.dim(`not connected (${status.detail})`) : `${p.green(status.source)}: ${status.detail}`}`,
527
+ ),
528
+ ` ${"broker".padEnd(11)} ${report.broker.paired ? `${p.green("paired")} with ${report.broker.baseUrl}` : p.dim("not paired (fullstackgtm login --via <hosted url>)")}`,
529
+ ` ${"llm".padEnd(11)} ${report.llm.configured ? `${p.green(`${report.llm.provider}`)} key (${report.llm.source}) — call parse/score ready` : p.dim(`not configured (${report.llm.detail})`)}`,
530
+ "",
531
+ report.mcp.peersInstalled
532
+ ? `MCP: ${p.green("peers installed")} — \`fullstackgtm-mcp\` is ready`
533
+ : `MCP: ${p.yellow("optional peers missing")} (${report.mcp.missing.join(", ")}) — needed only for \`fullstackgtm-mcp\``,
534
+ "",
535
+ "Workspace:",
536
+ ` ${"health".padEnd(11)} ${healthLine}`,
537
+ ` ${"plans".padEnd(11)} ${
538
+ workspace.pendingPlans.length === 0
539
+ ? "none awaiting approval"
540
+ : `${p.yellow(`${workspace.pendingPlans.length} awaiting approval`)}: ${workspace.pendingPlans.map((plan) => plan.id).join(", ")}`
541
+ }`,
542
+ "",
543
+ ...(p.enabled
544
+ ? box(nextSteps, p, "Next step")
545
+ : ["Next step:", ...nextSteps.map((step) => ` ${step}`)]),
546
+ ];
547
+ console.log(lines.join("\n"));
548
+ if (!report.node.ok) process.exitCode = 1;
549
+ }