evals 2.2.8 → 2.3.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/cli.js CHANGED
@@ -1,246 +1,379 @@
1
- #!/usr/bin/env node
2
-
3
- import React, { useState, useEffect } from 'react';
4
- import { render, Box, Text, useInput, useApp, Static } from 'ink';
5
- import Gradient from 'ink-gradient';
6
- import { exec } from 'child_process';
7
-
8
- const e = React.createElement;
9
-
10
- // Diagnostic: Uncomment to verify script is running
11
- console.error('CLI script started, platform:', process.platform, 'isTTY:', process.stdin.isTTY);
12
-
13
- // "ARIZE EVALS" - EXACTLY matched width (both 44 chars)
14
- const largeLogo = `
15
- █████╗ ██████╗ ██╗ ███████╗ ███████╗
16
- ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝
17
- ███████║ ██████╔╝ ██║ ███╔╝ █████╗
18
- ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝
19
- ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗
20
- ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
21
- ███████╗ ██╗ ██╗ █████╗ ██╗ ███████╗
22
- ██╔════╝ ██║ ██║ ██╔══██╗ ██║ ██╔════╝
23
- █████╗ ██║ ██║ ███████║ ██║ ███████╗
24
- ██╔══╝ ╚██╗ ██╔╝ ██╔══██║ ██║ ╚════██║
25
- ███████╗ ╚████╔╝ ██║ ██║ ███████╗███████║
26
- ╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝`;
27
-
28
- const mediumLogo = `
29
- █████╗ ██████╗ ██╗ ███████╗ ███████╗
30
- ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝
31
- ███████║ ██████╔╝ ██║ ███╔╝ █████╗
32
- ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝
33
- ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗
34
- ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
35
- █▀▀ ▄▀█ █ █▀
36
- ██▄ ▀▄▀ █▀█ █▄▄ ▄█`;
37
-
38
- const smallLogo = `ARIZE
39
- EVALS`;
40
-
41
- const OPTIONS = [
42
- {
43
- name: 'Evals Primer',
44
- subtext: 'Learn the Craft',
45
- url: 'https://arize.com/llm-evaluation/?utm_source=npmevals'
46
- },
47
- {
48
- name: 'Arize AX SaaS',
49
- subtext: 'Enterprise Agent Evaluation, Best in Class',
50
- url: 'https://arize.com/docs/ax?utm_source=npmevals'
51
- },
52
- {
53
- name: 'Arize Phoenix',
54
- subtext: 'OSS Agent Evals & Traces, Fully Local',
55
- url: 'https://arize.com/docs/phoenix?utm_source=npmevals'
56
- },
57
- {
58
- name: 'Book an Eval Assessment',
59
- subtext: 'Improve Your Agent Today',
60
- url: 'https://arize.com/get-an-eval-assessment?utm_source=npmevals'
61
- }
62
- ];
63
-
64
- function openUrlInBrowser(url) {
65
- const command = process.platform === 'win32'
66
- ? `start ${url}`
67
- : process.platform === 'darwin'
68
- ? `open "${url}"`
69
- : `xdg-open "${url}"`;
70
-
71
- exec(command, (error) => {
72
- if (error) {
73
- console.error(`Failed to open browser: ${error.message}`);
74
- }
75
- process.exit(0);
76
- });
77
- }
78
-
79
- // Header with hot pink to dark blue gradient
80
- function Header() {
81
- const terminalWidth = process.stdout.columns || 80;
82
-
83
- let logo;
84
- if (terminalWidth >= 45) {
85
- logo = largeLogo;
86
- } else if (terminalWidth >= 35) {
87
- logo = mediumLogo;
88
- } else {
89
- logo = smallLogo;
90
- }
91
-
92
- return e(Box, { flexDirection: 'column', marginBottom: 1 },
93
- e(Gradient, { colors: ['#FF008C', '#FF1493', '#8B5CF6', '#1E3A8A'] },
94
- e(Text, null, logo)
95
- ),
96
- e(Text, { color: 'gray', italic: true }, 'Evals and Observability for Agentic AI')
97
- );
98
- }
99
-
100
- // Character-by-character shimmer component
101
- function ShimmerText({ text, shimmerPos }) {
102
- const baseColor = '#FF1493';
103
- const shimmerColors = ['#FF5AA7', '#FF85C0', '#FFB8D9', '#FFE0EE', '#FFB8D9', '#FF85C0', '#FF5AA7'];
104
- const shimmerWidth = shimmerColors.length;
105
-
106
- const chars = text.split('').map((char, i) => {
107
- const distanceFromShimmer = i - shimmerPos;
108
-
109
- let color = baseColor;
110
- if (distanceFromShimmer >= 0 && distanceFromShimmer < shimmerWidth) {
111
- color = shimmerColors[distanceFromShimmer];
112
- }
113
-
114
- return e(Text, { key: i, color, bold: true }, char);
115
- });
116
-
117
- return e(Box, null, ...chars);
118
- }
119
-
120
- // Menu item component - single line, hot pink selected with character shimmer
121
- function MenuItem({ name, subtext, isSelected, shimmerPos }) {
122
- if (isSelected) {
123
- return e(Box, null,
124
- e(Text, { bold: true, color: '#FF1493' }, '❯ '),
125
- e(ShimmerText, { text: name, shimmerPos }),
126
- e(Text, { color: 'gray' }, ' — ' + subtext)
127
- );
128
- }
129
-
130
- return e(Box, null,
131
- e(Text, { color: 'white' }, ' ' + name),
132
- e(Text, { color: 'gray' }, ' ' + subtext)
133
- );
134
- }
135
-
136
- // Main App component
137
- function App() {
138
- const [selectedIndex, setSelectedIndex] = useState(0);
139
- const [shimmerPos, setShimmerPos] = useState(-7); // Start off-screen
140
- const { exit } = useApp();
141
-
142
- const selectedName = OPTIONS[selectedIndex].name;
143
-
144
- // Character-by-character shimmer animation
145
- useEffect(() => {
146
- const textLength = selectedName.length;
147
- const shimmerWidth = 7;
148
- const totalPositions = textLength + shimmerWidth + 5; // Extra padding for smooth loop
149
-
150
- const timer = setInterval(() => {
151
- setShimmerPos(prev => {
152
- const next = prev + 1;
153
- return next > totalPositions ? -shimmerWidth : next;
154
- });
155
- }, 120); // Speed of shimmer movement
156
-
157
- return () => clearInterval(timer);
158
- }, [selectedName]);
159
-
160
- // Reset shimmer position when selection changes
161
- useEffect(() => {
162
- setShimmerPos(-7);
163
- }, [selectedIndex]);
164
-
165
- useInput((input, key) => {
166
- if (input === 'q' || key.escape) {
167
- exit();
168
- return;
169
- }
170
-
171
- if (key.upArrow || input === 'k') {
172
- setSelectedIndex(prev => (prev > 0 ? prev - 1 : OPTIONS.length - 1));
173
- }
174
-
175
- if (key.downArrow || input === 'j') {
176
- setSelectedIndex(prev => (prev < OPTIONS.length - 1 ? prev + 1 : 0));
177
- }
178
-
179
- if (key.return) {
180
- const selected = OPTIONS[selectedIndex];
181
- exit();
182
- setTimeout(() => {
183
- console.log(`\nOpening ${selected.url} in your browser...\n`);
184
- openUrlInBrowser(selected.url);
185
- }, 100);
186
- }
187
- });
188
-
189
- return e(Box, { flexDirection: 'column', padding: 1 },
190
- e(Static, { items: ['header'] }, () => e(Header)),
191
- e(Box, { marginBottom: 1 },
192
- e(Text, { bold: true, color: 'white' }, 'Select an option:')
193
- ),
194
- e(Box, { flexDirection: 'column', paddingX: 1 },
195
- ...OPTIONS.map((option, index) =>
196
- e(MenuItem, {
197
- key: index.toString(),
198
- name: option.name,
199
- subtext: option.subtext,
200
- isSelected: index === selectedIndex,
201
- shimmerPos: shimmerPos
202
- })
203
- )
204
- ),
205
- e(Box, { marginTop: 1 },
206
- e(Text, { dimColor: true }, 'Enter to select · ↑↓ to navigate · Esc to cancel')
207
- )
208
- );
209
- }
210
-
211
- // Run the app
212
- // Check if we have an interactive terminal (required for Ink)
213
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
214
- console.error('Error: This command requires an interactive terminal.');
215
- console.error('Please run `npx evals` from your terminal (not from a script or non-interactive environment).');
216
- process.exit(1);
217
- }
218
-
219
- // Handle uncaught errors
220
- process.on('uncaughtException', (error) => {
221
- console.error('Uncaught exception:', error.message);
222
- console.error(error.stack);
223
- process.exit(1);
224
- });
225
-
226
- process.on('unhandledRejection', (reason, promise) => {
227
- console.error('Unhandled rejection at:', promise);
228
- console.error('Reason:', reason);
229
- process.exit(1);
230
- });
231
-
232
- // Ensure stdout is not buffered (important for Windows)
233
- if (process.stdout.isTTY) {
234
- process.stdout.setEncoding('utf8');
235
- }
236
-
237
- try {
238
- render(e(App), { patchConsole: false });
239
- } catch (error) {
240
- console.error('Failed to render interactive CLI:', error.message);
241
- console.error(error.stack);
242
- if (process.platform === 'win32') {
243
- console.error('\nNote: If you\'re using cmd.exe, try running in PowerShell or Git Bash instead.');
244
- }
245
- process.exit(1);
246
- }
1
+ #!/usr/bin/env node
2
+
3
+ import React, { useState, useEffect } from 'react';
4
+ import { render, Box, Text, useInput, useApp, Static } from 'ink';
5
+ import Gradient from 'ink-gradient';
6
+ import { exec, spawn } from 'child_process';
7
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync, realpathSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const e = React.createElement;
13
+
14
+ // The onboarding prompt is bundled with this package (onboarding-prompt.md, a
15
+ // copy of the docs landing-page prompt). We read it, write it to a temp file,
16
+ // and tell the agent to read that file — a ~27 KB prompt is too large to pass
17
+ // reliably as a command-line argument.
18
+ // TODO: sync this copy with the docs source (arize.com/docs) later.
19
+ const BUNDLED_PROMPT_PATH = fileURLToPath(new URL('./onboarding-prompt.md', import.meta.url));
20
+
21
+ // Write the bundled prompt to a temp file and return a short seed instruction
22
+ // that points the agent at it.
23
+ function prepareSeedPrompt() {
24
+ const promptText = readFileSync(BUNDLED_PROMPT_PATH, 'utf8');
25
+ const dir = mkdtempSync(join(tmpdir(), 'arize-onboarding-'));
26
+ const promptFile = join(dir, 'onboarding-prompt.md');
27
+ writeFileSync(promptFile, promptText, 'utf8');
28
+ return `Read the file ${promptFile} and follow it to set up Arize AX tracing in this project, walking me through each step and asking me questions as needed.`;
29
+ }
30
+
31
+ // Coding agents we can launch interactively, seeded with the prompt.
32
+ // `args(seed)` returns the argv that starts the agent's REPL pre-loaded with `seed`.
33
+ export const AGENTS = [
34
+ { id: 'claude', label: 'Claude Code', bin: 'claude', args: (s) => [s], installUrl: 'https://docs.claude.com/en/docs/claude-code' },
35
+ { id: 'codex', label: 'OpenAI Codex', bin: 'codex', args: (s) => [s], installUrl: 'https://developers.openai.com/codex/cli' },
36
+ { id: 'cursor-agent', label: 'Cursor', bin: 'cursor-agent', args: (s) => [s], installUrl: 'https://docs.cursor.com/en/cli/overview' },
37
+ { id: 'copilot', label: 'GitHub Copilot', bin: 'copilot', args: (s) => ['-i', s], installUrl: 'https://github.com/features/copilot/cli' },
38
+ { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', args: (s) => ['-i', s], installUrl: 'https://github.com/google-gemini/gemini-cli' }
39
+ ];
40
+
41
+ // PATH scan — detects an agent without executing it (running it could hang).
42
+ export function isInstalled(bin) {
43
+ const path = process.env.PATH || '';
44
+ const dirs = path.split(process.platform === 'win32' ? ';' : ':');
45
+ const exts = process.platform === 'win32'
46
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
47
+ : [''];
48
+ for (const dir of dirs) {
49
+ if (!dir) continue;
50
+ for (const ext of exts) {
51
+ if (existsSync(join(dir, bin + ext)) || existsSync(join(dir, bin + ext.toLowerCase()))) {
52
+ return true;
53
+ }
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ // "ARIZE EVALS" - EXACTLY matched width (both 44 chars)
60
+ const largeLogo = `
61
+ █████╗ ██████╗ ██╗ ███████╗ ███████╗
62
+ ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝
63
+ ███████║ ██████╔╝ ██║ ███╔╝ █████╗
64
+ ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝
65
+ ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗
66
+ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
67
+ ███████╗ ██╗ ██╗ █████╗ ██╗ ███████╗
68
+ ██╔════╝ ██║ ██║ ██╔══██╗ ██║ ██╔════╝
69
+ █████╗ ██║ ██║ ███████║ ██║ ███████╗
70
+ ██╔══╝ ╚██╗ ██╔╝ ██╔══██║ ██║ ╚════██║
71
+ ███████╗ ╚████╔╝ ██║ ██║ ███████╗███████║
72
+ ╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝`;
73
+
74
+ const mediumLogo = `
75
+ █████╗ ██████╗ ██╗ ███████╗ ███████╗
76
+ ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝
77
+ ███████║ ██████╔╝ ██║ ███╔╝ █████╗
78
+ ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝
79
+ ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗
80
+ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
81
+ █▀▀ ▄▀█ █ █▀
82
+ ██▄ ▀▄▀ █▀█ █▄▄ ▄█`;
83
+
84
+ const smallLogo = `ARIZE
85
+ EVALS`;
86
+
87
+ // Tag every URL the app opens with utm_source=npmevals (idempotent). Applied
88
+ // centrally in openUrlInBrowser so all opened links (the agent install links)
89
+ // carry it. Does not touch URLs inside the onboarding prompt.
90
+ export function withUtm(url) {
91
+ try {
92
+ const u = new URL(url);
93
+ if (!u.searchParams.has('utm_source')) {
94
+ u.searchParams.set('utm_source', 'npmevals');
95
+ }
96
+ return u.toString();
97
+ } catch {
98
+ return url;
99
+ }
100
+ }
101
+
102
+ function openUrlInBrowser(rawUrl) {
103
+ const url = withUtm(rawUrl);
104
+ const command = process.platform === 'win32'
105
+ ? `start ${url}`
106
+ : process.platform === 'darwin'
107
+ ? `open "${url}"`
108
+ : `xdg-open "${url}"`;
109
+
110
+ exec(command, (error) => {
111
+ if (error) {
112
+ console.error(`Failed to open browser: ${error.message}`);
113
+ }
114
+ process.exit(0);
115
+ });
116
+ }
117
+
118
+ // Header with hot pink to dark blue gradient
119
+ function Header() {
120
+ const terminalWidth = process.stdout.columns || 80;
121
+
122
+ let logo;
123
+ if (terminalWidth >= 45) {
124
+ logo = largeLogo;
125
+ } else if (terminalWidth >= 35) {
126
+ logo = mediumLogo;
127
+ } else {
128
+ logo = smallLogo;
129
+ }
130
+
131
+ return e(Box, { flexDirection: 'column', marginBottom: 1 },
132
+ e(Gradient, { colors: ['#FF008C', '#FF1493', '#8B5CF6', '#1E3A8A'] },
133
+ e(Text, null, logo)
134
+ ),
135
+ e(Text, { color: 'gray', italic: true }, 'Evals and Observability for Agentic AI')
136
+ );
137
+ }
138
+
139
+ // Character-by-character shimmer component
140
+ function ShimmerText({ text, shimmerPos }) {
141
+ const baseColor = '#FF1493';
142
+ const shimmerColors = ['#FF5AA7', '#FF85C0', '#FFB8D9', '#FFE0EE', '#FFB8D9', '#FF85C0', '#FF5AA7'];
143
+ const shimmerWidth = shimmerColors.length;
144
+
145
+ const chars = text.split('').map((char, i) => {
146
+ const distanceFromShimmer = i - shimmerPos;
147
+
148
+ let color = baseColor;
149
+ if (distanceFromShimmer >= 0 && distanceFromShimmer < shimmerWidth) {
150
+ color = shimmerColors[distanceFromShimmer];
151
+ }
152
+
153
+ return e(Text, { key: i, color, bold: true }, char);
154
+ });
155
+
156
+ return e(Box, null, ...chars);
157
+ }
158
+
159
+ // Menu item component - single line, hot pink selected with character shimmer
160
+ function MenuItem({ name, subtext, isSelected, shimmerPos }) {
161
+ if (isSelected) {
162
+ return e(Box, null,
163
+ e(Text, { bold: true, color: '#FF1493' }, '❯ '),
164
+ e(ShimmerText, { text: name, shimmerPos }),
165
+ e(Text, { color: 'gray' }, ' — ' + subtext)
166
+ );
167
+ }
168
+
169
+ return e(Box, null,
170
+ e(Text, { color: 'white' }, ' ' + name),
171
+ e(Text, { color: 'gray' }, '' + subtext)
172
+ );
173
+ }
174
+
175
+ // Build the coding-agent picker items — the entry screen for `npx evals`.
176
+ // Detected agents become launch items; if none are found we offer install
177
+ // links instead so the screen is never a dead end.
178
+ export function buildAgentItems() {
179
+ const detected = AGENTS.filter(a => isInstalled(a.bin));
180
+ if (detected.length > 0) {
181
+ return {
182
+ items: detected.map(a => ({
183
+ name: a.label,
184
+ subtext: 'Launch and walk me through setup',
185
+ launch: a
186
+ })),
187
+ none: false
188
+ };
189
+ }
190
+ return {
191
+ items: AGENTS.map(a => ({
192
+ name: `Install ${a.label}`,
193
+ subtext: 'No supported agent detected — open install docs',
194
+ url: a.installUrl
195
+ })),
196
+ none: true
197
+ };
198
+ }
199
+
200
+ // Main App component — goes straight into the onboarding flow: pick a coding
201
+ // agent and launch it seeded with the prompt (no top-level menu).
202
+ function App({ onDone }) {
203
+ const [agentState] = useState(() => buildAgentItems());
204
+ const [selectedIndex, setSelectedIndex] = useState(0);
205
+ const [shimmerPos, setShimmerPos] = useState(-7); // Start off-screen
206
+ const { exit } = useApp();
207
+
208
+ const items = agentState.items;
209
+ const selectedName = items[selectedIndex].name;
210
+
211
+ // Character-by-character shimmer animation
212
+ useEffect(() => {
213
+ const textLength = selectedName.length;
214
+ const shimmerWidth = 7;
215
+ const totalPositions = textLength + shimmerWidth + 5; // Extra padding for smooth loop
216
+
217
+ const timer = setInterval(() => {
218
+ setShimmerPos(prev => {
219
+ const next = prev + 1;
220
+ return next > totalPositions ? -shimmerWidth : next;
221
+ });
222
+ }, 120); // Speed of shimmer movement
223
+
224
+ return () => clearInterval(timer);
225
+ }, [selectedName]);
226
+
227
+ // Reset shimmer position when selection changes
228
+ useEffect(() => {
229
+ setShimmerPos(-7);
230
+ }, [selectedIndex]);
231
+
232
+ useInput((input, key) => {
233
+ if (input === 'q' || key.escape) {
234
+ exit();
235
+ return;
236
+ }
237
+
238
+ if (key.upArrow || input === 'k') {
239
+ setSelectedIndex(prev => (prev > 0 ? prev - 1 : items.length - 1));
240
+ }
241
+
242
+ if (key.downArrow || input === 'j') {
243
+ setSelectedIndex(prev => (prev < items.length - 1 ? prev + 1 : 0));
244
+ }
245
+
246
+ if (key.return) {
247
+ const selected = items[selectedIndex];
248
+
249
+ if (selected.launch) {
250
+ onDone({ type: 'launch', agent: selected.launch });
251
+ exit();
252
+ return;
253
+ }
254
+
255
+ if (selected.url) {
256
+ onDone({ type: 'url', url: selected.url });
257
+ exit();
258
+ return;
259
+ }
260
+ }
261
+ });
262
+
263
+ const heading = agentState.none
264
+ ? 'No coding agent found on your PATH. Install one, then re-run:'
265
+ : 'Choose your coding agent to instrument your app:';
266
+
267
+ return e(Box, { flexDirection: 'column', padding: 1 },
268
+ e(Static, { items: ['header'] }, (item) => e(Header, { key: item })),
269
+ e(Box, { marginBottom: 1 },
270
+ e(Text, { bold: true, color: 'white' }, heading)
271
+ ),
272
+ e(Box, { flexDirection: 'column', paddingX: 1 },
273
+ ...items.map((option, index) =>
274
+ e(MenuItem, {
275
+ key: index.toString(),
276
+ name: option.name,
277
+ subtext: option.subtext,
278
+ isSelected: index === selectedIndex,
279
+ shimmerPos: shimmerPos
280
+ })
281
+ )
282
+ ),
283
+ e(Box, { marginTop: 1 },
284
+ e(Text, { dimColor: true }, 'Enter to select · ↑↓ to navigate · Esc to quit')
285
+ )
286
+ );
287
+ }
288
+
289
+ // Launch a coding agent on the real terminal, seeded with the onboarding prompt.
290
+ // Ink has fully unmounted by this point, so the child inherits a clean TTY.
291
+ function launchAgent(agent) {
292
+ const isWin = process.platform === 'win32';
293
+
294
+ let seed;
295
+ try {
296
+ seed = prepareSeedPrompt();
297
+ } catch (err) {
298
+ console.error(`Could not read the onboarding prompt: ${err.message}`);
299
+ process.exit(1);
300
+ }
301
+
302
+ console.log(`\nLaunching ${agent.label}…\n`);
303
+
304
+ const child = spawn(agent.bin, agent.args(seed), {
305
+ stdio: 'inherit',
306
+ shell: isWin // .cmd/.bat shims on Windows need the shell to resolve
307
+ });
308
+
309
+ child.on('error', (err) => {
310
+ console.error(`Could not launch ${agent.bin}: ${err.message}`);
311
+ console.error(`Make sure "${agent.bin}" is on your PATH, then try again.`);
312
+ process.exit(1);
313
+ });
314
+
315
+ child.on('close', (code) => {
316
+ process.exit(code || 0);
317
+ });
318
+ }
319
+
320
+ // Render the interactive app and act on the user's choice. Exported so it can
321
+ // be driven explicitly; only auto-runs when this file is the entry point (see
322
+ // the guard below), so importing it in tests doesn't launch the TUI.
323
+ export async function main() {
324
+ // Ink needs an interactive terminal.
325
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
326
+ console.error('Error: This command requires an interactive terminal.');
327
+ console.error('Please run `npx evals` from your terminal (not from a script or non-interactive environment).');
328
+ process.exit(1);
329
+ }
330
+
331
+ // Handle uncaught errors
332
+ process.on('uncaughtException', (error) => {
333
+ console.error('Uncaught exception:', error.message);
334
+ console.error(error.stack);
335
+ process.exit(1);
336
+ });
337
+
338
+ process.on('unhandledRejection', (reason, promise) => {
339
+ console.error('Unhandled rejection at:', promise);
340
+ console.error('Reason:', reason);
341
+ process.exit(1);
342
+ });
343
+
344
+ // Ensure stdout is not buffered (important for Windows)
345
+ if (process.stdout.isTTY) {
346
+ process.stdout.setEncoding('utf8');
347
+ }
348
+
349
+ try {
350
+ let result = null;
351
+ const app = render(e(App, { onDone: (r) => { result = r; } }), { patchConsole: false });
352
+
353
+ await app.waitUntilExit();
354
+
355
+ if (!result) {
356
+ process.exit(0); // user quit / cancelled
357
+ } else if (result.type === 'url') {
358
+ console.log(`\nOpening ${result.url} in your browser...\n`);
359
+ openUrlInBrowser(result.url);
360
+ } else if (result.type === 'launch') {
361
+ launchAgent(result.agent);
362
+ }
363
+ } catch (error) {
364
+ console.error('Failed to render interactive CLI:', error.message);
365
+ console.error(error.stack);
366
+ if (process.platform === 'win32') {
367
+ console.error('\nNote: If you\'re using cmd.exe, try running in PowerShell or Git Bash instead.');
368
+ }
369
+ process.exit(1);
370
+ }
371
+ }
372
+
373
+ // Only run when invoked directly (as `evals`/`node cli.js`), not when imported.
374
+ const invokedDirectly =
375
+ process.argv[1] &&
376
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
377
+ if (invokedDirectly) {
378
+ await main();
379
+ }