evals 2.4.0 → 2.6.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
@@ -1,6 +1,6 @@
1
1
  # evals
2
2
 
3
- Go from zero to your first [Arize AX](https://arize.com/docs/ax) traces in one command. `evals` launches your coding agent, seeded with a guided onboarding prompt that walks you through signing up, instrumenting your app, and confirming traces land.
3
+ Go from zero to your first [Arize AX](https://arize.com/docs/ax) traces in one command. `evals` launches your coding agent, seeded with a guided onboarding prompt that walks you through signing up, adding tracing, and confirming traces land — for your app, or for the coding agent itself.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -12,9 +12,11 @@ You'll see a picker of the coding agents installed on your machine (Claude Code,
12
12
 
13
13
  No coding agent installed? The picker shows install links instead.
14
14
 
15
+ 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.
16
+
15
17
  ### Without Node
16
18
 
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):
19
+ 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
20
 
19
21
  ```bash
20
22
  # macOS / Linux
@@ -26,13 +28,15 @@ bash <(curl -fsSL https://cdn.jsdelivr.net/npm/evals/start.sh)
26
28
  irm https://cdn.jsdelivr.net/npm/evals/start.ps1 | iex
27
29
  ```
28
30
 
29
- When Node **is** present, these hand off to `npx evals` for the richer UI.
31
+ 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
32
 
31
33
  ## What it does
32
34
 
33
35
  1. Detects an installed coding agent (never executes it — just a PATH lookup).
34
36
  2. Launches the agent in your current directory, seeded with the bundled onboarding prompt ([`onboarding-prompt.md`](./onboarding-prompt.md)).
35
- 3. The agent walks you through: create/sign in to Arize AX → detect your stack → instrument it → verify your first traces.
37
+ 3. The agent walks you through: create/sign in to Arize AX → pick what to trace → instrument it → verify your first traces.
38
+
39
+ 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.
36
40
 
37
41
  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.
38
42
 
@@ -43,26 +47,16 @@ The agent runs with its **normal permission model** — `evals` never passes ski
43
47
  | `ARIZE_AGENT=<id>` | `start.sh` / `start.ps1` | Skip the picker and use this agent (`claude`, `codex`, `cursor-agent`, `copilot`, `gemini`). |
44
48
  | `ARIZE_SKIP_NPX=1` | `start.sh` / `start.ps1` | Force the shell path even when Node/npx is available. |
45
49
  | `ARIZE_PROMPT_URL=<url>` | `start.sh` / `start.ps1` | Fetch the onboarding prompt from a custom URL (supports `file://`). |
50
+ | `ARIZE_ONBOARDING_DIR=<dir>` | all three | Stage the prompt somewhere other than `~/.arize/onboarding`. |
46
51
 
47
52
  The shell launchers also accept `--agent <id>`.
48
53
 
49
- ## Development
50
-
51
- ```bash
52
- npm install
53
- npm test # unit tests for the pure helpers (node --test)
54
- node cli.js # run the interactive CLI locally
55
- ```
56
-
57
- `npm test` covers the headless logic (URL tagging, agent detection, the agent catalog, and the picker). The interactive Ink flow and the live agent launch need a real terminal — verify those by running `node cli.js` in a terminal and picking an agent.
58
-
59
- ### Layout
60
-
61
- - `cli.js` — the interactive `npx evals` entry point (Ink UI). Exports its pure helpers for testing; only launches the UI when run directly.
62
- - `onboarding-prompt.md` — the bundled onboarding prompt (a copy of the Arize docs landing-page prompt).
63
- - `start.sh` / `start.ps1` — the no-npm launchers, served to `curl`/`irm` via jsDelivr.
64
- - `test/` — unit tests.
54
+ 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.
65
55
 
66
56
  ## About Arize
67
57
 
68
58
  [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).
59
+
60
+ ## Contributing
61
+
62
+ Development setup and the release process live in [CONTRIBUTING.md](https://github.com/Arize-ai/npm-evals/blob/main/CONTRIBUTING.md).
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
@@ -4,28 +4,198 @@ import React, { useState, useEffect } from 'react';
4
4
  import { render, Box, Text, useInput, useApp, Static } from 'ink';
5
5
  import Gradient from 'ink-gradient';
6
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';
7
+ import {
8
+ existsSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ mkdtempSync,
12
+ mkdirSync,
13
+ realpathSync,
14
+ readdirSync,
15
+ copyFileSync,
16
+ chmodSync,
17
+ renameSync,
18
+ rmSync,
19
+ rmdirSync,
20
+ unlinkSync,
21
+ } from 'fs';
22
+ import { join, isAbsolute, resolve } from 'path';
23
+ import { tmpdir, homedir } from 'os';
10
24
  import { fileURLToPath } from 'url';
11
25
 
12
26
  const e = React.createElement;
13
27
 
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.
28
+ // The onboarding prompt is bundled with this package (onboarding-prompt.md).
29
+ // This repo is its only home there is no docs original to sync against, so
30
+ // edit it here. We read it, write it to a temp file, and tell the agent to read
31
+ // that file — a ~35 KB prompt is too large to pass reliably as a command-line
32
+ // argument.
19
33
  const BUNDLED_PROMPT_PATH = fileURLToPath(new URL('./onboarding-prompt.md', import.meta.url));
20
34
 
21
- // Write the bundled prompt to a temp file and return a short seed instruction
22
- // that points the agent at it.
35
+ // Wheels for the coding-agent tracing harness, built by scripts/build-harness-wheel.mjs.
36
+ // Staging these next to the prompt lets Step 4A install with no network and no
37
+ // remote code execution — which is what keeps it working under permission
38
+ // classifiers that block piping a downloaded script into a shell.
39
+ const VENDOR_DIR = fileURLToPath(new URL('./vendor', import.meta.url));
40
+ const OFFLINE_DIR_NAME = 'arize-offline';
41
+
42
+ // Files the offline install needs. Absent or incomplete vendor/ means we stage
43
+ // nothing — the prompt keys off the directory existing, so a half-populated one
44
+ // would send the agent down the offline path with no wheel to install.
45
+ const OFFLINE_FILES = ['harness-install.sh', 'harness-install.bat', 'LICENSE-coding-harness-tracing'];
46
+
47
+ const PROMPT_FILE_NAME = 'onboarding-prompt.md';
48
+
49
+ // Step 4A writes the harness credentials here and deletes them itself. We name it
50
+ // only so a stray one — left by a run that died between writing and deleting —
51
+ // gets cleared at the next launch instead of sitting around holding a live key.
52
+ const ENV_FILE_NAME = 'harness.env';
53
+
54
+ // MANIFEST only lands in the shell launchers' bundles, never in ours. Naming it
55
+ // anyway keeps one allowlist correct for every implementation, so a directory
56
+ // staged by start.sh can be cleared by `npx evals` and vice versa.
57
+ const BUNDLE_EXTRA_FILES = ['MANIFEST'];
58
+
59
+ // Where the prompt and the offline bundle get staged: `~/.arize/onboarding`.
60
+ // Deliberately stable rather than a fresh temp directory. The agent is asked to
61
+ // read a file outside its workspace, and that is an approval a human can grant
62
+ // with confidence for `~/.arize/onboarding/onboarding-prompt.md`, where a
63
+ // `/var/folders/_s/3_t5nrxs…/T/arize-onboarding-c5uxb8/` path just looks alarming.
64
+ //
65
+ // This sits beside Step 4A's own `~/.arize/harness` install, which the launcher
66
+ // must never touch — clearing our staging directory cannot uninstall a working
67
+ // harness, and that boundary is why we only ever name files under `onboarding/`.
68
+ export function resolveOnboardingDir({
69
+ home = homedir(),
70
+ override = process.env.ARIZE_ONBOARDING_DIR,
71
+ } = {}) {
72
+ if (override) return resolve(override);
73
+ return join(home, '.arize', 'onboarding');
74
+ }
75
+
76
+ // Clear a staged bundle directory by naming every entry we could have put in it,
77
+ // then rmdir. Bails out — deleting nothing further — the moment it meets an entry
78
+ // it doesn't recognise. Wheel names carry a version we can't know ahead of time,
79
+ // so `.whl` is matched by extension, non-recursively, inside this one directory.
80
+ function clearBundleDir(dir) {
81
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
82
+ const known =
83
+ entry.name.endsWith('.whl') ||
84
+ OFFLINE_FILES.includes(entry.name) ||
85
+ BUNDLE_EXTRA_FILES.includes(entry.name);
86
+ if (entry.isDirectory() || !known) return false;
87
+ unlinkSync(join(dir, entry.name));
88
+ }
89
+ rmdirSync(dir);
90
+ return true;
91
+ }
92
+
93
+ // Empty the staging directory by deleting the exact files we create — never with
94
+ // a recursive delete. Two things fall out of that: a bad path can at worst try to
95
+ // unlink a handful of names that won't exist, and `rmdir` refuses a non-empty
96
+ // directory, so anything unexpected in there stops us instead of being destroyed.
97
+ // Unlinking a symlink removes the link and never the target.
98
+ //
99
+ // Returns true when the directory is gone or empty and safe to stage into.
100
+ export function clearOnboardingDir(dir) {
101
+ if (!isAbsolute(dir)) return false;
102
+ if (!existsSync(dir)) return true;
103
+
104
+ try {
105
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
106
+ const path = join(dir, entry.name);
107
+
108
+ if (entry.isDirectory()) {
109
+ // arize-offline/ is ours; .staging-* is the leftover of a crashed run.
110
+ const ours = entry.name === OFFLINE_DIR_NAME || entry.name.startsWith('.staging-');
111
+ if (!ours || !clearBundleDir(path)) return false;
112
+ continue;
113
+ }
114
+
115
+ if (entry.name !== PROMPT_FILE_NAME && entry.name !== ENV_FILE_NAME) return false;
116
+ unlinkSync(path);
117
+ }
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ // Clear and create the staging directory, degrading to a temp directory when the
125
+ // home path can't be used: a read-only home, a directory owned by another user
126
+ // after a past `sudo npx evals`, a full disk, an offline redirected profile on
127
+ // Windows, or an unrecognised file we refuse to delete. We attempt the work and
128
+ // degrade rather than probing for writability first — a probe lies on NFS and
129
+ // ACL filesystems, races, and can't see ENOSPC at all.
130
+ //
131
+ // Mode 0700 because Step 4A writes credentials into this directory.
132
+ export function prepareOnboardingDir(dir = resolveOnboardingDir()) {
133
+ if (clearOnboardingDir(dir)) {
134
+ try {
135
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
136
+ // mkdir's mode is umask-filtered, so set it outright. Windows has no POSIX
137
+ // mode; there the file inherits the profile's own ACL, as it does today.
138
+ if (process.platform !== 'win32') chmodSync(dir, 0o700);
139
+ return { dir, fellBack: false };
140
+ } catch {
141
+ // Fall through to the temp directory.
142
+ }
143
+ }
144
+ return { dir: mkdtempSync(join(tmpdir(), 'arize-onboarding-')), fellBack: true };
145
+ }
146
+
147
+ // Copy the bundled wheels next to the prompt. Returns the staged directory, or
148
+ // null when this package has no usable vendor/ (a git checkout that hasn't run
149
+ // the build). The shell launchers stage the same bundle themselves, fetching it
150
+ // from jsDelivr via vendor/MANIFEST.
151
+ //
152
+ // Never throws, and never leaves a partial directory behind. Both matter: the
153
+ // prompt decides which install path to take purely on whether this directory
154
+ // exists, so a half-copied one would send the agent offline with no wheel to
155
+ // install — and this is an optional enhancement, so a broken vendor/ must not
156
+ // take the launcher down with it. Either outcome degrades to the network path.
157
+ export function stageOfflineHarness(dir, vendorDir = VENDOR_DIR) {
158
+ if (!existsSync(vendorDir)) return null;
159
+
160
+ let staging;
161
+ try {
162
+ const wheels = readdirSync(vendorDir).filter((f) => f.endsWith('.whl'));
163
+ const hasHarnessWheel = wheels.some((f) => f.startsWith('coding_harness_tracing-'));
164
+ if (!hasHarnessWheel || !OFFLINE_FILES.every((f) => existsSync(join(vendorDir, f)))) return null;
165
+
166
+ // Build under a temp name and rename into place, so the directory the prompt
167
+ // looks for only ever appears complete. Same parent, so the rename is atomic.
168
+ staging = mkdtempSync(join(dir, '.staging-'));
169
+ for (const name of [...wheels, ...OFFLINE_FILES]) {
170
+ copyFileSync(join(vendorDir, name), join(staging, name));
171
+ }
172
+ chmodSync(join(staging, 'harness-install.sh'), 0o755);
173
+
174
+ const target = join(dir, OFFLINE_DIR_NAME);
175
+ renameSync(staging, target);
176
+ return target;
177
+ } catch {
178
+ if (staging) rmSync(staging, { recursive: true, force: true });
179
+ return null;
180
+ }
181
+ }
182
+
183
+ // Stage the bundled prompt and return the seed instruction that points the agent
184
+ // at it, plus whether we had to fall back off the home directory.
23
185
  function prepareSeedPrompt() {
24
186
  const promptText = readFileSync(BUNDLED_PROMPT_PATH, 'utf8');
25
- const dir = mkdtempSync(join(tmpdir(), 'arize-onboarding-'));
26
- const promptFile = join(dir, 'onboarding-prompt.md');
187
+ const { dir, fellBack } = prepareOnboardingDir();
188
+ const promptFile = join(dir, PROMPT_FILE_NAME);
27
189
  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.`;
190
+ stageOfflineHarness(dir);
191
+ // Single quotes, not double: a home path is likelier to contain a space than a
192
+ // temp path was, but Windows spawns through the shell and embedded double
193
+ // quotes don't survive that reliably. cmd.exe leaves single quotes alone.
194
+ // No "in this project": the launcher may well be run from a home directory, and
195
+ // Step 4 is what scopes the work — an app here, an app at another path, a starter
196
+ // app, or the coding agent itself. Keep this wording in step with start.{sh,ps1}.
197
+ 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.`;
198
+ return { seed, dir, fellBack };
29
199
  }
30
200
 
31
201
  // Coding agents we can launch interactively, seeded with the prompt.
@@ -291,17 +461,26 @@ function App({ onDone }) {
291
461
  function launchAgent(agent) {
292
462
  const isWin = process.platform === 'win32';
293
463
 
294
- let seed;
464
+ let prepared;
295
465
  try {
296
- seed = prepareSeedPrompt();
466
+ prepared = prepareSeedPrompt();
297
467
  } catch (err) {
298
- console.error(`Could not read the onboarding prompt: ${err.message}`);
468
+ console.error(`Could not prepare the onboarding prompt: ${err.message}`);
299
469
  process.exit(1);
300
470
  }
301
471
 
472
+ // Say so when we couldn't use the home directory. Otherwise the seed path
473
+ // silently reverts to the /var/folders/… form this change exists to avoid, and
474
+ // Step 4A is going to fail later anyway — it writes the harness and its
475
+ // credentials under ~/.arize regardless of where the prompt was staged.
476
+ if (prepared.fellBack) {
477
+ console.log(`\nCouldn't use ${resolveOnboardingDir()} — staged in ${prepared.dir} instead.`);
478
+ console.log('Instrumenting an app still works; tracing this coding agent (Step 4A) needs a writable home directory.');
479
+ }
480
+
302
481
  console.log(`\nLaunching ${agent.label}…\n`);
303
482
 
304
- const child = spawn(agent.bin, agent.args(seed), {
483
+ const child = spawn(agent.bin, agent.args(prepared.seed), {
305
484
  stdio: 'inherit',
306
485
  shell: isWin // .cmd/.bat shims on Windows need the shell to resolve
307
486
  });