fullstackgtm 0.10.1 → 0.11.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.
@@ -0,0 +1,148 @@
1
+ import { requiresHumanInput } from "./rules.js";
2
+ export function suggestValues(plan, snapshot) {
3
+ const accountsByNorm = new Map();
4
+ for (const account of snapshot.accounts) {
5
+ accountsByNorm.set(normalize(account.name), { id: account.id, name: account.name });
6
+ }
7
+ const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
8
+ const contactsByName = new Map();
9
+ for (const contact of snapshot.contacts) {
10
+ const name = normalize(`${contact.firstName ?? ""} ${contact.lastName ?? ""}`);
11
+ if (name)
12
+ contactsByName.set(name, { id: contact.id, accountId: contact.accountId, name });
13
+ }
14
+ const dealsById = new Map(snapshot.deals.map((d) => [d.id, d]));
15
+ const activeUsers = snapshot.users.filter((user) => user.active !== false);
16
+ const suggestions = [];
17
+ for (const operation of plan.operations) {
18
+ if (!requiresHumanInput(operation.afterValue))
19
+ continue;
20
+ const placeholder = String(operation.afterValue);
21
+ if (placeholder === "requires_human_account_selection" && operation.objectType === "deal") {
22
+ suggestions.push(suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName));
23
+ continue;
24
+ }
25
+ if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
26
+ suggestions.push({
27
+ operationId: operation.id,
28
+ objectType: operation.objectType,
29
+ objectId: operation.objectId,
30
+ objectName: dealsById.get(operation.objectId)?.name,
31
+ placeholder,
32
+ suggestedValue: activeUsers[0].id,
33
+ confidence: "high",
34
+ reason: `${activeUsers[0].name} is the only active user in the org.`,
35
+ });
36
+ continue;
37
+ }
38
+ suggestions.push({
39
+ operationId: operation.id,
40
+ objectType: operation.objectType,
41
+ objectId: operation.objectId,
42
+ objectName: dealsById.get(operation.objectId)?.name,
43
+ placeholder,
44
+ suggestedValue: null,
45
+ confidence: "none",
46
+ reason: `No deterministic heuristic for ${placeholder}; supply --value ${operation.id}=<value>.`,
47
+ });
48
+ }
49
+ return suggestions;
50
+ }
51
+ function suggestDealAccount(operation, dealsById, accountsByNorm, accountsById, contactsByName) {
52
+ const deal = dealsById.get(operation.objectId);
53
+ const base = {
54
+ operationId: operation.id,
55
+ objectType: operation.objectType,
56
+ objectId: operation.objectId,
57
+ objectName: deal?.name,
58
+ placeholder: "requires_human_account_selection",
59
+ };
60
+ if (!deal) {
61
+ return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
62
+ }
63
+ // Convention: "Contact Name - Company Name". Both signals below are
64
+ // independent; agreement upgrades confidence, conflict downgrades it.
65
+ const separatorIndex = deal.name.indexOf(" - ");
66
+ const left = separatorIndex >= 0 ? deal.name.slice(0, separatorIndex) : deal.name;
67
+ const right = separatorIndex >= 0 ? deal.name.slice(separatorIndex + 3).trim() : "";
68
+ // Signal 1: company-name match against account names.
69
+ let nameMatch = null;
70
+ let nameMatchKind = "";
71
+ if (right) {
72
+ nameMatch = accountsByNorm.get(normalize(right)) ?? null;
73
+ nameMatchKind = nameMatch ? "exact name match" : "";
74
+ if (!nameMatch) {
75
+ const rightNorm = normalize(right);
76
+ const candidates = [...accountsByNorm.entries()].filter(([norm]) => norm.includes(rightNorm) || rightNorm.includes(norm));
77
+ if (candidates.length === 1) {
78
+ nameMatch = candidates[0][1];
79
+ nameMatchKind = "partial name match";
80
+ }
81
+ else if (candidates.length > 1) {
82
+ return {
83
+ ...base,
84
+ suggestedValue: null,
85
+ confidence: "none",
86
+ reason: `"${right}" matches ${candidates.length} accounts ambiguously: ${candidates.slice(0, 4).map(([, a]) => a.name).join(", ")}.`,
87
+ };
88
+ }
89
+ }
90
+ }
91
+ // Signal 2: the deal's named contact already belongs to an account.
92
+ const contact = contactsByName.get(normalize(left));
93
+ const contactAccount = contact?.accountId ? accountsById.get(contact.accountId) ?? null : null;
94
+ if (nameMatch && contactAccount) {
95
+ if (nameMatch.id === contactAccount.id) {
96
+ return {
97
+ ...base,
98
+ suggestedValue: nameMatch.id,
99
+ confidence: "high",
100
+ reason: `${nameMatchKind} on "${nameMatch.name}", confirmed by contact "${left}"'s account association.`,
101
+ };
102
+ }
103
+ return {
104
+ ...base,
105
+ suggestedValue: null,
106
+ confidence: "none",
107
+ reason: `Signals conflict: name matches "${nameMatch.name}" but contact "${left}" belongs to "${contactAccount.name}".`,
108
+ };
109
+ }
110
+ if (nameMatch) {
111
+ return {
112
+ ...base,
113
+ suggestedValue: nameMatch.id,
114
+ confidence: nameMatchKind === "exact name match" ? "high" : "low",
115
+ reason: `${nameMatchKind} on "${nameMatch.name}" (no contact signal to confirm).`,
116
+ };
117
+ }
118
+ if (contactAccount) {
119
+ return {
120
+ ...base,
121
+ suggestedValue: contactAccount.id,
122
+ confidence: "low",
123
+ reason: `Contact "${left}" belongs to "${contactAccount.name}" (no account-name match to confirm).`,
124
+ };
125
+ }
126
+ if (contact && !contact.accountId && right) {
127
+ return {
128
+ ...base,
129
+ suggestedValue: `create:${right}`,
130
+ confidence: "create",
131
+ reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
132
+ };
133
+ }
134
+ return {
135
+ ...base,
136
+ suggestedValue: null,
137
+ confidence: "none",
138
+ reason: right
139
+ ? `No account matches "${right}" and "${left}" is not a known contact. Supply --value ${operation.id}=<accountId> or --value ${operation.id}=create:<Company Name>.`
140
+ : `Deal name "${deal.name}" has no "Contact - Company" pattern to derive a company from. Supply --value ${operation.id}=<accountId> or create:<Company Name>.`,
141
+ };
142
+ }
143
+ function normalize(value) {
144
+ return value
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9]+/g, " ")
147
+ .trim();
148
+ }
package/docs/api.md CHANGED
@@ -57,7 +57,8 @@ release.
57
57
 
58
58
  ## CLI
59
59
 
60
- Commands: `login` / `logout`, `snapshot`, `audit`, `diff`, `merge`, `plans`, `apply`, `rules`.
60
+ Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
61
+ `apply`, `rules`, `profiles`, `doctor`.
61
62
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
62
63
  (`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
63
64
 
@@ -66,6 +67,17 @@ Credential resolution ladder: explicit `--token-env` → ambient env
66
67
  `STRIPE_SECRET_KEY`) → stored login (`~/.fullstackgtm`, `FSGTM_HOME` override)
67
68
  → broker pairing (`login --via`).
68
69
 
70
+ Profiles: the global `--profile <name>` flag (or `FULLSTACKGTM_PROFILE`) scopes
71
+ stored logins and stored plans to `profiles/<name>/` under the home directory,
72
+ so one operator can work across several organizations' CRMs without mixing
73
+ credentials or applying one org's plan through another's connection. The
74
+ default profile keeps the historical flat layout.
75
+
76
+ `report` renders an audit (or an existing plan via `--plan`) as a client-ready
77
+ deliverable in markdown or self-contained HTML: severity counts, prose summary,
78
+ per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
79
+ `auditReportToHtml` expose the same rendering programmatically.
80
+
69
81
  ## MCP
70
82
 
71
83
  Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.10.1",
3
+ "version": "0.11.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",
package/src/cli.ts CHANGED
@@ -19,11 +19,15 @@ import {
19
19
  validateSalesforceToken,
20
20
  } from "./connectors/salesforceAuth.ts";
21
21
  import {
22
+ activeProfile,
22
23
  credentialsPath,
24
+ DEFAULT_PROFILE,
23
25
  deleteCredential,
24
26
  getCredential,
27
+ listProfiles,
25
28
  resolveHubspotConnection,
26
29
  resolveSalesforceConnection,
30
+ setActiveProfile,
27
31
  storeCredential,
28
32
  type StoredCredential,
29
33
  } from "./credentials.ts";
@@ -31,8 +35,10 @@ import { generateDemoSnapshot } from "./demo.ts";
31
35
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
32
36
  import { mergeSnapshots } from "./merge.ts";
33
37
  import { createFilePlanStore } from "./planStore.ts";
38
+ import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
34
39
  import { builtinAuditRules } from "./rules.ts";
35
40
  import { sampleSnapshot } from "./sampleData.ts";
41
+ import { suggestValues, type ValueSuggestion } from "./suggest.ts";
36
42
  import type { FieldMappings } from "./mappings.ts";
37
43
  import type {
38
44
  AuditFindingSeverity,
@@ -60,14 +66,28 @@ Usage:
60
66
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
61
67
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
62
68
  fullstackgtm audit [source options] [audit options] [--save]
69
+ fullstackgtm report [source options] [audit options] [report options]
63
70
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
64
71
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
65
- fullstackgtm plans list [--status <s>] | show <id> | approve <id> --operations <ids|all> | reject <id>
72
+ fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
73
+ derive values for requires_human_* placeholders
74
+ from snapshot evidence, with confidence + reasons
75
+ fullstackgtm plans list [--status <s>] | show <id> | reject <id>
76
+ fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
77
+ fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
66
78
  fullstackgtm apply --plan-id <id> --provider <name>
67
79
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
68
80
  fullstackgtm rules [--json]
81
+ fullstackgtm profiles [--json] list credential profiles
69
82
  fullstackgtm doctor [--json] check install, credentials, and next step
70
83
 
84
+ Profiles (multi-organization use):
85
+ --profile <name> Scope credentials AND stored plans to a named profile
86
+ (also: FULLSTACKGTM_PROFILE). One profile per client
87
+ org keeps logins isolated and prevents a plan proposed
88
+ against one CRM from being applied through another's
89
+ credentials. Omitted = the default profile.
90
+
71
91
  Plan lifecycle:
72
92
  audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
73
93
  specific operations (optionally with --value <opId>=<v> for placeholders),
@@ -104,6 +124,17 @@ Audit options:
104
124
  --stale-days <n> Days without activity before an open deal is stale
105
125
  --fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
106
126
 
127
+ Report options (report renders the audit as a client-ready deliverable):
128
+ --plan <path> Render an existing plan JSON instead of re-auditing
129
+ (add --input <snapshot.json> for record counts)
130
+ --client <name> Organization name shown in the heading and summary
131
+ --title <text> Report heading (default "GTM Data Health Report")
132
+ --prepared-by <name> Attribution shown in the footer
133
+ --format <fmt> markdown (default) or html (self-contained, printable;
134
+ inferred from an --out path ending in .html)
135
+ --max-examples <n> Example records listed per rule (default 10)
136
+ --out <path> Write the report to a file instead of stdout
137
+
107
138
  Apply options:
108
139
  --plan <path> Patch plan JSON produced by \`audit --out\`
109
140
  --provider hubspot Connector to apply through
@@ -334,6 +365,64 @@ async function audit(args: string[]) {
334
365
  }
335
366
  }
336
367
 
368
+ /**
369
+ * Render an audit as a client-facing deliverable. Same sources and audit
370
+ * options as `audit`; `--plan` instead renders an existing plan JSON without
371
+ * re-fetching (useful for a plan produced earlier or by another machine).
372
+ */
373
+ async function reportCommand(args: string[]) {
374
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
375
+ const configuredRules = await resolveConfiguredRules(loaded);
376
+
377
+ let plan: PatchPlan;
378
+ let snapshot: CanonicalGtmSnapshot | undefined;
379
+ const planPath = option(args, "--plan");
380
+ if (planPath) {
381
+ plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
382
+ const input = option(args, "--input");
383
+ if (input) {
384
+ snapshot = JSON.parse(
385
+ readFileSync(resolve(process.cwd(), input), "utf8"),
386
+ ) as CanonicalGtmSnapshot;
387
+ }
388
+ } else {
389
+ snapshot = await readSnapshot(args);
390
+ const policy = mergePolicy(defaultPolicy(), loaded?.config);
391
+ const today = option(args, "--today");
392
+ if (today) policy.today = today;
393
+ const staleDealDays = numericOption(args, "--stale-days");
394
+ if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
395
+ plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
396
+ }
397
+
398
+ const reportOptions: ReportOptions = {
399
+ title: option(args, "--title") ?? undefined,
400
+ clientName: option(args, "--client") ?? undefined,
401
+ preparedBy: option(args, "--prepared-by") ?? undefined,
402
+ date: option(args, "--today") ?? undefined,
403
+ maxExamplesPerRule: numericOption(args, "--max-examples"),
404
+ rules: configuredRules,
405
+ snapshot,
406
+ };
407
+
408
+ const out = option(args, "--out");
409
+ const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
410
+ if (format !== "markdown" && format !== "html") {
411
+ throw new Error("--format must be markdown or html");
412
+ }
413
+ const rendered =
414
+ format === "html"
415
+ ? auditReportToHtml(plan, reportOptions)
416
+ : auditReportToMarkdown(plan, reportOptions);
417
+
418
+ if (out) {
419
+ writeFileSync(resolve(process.cwd(), out), rendered);
420
+ console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
421
+ } else {
422
+ console.log(rendered.trimEnd());
423
+ }
424
+ }
425
+
337
426
  async function rulesCommand(args: string[]) {
338
427
  const loaded = loadConfig(option(args, "--config") ?? undefined);
339
428
  const rules = await resolveConfiguredRules(loaded);
@@ -376,6 +465,83 @@ function parseValueOverrides(args: string[]) {
376
465
  return valueOverrides;
377
466
  }
378
467
 
468
+ async function suggest(args: string[]) {
469
+ const planId = option(args, "--plan-id");
470
+ const planPath = option(args, "--plan");
471
+ if (!planId && !planPath) throw new Error("suggest requires --plan <path> or --plan-id <id>");
472
+ let plan: PatchPlan;
473
+ if (planId) {
474
+ const stored = await createFilePlanStore().get(planId);
475
+ if (!stored) throw new Error(`No stored plan with id ${planId}.`);
476
+ plan = stored.plan;
477
+ } else {
478
+ plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath!), "utf8")) as PatchPlan;
479
+ }
480
+
481
+ const snapshot = await readSnapshot(args);
482
+ const suggestions = suggestValues(plan, snapshot);
483
+ const payload = { planId: planId ?? planPath, suggestions };
484
+
485
+ const outPath = option(args, "--out");
486
+ if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(payload, null, 2)}\n`);
487
+
488
+ if (args.includes("--json")) {
489
+ console.log(JSON.stringify(payload, null, 2));
490
+ return;
491
+ }
492
+
493
+ if (suggestions.length === 0) {
494
+ console.log("No requires_human_* placeholder operations in this plan — nothing to suggest.");
495
+ return;
496
+ }
497
+ const byConfidence: Record<string, number> = {};
498
+ for (const s of suggestions) byConfidence[s.confidence] = (byConfidence[s.confidence] ?? 0) + 1;
499
+ console.log(`Suggestions for ${suggestions.length} placeholder operation(s):\n`);
500
+ for (const s of suggestions) {
501
+ const marker =
502
+ s.confidence === "high" ? "✓" : s.confidence === "low" ? "~" : s.confidence === "create" ? "+" : "✗";
503
+ console.log(`${marker} [${s.confidence}] ${s.operationId} ${s.objectName ?? s.objectId}`);
504
+ console.log(` ${s.suggestedValue ? `→ ${s.suggestedValue}` : "(no suggestion)"} — ${s.reason}`);
505
+ }
506
+ console.log(`\n${Object.entries(byConfidence).map(([k, v]) => `${k}: ${v}`).join(" · ")}`);
507
+ if (planId && (byConfidence.high ?? 0) > 0 && !outPath) {
508
+ console.log(
509
+ `\nChain it:\n fullstackgtm suggest --plan-id ${planId} ${snapshotSourceHint(args)}--out suggestions.json\n fullstackgtm plans approve ${planId} --values-from suggestions.json\n fullstackgtm apply --plan-id ${planId} --provider <name>`,
510
+ );
511
+ }
512
+ }
513
+
514
+ function snapshotSourceHint(args: string[]) {
515
+ const provider = option(args, "--provider");
516
+ if (provider) return `--provider ${provider} `;
517
+ const input = option(args, "--input");
518
+ if (input) return `--input ${input} `;
519
+ return "";
520
+ }
521
+
522
+ function readSuggestionValues(path: string, minConfidence: string, includeCreates: boolean) {
523
+ const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")) as {
524
+ suggestions?: ValueSuggestion[];
525
+ };
526
+ if (!Array.isArray(raw.suggestions)) {
527
+ throw new Error(
528
+ `${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`,
529
+ );
530
+ }
531
+ const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
532
+ const overrides: Record<string, string> = {};
533
+ let skipped = 0;
534
+ for (const s of raw.suggestions) {
535
+ if (!s.suggestedValue) continue;
536
+ if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
537
+ overrides[s.operationId] = s.suggestedValue;
538
+ } else {
539
+ skipped += 1;
540
+ }
541
+ }
542
+ return { overrides, skipped };
543
+ }
544
+
379
545
  async function apply(args: string[]) {
380
546
  const provider = option(args, "--provider");
381
547
  if (!provider) throw new Error("apply requires --provider <name>");
@@ -565,16 +731,50 @@ async function plansCommand(args: string[]) {
565
731
 
566
732
  if (subcommand === "approve") {
567
733
  const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
568
- if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all>");
734
+ if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
569
735
  const operations = option(rest, "--operations");
570
- if (!operations) throw new Error("plans approve requires --operations <ids|all>");
736
+ const valuesFrom = option(rest, "--values-from");
737
+ if (!operations && !valuesFrom) {
738
+ throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
739
+ }
571
740
  const stored = await store.get(planId);
572
741
  if (!stored) throw new Error(`No stored plan with id ${planId}.`);
742
+
743
+ // Values from a `fullstackgtm suggest --out` file. High-confidence only by
744
+ // default; widen with --min-confidence low, opt into record-creating
745
+ // values (create:<Name>) with --include-creates. Explicit --value wins.
746
+ let fileOverrides: Record<string, string> = {};
747
+ if (valuesFrom) {
748
+ const minConfidence = option(rest, "--min-confidence") ?? "high";
749
+ if (!["high", "low"].includes(minConfidence)) {
750
+ throw new Error("--min-confidence must be high or low");
751
+ }
752
+ const { overrides, skipped } = readSuggestionValues(
753
+ valuesFrom,
754
+ minConfidence,
755
+ rest.includes("--include-creates"),
756
+ );
757
+ fileOverrides = overrides;
758
+ if (Object.keys(overrides).length === 0) {
759
+ throw new Error(
760
+ `No suggestions in ${valuesFrom} meet the confidence bar (${skipped} below it). Re-run with --min-confidence low or --include-creates, or pass explicit --value overrides.`,
761
+ );
762
+ }
763
+ if (skipped > 0) {
764
+ console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
765
+ }
766
+ }
767
+ const explicitOverrides = parseValueOverrides(rest);
573
768
  const operationIds =
574
769
  operations === "all"
575
770
  ? stored.plan.operations.map((operation) => operation.id)
576
- : operations.split(",").map((id) => id.trim()).filter(Boolean);
577
- const updated = await store.approveOperations(planId, operationIds, parseValueOverrides(rest));
771
+ : operations
772
+ ? operations.split(",").map((id) => id.trim()).filter(Boolean)
773
+ : Object.keys(fileOverrides);
774
+ const updated = await store.approveOperations(planId, operationIds, {
775
+ ...fileOverrides,
776
+ ...explicitOverrides,
777
+ });
578
778
  console.log(
579
779
  `Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
580
780
  );
@@ -656,11 +856,17 @@ async function brokerLogin(baseUrl: string) {
656
856
  // Self-reported, shown to the approver so they can recognize this request
657
857
  // and refuse one they didn't initiate.
658
858
  const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
659
- const startResponse = await fetch(`${base}/api/cli/auth/start`, {
660
- method: "POST",
661
- headers: { "Content-Type": "application/json" },
662
- body: JSON.stringify({ requesterLabel }),
663
- });
859
+ let startResponse: Response;
860
+ try {
861
+ startResponse = await fetch(`${base}/api/cli/auth/start`, {
862
+ method: "POST",
863
+ headers: { "Content-Type": "application/json" },
864
+ body: JSON.stringify({ requesterLabel }),
865
+ });
866
+ } catch (error) {
867
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
868
+ throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
869
+ }
664
870
  if (!startResponse.ok) {
665
871
  throw new Error(
666
872
  `Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`,
@@ -926,6 +1132,7 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
926
1132
  return {
927
1133
  package: packageInfo,
928
1134
  node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
1135
+ profile: activeProfile(),
929
1136
  credentialStore: { path: storePath, exists: existsSync(storePath) },
930
1137
  config: { path: configPath, exists: existsSync(configPath) },
931
1138
  providers,
@@ -967,6 +1174,7 @@ function doctorCommand(args: string[]) {
967
1174
  const lines = [
968
1175
  `Package: ${report.package.name} ${report.package.version}`,
969
1176
  `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
1177
+ `Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
970
1178
  `Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
971
1179
  `Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
972
1180
  "",
@@ -987,8 +1195,41 @@ function doctorCommand(args: string[]) {
987
1195
  if (!report.node.ok) process.exitCode = 1;
988
1196
  }
989
1197
 
1198
+ /**
1199
+ * Pull the global `--profile <name>` flag out of argv (it may appear before
1200
+ * or after the command) and activate it. Stripping it keeps positional
1201
+ * detection in subcommands — `login <provider>`, `plans show <id>` — simple.
1202
+ */
1203
+ function extractProfile(argv: string[]): string[] {
1204
+ const index = argv.indexOf("--profile");
1205
+ if (index === -1) return argv;
1206
+ const name = argv[index + 1];
1207
+ if (!name) throw new Error("--profile requires a name, e.g. --profile acme");
1208
+ setActiveProfile(name);
1209
+ return [...argv.slice(0, index), ...argv.slice(index + 2)];
1210
+ }
1211
+
1212
+ function profilesCommand(args: string[]) {
1213
+ const profiles = listProfiles();
1214
+ const current = activeProfile();
1215
+ if (args.includes("--json")) {
1216
+ console.log(JSON.stringify({ active: current, profiles }, null, 2));
1217
+ return;
1218
+ }
1219
+ for (const profile of profiles) {
1220
+ console.log(`${profile === current ? "*" : " "} ${profile}`);
1221
+ }
1222
+ if (!profiles.includes(current)) {
1223
+ console.log(`* ${current} (selected; created on first login)`);
1224
+ }
1225
+ console.log(
1226
+ "\nSelect with --profile <name> on any command, or set FULLSTACKGTM_PROFILE. " +
1227
+ "Each profile keeps its own credentials and stored plans.",
1228
+ );
1229
+ }
1230
+
990
1231
  export async function runCli(argv: string[]) {
991
- const [command, ...args] = argv;
1232
+ const [command, ...args] = extractProfile(argv);
992
1233
  if (!command || command === "--help" || command === "-h") {
993
1234
  console.log(usage());
994
1235
  return;
@@ -1014,6 +1255,10 @@ export async function runCli(argv: string[]) {
1014
1255
  await audit(args);
1015
1256
  return;
1016
1257
  }
1258
+ if (command === "report") {
1259
+ await reportCommand(args);
1260
+ return;
1261
+ }
1017
1262
  if (command === "rules") {
1018
1263
  await rulesCommand(args);
1019
1264
  return;
@@ -1022,6 +1267,14 @@ export async function runCli(argv: string[]) {
1022
1267
  doctorCommand(args);
1023
1268
  return;
1024
1269
  }
1270
+ if (command === "suggest") {
1271
+ await suggest(args);
1272
+ return;
1273
+ }
1274
+ if (command === "profiles") {
1275
+ profilesCommand(args);
1276
+ return;
1277
+ }
1025
1278
  if (command === "diff") {
1026
1279
  await diffCommand(args);
1027
1280
  return;
@@ -380,10 +380,26 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
380
380
  detail: "link_record is supported for deals and contacts (to a company).",
381
381
  };
382
382
  }
383
- const companyId = String(operation.afterValue ?? "");
383
+ let companyId = String(operation.afterValue ?? "");
384
384
  if (!companyId) {
385
385
  return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
386
386
  }
387
+ // `create:<Name>` creates the company first, then links — the approved
388
+ // value spells out exactly what will happen, so creation stays inside
389
+ // the typed, human-approved operation model.
390
+ let createdCompanyName: string | null = null;
391
+ if (companyId.startsWith("create:")) {
392
+ const name = companyId.slice("create:".length).trim();
393
+ if (!name) {
394
+ return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
395
+ }
396
+ const created = await request(`/crm/v3/objects/companies`, {
397
+ method: "POST",
398
+ body: JSON.stringify({ properties: { name } }),
399
+ });
400
+ companyId = String(created.id);
401
+ createdCompanyName = name;
402
+ }
387
403
  await request(
388
404
  `/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
389
405
  { method: "PUT" },
@@ -391,8 +407,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
391
407
  return {
392
408
  operationId: operation.id,
393
409
  status: "applied",
394
- detail: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
395
- providerData: { companyId },
410
+ detail: createdCompanyName
411
+ ? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
412
+ : `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
413
+ providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
396
414
  };
397
415
  }
398
416
 
@@ -63,8 +63,12 @@ export async function validateHubspotToken(
63
63
  if (response.ok) {
64
64
  return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
65
65
  }
66
- const body = await response.text();
67
- return { ok: false, detail: `HubSpot rejected the token (${response.status}): ${body}` };
66
+ // Never echo the response body: provider error payloads can reflect request
67
+ // details and end up in logs or shell scrollback.
68
+ return {
69
+ ok: false,
70
+ detail: `HubSpot rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
71
+ };
68
72
  }
69
73
 
70
74
  async function tokenRequest(
@@ -363,8 +363,26 @@ export function createSalesforceConnector(
363
363
  case "clear_field":
364
364
  // link_record on a deal is just setting AccountId in Salesforce.
365
365
  return await setField(operation);
366
- case "link_record":
366
+ case "link_record": {
367
+ // `create:<Name>` creates the Account first, then links — creation
368
+ // stays inside the typed, human-approved operation model.
369
+ const value = String(operation.afterValue ?? "");
370
+ if (value.startsWith("create:")) {
371
+ const name = value.slice("create:".length).trim();
372
+ if (!name) {
373
+ return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
374
+ }
375
+ const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
376
+ method: "POST",
377
+ body: JSON.stringify({ Name: name }),
378
+ });
379
+ const result = await setField({ ...operation, operation: "set_field", afterValue: String(created.id) });
380
+ return result.status === "applied"
381
+ ? { ...result, detail: `Created account "${name}" (${created.id}) and linked ${operation.objectType}/${operation.objectId} to it.`, providerData: { accountId: String(created.id), createdAccount: true } }
382
+ : result;
383
+ }
367
384
  return await setField({ ...operation, operation: "set_field" });
385
+ }
368
386
  case "create_task":
369
387
  return await createTask(operation);
370
388
  case "archive_record":