newmeasure-cli 0.2.0__tar.gz → 0.2.1__tar.gz

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.
@@ -12,3 +12,7 @@ credentials.env
12
12
  tasks/
13
13
  build-docs/
14
14
  docs/
15
+ TASKS.md
16
+ .claude/
17
+ # Leftover local data of the retired review app (expert reviews, sandbox archives, builds).
18
+ /review/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newmeasure-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: NewMeasure eval CLI
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.28
@@ -68,6 +68,31 @@ newm result <run-id>
68
68
  > [!NOTE]
69
69
  > Task IDs persist across uploads (the first upload writes `task_id` into `task.toml`). Each execution gets a fresh trial ID.
70
70
 
71
+ ## Preview environments and saved logins
72
+
73
+ In the app, open **Manage logins** from the Run dialog to create a personal CLI token, then:
74
+
75
+ ```bash
76
+ newm login --evals
77
+ newm logins
78
+ newm run t_abc123 --url https://my-branch.solstice.co --n-trials 3
79
+ newm run tasks/ --url https://my-branch.solstice.co --login "Preview team"
80
+ newm run t_abc123 --prompt-credentials --save-login "My preview"
81
+ # Add --share-login to make the saved login available to your organization.
82
+ newm run t_abc123 --from-run e_previous --login "My preview"
83
+ ```
84
+
85
+ The personal CLI token supplements your customer API key (`newm login`), identifying whose saved
86
+ logins to use. Tokens expire after 90 days and can be revoked from Manage logins. Omit `--login`
87
+ to use the NewMeasure account. Saved logins are personal unless shared; `newm logins` shows who
88
+ saved each one. Passwords are prompted privately and never accepted as command arguments.
89
+
90
+ Trials are limited to **1–5 per task** in both the app and CLI. Every launch creates a separate
91
+ entry on Runs. `--from-run` links the original and inherits its environment unless `--url` is set;
92
+ it does not reuse the original login. Without either option the task's staging URL is used.
93
+ Solstice previews must use HTTPS on a subdomain of `solstice.co`, without user info, query strings
94
+ or fragments. The override applies only to the prepared run, never to your task files.
95
+
71
96
  ## Browser tasks
72
97
 
73
98
  Browser tasks test a web product: an AI agent uses it in a cloud browser. Put the application login in a local env file, using the field names the task declares in `credential_fields`:
@@ -57,6 +57,31 @@ newm result <run-id>
57
57
  > [!NOTE]
58
58
  > Task IDs persist across uploads (the first upload writes `task_id` into `task.toml`). Each execution gets a fresh trial ID.
59
59
 
60
+ ## Preview environments and saved logins
61
+
62
+ In the app, open **Manage logins** from the Run dialog to create a personal CLI token, then:
63
+
64
+ ```bash
65
+ newm login --evals
66
+ newm logins
67
+ newm run t_abc123 --url https://my-branch.solstice.co --n-trials 3
68
+ newm run tasks/ --url https://my-branch.solstice.co --login "Preview team"
69
+ newm run t_abc123 --prompt-credentials --save-login "My preview"
70
+ # Add --share-login to make the saved login available to your organization.
71
+ newm run t_abc123 --from-run e_previous --login "My preview"
72
+ ```
73
+
74
+ The personal CLI token supplements your customer API key (`newm login`), identifying whose saved
75
+ logins to use. Tokens expire after 90 days and can be revoked from Manage logins. Omit `--login`
76
+ to use the NewMeasure account. Saved logins are personal unless shared; `newm logins` shows who
77
+ saved each one. Passwords are prompted privately and never accepted as command arguments.
78
+
79
+ Trials are limited to **1–5 per task** in both the app and CLI. Every launch creates a separate
80
+ entry on Runs. `--from-run` links the original and inherits its environment unless `--url` is set;
81
+ it does not reuse the original login. Without either option the task's staging URL is used.
82
+ Solstice previews must use HTTPS on a subdomain of `solstice.co`, without user info, query strings
83
+ or fragments. The override applies only to the prepared run, never to your task files.
84
+
60
85
  ## Browser tasks
61
86
 
62
87
  Browser tasks test a web product: an AI agent uses it in a cloud browser. Put the application login in a local env file, using the field names the task declares in `credential_fields`:
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import typer
5
+
6
+ from cli.config import get
7
+
8
+
9
+ def client() -> httpx.Client:
10
+ api_url = get("api_url")
11
+ token = get("token")
12
+ if not token:
13
+ typer.echo("Not logged in. Run: newm login")
14
+ raise typer.Exit(1)
15
+ return httpx.Client(
16
+ base_url=api_url,
17
+ headers={"Authorization": f"Bearer {token}"},
18
+ timeout=300,
19
+ )
20
+
21
+
22
+ def evals_client() -> httpx.Client:
23
+ token = get("evals_token")
24
+ if not token:
25
+ raise typer.BadParameter(
26
+ "Connect your app account with newm login --evals (create a token at /settings/runs in the app)"
27
+ )
28
+ url = get("evals_url")
29
+ from urllib.parse import urlsplit
30
+
31
+ p = urlsplit(url)
32
+ if p.scheme != "https" and p.hostname not in ("localhost", "127.0.0.1", "::1"):
33
+ raise typer.BadParameter("CLI access requires HTTPS")
34
+ return httpx.Client(
35
+ base_url=url.rstrip("/") + "/",
36
+ headers={"Authorization": f"Bearer {token}"},
37
+ timeout=300,
38
+ )
@@ -16,10 +16,14 @@ admin_app = typer.Typer(help="Admin commands")
16
16
  def create_token(
17
17
  customer: str = typer.Option(..., "--customer", help="Customer name"),
18
18
  agent_url: str = typer.Option(..., "--agent-url", help="Customer's agent URL"),
19
- agent_protocol: str = typer.Option(..., "--agent-protocol", help="Protocol (eve, mastra)"),
19
+ agent_protocol: str = typer.Option(
20
+ ..., "--agent-protocol", help="Protocol (eve, mastra)"
21
+ ),
20
22
  agent_auth_user: Optional[str] = typer.Option(None, "--agent-auth-user"),
21
23
  agent_auth_pass: Optional[str] = typer.Option(None, "--agent-auth-pass"),
22
- mcp_token: Optional[str] = typer.Option(None, "--mcp-token", help="Comma-separated MCP tokens for routing"),
24
+ mcp_token: Optional[str] = typer.Option(
25
+ None, "--mcp-token", help="Comma-separated MCP tokens for routing"
26
+ ),
23
27
  ):
24
28
  """Create a new customer API token (requires admin auth)."""
25
29
  import httpx
@@ -6,11 +6,14 @@ from rich.prompt import Prompt
6
6
  from cli import config
7
7
 
8
8
 
9
- def login():
9
+ def login(evals: bool = False):
10
10
  """Store API token."""
11
11
  cfg = config.load()
12
12
 
13
- cfg["token"] = Prompt.ask("API Token", password=True)
13
+ cfg["evals_token" if evals else "token"] = Prompt.ask(
14
+ "Personal CLI token from Manage logins in the app" if evals else "API Token",
15
+ password=True,
16
+ )
14
17
 
15
18
  config.save(cfg)
16
19
  typer.echo(f"Saved to {config.CONFIG_PATH}")
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import typer
4
3
  from rich.console import Console
5
4
  from rich.table import Table
6
5
 
@@ -44,7 +43,9 @@ def result(run_id: str):
44
43
  reward = f"{t['reward']:.2f}" if t.get("reward") is not None else "-"
45
44
  dur = f"{t['duration_sec']:.1f}s" if t.get("duration_sec") else "-"
46
45
  err = (t.get("error") or "")[:50]
47
- table.add_row(t["id"], t["status"], reward, dur, t.get("last_stage") or "-", err)
46
+ table.add_row(
47
+ t["id"], t["status"], reward, dur, t.get("last_stage") or "-", err
48
+ )
48
49
 
49
50
  console.print(table)
50
51
 
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+ import tomllib
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+
9
+ import typer
10
+ from dotenv import dotenv_values
11
+ from rich.console import Console
12
+
13
+ from cli.api_client import client, evals_client
14
+ from cli.commands.upload import upload
15
+ from cli.progress import progress_lines
16
+
17
+ console = Console()
18
+
19
+
20
+ def task_dirs(target: Path) -> list[Path]:
21
+ """The task folders TARGET names: itself, or every task folder in it (or in its tasks/)."""
22
+ if not target.is_dir():
23
+ return []
24
+ if (target / "task.toml").is_file():
25
+ return [target]
26
+ for root in (target, target / "tasks"):
27
+ found = sorted(p.parent for p in root.glob("*/task.toml"))
28
+ if found:
29
+ return found
30
+ raise typer.BadParameter(f"No task folders (with task.toml) in {target}")
31
+
32
+
33
+ def _task_id(task_dir: Path) -> str:
34
+ upload(task_dir)
35
+ task_id = tomllib.loads((task_dir / "task.toml").read_text()).get("task_id")
36
+ if not task_id:
37
+ typer.echo(f"Failed to get task_id after uploading {task_dir}")
38
+ raise typer.Exit(1)
39
+ return task_id
40
+
41
+
42
+ def _credentials(c, tasks: list[dict], credentials_file: Path | None) -> dict:
43
+ """The fields the tasks need, from the local file; never sent over remote plain HTTP."""
44
+ url = urlparse(str(c.base_url))
45
+ if url.scheme != "https" and url.hostname not in {"127.0.0.1", "localhost", "::1"}:
46
+ raise typer.BadParameter(
47
+ "Application credentials require an HTTPS NewMeasure API URL"
48
+ )
49
+ if credentials_file is None or not credentials_file.is_file():
50
+ raise typer.BadParameter(
51
+ "Browser runs require --credentials-file pointing to a local environment file"
52
+ )
53
+ values = dotenv_values(credentials_file, interpolate=False)
54
+ fields = sorted({f for t in tasks for f in t.get("credential_fields", [])})
55
+ if any(not values.get(field) for field in fields):
56
+ raise typer.BadParameter(f"Credential file must define: {', '.join(fields)}")
57
+ return {field: values[field] for field in fields}
58
+
59
+
60
+ def _who(where: Path) -> str:
61
+ """Who is starting the run, for the evals app: git's user.email where the tasks are."""
62
+ try:
63
+ got = subprocess.run(
64
+ ["git", "-C", str(where), "config", "user.email"],
65
+ check=False,
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=5,
69
+ )
70
+ except (OSError, subprocess.SubprocessError):
71
+ return ""
72
+ return got.stdout.strip()[:200]
73
+
74
+
75
+ def task_kind(task_dir: Path) -> str:
76
+ """The task's kind from [metadata.evals] kind in task.toml: "banner", or "email" when unset."""
77
+ config = tomllib.loads((task_dir / "task.toml").read_text())
78
+ return config.get("metadata", {}).get("evals", {}).get("kind") or "email"
79
+
80
+
81
+ def run(
82
+ target: str,
83
+ n_trials: int = 1,
84
+ credentials_file: Path | None = None,
85
+ wait: bool = True,
86
+ kind: str | None = None,
87
+ url: str | None = None,
88
+ saved_login: str | None = None,
89
+ prompt_credentials: bool = False,
90
+ save_login: str | None = None,
91
+ share_login: bool = False,
92
+ parent: str | None = None,
93
+ ):
94
+ """Run an eval. TARGET is a task ID (t_...), a task folder, or a folder of task folders (such
95
+ as tasks/). Browser tasks run together as one eval run, graded and shown in the evals app.
96
+ KIND keeps only task folders of that kind (email or banner)."""
97
+ if not 1 <= n_trials <= 5:
98
+ raise typer.BadParameter("Trials must be 1 to 5")
99
+ if sum(bool(x) for x in (credentials_file, saved_login, prompt_credentials)) > 1:
100
+ raise typer.BadParameter("Choose only one login source")
101
+ if save_login and not (credentials_file or prompt_credentials):
102
+ raise typer.BadParameter("Saving a login requires credentials")
103
+ if share_login and not save_login:
104
+ raise typer.BadParameter("--share-login requires --save-login")
105
+ dirs = task_dirs(Path(target))
106
+ if kind:
107
+ if not dirs:
108
+ raise typer.BadParameter(
109
+ "--kind needs a task folder or a folder of task folders"
110
+ )
111
+ dirs = [d for d in dirs if task_kind(d) == kind]
112
+ if not dirs:
113
+ raise typer.BadParameter(f"No {kind} tasks in {target}")
114
+ task_ids = [_task_id(d) for d in dirs] if dirs else [target]
115
+ c = client()
116
+ tasks = []
117
+ for task_id in task_ids:
118
+ response = c.get(f"/api/v1/tasks/{task_id}")
119
+ response.raise_for_status()
120
+ tasks.append(response.json())
121
+ if not all(t.get("runner") == "browser" for t in tasks):
122
+ if len(tasks) > 1:
123
+ raise typer.BadParameter("Only browser tasks can run together")
124
+ if (
125
+ credentials_file
126
+ or url
127
+ or saved_login
128
+ or prompt_credentials
129
+ or save_login
130
+ or parent
131
+ ):
132
+ raise typer.BadParameter(
133
+ "This task does not accept per-run browser credentials"
134
+ )
135
+ return _run_one(c, task_ids[0], n_trials, wait)
136
+
137
+ use_app = bool(
138
+ not credentials_file
139
+ or saved_login
140
+ or prompt_credentials
141
+ or save_login
142
+ or parent
143
+ )
144
+ values = _credentials(c, tasks, credentials_file) if credentials_file else {}
145
+ if prompt_credentials:
146
+ from rich.prompt import Prompt
147
+
148
+ values = {
149
+ "username": Prompt.ask("Username"),
150
+ "password": Prompt.ask("Password", password=True),
151
+ }
152
+ body = {"task_ids": task_ids, "n_trials": n_trials, "target_url": url or ""}
153
+ if use_app:
154
+ body.update(
155
+ login=saved_login or ("custom" if values else "default"),
156
+ **values,
157
+ save_login=save_login or "",
158
+ share_login=share_login,
159
+ parent_eval_id=parent or "",
160
+ )
161
+ with evals_client() as ec:
162
+ resp = ec.post("cli/evals", json=body)
163
+ else:
164
+ # Existing scripts using explicit files continue to work without a personal token.
165
+ body.update(
166
+ credentials=values,
167
+ source="cli",
168
+ created_by=_who(dirs[0] if dirs else Path.cwd()),
169
+ )
170
+ resp = c.post("/api/v1/evals", json=body)
171
+ values.clear()
172
+ body.clear()
173
+ resp.raise_for_status()
174
+ started = resp.json()
175
+ eval_id, runs = started["eval_id"], started["runs"]
176
+ plural = "s" if len(runs) != 1 else ""
177
+ console.print(
178
+ f"Eval run started: [bold]{eval_id}[/bold] ({len(runs)} task{plural})"
179
+ )
180
+ url = started.get("results_url")
181
+ if not wait:
182
+ if url:
183
+ console.print(f"Results: {url}")
184
+ return
185
+ if len(runs) == 1:
186
+ try:
187
+ _wait_run(c, runs[0]["run_id"])
188
+ finally:
189
+ if url:
190
+ console.print(f"Results: {url}")
191
+ return
192
+ _wait_eval(c, eval_id, url)
193
+
194
+
195
+ def _run_one(c, task_id: str, n_trials: int, wait: bool) -> None:
196
+ """One task of a hosted HTTP agent (not graded by the evals app)."""
197
+ resp = c.post("/api/v1/runs", json={"task_id": task_id, "n_trials": n_trials})
198
+ resp.raise_for_status()
199
+ run_id = resp.json()["run_id"]
200
+ console.print(f"Run started: [bold]{run_id}[/bold]")
201
+ if wait:
202
+ _wait_run(c, run_id)
203
+
204
+
205
+ def _wait_run(c, run_id: str) -> None:
206
+ console.print("Waiting for results...")
207
+ shown = 0
208
+ while True:
209
+ time.sleep(3)
210
+ resp = c.get(f"/api/v1/runs/{run_id}")
211
+ resp.raise_for_status()
212
+ data = resp.json()
213
+ status = data["status"]
214
+ lines = progress_lines(data.get("progress", []))
215
+ for line in lines[shown:]:
216
+ console.print(line)
217
+ shown = len(lines)
218
+
219
+ if status == "completed":
220
+ lines, all_failed = summarize(data)
221
+ for line in lines:
222
+ console.print(line)
223
+ if all_failed:
224
+ raise typer.Exit(1)
225
+ break
226
+ elif status == "failed":
227
+ console.print("[red]Run failed.[/red]")
228
+ raise typer.Exit(1)
229
+
230
+
231
+ def _wait_eval(c, eval_id: str, url: str | None) -> None:
232
+ """Print each task as it finishes, then every task's result and where to see the scores."""
233
+ console.print("Waiting for results (each task is printed as it finishes)...")
234
+ done: set[str] = set()
235
+ while True:
236
+ time.sleep(10)
237
+ resp = c.get(f"/api/v1/evals/{eval_id}")
238
+ resp.raise_for_status()
239
+ data = resp.json()
240
+ for r in data["runs"]:
241
+ if r["status"] in ("completed", "failed") and r["id"] not in done:
242
+ done.add(r["id"])
243
+ console.print(eval_line(r))
244
+ if data["status"] in ("completed", "failed"):
245
+ break
246
+ ok = sum(r["status"] == "completed" for r in data["runs"])
247
+ console.print(f"Done. {ok} of {len(data['runs'])} tasks completed.")
248
+ if url:
249
+ console.print(f"Scores: {url}")
250
+ if ok == 0:
251
+ raise typer.Exit(1)
252
+
253
+
254
+ def eval_line(r: dict) -> str:
255
+ name = r["task"].rsplit("/", 1)[-1]
256
+ if r["status"] == "failed":
257
+ return f" [red]{name}: failed[/red]"
258
+ reward = r.get("avg_reward")
259
+ return f" {name}: " + (
260
+ f"reward {reward:.2f}" if reward is not None else "no reward"
261
+ )
262
+
263
+
264
+ def summarize(data: dict) -> tuple[list[str], bool]:
265
+ """Final lines for a completed run, and whether every trial failed."""
266
+ trials = data.get("trials", [])
267
+ n = len(trials)
268
+ failed = [t for t in trials if t.get("status") == "failed"]
269
+ plural = "s" if n != 1 else ""
270
+ avg = data.get("avg_reward")
271
+ head = f"Done. {n} trial{plural}" + (
272
+ f", avg reward = {avg:.2f}" if avg is not None else ""
273
+ )
274
+ if not failed:
275
+ return [head + "."], False
276
+ lines = [f"[red]{head}. {len(failed)} of {n} trial{plural} failed:[/red]"]
277
+ for t in failed:
278
+ why = t.get("error") or "no error recorded"
279
+ stage = f" (last stage: {t['last_stage']})" if t.get("last_stage") else ""
280
+ lines.append(f" {t['id']}: {why}{stage}")
281
+ return lines, len(failed) == n
@@ -116,7 +116,9 @@ def upload_browser(task_dir: Path, config: dict):
116
116
  if path.is_symlink():
117
117
  raise typer.BadParameter("Task files must not be symlinks")
118
118
  info = zipfile.ZipInfo(path.relative_to(task_dir).as_posix())
119
- info.external_attr = (path.stat().st_mode & 0o777) << 16 # keep test.sh executable
119
+ info.external_attr = (
120
+ path.stat().st_mode & 0o777
121
+ ) << 16 # keep test.sh executable
120
122
  archive.writestr(info, path.read_bytes(), zipfile.ZIP_DEFLATED)
121
123
  if not (task_dir / "product.md").exists():
122
124
  # Shared task context is packaged by the CLI, never resolved on the server.
@@ -9,6 +9,8 @@ CONFIG_PATH = CONFIG_DIR / "config.json"
9
9
  DEFAULTS = {
10
10
  "api_url": "https://api.newmeasure.cc",
11
11
  "token": "",
12
+ "evals_url": "https://review.mocks.newmeasure.cc/evals-api",
13
+ "evals_token": "",
12
14
  }
13
15
 
14
16
 
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+
6
+ def describe(response: httpx.Response) -> str:
7
+ """The server's reason for an error response, as one line."""
8
+ try:
9
+ body = response.json()
10
+ except ValueError:
11
+ return f"{response.reason_phrase or 'Request failed'} from {response.url.host}"
12
+ detail = body.get("detail") if isinstance(body, dict) else None
13
+ if isinstance(detail, str) and detail:
14
+ return detail
15
+ if isinstance(detail, list) and detail:
16
+ parts = []
17
+ for item in detail:
18
+ if not isinstance(item, dict):
19
+ parts.append(str(item))
20
+ continue
21
+ loc = ".".join(str(p) for p in item.get("loc", []) if p != "body")
22
+ msg = item.get("msg", "invalid")
23
+ parts.append(f"{loc}: {msg}" if loc else msg)
24
+ return "; ".join(parts)
25
+ return response.reason_phrase or "Request failed"
26
+
27
+
28
+ def http_error_message(exc: httpx.HTTPStatusError) -> str:
29
+ response = exc.response
30
+ request = exc.request
31
+ message = (
32
+ f"Error: {describe(response)} "
33
+ f"(HTTP {response.status_code} on {request.method} {request.url.path})"
34
+ )
35
+ if response.status_code == 401:
36
+ message += "\nCheck your API token: newm login"
37
+ return message
38
+
39
+
40
+ def transport_error_message(exc: httpx.TransportError) -> str:
41
+ try:
42
+ target = f"{exc.request.url.scheme}://{exc.request.url.host}"
43
+ except RuntimeError:
44
+ target = "the NewMeasure API"
45
+ return f"Error: couldn't reach {target} ({type(exc).__name__}: {exc})"
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import typer
9
+
10
+ from cli.commands.admin import admin_app
11
+ from cli.commands.artifacts import artifacts as artifacts_cmd
12
+ from cli.commands.login import login as login_cmd
13
+ from cli.commands.result import result as result_cmd
14
+ from cli.commands.run import run as run_cmd
15
+ from cli.commands.runs import list_runs
16
+ from cli.commands.tasks import list_tasks
17
+ from cli.commands.upload import upload as upload_cmd
18
+ from cli.errors import http_error_message, transport_error_message
19
+
20
+ app = typer.Typer(
21
+ name="newm",
22
+ help="NewMeasure eval CLI",
23
+ no_args_is_help=True,
24
+ )
25
+ app.add_typer(admin_app, name="admin")
26
+
27
+
28
+ @app.command()
29
+ def login(
30
+ evals: bool = typer.Option(
31
+ False, "--evals", help="Connect your personal app account"
32
+ ),
33
+ ):
34
+ """Store API token."""
35
+ login_cmd(evals)
36
+
37
+
38
+ @app.command()
39
+ def upload(task_dir: Path = typer.Argument(..., help="Path to task directory")):
40
+ """Validate and upload a task."""
41
+ upload_cmd(task_dir)
42
+
43
+
44
+ @app.command()
45
+ def run(
46
+ target: str = typer.Argument(
47
+ ...,
48
+ help="Task ID (t_...), a task folder, or a folder of task folders (e.g. tasks/)",
49
+ ),
50
+ n_trials: int = typer.Option(
51
+ 1, "--n-trials", min=1, max=5, help="Trials per task (1–5)"
52
+ ),
53
+ credentials_file: Path | None = typer.Option(
54
+ None,
55
+ "--credentials-file",
56
+ help="Per-run application credentials (not uploaded as a file)",
57
+ ),
58
+ url: str | None = typer.Option(None, "--url", help="HTTPS target URL for this run"),
59
+ saved_login: str | None = typer.Option(
60
+ None, "--login", help="Saved login name or ID"
61
+ ),
62
+ prompt_credentials: bool = typer.Option(
63
+ False,
64
+ "--prompt-credentials",
65
+ help="Prompt for application username and password",
66
+ ),
67
+ save_login: str | None = typer.Option(
68
+ None, "--save-login", help="Save the supplied login under this name"
69
+ ),
70
+ share_login: bool = typer.Option(
71
+ False, "--share-login", help="Share the saved login with your organization"
72
+ ),
73
+ parent: str | None = typer.Option(
74
+ None,
75
+ "--from-run",
76
+ help="Link to an original run; reuse its environment unless --url is set",
77
+ ),
78
+ wait: bool = typer.Option(
79
+ True, "--wait/--no-wait", help="Wait for results after submission"
80
+ ),
81
+ kind: str | None = typer.Option(
82
+ None,
83
+ "--kind",
84
+ help="Only tasks of this kind: email or banner (kind in task.toml under metadata.evals)",
85
+ ),
86
+ ):
87
+ """Run an eval. Several browser tasks run together as one eval run."""
88
+ run_cmd(
89
+ target=target,
90
+ n_trials=n_trials,
91
+ credentials_file=credentials_file,
92
+ wait=wait,
93
+ kind=kind,
94
+ url=url,
95
+ saved_login=saved_login,
96
+ prompt_credentials=prompt_credentials,
97
+ save_login=save_login,
98
+ share_login=share_login,
99
+ parent=parent,
100
+ )
101
+
102
+
103
+ @app.command()
104
+ def artifacts(
105
+ run_id: str,
106
+ trial_id: str,
107
+ output: Path | None = typer.Option(
108
+ None, "--output", help="Download artifacts to this directory"
109
+ ),
110
+ ):
111
+ """List or download a trial's stored artifacts."""
112
+ artifacts_cmd(run_id, trial_id, output)
113
+
114
+
115
+ @app.command()
116
+ def tasks():
117
+ """List uploaded tasks."""
118
+ list_tasks()
119
+
120
+
121
+ @app.command()
122
+ def runs(task_id: str | None = typer.Argument(None, help="Filter by task ID")):
123
+ """List runs."""
124
+ list_runs(task_id)
125
+
126
+
127
+ @app.command()
128
+ def result(run_id: str = typer.Argument(..., help="Run ID")):
129
+ """Get detailed run results."""
130
+ result_cmd(run_id)
131
+
132
+
133
+ @app.command(
134
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
135
+ )
136
+ def harbor(ctx: typer.Context):
137
+ """Passthrough to Harbor CLI (power users)."""
138
+ cmd = ["harbor", *ctx.args]
139
+ sys.exit(subprocess.call(cmd))
140
+
141
+
142
+ def main() -> None:
143
+ """Entry point: show the server's reason for a failed request, not a traceback."""
144
+ try:
145
+ app()
146
+ except httpx.HTTPStatusError as exc:
147
+ typer.secho(http_error_message(exc), err=True, fg=typer.colors.RED)
148
+ raise SystemExit(1) from None
149
+ except httpx.TransportError as exc:
150
+ typer.secho(transport_error_message(exc), err=True, fg=typer.colors.RED)
151
+ raise SystemExit(1) from None
152
+
153
+
154
+ @app.command("logins")
155
+ def logins():
156
+ """List the same personal and shared logins as the app."""
157
+ from cli.api_client import evals_client
158
+
159
+ with evals_client() as c:
160
+ r = c.get("run-logins")
161
+ r.raise_for_status()
162
+ for entry in r.json()["logins"]:
163
+ typer.echo(
164
+ f"{entry['id']} {entry['name']} {'Organization' if entry['shared'] else 'Personal'} Saved by {entry['saved_by']}"
165
+ )
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "newmeasure-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "NewMeasure eval CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -25,4 +25,4 @@ packages = ["cli"]
25
25
  include = ["/cli", "/README.md", "/pyproject.toml"]
26
26
 
27
27
  [project.scripts]
28
- newm = "cli.main:app"
28
+ newm = "cli.main:main"
@@ -1,19 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import httpx
4
- import typer
5
-
6
- from cli.config import get
7
-
8
-
9
- def client() -> httpx.Client:
10
- api_url = get("api_url")
11
- token = get("token")
12
- if not token:
13
- typer.echo("Not logged in. Run: newm login")
14
- raise typer.Exit(1)
15
- return httpx.Client(
16
- base_url=api_url,
17
- headers={"Authorization": f"Bearer {token}"},
18
- timeout=300,
19
- )
@@ -1,120 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import time
4
- from pathlib import Path
5
- from urllib.parse import urlparse
6
-
7
- import typer
8
- from dotenv import dotenv_values
9
- from rich.console import Console
10
-
11
- from cli.api_client import client
12
- from cli.progress import progress_lines
13
- from cli.commands.upload import upload
14
-
15
- console = Console()
16
-
17
-
18
- def run(
19
- target: str,
20
- n_trials: int = 1,
21
- credentials_file: Path | None = None,
22
- wait: bool = True,
23
- ):
24
- """Run an eval. TARGET is a task ID (t_...) or a local task directory path."""
25
- task_id = target
26
- target_path = Path(target)
27
- if target_path.is_dir():
28
- upload(target_path)
29
- import tomllib
30
-
31
- toml_text = (target_path / "task.toml").read_text()
32
- task_id = tomllib.loads(toml_text).get("task_id")
33
- if not task_id:
34
- typer.echo("Failed to get task_id after upload")
35
- raise typer.Exit(1)
36
-
37
- c = client()
38
- body = {
39
- "task_id": task_id,
40
- "n_trials": n_trials,
41
- }
42
-
43
- task_response = c.get(f"/api/v1/tasks/{task_id}")
44
- task_response.raise_for_status()
45
- task = task_response.json()
46
- if task.get("runner") == "browser":
47
- url = urlparse(str(c.base_url))
48
- if url.scheme != "https" and url.hostname not in {
49
- "127.0.0.1",
50
- "localhost",
51
- "::1",
52
- }:
53
- raise typer.BadParameter(
54
- "Application credentials require an HTTPS NewMeasure API URL"
55
- )
56
- if credentials_file is None or not credentials_file.is_file():
57
- raise typer.BadParameter(
58
- "Browser runs require --credentials-file pointing to a local environment file"
59
- )
60
- values = dotenv_values(credentials_file, interpolate=False)
61
- fields = task.get("credential_fields", [])
62
- if any(not values.get(field) for field in fields):
63
- raise typer.BadParameter(
64
- f"Credential file must define: {', '.join(fields)}"
65
- )
66
- body["credentials"] = {field: values[field] for field in fields}
67
- elif credentials_file:
68
- raise typer.BadParameter(
69
- "This task does not accept per-run browser credentials"
70
- )
71
-
72
- resp = c.post("/api/v1/runs", json=body)
73
- body.pop("credentials", None)
74
- resp.raise_for_status()
75
- run_id = resp.json()["run_id"]
76
- console.print(f"Run started: [bold]{run_id}[/bold]")
77
- if not wait:
78
- return
79
-
80
- console.print("Waiting for results...")
81
- shown = 0
82
- while True:
83
- time.sleep(3)
84
- resp = c.get(f"/api/v1/runs/{run_id}")
85
- resp.raise_for_status()
86
- data = resp.json()
87
- status = data["status"]
88
- lines = progress_lines(data.get("progress", []))
89
- for line in lines[shown:]:
90
- console.print(line)
91
- shown = len(lines)
92
-
93
- if status == "completed":
94
- lines, all_failed = summarize(data)
95
- for line in lines:
96
- console.print(line)
97
- if all_failed:
98
- raise typer.Exit(1)
99
- break
100
- elif status == "failed":
101
- console.print("[red]Run failed.[/red]")
102
- raise typer.Exit(1)
103
-
104
-
105
- def summarize(data: dict) -> tuple[list[str], bool]:
106
- """Final lines for a completed run, and whether every trial failed."""
107
- trials = data.get("trials", [])
108
- n = len(trials)
109
- failed = [t for t in trials if t.get("status") == "failed"]
110
- plural = "s" if n != 1 else ""
111
- avg = data.get("avg_reward")
112
- head = f"Done. {n} trial{plural}" + (f", avg reward = {avg:.2f}" if avg is not None else "")
113
- if not failed:
114
- return [head + "."], False
115
- lines = [f"[red]{head}. {len(failed)} of {n} trial{plural} failed:[/red]"]
116
- for t in failed:
117
- why = t.get("error") or "no error recorded"
118
- stage = f" (last stage: {t['last_stage']})" if t.get("last_stage") else ""
119
- lines.append(f" {t['id']}: {why}{stage}")
120
- return lines, len(failed) == n
@@ -1,98 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import subprocess
4
- import sys
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- import typer
9
-
10
- from cli.commands.admin import admin_app
11
- from cli.commands.artifacts import artifacts as artifacts_cmd
12
- from cli.commands.login import login as login_cmd
13
- from cli.commands.result import result as result_cmd
14
- from cli.commands.run import run as run_cmd
15
- from cli.commands.runs import list_runs
16
- from cli.commands.tasks import list_tasks
17
- from cli.commands.upload import upload as upload_cmd
18
-
19
- app = typer.Typer(
20
- name="newm",
21
- help="NewMeasure eval CLI",
22
- no_args_is_help=True,
23
- )
24
- app.add_typer(admin_app, name="admin")
25
-
26
-
27
- @app.command()
28
- def login():
29
- """Store API token."""
30
- login_cmd()
31
-
32
-
33
- @app.command()
34
- def upload(task_dir: Path = typer.Argument(..., help="Path to task directory")):
35
- """Validate and upload a task."""
36
- upload_cmd(task_dir)
37
-
38
-
39
- @app.command()
40
- def run(
41
- target: str = typer.Argument(..., help="Task ID (t_...) or local task directory"),
42
- n_trials: int = typer.Option(1, "--n-trials", help="Number of trials"),
43
- credentials_file: Optional[Path] = typer.Option(
44
- None,
45
- "--credentials-file",
46
- help="Per-run application credentials (not uploaded as a file)",
47
- ),
48
- wait: bool = typer.Option(
49
- True, "--wait/--no-wait", help="Wait for results after submission"
50
- ),
51
- ):
52
- """Run an eval against your agent."""
53
- run_cmd(
54
- target=target, n_trials=n_trials, credentials_file=credentials_file, wait=wait
55
- )
56
-
57
-
58
- @app.command()
59
- def artifacts(
60
- run_id: str,
61
- trial_id: str,
62
- output: Optional[Path] = typer.Option(
63
- None, "--output", help="Download artifacts to this directory"
64
- ),
65
- ):
66
- """List or download a trial's stored artifacts."""
67
- artifacts_cmd(run_id, trial_id, output)
68
-
69
-
70
- @app.command()
71
- def tasks():
72
- """List uploaded tasks."""
73
- list_tasks()
74
-
75
-
76
- @app.command()
77
- def runs(task_id: Optional[str] = typer.Argument(None, help="Filter by task ID")):
78
- """List runs."""
79
- list_runs(task_id)
80
-
81
-
82
- @app.command()
83
- def result(run_id: str = typer.Argument(..., help="Run ID")):
84
- """Get detailed run results."""
85
- result_cmd(run_id)
86
-
87
-
88
- @app.command(
89
- context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
90
- )
91
- def harbor(ctx: typer.Context):
92
- """Passthrough to Harbor CLI (power users)."""
93
- cmd = ["harbor", *ctx.args]
94
- sys.exit(subprocess.call(cmd))
95
-
96
-
97
- if __name__ == "__main__":
98
- app()