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 +12 -0
- package/dist/cli/icp.js +31 -5
- package/dist/cli/shared.js +14 -1
- package/dist/llm.js +13 -2
- package/package.json +1 -1
- package/src/cli/icp.ts +25 -5
- package/src/cli/shared.ts +12 -2
- package/src/llm.ts +14 -2
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
|
-
...
|
|
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
|
-
|
|
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
|
});
|
package/dist/cli/shared.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|