newmeasure-cli 0.2.0__tar.gz → 0.3.0__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.3.0
4
4
  Summary: NewMeasure eval CLI
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.28
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  *Upload eval tasks, start cloud runs, and download results with `newm`*
18
18
 
19
- [Install](#install) • [Usage](#usage) • [Browser tasks](#browser-tasks) • [Commands](#commands) • [Development](#development)
19
+ [Install](#install) • [Usage](#usage) • [Browser tasks](#browser-tasks) • [Edit tasks](#edit-tasks) • [Commands](#commands) • [Development](#development)
20
20
 
21
21
  </div>
22
22
 
@@ -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`:
@@ -95,6 +120,28 @@ Downloads include `outputs/` (the files the product generated, such as PDF and H
95
120
 
96
121
  A browser task bundle contains `task.toml`, `instruction.md`, and, when present, `scenario.yaml`, `product.md` (or `../../shared/product.md`), and the `environment/` and `tests/` folders.
97
122
 
123
+ ## Edit tasks
124
+
125
+ Turn a Solstice edit session into an Edit-suite task in the app. This uses the personal CLI token
126
+ from `newm login --evals`. Pass the session's `operation_id` or its asset URL
127
+ (`https://<tenant>.solstice.co/home/assets/<operation_id>`):
128
+
129
+ ```bash
130
+ # List the operation's turns, with their surface, whether starting HTML is ready, and the request
131
+ newm edit-task 0b7f2a7e-1111-4222-8333-444455556666
132
+
133
+ # Create a task from turn 2 (a turn number or turn ID)
134
+ newm edit-task 0b7f2a7e-1111-4222-8333-444455556666 --turn 2
135
+
136
+ # Merge consecutive turns into one task, with your own title
137
+ newm edit-task https://acme.solstice.co/home/assets/0b7f2a7e-1111-4222-8333-444455556666 \
138
+ -t 2 -t 3 --title "Swap headline and CTA"
139
+ ```
140
+
141
+ The task's checks and title are drafted from the chosen turns; refine them in the app's Edits tab.
142
+ Turns listed as `awaiting` have no starting HTML yet: the task is created, but stays
143
+ `awaiting_html` until the HTML is imported.
144
+
98
145
  ## Commands
99
146
 
100
147
  ```bash
@@ -105,6 +152,8 @@ newm result <run-id> # run details and trial results
105
152
  newm artifacts <run-id> <trial-id> [--output DIR]
106
153
  newm tasks # list uploaded tasks
107
154
  newm runs [<task-id>] # list runs
155
+ newm edit-task <operation> [-t <turn> ...] # list turns or create an edit task
156
+ newm logins # list saved logins
108
157
  newm harbor ... # pass through to the Harbor CLI
109
158
  newm --help
110
159
  ```
@@ -5,7 +5,7 @@
5
5
 
6
6
  *Upload eval tasks, start cloud runs, and download results with `newm`*
7
7
 
8
- [Install](#install) • [Usage](#usage) • [Browser tasks](#browser-tasks) • [Commands](#commands) • [Development](#development)
8
+ [Install](#install) • [Usage](#usage) • [Browser tasks](#browser-tasks) • [Edit tasks](#edit-tasks) • [Commands](#commands) • [Development](#development)
9
9
 
10
10
  </div>
11
11
 
@@ -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`:
@@ -84,6 +109,28 @@ Downloads include `outputs/` (the files the product generated, such as PDF and H
84
109
 
85
110
  A browser task bundle contains `task.toml`, `instruction.md`, and, when present, `scenario.yaml`, `product.md` (or `../../shared/product.md`), and the `environment/` and `tests/` folders.
86
111
 
112
+ ## Edit tasks
113
+
114
+ Turn a Solstice edit session into an Edit-suite task in the app. This uses the personal CLI token
115
+ from `newm login --evals`. Pass the session's `operation_id` or its asset URL
116
+ (`https://<tenant>.solstice.co/home/assets/<operation_id>`):
117
+
118
+ ```bash
119
+ # List the operation's turns, with their surface, whether starting HTML is ready, and the request
120
+ newm edit-task 0b7f2a7e-1111-4222-8333-444455556666
121
+
122
+ # Create a task from turn 2 (a turn number or turn ID)
123
+ newm edit-task 0b7f2a7e-1111-4222-8333-444455556666 --turn 2
124
+
125
+ # Merge consecutive turns into one task, with your own title
126
+ newm edit-task https://acme.solstice.co/home/assets/0b7f2a7e-1111-4222-8333-444455556666 \
127
+ -t 2 -t 3 --title "Swap headline and CTA"
128
+ ```
129
+
130
+ The task's checks and title are drafted from the chosen turns; refine them in the app's Edits tab.
131
+ Turns listed as `awaiting` have no starting HTML yet: the task is created, but stays
132
+ `awaiting_html` until the HTML is imported.
133
+
87
134
  ## Commands
88
135
 
89
136
  ```bash
@@ -94,6 +141,8 @@ newm result <run-id> # run details and trial results
94
141
  newm artifacts <run-id> <trial-id> [--output DIR]
95
142
  newm tasks # list uploaded tasks
96
143
  newm runs [<task-id>] # list runs
144
+ newm edit-task <operation> [-t <turn> ...] # list turns or create an edit task
145
+ newm logins # list saved logins
97
146
  newm harbor ... # pass through to the Harbor CLI
98
147
  newm --help
99
148
  ```
@@ -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
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from cli.api_client import evals_client
10
+
11
+ console = Console()
12
+
13
+
14
+ def _turn_ids(turns: list[dict], picks: list[str]) -> list[str]:
15
+ """Map 1-based turn numbers to turn IDs; pass turn IDs through."""
16
+ by_seq = {str(t["seq"]): t["turn_id"] for t in turns}
17
+ known = {t["turn_id"] for t in turns}
18
+ out = []
19
+ for pick in picks:
20
+ tid = by_seq.get(pick, pick)
21
+ if tid not in known:
22
+ raise typer.BadParameter(
23
+ f"Turn {pick!r} is not in this operation; run without --turn to list turns"
24
+ )
25
+ out.append(tid)
26
+ return out
27
+
28
+
29
+ def _show_turns(inspected: dict) -> None:
30
+ console.print(
31
+ f"Operation {inspected['operation_id']} {inspected.get('brand_label') or ''}"
32
+ f" {inspected.get('kind') or ''}"
33
+ )
34
+ table = Table()
35
+ for col in ("#", "Turn ID", "Surface", "HTML", "Request"):
36
+ table.add_column(col)
37
+ for t in inspected["turns"]:
38
+ request = " ".join((t.get("user_request") or "").split())
39
+ table.add_row(
40
+ str(t["seq"]),
41
+ t["turn_id"],
42
+ t.get("surface") or "",
43
+ ("ready" if t.get("html_ready") else "awaiting")
44
+ + (" (reverted)" if t.get("reverted") else ""),
45
+ request[:80] + ("…" if len(request) > 80 else ""),
46
+ )
47
+ console.print(table)
48
+ for task in inspected.get("existing_tasks") or []:
49
+ console.print(
50
+ f"Existing task {task['id']}: {task['title']} (turns {', '.join(task['turn_ids'])})"
51
+ )
52
+ console.print("Create a task with: newm edit-task <operation> --turn <#>")
53
+
54
+
55
+ def edit_task(operation: str, turns: list[str], title: str) -> None:
56
+ """List an operation's turns, or create an edit task from the chosen turns."""
57
+ with evals_client() as c:
58
+ r = c.post("edit-tasks/inspect", json={"operation": operation})
59
+ r.raise_for_status()
60
+ inspected = r.json()
61
+ if not turns:
62
+ _show_turns(inspected)
63
+ return
64
+ r = c.post(
65
+ "edit-tasks",
66
+ json={
67
+ "operation": inspected["operation_id"],
68
+ "turn_ids": _turn_ids(inspected["turns"], turns),
69
+ "title": title,
70
+ },
71
+ )
72
+ r.raise_for_status()
73
+ typer.echo(json.dumps(r.json(), indent=1))
@@ -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,187 @@
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.edit_task import edit_task as edit_task_cmd
13
+ from cli.commands.login import login as login_cmd
14
+ from cli.commands.result import result as result_cmd
15
+ from cli.commands.run import run as run_cmd
16
+ from cli.commands.runs import list_runs
17
+ from cli.commands.tasks import list_tasks
18
+ from cli.commands.upload import upload as upload_cmd
19
+ from cli.errors import http_error_message, transport_error_message
20
+
21
+ app = typer.Typer(
22
+ name="newm",
23
+ help="NewMeasure eval CLI",
24
+ no_args_is_help=True,
25
+ )
26
+ app.add_typer(admin_app, name="admin")
27
+
28
+
29
+ @app.command()
30
+ def login(
31
+ evals: bool = typer.Option(
32
+ False, "--evals", help="Connect your personal app account"
33
+ ),
34
+ ):
35
+ """Store API token."""
36
+ login_cmd(evals)
37
+
38
+
39
+ @app.command()
40
+ def upload(task_dir: Path = typer.Argument(..., help="Path to task directory")):
41
+ """Validate and upload a task."""
42
+ upload_cmd(task_dir)
43
+
44
+
45
+ @app.command()
46
+ def run(
47
+ target: str = typer.Argument(
48
+ ...,
49
+ help="Task ID (t_...), a task folder, or a folder of task folders (e.g. tasks/)",
50
+ ),
51
+ n_trials: int = typer.Option(
52
+ 1, "--n-trials", min=1, max=5, help="Trials per task (1–5)"
53
+ ),
54
+ credentials_file: Path | None = typer.Option(
55
+ None,
56
+ "--credentials-file",
57
+ help="Per-run application credentials (not uploaded as a file)",
58
+ ),
59
+ url: str | None = typer.Option(None, "--url", help="HTTPS target URL for this run"),
60
+ saved_login: str | None = typer.Option(
61
+ None, "--login", help="Saved login name or ID"
62
+ ),
63
+ prompt_credentials: bool = typer.Option(
64
+ False,
65
+ "--prompt-credentials",
66
+ help="Prompt for application username and password",
67
+ ),
68
+ save_login: str | None = typer.Option(
69
+ None, "--save-login", help="Save the supplied login under this name"
70
+ ),
71
+ share_login: bool = typer.Option(
72
+ False, "--share-login", help="Share the saved login with your organization"
73
+ ),
74
+ parent: str | None = typer.Option(
75
+ None,
76
+ "--from-run",
77
+ help="Link to an original run; reuse its environment unless --url is set",
78
+ ),
79
+ wait: bool = typer.Option(
80
+ True, "--wait/--no-wait", help="Wait for results after submission"
81
+ ),
82
+ kind: str | None = typer.Option(
83
+ None,
84
+ "--kind",
85
+ help="Only tasks of this kind: email or banner (kind in task.toml under metadata.evals)",
86
+ ),
87
+ ):
88
+ """Run an eval. Several browser tasks run together as one eval run."""
89
+ run_cmd(
90
+ target=target,
91
+ n_trials=n_trials,
92
+ credentials_file=credentials_file,
93
+ wait=wait,
94
+ kind=kind,
95
+ url=url,
96
+ saved_login=saved_login,
97
+ prompt_credentials=prompt_credentials,
98
+ save_login=save_login,
99
+ share_login=share_login,
100
+ parent=parent,
101
+ )
102
+
103
+
104
+ @app.command()
105
+ def artifacts(
106
+ run_id: str,
107
+ trial_id: str,
108
+ output: Path | None = typer.Option(
109
+ None, "--output", help="Download artifacts to this directory"
110
+ ),
111
+ ):
112
+ """List or download a trial's stored artifacts."""
113
+ artifacts_cmd(run_id, trial_id, output)
114
+
115
+
116
+ @app.command()
117
+ def tasks():
118
+ """List uploaded tasks."""
119
+ list_tasks()
120
+
121
+
122
+ @app.command()
123
+ def runs(task_id: str | None = typer.Argument(None, help="Filter by task ID")):
124
+ """List runs."""
125
+ list_runs(task_id)
126
+
127
+
128
+ @app.command()
129
+ def result(run_id: str = typer.Argument(..., help="Run ID")):
130
+ """Get detailed run results."""
131
+ result_cmd(run_id)
132
+
133
+
134
+ @app.command("edit-task")
135
+ def edit_task(
136
+ operation: str = typer.Argument(
137
+ ..., help="Solstice operation ID or /home/assets/<id> URL"
138
+ ),
139
+ turn: list[str] = typer.Option(
140
+ [],
141
+ "--turn",
142
+ "-t",
143
+ help="Turn number or turn ID to convert; repeat to merge turns. Omit to list turns",
144
+ ),
145
+ title: str = typer.Option("", "--title", help="Task title (default: drafted)"),
146
+ ):
147
+ """Create an Edit-suite task from a Solstice operation."""
148
+ edit_task_cmd(operation, turn, title)
149
+
150
+
151
+ @app.command(
152
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
153
+ )
154
+ def harbor(ctx: typer.Context):
155
+ """Passthrough to Harbor CLI (power users)."""
156
+ cmd = ["harbor", *ctx.args]
157
+ sys.exit(subprocess.call(cmd))
158
+
159
+
160
+ def main() -> None:
161
+ """Entry point: show the server's reason for a failed request, not a traceback."""
162
+ try:
163
+ app()
164
+ except httpx.HTTPStatusError as exc:
165
+ typer.secho(http_error_message(exc), err=True, fg=typer.colors.RED)
166
+ raise SystemExit(1) from None
167
+ except httpx.TransportError as exc:
168
+ typer.secho(transport_error_message(exc), err=True, fg=typer.colors.RED)
169
+ raise SystemExit(1) from None
170
+
171
+
172
+ @app.command("logins")
173
+ def logins():
174
+ """List the same personal and shared logins as the app."""
175
+ from cli.api_client import evals_client
176
+
177
+ with evals_client() as c:
178
+ r = c.get("run-logins")
179
+ r.raise_for_status()
180
+ for entry in r.json()["logins"]:
181
+ typer.echo(
182
+ f"{entry['id']} {entry['name']} {'Organization' if entry['shared'] else 'Personal'} Saved by {entry['saved_by']}"
183
+ )
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "newmeasure-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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()