evals 2.5.0 → 2.7.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/README.md CHANGED
@@ -8,13 +8,17 @@ Go from zero to your first [Arize AX](https://arize.com/docs/ax) traces in one c
8
8
  npx evals
9
9
  ```
10
10
 
11
- You'll see a picker of the coding agents installed on your machine (Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI). Pick one and it launches in your current directory, seeded with the onboarding prompt — so run `npx evals` from the project you want to instrument.
11
+ You'll see a picker of the coding agents installed on your machine (Claude Code, Codex, Cursor, GitHub Copilot, Antigravity CLI). Pick one and it launches in your current directory, seeded with the onboarding prompt — so run `npx evals` from the project you want to instrument.
12
+
13
+ **Pasted `npx evals` into a coding agent?** If an agent runs it as a shell command there is no interactive terminal, so nothing is launched. It stages the onboarding prompt and prints two lines asking you to re-run in a terminal, with the staged prompt path in case you would rather point your agent at it yourself. Agents treat command output as data rather than instructions, so this deliberately asks *you* rather than trying to hand the flow to them.
12
14
 
13
15
  No coding agent installed? The picker shows install links instead.
14
16
 
17
+ Needs **Node.js 20 or newer** (the Ink UI does). On anything older, `npx evals` says so and points you at the shell launcher below rather than failing obscurely.
18
+
15
19
  ### Without Node
16
20
 
17
- If you don't have Node, use the shell launchers (they detect an agent and fetch the prompt from this published package via jsDelivr):
21
+ If you don't have Node — or have one older than 20 — use the shell launchers (they detect an agent and fetch the prompt from this published package via jsDelivr):
18
22
 
19
23
  ```bash
20
24
  # macOS / Linux
@@ -26,7 +30,7 @@ bash <(curl -fsSL https://cdn.jsdelivr.net/npm/evals/start.sh)
26
30
  irm https://cdn.jsdelivr.net/npm/evals/start.ps1 | iex
27
31
  ```
28
32
 
29
- When Node **is** present, these hand off to `npx evals` for the richer UI.
33
+ When **Node 20+** is present, these hand off to `npx evals` for the richer UI. On an older Node they say so and carry on in the shell, which needs no Node at all.
30
34
 
31
35
  ## What it does
32
36
 
@@ -34,7 +38,7 @@ When Node **is** present, these hand off to `npx evals` for the richer UI.
34
38
  2. Launches the agent in your current directory, seeded with the bundled onboarding prompt ([`onboarding-prompt.md`](./onboarding-prompt.md)).
35
39
  3. The agent walks you through: create/sign in to Arize AX → pick what to trace → instrument it → verify your first traces.
36
40
 
37
- You can trace an app in the current folder, a starter app the agent creates, or **the coding agent itself** — so every session you run, in any project, shows up in Arize AX. Agent tracing is machine-wide and captures prompts and tool output by default, so the prompt asks for that explicitly and offers per-category opt-outs.
41
+ You can trace an existing app (the one in the current folder, or another one you point it at by path), a starter app the agent creates, or **the coding agent itself** — so every session you run, in any project, shows up in Arize AX. Agent tracing is machine-wide and captures prompts and tool output by default, so the prompt asks for that explicitly and offers per-category opt-outs.
38
42
 
39
43
  The agent runs with its **normal permission model** — `evals` never passes skip-permissions, so you approve each step, and the prompt itself gates real changes on your confirmation.
40
44
 
@@ -42,12 +46,15 @@ The agent runs with its **normal permission model** — `evals` never passes ski
42
46
 
43
47
  | Variable | Applies to | Effect |
44
48
  |----------|-----------|--------|
45
- | `ARIZE_AGENT=<id>` | `start.sh` / `start.ps1` | Skip the picker and use this agent (`claude`, `codex`, `cursor-agent`, `copilot`, `gemini`). |
49
+ | `ARIZE_AGENT=<id>` | `start.sh` / `start.ps1` | Skip the picker and use this agent (`claude`, `codex`, `cursor-agent`, `copilot`, `agy`). |
46
50
  | `ARIZE_SKIP_NPX=1` | `start.sh` / `start.ps1` | Force the shell path even when Node/npx is available. |
47
51
  | `ARIZE_PROMPT_URL=<url>` | `start.sh` / `start.ps1` | Fetch the onboarding prompt from a custom URL (supports `file://`). |
52
+ | `ARIZE_ONBOARDING_DIR=<dir>` | all three | Stage the prompt somewhere other than `~/.arize/onboarding`. |
48
53
 
49
54
  The shell launchers also accept `--agent <id>`.
50
55
 
56
+ The prompt is staged in `~/.arize/onboarding` and handed to your agent by path, since it's too large to pass as a command-line argument. That directory is cleared at the start of each launch and holds nothing but the prompt and the offline tracing bundle. If your home directory isn't writable, the launcher says so and falls back to a temp directory — instrumenting an app still works, but tracing the coding agent itself needs a writable home.
57
+
51
58
  ## About Arize
52
59
 
53
60
  [Arize AX](https://arize.com/docs/ax) is the AI engineering platform for tracing, evaluating, and observing LLM and agent applications. Learn more at [arize.com](https://arize.com).
package/bin.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Node version gate for `npx evals`.
4
+ //
5
+ // This is a separate file from cli.js on purpose. ESM `import` statements are
6
+ // hoisted and evaluated before any of the importing module's own code runs, so a
7
+ // check at the top of cli.js would execute *after* Ink's dependency graph loads —
8
+ // too late. On Node 18 that graph dies while parsing string-width with
9
+ // "SyntaxError: Invalid regular expression flags" (the regex `v` flag is Node 20+),
10
+ // which tells the user nothing about Node versions. So: check here, where nothing
11
+ // but Node builtins have loaded, then reach cli.js through a dynamic import.
12
+ //
13
+ // Keep this file dependency-free and conservative in syntax — it has to parse and
14
+ // run on the very versions it exists to reject.
15
+ //
16
+ // MIN_MAJOR tracks ink's own `engines.node`, and package.json's `engines` field
17
+ // must say the same thing; test/cli.test.js asserts they agree.
18
+
19
+ const MIN_MAJOR = 20;
20
+
21
+ const major = Number.parseInt(process.versions.node.split('.')[0], 10);
22
+
23
+ if (!Number.isInteger(major) || major < MIN_MAJOR) {
24
+ const shell = process.platform === 'win32'
25
+ ? 'irm https://cdn.jsdelivr.net/npm/evals/start.ps1 | iex'
26
+ : 'bash <(curl -fsSL https://cdn.jsdelivr.net/npm/evals/start.sh)';
27
+
28
+ console.error(`evals needs Node.js ${MIN_MAJOR} or newer — this is ${process.version}.`);
29
+ console.error('');
30
+ console.error('Either upgrade Node (https://nodejs.org), or use the launcher that');
31
+ console.error('needs no Node at all — it runs the same onboarding flow:');
32
+ console.error('');
33
+ console.error(` ${shell}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ // cli.js only auto-runs when it is the entry point, so call main() explicitly.
38
+ import('./cli.js')
39
+ .then((cli) => cli.main())
40
+ .catch((err) => {
41
+ console.error(`Could not start evals: ${err && err.message ? err.message : err}`);
42
+ process.exit(1);
43
+ });
package/cli.js CHANGED
@@ -2,31 +2,40 @@
2
2
 
3
3
  import React, { useState, useEffect } from 'react';
4
4
  import { render, Box, Text, useInput, useApp, Static } from 'ink';
5
- import Gradient from 'ink-gradient';
6
5
  import { exec, spawn } from 'child_process';
7
6
  import {
8
7
  existsSync,
9
8
  readFileSync,
10
9
  writeFileSync,
11
10
  mkdtempSync,
11
+ mkdirSync,
12
12
  realpathSync,
13
13
  readdirSync,
14
14
  copyFileSync,
15
15
  chmodSync,
16
16
  renameSync,
17
17
  rmSync,
18
+ rmdirSync,
19
+ unlinkSync,
18
20
  } from 'fs';
19
- import { join } from 'path';
20
- import { tmpdir } from 'os';
21
+ import { join, isAbsolute, resolve } from 'path';
22
+ import { tmpdir, homedir } from 'os';
21
23
  import { fileURLToPath } from 'url';
22
24
 
23
25
  const e = React.createElement;
24
26
 
25
- // The onboarding prompt is bundled with this package (onboarding-prompt.md, a
26
- // copy of the docs landing-page prompt). We read it, write it to a temp file,
27
- // and tell the agent to read that file a ~27 KB prompt is too large to pass
28
- // reliably as a command-line argument.
29
- // TODO: sync this copy with the docs source (arize.com/docs) later.
27
+ // Arize brand magenta, from the 2026 rebrand design system. The mark is a single
28
+ // flat fill (`--brand-accent-ink`, the hex in arize-mark.svg) the brand has no
29
+ // gradient anywhere, so the logo doesn't get one either. Interactive accents use
30
+ // Pink 400 (`--brand-accent`), which the palette keeps distinct from the mark.
31
+ const BRAND_MARK = '#EC2088';
32
+ const BRAND_ACCENT = '#FF3CA8';
33
+
34
+ // The onboarding prompt is bundled with this package (onboarding-prompt.md).
35
+ // This repo is its only home — there is no docs original to sync against, so
36
+ // edit it here. We read it, write it to a temp file, and tell the agent to read
37
+ // that file — a ~35 KB prompt is too large to pass reliably as a command-line
38
+ // argument.
30
39
  const BUNDLED_PROMPT_PATH = fileURLToPath(new URL('./onboarding-prompt.md', import.meta.url));
31
40
 
32
41
  // Wheels for the coding-agent tracing harness, built by scripts/build-harness-wheel.mjs.
@@ -41,6 +50,106 @@ const OFFLINE_DIR_NAME = 'arize-offline';
41
50
  // would send the agent down the offline path with no wheel to install.
42
51
  const OFFLINE_FILES = ['harness-install.sh', 'harness-install.bat', 'LICENSE-coding-harness-tracing'];
43
52
 
53
+ const PROMPT_FILE_NAME = 'onboarding-prompt.md';
54
+
55
+ // Step 4A writes the harness credentials here and deletes them itself. We name it
56
+ // only so a stray one — left by a run that died between writing and deleting —
57
+ // gets cleared at the next launch instead of sitting around holding a live key.
58
+ const ENV_FILE_NAME = 'harness.env';
59
+
60
+ // MANIFEST only lands in the shell launchers' bundles, never in ours. Naming it
61
+ // anyway keeps one allowlist correct for every implementation, so a directory
62
+ // staged by start.sh can be cleared by `npx evals` and vice versa.
63
+ const BUNDLE_EXTRA_FILES = ['MANIFEST'];
64
+
65
+ // Where the prompt and the offline bundle get staged: `~/.arize/onboarding`.
66
+ // Deliberately stable rather than a fresh temp directory. The agent is asked to
67
+ // read a file outside its workspace, and that is an approval a human can grant
68
+ // with confidence for `~/.arize/onboarding/onboarding-prompt.md`, where a
69
+ // `/var/folders/_s/3_t5nrxs…/T/arize-onboarding-c5uxb8/` path just looks alarming.
70
+ //
71
+ // This sits beside Step 4A's own `~/.arize/harness` install, which the launcher
72
+ // must never touch — clearing our staging directory cannot uninstall a working
73
+ // harness, and that boundary is why we only ever name files under `onboarding/`.
74
+ export function resolveOnboardingDir({
75
+ home = homedir(),
76
+ override = process.env.ARIZE_ONBOARDING_DIR,
77
+ } = {}) {
78
+ if (override) return resolve(override);
79
+ return join(home, '.arize', 'onboarding');
80
+ }
81
+
82
+ // Clear a staged bundle directory by naming every entry we could have put in it,
83
+ // then rmdir. Bails out — deleting nothing further — the moment it meets an entry
84
+ // it doesn't recognise. Wheel names carry a version we can't know ahead of time,
85
+ // so `.whl` is matched by extension, non-recursively, inside this one directory.
86
+ function clearBundleDir(dir) {
87
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
88
+ const known =
89
+ entry.name.endsWith('.whl') ||
90
+ OFFLINE_FILES.includes(entry.name) ||
91
+ BUNDLE_EXTRA_FILES.includes(entry.name);
92
+ if (entry.isDirectory() || !known) return false;
93
+ unlinkSync(join(dir, entry.name));
94
+ }
95
+ rmdirSync(dir);
96
+ return true;
97
+ }
98
+
99
+ // Empty the staging directory by deleting the exact files we create — never with
100
+ // a recursive delete. Two things fall out of that: a bad path can at worst try to
101
+ // unlink a handful of names that won't exist, and `rmdir` refuses a non-empty
102
+ // directory, so anything unexpected in there stops us instead of being destroyed.
103
+ // Unlinking a symlink removes the link and never the target.
104
+ //
105
+ // Returns true when the directory is gone or empty and safe to stage into.
106
+ export function clearOnboardingDir(dir) {
107
+ if (!isAbsolute(dir)) return false;
108
+ if (!existsSync(dir)) return true;
109
+
110
+ try {
111
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
112
+ const path = join(dir, entry.name);
113
+
114
+ if (entry.isDirectory()) {
115
+ // arize-offline/ is ours; .staging-* is the leftover of a crashed run.
116
+ const ours = entry.name === OFFLINE_DIR_NAME || entry.name.startsWith('.staging-');
117
+ if (!ours || !clearBundleDir(path)) return false;
118
+ continue;
119
+ }
120
+
121
+ if (entry.name !== PROMPT_FILE_NAME && entry.name !== ENV_FILE_NAME) return false;
122
+ unlinkSync(path);
123
+ }
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ // Clear and create the staging directory, degrading to a temp directory when the
131
+ // home path can't be used: a read-only home, a directory owned by another user
132
+ // after a past `sudo npx evals`, a full disk, an offline redirected profile on
133
+ // Windows, or an unrecognised file we refuse to delete. We attempt the work and
134
+ // degrade rather than probing for writability first — a probe lies on NFS and
135
+ // ACL filesystems, races, and can't see ENOSPC at all.
136
+ //
137
+ // Mode 0700 because Step 4A writes credentials into this directory.
138
+ export function prepareOnboardingDir(dir = resolveOnboardingDir()) {
139
+ if (clearOnboardingDir(dir)) {
140
+ try {
141
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
142
+ // mkdir's mode is umask-filtered, so set it outright. Windows has no POSIX
143
+ // mode; there the file inherits the profile's own ACL, as it does today.
144
+ if (process.platform !== 'win32') chmodSync(dir, 0o700);
145
+ return { dir, fellBack: false };
146
+ } catch {
147
+ // Fall through to the temp directory.
148
+ }
149
+ }
150
+ return { dir: mkdtempSync(join(tmpdir(), 'arize-onboarding-')), fellBack: true };
151
+ }
152
+
44
153
  // Copy the bundled wheels next to the prompt. Returns the staged directory, or
45
154
  // null when this package has no usable vendor/ (a git checkout that hasn't run
46
155
  // the build). The shell launchers stage the same bundle themselves, fetching it
@@ -77,15 +186,22 @@ export function stageOfflineHarness(dir, vendorDir = VENDOR_DIR) {
77
186
  }
78
187
  }
79
188
 
80
- // Write the bundled prompt to a temp file and return a short seed instruction
81
- // that points the agent at it.
189
+ // Stage the bundled prompt and return the seed instruction that points the agent
190
+ // at it, plus whether we had to fall back off the home directory.
82
191
  function prepareSeedPrompt() {
83
192
  const promptText = readFileSync(BUNDLED_PROMPT_PATH, 'utf8');
84
- const dir = mkdtempSync(join(tmpdir(), 'arize-onboarding-'));
85
- const promptFile = join(dir, 'onboarding-prompt.md');
193
+ const { dir, fellBack } = prepareOnboardingDir();
194
+ const promptFile = join(dir, PROMPT_FILE_NAME);
86
195
  writeFileSync(promptFile, promptText, 'utf8');
87
196
  stageOfflineHarness(dir);
88
- 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.`;
197
+ // Single quotes, not double: a home path is likelier to contain a space than a
198
+ // temp path was, but Windows spawns through the shell and embedded double
199
+ // quotes don't survive that reliably. cmd.exe leaves single quotes alone.
200
+ // No "in this project": the launcher may well be run from a home directory, and
201
+ // Step 4 is what scopes the work — an app here, an app at another path, a starter
202
+ // app, or the coding agent itself. Keep this wording in step with start.{sh,ps1}.
203
+ const seed = `Read the file '${promptFile}' and follow it to set up Arize AX tracing, walking me through each step and asking me questions as needed.`;
204
+ return { seed, dir, fellBack };
89
205
  }
90
206
 
91
207
  // Coding agents we can launch interactively, seeded with the prompt.
@@ -95,7 +211,7 @@ export const AGENTS = [
95
211
  { id: 'codex', label: 'OpenAI Codex', bin: 'codex', args: (s) => [s], installUrl: 'https://developers.openai.com/codex/cli' },
96
212
  { id: 'cursor-agent', label: 'Cursor', bin: 'cursor-agent', args: (s) => [s], installUrl: 'https://docs.cursor.com/en/cli/overview' },
97
213
  { id: 'copilot', label: 'GitHub Copilot', bin: 'copilot', args: (s) => ['-i', s], installUrl: 'https://github.com/features/copilot/cli' },
98
- { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', args: (s) => ['-i', s], installUrl: 'https://github.com/google-gemini/gemini-cli' }
214
+ { id: 'agy', label: 'Antigravity CLI', bin: 'agy', args: (s) => ['-i', s], installUrl: 'https://antigravity.google/docs/cli/getting-started' }
99
215
  ];
100
216
 
101
217
  // PATH scan — detects an agent without executing it (running it could hang).
@@ -116,33 +232,21 @@ export function isInstalled(bin) {
116
232
  return false;
117
233
  }
118
234
 
119
- // "ARIZE EVALS" - EXACTLY matched width (both 44 chars)
235
+ // "ARIZE AX" on one line 66 columns wide, so Header() drops to the compact
236
+ // two-row art below 68 and to plain text below 30.
120
237
  const largeLogo = `
121
- █████╗ ██████╗ ██╗ ███████╗ ███████╗
122
- ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝
123
- ███████║ ██████╔╝ ██║ ███╔╝ █████╗
124
- ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝
125
- ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗
126
- ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
127
- ███████╗ ██╗ ██╗ █████╗ ██╗ ███████╗
128
- ██╔════╝ ██║ ██║ ██╔══██╗ ██║ ██╔════╝
129
- █████╗ ██║ ██║ ███████║ ██║ ███████╗
130
- ██╔══╝ ╚██╗ ██╔╝ ██╔══██║ ██║ ╚════██║
131
- ███████╗ ╚████╔╝ ██║ ██║ ███████╗███████║
132
- ╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝`;
238
+ █████╗ ██████╗ ██╗ ███████╗ ███████╗ █████╗ ██╗ ██╗
239
+ ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝ ██╔══██╗ ╚██╗██╔╝
240
+ ███████║ ██████╔╝ ██║ ███╔╝ █████╗ ███████║ ╚███╔╝
241
+ ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝ ██╔══██║ ██╔██╗
242
+ ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗ ██║ ██║ ██╔╝ ██╗
243
+ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝`;
133
244
 
134
245
  const mediumLogo = `
135
- █████╗ ██████╗ ██╗ ███████╗ ███████╗
136
- ██╔══██╗ ██╔══██╗ ██║ ╚══███╔╝ ██╔════╝
137
- ███████║ ██████╔╝ ██║ ███╔╝ █████╗
138
- ██╔══██║ ██╔══██╗ ██║ ███╔╝ ██╔══╝
139
- ██║ ██║ ██║ ██║ ██║ ███████╗ ███████╗
140
- ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
141
- █▀▀ █ █ ▄▀█ █ █▀
142
- ██▄ ▀▄▀ █▀█ █▄▄ ▄█`;
143
-
144
- const smallLogo = `ARIZE
145
- EVALS`;
246
+ ▄▀█ █▀▄ █ ▀█ █▀▀ ▄▀█ ▀▄▀
247
+ █▀█ █▀▄ █ █▄ ██▄ █▀█ ▄▀▄`;
248
+
249
+ const smallLogo = `ARIZE AX`;
146
250
 
147
251
  // Tag every URL the app opens with utm_source=npmevals (idempotent). Applied
148
252
  // centrally in openUrlInBrowser so all opened links (the agent install links)
@@ -175,31 +279,30 @@ function openUrlInBrowser(rawUrl) {
175
279
  });
176
280
  }
177
281
 
178
- // Header with hot pink to dark blue gradient
282
+ // Header the brand mark in flat brand magenta
179
283
  function Header() {
180
284
  const terminalWidth = process.stdout.columns || 80;
181
285
 
182
286
  let logo;
183
- if (terminalWidth >= 45) {
287
+ if (terminalWidth >= 68) {
184
288
  logo = largeLogo;
185
- } else if (terminalWidth >= 35) {
289
+ } else if (terminalWidth >= 30) {
186
290
  logo = mediumLogo;
187
291
  } else {
188
292
  logo = smallLogo;
189
293
  }
190
294
 
191
295
  return e(Box, { flexDirection: 'column', marginBottom: 1 },
192
- e(Gradient, { colors: ['#FF008C', '#FF1493', '#8B5CF6', '#1E3A8A'] },
193
- e(Text, null, logo)
194
- ),
195
- e(Text, { color: 'gray', italic: true }, 'Evals and Observability for Agentic AI')
296
+ e(Text, { color: BRAND_MARK }, logo),
297
+ e(Text, { color: 'gray', italic: true }, 'Observability and Evals for Agentic AI')
196
298
  );
197
299
  }
198
300
 
199
301
  // Character-by-character shimmer component
200
302
  function ShimmerText({ text, shimmerPos }) {
201
- const baseColor = '#FF1493';
202
- const shimmerColors = ['#FF5AA7', '#FF85C0', '#FFB8D9', '#FFE0EE', '#FFB8D9', '#FF85C0', '#FF5AA7'];
303
+ const baseColor = BRAND_ACCENT;
304
+ // Pink 300 / 200 / 100 off the brand ramp, peaking at white.
305
+ const shimmerColors = ['#FF6BBA', '#FFB5D6', '#FEEDF3', '#FFFFFF', '#FEEDF3', '#FFB5D6', '#FF6BBA'];
203
306
  const shimmerWidth = shimmerColors.length;
204
307
 
205
308
  const chars = text.split('').map((char, i) => {
@@ -216,19 +319,22 @@ function ShimmerText({ text, shimmerPos }) {
216
319
  return e(Box, null, ...chars);
217
320
  }
218
321
 
219
- // Menu item component - single line, hot pink selected with character shimmer
322
+ // Menu item component - single line, brand pink selected with character shimmer
220
323
  function MenuItem({ name, subtext, isSelected, shimmerPos }) {
324
+ // subtext is optional: the detected-agent items are just the agent's name.
325
+ const tail = subtext ? [e(Text, { color: 'gray', key: 'sub' }, ' — ' + subtext)] : [];
326
+
221
327
  if (isSelected) {
222
328
  return e(Box, null,
223
- e(Text, { bold: true, color: '#FF1493' }, '❯ '),
329
+ e(Text, { bold: true, color: BRAND_ACCENT }, '❯ '),
224
330
  e(ShimmerText, { text: name, shimmerPos }),
225
- e(Text, { color: 'gray' }, ' — ' + subtext)
331
+ ...tail
226
332
  );
227
333
  }
228
334
 
229
335
  return e(Box, null,
230
336
  e(Text, { color: 'white' }, ' ' + name),
231
- e(Text, { color: 'gray' }, ' — ' + subtext)
337
+ ...tail
232
338
  );
233
339
  }
234
340
 
@@ -241,7 +347,6 @@ export function buildAgentItems() {
241
347
  return {
242
348
  items: detected.map(a => ({
243
349
  name: a.label,
244
- subtext: 'Launch and walk me through setup',
245
350
  launch: a
246
351
  })),
247
352
  none: false
@@ -351,17 +456,26 @@ function App({ onDone }) {
351
456
  function launchAgent(agent) {
352
457
  const isWin = process.platform === 'win32';
353
458
 
354
- let seed;
459
+ let prepared;
355
460
  try {
356
- seed = prepareSeedPrompt();
461
+ prepared = prepareSeedPrompt();
357
462
  } catch (err) {
358
- console.error(`Could not read the onboarding prompt: ${err.message}`);
463
+ console.error(`Could not prepare the onboarding prompt: ${err.message}`);
359
464
  process.exit(1);
360
465
  }
361
466
 
467
+ // Say so when we couldn't use the home directory. Otherwise the seed path
468
+ // silently reverts to the /var/folders/… form this change exists to avoid, and
469
+ // Step 4A is going to fail later anyway — it writes the harness and its
470
+ // credentials under ~/.arize regardless of where the prompt was staged.
471
+ if (prepared.fellBack) {
472
+ console.log(`\nCouldn't use ${resolveOnboardingDir()} — staged in ${prepared.dir} instead.`);
473
+ console.log('Instrumenting an app still works; tracing this coding agent (Step 4A) needs a writable home directory.');
474
+ }
475
+
362
476
  console.log(`\nLaunching ${agent.label}…\n`);
363
477
 
364
- const child = spawn(agent.bin, agent.args(seed), {
478
+ const child = spawn(agent.bin, agent.args(prepared.seed), {
365
479
  stdio: 'inherit',
366
480
  shell: isWin // .cmd/.bat shims on Windows need the shell to resolve
367
481
  });
@@ -378,14 +492,66 @@ function launchAgent(agent) {
378
492
  }
379
493
 
380
494
  // Render the interactive app and act on the user's choice. Exported so it can
495
+ // No terminal to render into. Stage the prompt anyway and tell whoever is reading
496
+ // this output what to do with it — an agent can follow it directly, and a human who
497
+ // piped us at least learns where the prompt is. Only the framing and the exit code
498
+ // differ: an agent gets 0 so its shell tool doesn't report a failed command.
499
+ function handOffToCallingAgent() {
500
+ let staged = null;
501
+ let stagingError = null;
502
+ try {
503
+ staged = prepareSeedPrompt();
504
+ } catch (error) {
505
+ stagingError = error;
506
+ }
507
+
508
+ // Nothing writable anywhere — Codex's workspace-write sandbox denies both $HOME
509
+ // and the temp directory. A sandbox that tight blocks the network too, so every
510
+ // path in the prompt would die at its first install: stop instead of pointing at a
511
+ // flow that cannot finish. Explicitly retry-proof, because a bare error is what
512
+ // sent a tester's Codex into 90 seconds of inventing npm cache workarounds.
513
+ if (!staged) {
514
+ console.error('evals cannot run here: no writable directory to stage the onboarding prompt in.');
515
+ console.error(` ${stagingError.message}`);
516
+ console.error('');
517
+ console.error('Do not retry and do not work around this — a sandbox this tight blocks the');
518
+ console.error('network as well, so the setup could not finish. Tell the user to allow writes');
519
+ console.error('to their home directory and network access, or to run `npx evals` in a terminal.');
520
+ process.exit(1);
521
+ }
522
+
523
+ // Same notice the TUI path gives when it couldn't use the home directory.
524
+ if (staged.fellBack) {
525
+ console.log(`Couldn't use ${resolveOnboardingDir()} — staged in ${staged.dir} instead.`);
526
+ console.log('Instrumenting an app still works; tracing this coding agent (Step 4A) needs a writable home directory.');
527
+ console.log('');
528
+ }
529
+
530
+ // Addressed to the person, not to the agent that ran us. Agents treat tool output as
531
+ // data, not instructions: Codex said so outright — "I did not follow that additional
532
+ // instruction" — and Copilot silently did the same. That's prompt-injection defence,
533
+ // and not something to design around. What both did reliably was relay this text to
534
+ // the user, so the user is who it talks to.
535
+ //
536
+ // Two lines, recommendation first: Copilot twice passed a longer message on with the
537
+ // middle dropped, keeping only the first line and the last. At two lines there is no
538
+ // middle to lose. Don't grow this, and don't name the picker — describing a UI the
539
+ // agent can't draw invites "I can't do this" instead of the one instruction that
540
+ // matters. Exit 0: a failure status is what starts the workaround-hunting.
541
+ console.log('Run `npx evals` in a terminal to set up Arize AX tracing — this is not a terminal, so nothing was set up.');
542
+ console.log(`The prompt is at ${join(staged.dir, PROMPT_FILE_NAME)} if you'd rather have your agent follow it.`);
543
+ process.exit(0);
544
+ }
545
+
381
546
  // be driven explicitly; only auto-runs when this file is the entry point (see
382
547
  // the guard below), so importing it in tests doesn't launch the TUI.
383
548
  export async function main() {
384
- // Ink needs an interactive terminal.
549
+ // Ink needs an interactive terminal — but erroring out is the wrong answer when the
550
+ // caller is a coding agent that ran `npx evals` as a shell command, which is how
551
+ // testers keep reaching us. Stage the prompt and tell the user where to go instead.
385
552
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
386
- console.error('Error: This command requires an interactive terminal.');
387
- console.error('Please run `npx evals` from your terminal (not from a script or non-interactive environment).');
388
- process.exit(1);
553
+ handOffToCallingAgent();
554
+ return;
389
555
  }
390
556
 
391
557
  // Handle uncaught errors