fullstackgtm 0.52.0 → 0.52.1

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 CHANGED
@@ -7,6 +7,18 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.52.1] — 2026-07-11
11
+
12
+ ### Fixed
13
+
14
+ - OpenRouter 401/403 failures now name `fullstackgtm login openrouter` as the
15
+ credential replacement path and explain that `OPENROUTER_API_KEY` overrides
16
+ the stored profile key.
17
+ - Interactive secret prompts remain non-echoing but now confirm paste activity
18
+ with a masked glyph preview and received-character count.
19
+ - Interactive ICP review cards cap their width, wrap long segment values, and
20
+ repaint only their own terminal rows instead of clearing the whole screen.
21
+
10
22
  ## [0.52.0] — 2026-07-11
11
23
 
12
24
  ### Added
package/dist/cli/icp.js CHANGED
@@ -10,7 +10,7 @@ import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals }
10
10
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.js";
11
11
  import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
12
12
  import { createStatusLine } from "./ui.js";
13
- import { box, colorEnabled, paint } from "./ui.js";
13
+ import { box, colorEnabled, paint, truncateToWidth } from "./ui.js";
14
14
  import { getCredential, storeCredential } from "../credentials.js";
15
15
  import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE } from "../icpDerive.js";
16
16
  import { unknownSubcommandError } from "./suggest.js";
@@ -43,11 +43,30 @@ function updateReviewSegment(icp, id, raw) {
43
43
  function renderDerivedIcp(result, selected = -1) {
44
44
  const p = paint(colorEnabled());
45
45
  const segments = icpReviewSegments(result.icp);
46
+ const contentWidth = Math.min(88, Math.max(64, (process.stdout.columns ?? 92) - 4));
47
+ const valueWidth = contentWidth - 18;
48
+ const segmentLines = segments.flatMap((segment, index) => {
49
+ const words = segment.value.split(/\s+/);
50
+ const wrapped = [];
51
+ let line = "";
52
+ for (const word of words) {
53
+ if (!line || line.length + word.length + 1 <= valueWidth)
54
+ line += `${line ? " " : ""}${word}`;
55
+ else {
56
+ wrapped.push(line);
57
+ line = word;
58
+ }
59
+ }
60
+ if (line)
61
+ wrapped.push(line);
62
+ const prefix = `${index === selected ? "›" : " "} ${segment.label.padEnd(15)} `;
63
+ return (wrapped.length ? wrapped : ["—"]).map((value, lineIndex) => `${lineIndex === 0 ? prefix : " ".repeat(18)}${truncateToWidth(value, valueWidth)}`);
64
+ });
46
65
  const lines = [
47
- `${result.company.name} ${result.company.domain}`,
66
+ truncateToWidth(`${result.company.name} ${result.company.domain}`, contentWidth),
48
67
  `${Math.round(result.confidence * 100)}% confidence · ${result.derivation.model}`,
49
68
  "",
50
- ...segments.map((segment, index) => `${index === selected ? "›" : " "} ${segment.label.padEnd(15)} ${segment.value}`),
69
+ ...segmentLines,
51
70
  "",
52
71
  selected >= 0 ? "↑/↓ select · enter edit · s save · q cancel" : `${result.evidence.length} website-verifiable evidence quote(s)`,
53
72
  ];
@@ -98,7 +117,14 @@ async function reviewDerivedIcp(result) {
98
117
  return result;
99
118
  let current = result;
100
119
  let selected = 0;
101
- const render = () => process.stdout.write(`\u001b[2J\u001b[H${renderDerivedIcp(current, selected)}\n`);
120
+ let paintedLines = 0;
121
+ const render = (promptLines = 0) => {
122
+ const output = renderDerivedIcp(current, selected);
123
+ if (paintedLines > 0)
124
+ process.stdout.write(`\u001b[${paintedLines + promptLines}A\r\u001b[0J`);
125
+ process.stdout.write(`${output}\n`);
126
+ paintedLines = output.split("\n").length;
127
+ };
102
128
  emitKeypressEvents(process.stdin);
103
129
  process.stdin.setRawMode?.(true);
104
130
  process.stdin.resume();
@@ -137,7 +163,7 @@ async function reviewDerivedIcp(result) {
137
163
  emitKeypressEvents(process.stdin);
138
164
  process.stdin.setRawMode?.(true);
139
165
  process.stdin.on("keypress", onKey);
140
- render();
166
+ render(2);
141
167
  };
142
168
  process.stdin.on("keypress", onKey);
143
169
  });
@@ -305,6 +305,7 @@ export async function readSecret(label) {
305
305
  }
306
306
  const readline = await import("node:readline");
307
307
  return new Promise((resolveSecret) => {
308
+ process.stderr.write("Paste once, then press Enter. Input is masked.\n");
308
309
  const rl = readline.createInterface({
309
310
  input: process.stdin,
310
311
  output: process.stderr,
@@ -312,9 +313,21 @@ export async function readSecret(label) {
312
313
  });
313
314
  let muted = false;
314
315
  const mutable = rl;
316
+ let renderQueued = false;
315
317
  mutable._writeToOutput = (value) => {
316
- if (!muted)
318
+ if (!muted) {
317
319
  process.stderr.write(value);
320
+ return;
321
+ }
322
+ if (renderQueued)
323
+ return;
324
+ renderQueued = true;
325
+ queueMicrotask(() => {
326
+ renderQueued = false;
327
+ const length = mutable.line?.length ?? 0;
328
+ const mask = `${"•".repeat(Math.min(length, 12))}${length > 12 ? "…" : ""}`;
329
+ process.stderr.write(`\r\u001b[2K${label}: ${mask} (${length} character${length === 1 ? "" : "s"} received)`);
330
+ });
318
331
  };
319
332
  rl.question(`${label}: `, (answer) => {
320
333
  rl.close();
package/dist/llm.js CHANGED
@@ -343,7 +343,7 @@ async function streamOpenAiToolCall(fetchImpl, url, body, options) {
343
343
  throw new Error(`Cannot reach ${new URL(url).hostname}${cause}. Check network access.`);
344
344
  }
345
345
  if (!response.ok)
346
- throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key and model name.`);
346
+ throw llmApiError(response, url);
347
347
  if (!response.body)
348
348
  throw new Error("LLM API returned no streaming response body.");
349
349
  const reader = response.body.getReader();
@@ -407,10 +407,21 @@ async function llmFetch(fetchImpl, url, init) {
407
407
  }
408
408
  if (!response.ok) {
409
409
  // Status line only — provider error bodies can reflect request content.
410
- throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key (\`fullstackgtm login anthropic|openai\`) and model name.`);
410
+ throw llmApiError(response, url);
411
411
  }
412
412
  return response.json();
413
413
  }
414
+ function llmApiError(response, url) {
415
+ const host = new URL(url).hostname;
416
+ const prefix = `LLM API error ${response.status} ${response.statusText} from ${host}.`;
417
+ if ((response.status === 401 || response.status === 403) && host === "openrouter.ai") {
418
+ return new Error(`${prefix} Replace the saved key with \`fullstackgtm login openrouter\`. If OPENROUTER_API_KEY is set, update or unset it first because environment credentials override saved login.`);
419
+ }
420
+ if (response.status === 401 || response.status === 403) {
421
+ return new Error(`${prefix} Replace the API key with \`fullstackgtm login anthropic|openai\` (choose one provider), then retry.`);
422
+ }
423
+ return new Error(`${prefix} Check the model name, provider availability, and account limits.`);
424
+ }
414
425
  function truncateTranscript(transcript) {
415
426
  if (transcript.length <= MAX_TRANSCRIPT_CHARS)
416
427
  return transcript;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.52.0",
3
+ "version": "0.52.1",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/cli/icp.ts CHANGED
@@ -11,7 +11,7 @@ import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals }
11
11
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.ts";
12
12
  import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
13
13
  import { createStatusLine } from "./ui.ts";
14
- import { box, colorEnabled, paint } from "./ui.ts";
14
+ import { box, colorEnabled, paint, truncateToWidth } from "./ui.ts";
15
15
  import { getCredential, storeCredential } from "../credentials.ts";
16
16
  import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE, type WebsiteIcpDerivation } from "../icpDerive.ts";
17
17
  import type { Icp } from "../icp.ts";
@@ -44,11 +44,25 @@ function updateReviewSegment(icp: Icp, id: string, raw: string): Icp {
44
44
  function renderDerivedIcp(result: WebsiteIcpDerivation, selected = -1): string {
45
45
  const p = paint(colorEnabled());
46
46
  const segments = icpReviewSegments(result.icp);
47
+ const contentWidth = Math.min(88, Math.max(64, (process.stdout.columns ?? 92) - 4));
48
+ const valueWidth = contentWidth - 18;
49
+ const segmentLines = segments.flatMap((segment, index) => {
50
+ const words = segment.value.split(/\s+/);
51
+ const wrapped: string[] = [];
52
+ let line = "";
53
+ for (const word of words) {
54
+ if (!line || line.length + word.length + 1 <= valueWidth) line += `${line ? " " : ""}${word}`;
55
+ else { wrapped.push(line); line = word; }
56
+ }
57
+ if (line) wrapped.push(line);
58
+ const prefix = `${index === selected ? "›" : " "} ${segment.label.padEnd(15)} `;
59
+ return (wrapped.length ? wrapped : ["—"]).map((value, lineIndex) => `${lineIndex === 0 ? prefix : " ".repeat(18)}${truncateToWidth(value, valueWidth)}`);
60
+ });
47
61
  const lines = [
48
- `${result.company.name} ${result.company.domain}`,
62
+ truncateToWidth(`${result.company.name} ${result.company.domain}`, contentWidth),
49
63
  `${Math.round(result.confidence * 100)}% confidence · ${result.derivation.model}`,
50
64
  "",
51
- ...segments.map((segment, index) => `${index === selected ? "›" : " "} ${segment.label.padEnd(15)} ${segment.value}`),
65
+ ...segmentLines,
52
66
  "",
53
67
  selected >= 0 ? "↑/↓ select · enter edit · s save · q cancel" : `${result.evidence.length} website-verifiable evidence quote(s)`,
54
68
  ];
@@ -98,7 +112,13 @@ async function reviewDerivedIcp(result: WebsiteIcpDerivation): Promise<WebsiteIc
98
112
  if (!process.stdin.isTTY || !process.stdout.isTTY) return result;
99
113
  let current = result;
100
114
  let selected = 0;
101
- const render = () => process.stdout.write(`\u001b[2J\u001b[H${renderDerivedIcp(current, selected)}\n`);
115
+ let paintedLines = 0;
116
+ const render = (promptLines = 0) => {
117
+ const output = renderDerivedIcp(current, selected);
118
+ if (paintedLines > 0) process.stdout.write(`\u001b[${paintedLines + promptLines}A\r\u001b[0J`);
119
+ process.stdout.write(`${output}\n`);
120
+ paintedLines = output.split("\n").length;
121
+ };
102
122
  emitKeypressEvents(process.stdin);
103
123
  process.stdin.setRawMode?.(true);
104
124
  process.stdin.resume();
@@ -127,7 +147,7 @@ async function reviewDerivedIcp(result: WebsiteIcpDerivation): Promise<WebsiteIc
127
147
  emitKeypressEvents(process.stdin);
128
148
  process.stdin.setRawMode?.(true);
129
149
  process.stdin.on("keypress", onKey);
130
- render();
150
+ render(2);
131
151
  };
132
152
  process.stdin.on("keypress", onKey);
133
153
  });
package/src/cli/shared.ts CHANGED
@@ -356,15 +356,25 @@ export async function readSecret(label: string): Promise<string> {
356
356
 
357
357
  const readline = await import("node:readline");
358
358
  return new Promise<string>((resolveSecret) => {
359
+ process.stderr.write("Paste once, then press Enter. Input is masked.\n");
359
360
  const rl = readline.createInterface({
360
361
  input: process.stdin,
361
362
  output: process.stderr,
362
363
  terminal: true,
363
364
  });
364
365
  let muted = false;
365
- const mutable = rl as unknown as { _writeToOutput?: (value: string) => void };
366
+ const mutable = rl as unknown as { _writeToOutput?: (value: string) => void; line?: string };
367
+ let renderQueued = false;
366
368
  mutable._writeToOutput = (value: string) => {
367
- if (!muted) process.stderr.write(value);
369
+ if (!muted) { process.stderr.write(value); return; }
370
+ if (renderQueued) return;
371
+ renderQueued = true;
372
+ queueMicrotask(() => {
373
+ renderQueued = false;
374
+ const length = mutable.line?.length ?? 0;
375
+ const mask = `${"•".repeat(Math.min(length, 12))}${length > 12 ? "…" : ""}`;
376
+ process.stderr.write(`\r\u001b[2K${label}: ${mask} (${length} character${length === 1 ? "" : "s"} received)`);
377
+ });
368
378
  };
369
379
  rl.question(`${label}: `, (answer) => {
370
380
  rl.close();
package/src/llm.ts CHANGED
@@ -489,7 +489,7 @@ async function streamOpenAiToolCall(
489
489
  const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
490
490
  throw new Error(`Cannot reach ${new URL(url).hostname}${cause}. Check network access.`);
491
491
  }
492
- if (!response.ok) throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key and model name.`);
492
+ if (!response.ok) throw llmApiError(response, url);
493
493
  if (!response.body) throw new Error("LLM API returned no streaming response body.");
494
494
  const reader = response.body.getReader();
495
495
  const decoder = new TextDecoder();
@@ -539,11 +539,23 @@ async function llmFetch(fetchImpl: typeof fetch, url: string, init: RequestInit)
539
539
  }
540
540
  if (!response.ok) {
541
541
  // Status line only — provider error bodies can reflect request content.
542
- throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key (\`fullstackgtm login anthropic|openai\`) and model name.`);
542
+ throw llmApiError(response, url);
543
543
  }
544
544
  return response.json();
545
545
  }
546
546
 
547
+ function llmApiError(response: Response, url: string): Error {
548
+ const host = new URL(url).hostname;
549
+ const prefix = `LLM API error ${response.status} ${response.statusText} from ${host}.`;
550
+ if ((response.status === 401 || response.status === 403) && host === "openrouter.ai") {
551
+ return new Error(`${prefix} Replace the saved key with \`fullstackgtm login openrouter\`. If OPENROUTER_API_KEY is set, update or unset it first because environment credentials override saved login.`);
552
+ }
553
+ if (response.status === 401 || response.status === 403) {
554
+ return new Error(`${prefix} Replace the API key with \`fullstackgtm login anthropic|openai\` (choose one provider), then retry.`);
555
+ }
556
+ return new Error(`${prefix} Check the model name, provider availability, and account limits.`);
557
+ }
558
+
547
559
  function truncateTranscript(transcript: string): string {
548
560
  if (transcript.length <= MAX_TRANSCRIPT_CHARS) return transcript;
549
561
  const half = MAX_TRANSCRIPT_CHARS / 2;