haliosai-cli 2.0.4__tar.gz → 2.0.6__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.
Files changed (28) hide show
  1. {haliosai_cli-2.0.4/haliosai_cli.egg-info → haliosai_cli-2.0.6}/PKG-INFO +12 -5
  2. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/README.md +11 -4
  3. haliosai_cli-2.0.6/halios_cli/_version.py +1 -0
  4. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_eval.py +32 -3
  5. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_optimize.py +45 -8
  6. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_project.py +39 -9
  7. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_support.py +83 -0
  8. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_trace.py +25 -5
  9. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6/haliosai_cli.egg-info}/PKG-INFO +12 -5
  10. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/tests/test_cli_surface.py +92 -2
  11. haliosai_cli-2.0.4/halios_cli/_version.py +0 -1
  12. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/LICENSE +0 -0
  13. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/MANIFEST.in +0 -0
  14. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/__init__.py +0 -0
  15. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli.py +0 -0
  16. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_auth.py +0 -0
  17. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/cli_scenario.py +0 -0
  18. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/py.typed +0 -0
  19. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/schemas/__init__.py +0 -0
  20. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/schemas/eval.schema.json +0 -0
  21. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/halios_cli/schemas/scenarios.schema.json +0 -0
  22. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/haliosai_cli.egg-info/SOURCES.txt +0 -0
  23. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/haliosai_cli.egg-info/dependency_links.txt +0 -0
  24. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/haliosai_cli.egg-info/entry_points.txt +0 -0
  25. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/haliosai_cli.egg-info/requires.txt +0 -0
  26. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/haliosai_cli.egg-info/top_level.txt +0 -0
  27. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/pyproject.toml +0 -0
  28. {haliosai_cli-2.0.4 → haliosai_cli-2.0.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haliosai-cli
3
- Version: 2.0.4
3
+ Version: 2.0.6
4
4
  Summary: Halios CLI for coding-agent evaluations, guardrails, and OpenTelemetry evidence
5
5
  Author-email: HaliosLabs <support@halios.ai>
6
6
  License-Expression: Apache-2.0
@@ -65,10 +65,13 @@ npx skills add HaliosAI/halios --skill halios
65
65
  Prompt your agent to set up evaluations for your project:
66
66
 
67
67
  ```text
68
- "Set up evals for this agent: inspect the repository, create realistic test scenarios and checks, and run a baseline."
68
+ "Set up evals for this agent: inspect the repository, create realistic test scenarios and checks, run one smoke test, and summarize before asking whether to run the full suite."
69
69
  ```
70
70
 
71
- Your agent will inspect your application entrypoint, configure standard OpenTelemetry, draft scenarios in `.halios/`, and run baseline evaluations.
71
+ Your agent will inspect your application entrypoint, configure standard OpenTelemetry, draft
72
+ scenarios in `.halios/`, and run one smoke scenario. It stops with a verification summary before
73
+ asking whether to run the full suite and how many trials per scenario to use. The summary includes
74
+ direct Halios UI links for reviewing the materialized suite, smoke run, and trace evidence.
72
75
 
73
76
  ---
74
77
 
@@ -78,15 +81,19 @@ If you prefer to drive evaluations directly from the command line or CI:
78
81
 
79
82
  ```bash
80
83
  # Recommended: Install with uv tool
81
- uv tool install 'haliosai-cli>=2.0.0'
84
+ uv tool install 'haliosai-cli>=2.0.6'
82
85
 
83
86
  # Or install with pipx
84
- pipx install 'haliosai-cli>=2.0.0'
87
+ pipx install 'haliosai-cli>=2.0.6'
85
88
 
86
89
  # See available commands and usage
87
90
  halios --help
88
91
  ```
89
92
 
93
+ The API and UI use the same origin by default. For a self-hosted deployment with separate origins,
94
+ set `HALIOS_UI_URL` so structured review links point to the web application without changing the
95
+ API endpoint used by `HALIOS_BASE_URL`.
96
+
90
97
  ---
91
98
 
92
99
  ## How It Works
@@ -28,10 +28,13 @@ npx skills add HaliosAI/halios --skill halios
28
28
  Prompt your agent to set up evaluations for your project:
29
29
 
30
30
  ```text
31
- "Set up evals for this agent: inspect the repository, create realistic test scenarios and checks, and run a baseline."
31
+ "Set up evals for this agent: inspect the repository, create realistic test scenarios and checks, run one smoke test, and summarize before asking whether to run the full suite."
32
32
  ```
33
33
 
34
- Your agent will inspect your application entrypoint, configure standard OpenTelemetry, draft scenarios in `.halios/`, and run baseline evaluations.
34
+ Your agent will inspect your application entrypoint, configure standard OpenTelemetry, draft
35
+ scenarios in `.halios/`, and run one smoke scenario. It stops with a verification summary before
36
+ asking whether to run the full suite and how many trials per scenario to use. The summary includes
37
+ direct Halios UI links for reviewing the materialized suite, smoke run, and trace evidence.
35
38
 
36
39
  ---
37
40
 
@@ -41,15 +44,19 @@ If you prefer to drive evaluations directly from the command line or CI:
41
44
 
42
45
  ```bash
43
46
  # Recommended: Install with uv tool
44
- uv tool install 'haliosai-cli>=2.0.0'
47
+ uv tool install 'haliosai-cli>=2.0.6'
45
48
 
46
49
  # Or install with pipx
47
- pipx install 'haliosai-cli>=2.0.0'
50
+ pipx install 'haliosai-cli>=2.0.6'
48
51
 
49
52
  # See available commands and usage
50
53
  halios --help
51
54
  ```
52
55
 
56
+ The API and UI use the same origin by default. For a self-hosted deployment with separate origins,
57
+ set `HALIOS_UI_URL` so structured review links point to the web application without changing the
58
+ API endpoint used by `HALIOS_BASE_URL`.
59
+
53
60
  ---
54
61
 
55
62
  ## How It Works
@@ -0,0 +1 @@
1
+ __version__ = "2.0.6"
@@ -24,8 +24,10 @@ from jsonschema import Draft202012Validator
24
24
 
25
25
  from .cli_support import (
26
26
  ApiClient,
27
+ emit_review_links,
27
28
  evaluation_suite_digest,
28
29
  git_provenance,
30
+ halios_ui_links,
29
31
  load_project_config,
30
32
  load_yaml,
31
33
  resolve_credentials,
@@ -354,8 +356,8 @@ def _eval_quality_gaps(eval_plan: dict[str, Any], scenarios: list[dict[str, Any]
354
356
  ):
355
357
  gaps.append(
356
358
  f"Scenario '{scenario_id}': simulator_context is empty for a multi-turn scenario; "
357
- "populate private user facts/preferences/state in simulator_context so the simulator "
358
- "can reveal them dynamically upon agent request"
359
+ "populate private user facts/preferences/state in simulator_context so the "
360
+ "simulator can reveal them dynamically upon agent request"
359
361
  )
360
362
  return list(dict.fromkeys(gaps))
361
363
 
@@ -801,9 +803,28 @@ def _raise_for_failed_run(report: dict[str, Any], run_id: str) -> None:
801
803
  message = str(error.get("message") or trial.get("outcome") or trial.get("state"))
802
804
  details.append(f"{trial.get('scenario_id') or trial.get('id')}: {message}")
803
805
  suffix = f" ({'; '.join(details)})" if details else ""
806
+ run_url = str((report.get("links") or {}).get("evaluation_run") or "")
807
+ review = f" Review in Halios: {run_url}." if run_url else ""
804
808
  raise typer.BadParameter(
805
809
  f"Evaluation run {run_id} failed{suffix}. "
806
- f"Inspect `halios eval report {run_id} --failures --json`."
810
+ f"Inspect `halios eval report {run_id} --failures --json`.{review}"
811
+ )
812
+
813
+
814
+ def _representative_trace_id(report: dict[str, Any]) -> str | None:
815
+ for trial in report.get("trials") or []:
816
+ if isinstance(trial, dict) and trial.get("trace_id"):
817
+ return str(trial["trace_id"])
818
+ return None
819
+
820
+
821
+ def _evaluation_links(*, base_url: str, agent_id: str, report: dict[str, Any]) -> dict[str, str]:
822
+ return halios_ui_links(
823
+ base_url,
824
+ agent_id,
825
+ include_evaluations=True,
826
+ run_tag=str(report.get("run_tag") or "") or None,
827
+ trace_id=_representative_trace_id(report),
807
828
  )
808
829
 
809
830
 
@@ -1028,6 +1049,9 @@ def run(
1028
1049
  f"Evaluation run {run_id} did not finish within {timeout}s. "
1029
1050
  f"Inspect `halios eval report {run_id} --failures --json`."
1030
1051
  )
1052
+ report["links"] = _evaluation_links(
1053
+ base_url=credentials.ui_base_url, agent_id=agent_id, report=report
1054
+ )
1031
1055
  _raise_for_failed_run(report, run_id)
1032
1056
  if expected_roots:
1033
1057
  report["telemetry_verification"] = _verify_simulation_telemetry(
@@ -1047,6 +1071,7 @@ def run(
1047
1071
  if telemetry.get("verified"):
1048
1072
  typer.echo(f"Telemetry: verified ({telemetry['trace_count']} traces)")
1049
1073
  typer.echo(f"Run {run_id}: pass@{k}={pass_at_k:.1%} gate={gate}")
1074
+ emit_review_links(report["links"])
1050
1075
  if not report.get("gate_passed"):
1051
1076
  raise typer.Exit(2)
1052
1077
 
@@ -1072,6 +1097,9 @@ def report(
1072
1097
  **result,
1073
1098
  "trials": [item for item in result.get("trials", []) if not item.get("passed")],
1074
1099
  }
1100
+ result["links"] = _evaluation_links(
1101
+ base_url=credentials.ui_base_url, agent_id=agent_id, report=result
1102
+ )
1075
1103
  if json_output:
1076
1104
  typer.echo(json.dumps(result, indent=2, sort_keys=True))
1077
1105
  else:
@@ -1086,3 +1114,4 @@ def report(
1086
1114
  f"trial_failures={int(result.get('evaluation_failed_count') or 0)} "
1087
1115
  f"revision={result.get('report_revision')}{delta}"
1088
1116
  )
1117
+ emit_review_links(result["links"])
@@ -8,7 +8,14 @@ from typing import Any
8
8
 
9
9
  import typer
10
10
 
11
- from .cli_support import ApiClient, atomic_write_text, load_project_config, resolve_credentials
11
+ from .cli_support import (
12
+ ApiClient,
13
+ atomic_write_text,
14
+ emit_review_links,
15
+ halios_ui_links,
16
+ load_project_config,
17
+ resolve_credentials,
18
+ )
12
19
 
13
20
  app = typer.Typer(
14
21
  help="Guide, record, and verify coding-agent prompt optimization.",
@@ -27,7 +34,16 @@ def _emit(value: dict[str, Any], json_output: bool) -> None:
27
34
  if json_output:
28
35
  typer.echo(json.dumps(value, indent=2, sort_keys=True, default=str))
29
36
  else:
30
- typer.echo(json.dumps(value, indent=2, default=str))
37
+ links = value.get("links")
38
+ typer.echo(
39
+ json.dumps(
40
+ {key: item for key, item in value.items() if key != "links"},
41
+ indent=2,
42
+ default=str,
43
+ )
44
+ )
45
+ if isinstance(links, dict):
46
+ emit_review_links(links)
31
47
 
32
48
 
33
49
  def _resolve_baseline(api: ApiClient, agent_id: str, explicit_run_id: str | None) -> dict[str, Any]:
@@ -149,6 +165,12 @@ def start(
149
165
  f"`halios optimize record {run_id} --evaluation-run <run-id> "
150
166
  f"--prompt-file {prompt_file}`."
151
167
  ),
168
+ "links": halios_ui_links(
169
+ credentials.ui_base_url,
170
+ agent_id,
171
+ run_tag=str(baseline.get("run_tag") or "") or None,
172
+ optimization_run_id=run_id,
173
+ ),
152
174
  }
153
175
  _emit(result, json_output)
154
176
 
@@ -156,9 +178,10 @@ def start(
156
178
  @app.command("guidance")
157
179
  def guidance(run_id: str, json_output: bool = typer.Option(False, "--json")) -> None:
158
180
  """Return the next bounded edit contract and negative memory for a coding agent."""
159
- _agent_id, credentials = _context()
181
+ agent_id, credentials = _context()
160
182
  with ApiClient(credentials) as api:
161
183
  result = api.request("POST", f"/api/v1/optimization-runs/{run_id}/next-action")
184
+ result["links"] = halios_ui_links(credentials.ui_base_url, agent_id, optimization_run_id=run_id)
162
185
  _emit(result, json_output)
163
186
 
164
187
 
@@ -170,7 +193,7 @@ def record(
170
193
  json_output: bool = typer.Option(False, "--json"),
171
194
  ) -> None:
172
195
  """Record one candidate using evidence from the unchanged canonical eval suite."""
173
- _agent_id, credentials = _context()
196
+ agent_id, credentials = _context()
174
197
  prompt_after = prompt_file.read_text(encoding="utf-8")
175
198
  with ApiClient(credentials) as api:
176
199
  run = api.request("GET", f"/api/v1/optimization-runs/{run_id}")
@@ -229,6 +252,12 @@ def record(
229
252
  if accepted
230
253
  else "Revert the rejected prompt edit, inspect next_action, and try one different edit."
231
254
  ),
255
+ "links": halios_ui_links(
256
+ credentials.ui_base_url,
257
+ agent_id,
258
+ run_tag=str(report.get("run_tag") or "") or None,
259
+ optimization_run_id=run_id,
260
+ ),
232
261
  }
233
262
  _emit(result, json_output)
234
263
  if not accepted:
@@ -242,7 +271,7 @@ def apply_candidate(
242
271
  json_output: bool = typer.Option(False, "--json"),
243
272
  ) -> None:
244
273
  """Retrieve one backend-approved prompt candidate for repository application."""
245
- _agent_id, credentials = _context()
274
+ agent_id, credentials = _context()
246
275
  with ApiClient(credentials) as api:
247
276
  handoff = api.request(
248
277
  "POST",
@@ -257,6 +286,11 @@ def apply_candidate(
257
286
  "Run `halios eval run --json` after applying the prompt, then use "
258
287
  f"`halios optimize verify {handoff['optimization_run_id']} --evaluation-run <run-id>`."
259
288
  )
289
+ handoff["links"] = halios_ui_links(
290
+ credentials.ui_base_url,
291
+ agent_id,
292
+ optimization_run_id=str(handoff["optimization_run_id"]),
293
+ )
260
294
  if json_output:
261
295
  _emit(handoff, True)
262
296
  elif output:
@@ -272,13 +306,14 @@ def verify_candidate(
272
306
  json_output: bool = typer.Option(False, "--json"),
273
307
  ) -> None:
274
308
  """Verify the applied candidate against the frozen baseline and unchanged suite."""
275
- _agent_id, credentials = _context()
309
+ agent_id, credentials = _context()
276
310
  with ApiClient(credentials) as api:
277
311
  result = api.request(
278
312
  "POST",
279
313
  f"/api/v1/optimization-runs/{run_id}/verify",
280
314
  json={"evaluation_run_id": evaluation_run_id},
281
315
  )
316
+ result["links"] = halios_ui_links(credentials.ui_base_url, agent_id, optimization_run_id=run_id)
282
317
  _emit(result, json_output)
283
318
  if not result.get("passed"):
284
319
  raise typer.Exit(2)
@@ -294,15 +329,17 @@ def list_runs(json_output: bool = typer.Option(False, "--json")) -> None:
294
329
 
295
330
  @app.command("status")
296
331
  def status(run_id: str, json_output: bool = typer.Option(False, "--json")) -> None:
297
- _agent_id, credentials = _context()
332
+ agent_id, credentials = _context()
298
333
  with ApiClient(credentials) as api:
299
334
  result = api.request("GET", f"/api/v1/optimization-runs/{run_id}")
335
+ result["links"] = halios_ui_links(credentials.ui_base_url, agent_id, optimization_run_id=run_id)
300
336
  _emit(result, json_output)
301
337
 
302
338
 
303
339
  @app.command("cancel")
304
340
  def cancel(run_id: str, json_output: bool = typer.Option(False, "--json")) -> None:
305
- _agent_id, credentials = _context()
341
+ agent_id, credentials = _context()
306
342
  with ApiClient(credentials) as api:
307
343
  result = api.request("POST", f"/api/v1/optimization-runs/{run_id}/cancel")
344
+ result["links"] = halios_ui_links(credentials.ui_base_url, agent_id, optimization_run_id=run_id)
308
345
  _emit(result, json_output)
@@ -17,8 +17,10 @@ import typer
17
17
  from .cli_support import (
18
18
  ApiClient,
19
19
  ApiError,
20
+ emit_review_links,
20
21
  evaluation_suite_digest,
21
22
  git_provenance,
23
+ halios_ui_links,
22
24
  load_project_config,
23
25
  load_yaml,
24
26
  preserve_suite_recovery,
@@ -92,6 +94,7 @@ def init(
92
94
  command: str = typer.Option(
93
95
  "", "--command", help="Project adapter command; may be added later by the coding agent."
94
96
  ),
97
+ json_output: bool = typer.Option(False, "--json"),
95
98
  ) -> None:
96
99
  """Create a fresh agent, or explicitly link one by UUID, and initialize the checkout."""
97
100
  root = pathlib.Path.cwd().resolve()
@@ -167,16 +170,37 @@ def init(
167
170
  created.append(".halios/scenarios.yml")
168
171
 
169
172
  verb = "Created" if created_agent else "Linked"
170
- typer.echo(f"{verb} Halios agent: {resolved_agent['name']}")
171
- typer.echo(f"Agent ID: {resolved_agent['id']}")
172
- typer.echo(f"Agent URL: {credentials.base_url}/agents/{resolved_agent['id']}")
173
- if not created_agent:
173
+ links = halios_ui_links(credentials.ui_base_url, str(resolved_agent["id"]))
174
+ result = {
175
+ "created": created_agent,
176
+ "agent": {
177
+ "id": str(resolved_agent["id"]),
178
+ "name": str(resolved_agent["name"]),
179
+ "slug": str(resolved_agent["slug"]),
180
+ },
181
+ "suite": {
182
+ "revision": int(suite.get("revision") or 0),
183
+ "check_count": len((suite.get("eval") or {}).get("checks") or []),
184
+ "scenario_count": len((suite.get("scenarios") or {}).get("scenarios") or []),
185
+ },
186
+ "created_files": created,
187
+ "links": links,
188
+ }
189
+ if json_output:
190
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
191
+ else:
192
+ typer.echo(f"{verb} Halios agent: {resolved_agent['name']}")
193
+ typer.echo(f"Agent ID: {resolved_agent['id']}")
194
+ if not created_agent:
195
+ typer.echo(
196
+ f"Existing evaluation suite: revision {suite['revision']}, "
197
+ f"{result['suite']['check_count']} checks, "
198
+ f"{result['suite']['scenario_count']} scenarios"
199
+ )
174
200
  typer.echo(
175
- f"Existing evaluation suite: revision {suite['revision']}, "
176
- f"{len((suite.get('eval') or {}).get('checks') or [])} checks, "
177
- f"{len((suite.get('scenarios') or {}).get('scenarios') or [])} scenarios"
201
+ "Created: " + ", ".join(created) if created else "Project was already initialized."
178
202
  )
179
- typer.echo("Created: " + ", ".join(created) if created else "Project was already initialized.")
203
+ emit_review_links(links)
180
204
 
181
205
 
182
206
  def _apply_suite_response(root: pathlib.Path, response: dict[str, Any]) -> None:
@@ -244,6 +268,7 @@ def configure(json_output: bool = typer.Option(False, "--json")) -> None:
244
268
  "revision": response["revision"],
245
269
  "digest": response.get("digest"),
246
270
  "verification": verification,
271
+ "links": halios_ui_links(credentials.ui_base_url, agent_id, include_suite=True),
247
272
  }
248
273
  if json_output:
249
274
  typer.echo(json.dumps(result, indent=2, sort_keys=True))
@@ -253,6 +278,7 @@ def configure(json_output: bool = typer.Option(False, "--json")) -> None:
253
278
  f"{verification['check_count']} checks, {verification['rule_count']} rules, "
254
279
  f"{verification['rubric_count']} rubrics, {verification['scenario_count']} scenarios"
255
280
  )
281
+ emit_review_links(result["links"])
256
282
 
257
283
 
258
284
  @app.command("refresh")
@@ -261,10 +287,12 @@ def refresh() -> None:
261
287
  root, config = load_project_config()
262
288
  agent_id = str((config.get("agent") or {}).get("id") or "")
263
289
  profile = str(config.get("profile") or "default")
264
- with ApiClient(resolve_credentials(profile, agent_id)) as api:
290
+ credentials = resolve_credentials(profile, agent_id)
291
+ with ApiClient(credentials) as api:
265
292
  response = api.request("GET", f"/api/v1/agents/{agent_id}/evaluation-suite")
266
293
  _apply_suite_response(root, response)
267
294
  typer.echo(f"Refreshed evaluation suite revision {response['revision']} from Halios")
295
+ emit_review_links(halios_ui_links(credentials.ui_base_url, agent_id, include_suite=True))
268
296
 
269
297
 
270
298
  @app.command("check")
@@ -345,6 +373,7 @@ def check(
345
373
  "git": {"branch": branch, "commit": commit, "dirty": dirty == "true"},
346
374
  "evaluation_ai": evaluation_status,
347
375
  "suite": {"verified": True, "revision": suite["revision"]},
376
+ "links": halios_ui_links(credentials.ui_base_url, agent_id, include_suite=True),
348
377
  }
349
378
  if json_output:
350
379
  typer.echo(json.dumps(result, indent=2, sort_keys=True))
@@ -355,6 +384,7 @@ def check(
355
384
  typer.echo(f"Git: {branch}@{commit} dirty={dirty}")
356
385
  typer.echo(f"Evaluation AI: {evaluation_status}")
357
386
  typer.echo(f"Evaluation suite: verified (revision {suite['revision']})")
387
+ emit_review_links(result["links"])
358
388
 
359
389
 
360
390
  @app.command("instrumentation")
@@ -10,6 +10,7 @@ import platform
10
10
  import re
11
11
  import subprocess
12
12
  import tempfile
13
+ import urllib.parse
13
14
  from dataclasses import dataclass
14
15
  from datetime import datetime, timezone
15
16
  from typing import Any
@@ -31,6 +32,77 @@ def normalize_url(value: str) -> str:
31
32
  return (value if "://" in value else f"http://{value}").rstrip("/")
32
33
 
33
34
 
35
+ def _ui_origin(base_url: str) -> str:
36
+ """Return a credential-free origin suitable for links shown to users."""
37
+ parsed = urllib.parse.urlsplit(normalize_url(base_url))
38
+ hostname = parsed.hostname
39
+ if parsed.scheme not in {"http", "https"} or not hostname:
40
+ raise typer.BadParameter(f"Invalid Halios base URL: {base_url}")
41
+ rendered_host = f"[{hostname}]" if ":" in hostname else hostname
42
+ if parsed.port is not None:
43
+ rendered_host = f"{rendered_host}:{parsed.port}"
44
+ return urllib.parse.urlunsplit((parsed.scheme, rendered_host, "", "", ""))
45
+
46
+
47
+ def halios_ui_links(
48
+ base_url: str,
49
+ agent_id: str,
50
+ *,
51
+ include_suite: bool = False,
52
+ include_evaluations: bool = False,
53
+ run_tag: str | None = None,
54
+ trace_id: str | None = None,
55
+ optimization_run_id: str | None = None,
56
+ ) -> dict[str, str]:
57
+ """Build stable, secret-free Halios UI review links for durable resources."""
58
+ if not agent_id:
59
+ return {}
60
+ origin = _ui_origin(base_url)
61
+ encoded_agent = urllib.parse.quote(agent_id, safe="")
62
+ agent_path = f"{origin}/agents/{encoded_agent}"
63
+ links = {"agent": agent_path}
64
+ if include_suite:
65
+ links["scenarios"] = (
66
+ f"{agent_path}/evaluations?{urllib.parse.urlencode({'tab': 'scenarios'})}"
67
+ )
68
+ links["rules"] = f"{agent_path}/evaluations?{urllib.parse.urlencode({'tab': 'rules'})}"
69
+ if include_evaluations or run_tag:
70
+ links["evaluations"] = f"{agent_path}/evaluations"
71
+ if run_tag:
72
+ encoded_run_tag = urllib.parse.quote(run_tag, safe="")
73
+ links["evaluation_run"] = f"{agent_path}/evaluations/{encoded_run_tag}"
74
+ links["evaluation_traces"] = (
75
+ f"{agent_path}/traces?{urllib.parse.urlencode({'eval_run': run_tag})}"
76
+ )
77
+ if trace_id:
78
+ links["trace"] = f"{agent_path}/traces/{urllib.parse.quote(trace_id, safe='')}"
79
+ if optimization_run_id:
80
+ encoded_run_id = urllib.parse.quote(optimization_run_id, safe="")
81
+ links["optimization_run"] = f"{agent_path}/optimize/{encoded_run_id}"
82
+ return links
83
+
84
+
85
+ _LINK_LABELS = {
86
+ "agent": "Agent overview",
87
+ "scenarios": "Scenarios",
88
+ "rules": "Rules and rubrics",
89
+ "evaluations": "Evaluation runs",
90
+ "evaluation_run": "Evaluation run",
91
+ "evaluation_traces": "Run traces",
92
+ "trace": "Trace evidence",
93
+ "optimization_run": "Optimization run",
94
+ }
95
+
96
+
97
+ def emit_review_links(links: dict[str, str]) -> None:
98
+ """Print a compact human-review handoff without making UI usage mandatory."""
99
+ if not links:
100
+ return
101
+ typer.echo("Review in Halios:")
102
+ for key, url in links.items():
103
+ typer.echo(f"- {_LINK_LABELS.get(key, key.replace('_', ' ').title())}: {url}")
104
+
105
+
34
106
  def credentials_path() -> pathlib.Path:
35
107
  if os.getenv("HALIOS_CONFIG_HOME"):
36
108
  root = pathlib.Path(os.environ["HALIOS_CONFIG_HOME"])
@@ -77,6 +149,7 @@ def save_profile(
77
149
  *,
78
150
  base_url: str,
79
151
  api_key: str,
152
+ ui_base_url: str | None = None,
80
153
  organization_id: str | None = None,
81
154
  api_key_id: int | None = None,
82
155
  expires_at: str | None = None,
@@ -87,6 +160,7 @@ def save_profile(
87
160
  profiles[profile] = {
88
161
  **previous,
89
162
  "base_url": normalize_url(base_url),
163
+ "ui_base_url": normalize_url(ui_base_url or base_url),
90
164
  "api_key": api_key,
91
165
  "organization_id": organization_id,
92
166
  "api_key_id": api_key_id,
@@ -117,6 +191,7 @@ def save_agent_ingest_token(profile: str, agent_id: str, token: str) -> None:
117
191
  class Credentials:
118
192
  profile: str
119
193
  base_url: str
194
+ ui_base_url: str
120
195
  api_key: str
121
196
  organization_id: str | None
122
197
  otlp_token: str | None = None
@@ -133,6 +208,9 @@ def stored_profile_credentials(profile: str = "default") -> Credentials | None:
133
208
  return Credentials(
134
209
  profile=profile,
135
210
  base_url=normalize_url(str(entry.get("base_url") or DEFAULT_BASE_URL)),
211
+ ui_base_url=normalize_url(
212
+ str(entry.get("ui_base_url") or entry.get("base_url") or DEFAULT_BASE_URL)
213
+ ),
136
214
  api_key=str(entry["api_key"]),
137
215
  organization_id=entry.get("organization_id"),
138
216
  api_key_id=raw_key_id if isinstance(raw_key_id, int) else None,
@@ -150,6 +228,10 @@ def resolve_credentials(profile: str = "default", agent_id: str | None = None) -
150
228
  base_url = normalize_url(
151
229
  os.getenv("HALIOS_BASE_URL") or str(entry.get("base_url") or DEFAULT_BASE_URL)
152
230
  )
231
+ ui_base_url = normalize_url(
232
+ os.getenv("HALIOS_UI_URL")
233
+ or str(entry.get("ui_base_url") or entry.get("base_url") or base_url)
234
+ )
153
235
  # INTENT: CI runners need both control-plane and agent-scoped ingest credentials without
154
236
  # writing a persistent profile to the ephemeral filesystem.
155
237
  token = os.getenv("HALIOS_OTLP_TOKEN")
@@ -160,6 +242,7 @@ def resolve_credentials(profile: str = "default", agent_id: str | None = None) -
160
242
  return Credentials(
161
243
  profile=profile,
162
244
  base_url=base_url,
245
+ ui_base_url=ui_base_url,
163
246
  api_key=env_key or str(entry.get("api_key") or ""),
164
247
  organization_id=entry.get("organization_id"),
165
248
  otlp_token=token,
@@ -7,7 +7,13 @@ import re
7
7
 
8
8
  import typer
9
9
 
10
- from .cli_support import ApiClient, load_project_config, resolve_credentials
10
+ from .cli_support import (
11
+ ApiClient,
12
+ emit_review_links,
13
+ halios_ui_links,
14
+ load_project_config,
15
+ resolve_credentials,
16
+ )
11
17
 
12
18
  app = typer.Typer(help="Inspect trace evidence and production failures.", no_args_is_help=True)
13
19
 
@@ -23,7 +29,17 @@ def _emit(value: object, json_output: bool) -> None:
23
29
  if json_output:
24
30
  typer.echo(json.dumps(value, indent=2, sort_keys=True, default=str))
25
31
  else:
26
- items = value.get("data", value) if isinstance(value, dict) else value
32
+ links = value.get("links") if isinstance(value, dict) else None
33
+ display_value = (
34
+ {key: item for key, item in value.items() if key != "links"}
35
+ if isinstance(value, dict)
36
+ else value
37
+ )
38
+ items = (
39
+ display_value.get("data", display_value)
40
+ if isinstance(display_value, dict)
41
+ else display_value
42
+ )
27
43
  if isinstance(items, list):
28
44
  for item in items:
29
45
  if isinstance(item, dict):
@@ -33,7 +49,9 @@ def _emit(value: object, json_output: bool) -> None:
33
49
  )
34
50
  )
35
51
  else:
36
- typer.echo(json.dumps(value, indent=2, default=str))
52
+ typer.echo(json.dumps(display_value, indent=2, default=str))
53
+ if isinstance(links, dict):
54
+ emit_review_links(links)
37
55
 
38
56
 
39
57
  @app.command("list")
@@ -57,7 +75,7 @@ def show(
57
75
  include: str = typer.Option("spans,checks", "--include"),
58
76
  json_output: bool = typer.Option(False, "--json"),
59
77
  ) -> None:
60
- _agent_id, credentials = _context()
78
+ agent_id, credentials = _context()
61
79
  with ApiClient(credentials) as api:
62
80
  result = api.request("GET", f"/api/v1/traces/{trace_id}")
63
81
  allowed = {item.strip() for item in include.split(",") if item.strip()}
@@ -66,6 +84,7 @@ def show(
66
84
  result.pop("spans", None)
67
85
  if "checks" not in allowed:
68
86
  result.pop("check_executions", None)
87
+ result["links"] = halios_ui_links(credentials.ui_base_url, agent_id, trace_id=trace_id)
69
88
  _emit(result, json_output)
70
89
 
71
90
 
@@ -122,7 +141,7 @@ def verify(
122
141
  json_output: bool = typer.Option(False, "--json"),
123
142
  ) -> None:
124
143
  """Fail closed unless a stored runtime trace has usable standard OTel evidence."""
125
- _agent_id, credentials = _context()
144
+ agent_id, credentials = _context()
126
145
  with ApiClient(credentials) as api:
127
146
  detail = api.request("GET", f"/api/v1/traces/{trace_id}")
128
147
  spans = detail.get("spans") or []
@@ -169,6 +188,7 @@ def verify(
169
188
  "span_count": len(spans),
170
189
  "root_count": len(roots),
171
190
  "issues": issues,
191
+ "links": halios_ui_links(credentials.ui_base_url, agent_id, trace_id=trace_id),
172
192
  }
173
193
  _emit(result, json_output)
174
194
  if issues:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haliosai-cli
3
- Version: 2.0.4
3
+ Version: 2.0.6
4
4
  Summary: Halios CLI for coding-agent evaluations, guardrails, and OpenTelemetry evidence
5
5
  Author-email: HaliosLabs <support@halios.ai>
6
6
  License-Expression: Apache-2.0
@@ -65,10 +65,13 @@ npx skills add HaliosAI/halios --skill halios
65
65
  Prompt your agent to set up evaluations for your project:
66
66
 
67
67
  ```text
68
- "Set up evals for this agent: inspect the repository, create realistic test scenarios and checks, and run a baseline."
68
+ "Set up evals for this agent: inspect the repository, create realistic test scenarios and checks, run one smoke test, and summarize before asking whether to run the full suite."
69
69
  ```
70
70
 
71
- Your agent will inspect your application entrypoint, configure standard OpenTelemetry, draft scenarios in `.halios/`, and run baseline evaluations.
71
+ Your agent will inspect your application entrypoint, configure standard OpenTelemetry, draft
72
+ scenarios in `.halios/`, and run one smoke scenario. It stops with a verification summary before
73
+ asking whether to run the full suite and how many trials per scenario to use. The summary includes
74
+ direct Halios UI links for reviewing the materialized suite, smoke run, and trace evidence.
72
75
 
73
76
  ---
74
77
 
@@ -78,15 +81,19 @@ If you prefer to drive evaluations directly from the command line or CI:
78
81
 
79
82
  ```bash
80
83
  # Recommended: Install with uv tool
81
- uv tool install 'haliosai-cli>=2.0.0'
84
+ uv tool install 'haliosai-cli>=2.0.6'
82
85
 
83
86
  # Or install with pipx
84
- pipx install 'haliosai-cli>=2.0.0'
87
+ pipx install 'haliosai-cli>=2.0.6'
85
88
 
86
89
  # See available commands and usage
87
90
  halios --help
88
91
  ```
89
92
 
93
+ The API and UI use the same origin by default. For a self-hosted deployment with separate origins,
94
+ set `HALIOS_UI_URL` so structured review links point to the web application without changing the
95
+ API endpoint used by `HALIOS_BASE_URL`.
96
+
90
97
  ---
91
98
 
92
99
  ## How It Works
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import sys
5
+ from types import SimpleNamespace
4
6
  from typing import Any
5
7
 
8
+ import pytest
9
+ import typer
6
10
  from typer.testing import CliRunner
7
11
 
8
- from halios_cli import cli_support, cli_trace
12
+ from halios_cli import cli_project, cli_support, cli_trace
9
13
  from halios_cli.cli import app
10
14
  from halios_cli.cli_eval import _invoke_adapter, _otlp_root_payload, _scenario_schema_errors
11
15
 
@@ -30,7 +34,8 @@ class FakeApiClient:
30
34
 
31
35
  def _patch_trace_context(monkeypatch) -> None:
32
36
  FakeApiClient.calls = []
33
- monkeypatch.setattr(cli_trace, "_context", lambda: ("agent-id", object()))
37
+ credentials = SimpleNamespace(ui_base_url="https://app.halios.ai")
38
+ monkeypatch.setattr(cli_trace, "_context", lambda: ("agent-id", credentials))
34
39
  monkeypatch.setattr(cli_trace, "ApiClient", FakeApiClient)
35
40
 
36
41
 
@@ -46,12 +51,96 @@ def test_ci_credentials_accept_agent_scoped_otlp_token_from_environment(monkeypa
46
51
  monkeypatch.setenv("HALIOS_API_KEY", "halios_control_plane_key")
47
52
  monkeypatch.setenv("HALIOS_OTLP_TOKEN", "halios_agent_ingest_token")
48
53
  monkeypatch.setenv("HALIOS_BASE_URL", "https://api.halios.ai")
54
+ monkeypatch.setenv("HALIOS_UI_URL", "https://app.halios.ai")
49
55
 
50
56
  credentials = cli_support.resolve_credentials(agent_id="agent-id")
51
57
 
52
58
  assert credentials.api_key == "halios_control_plane_key"
53
59
  assert credentials.otlp_token == "halios_agent_ingest_token"
54
60
  assert credentials.base_url == "https://api.halios.ai"
61
+ assert credentials.ui_base_url == "https://app.halios.ai"
62
+
63
+
64
+ def test_ui_links_are_encoded_and_strip_credentials() -> None:
65
+ links = cli_support.halios_ui_links(
66
+ "https://user:secret@app.halios.ai/api?token=do-not-copy",
67
+ "agent/id",
68
+ include_suite=True,
69
+ run_tag="run:hello world",
70
+ trace_id="1" * 32,
71
+ optimization_run_id="opt/id",
72
+ )
73
+
74
+ assert links["agent"] == "https://app.halios.ai/agents/agent%2Fid"
75
+ assert links["scenarios"].endswith("/evaluations?tab=scenarios")
76
+ assert links["rules"].endswith("/evaluations?tab=rules")
77
+ assert links["evaluation_run"].endswith("/evaluations/run%3Ahello%20world")
78
+ assert links["evaluation_traces"].endswith("/traces?eval_run=run%3Ahello+world")
79
+ assert links["optimization_run"].endswith("/optimize/opt%2Fid")
80
+ assert "secret" not in "\n".join(links.values())
81
+ assert "token" not in "\n".join(links.values())
82
+
83
+
84
+ def test_ui_links_require_agent_identity() -> None:
85
+ assert cli_support.halios_ui_links("https://app.halios.ai", "") == {}
86
+
87
+ with pytest.raises(typer.BadParameter, match="Invalid Halios base URL"):
88
+ cli_support.halios_ui_links("ftp://app.halios.ai", "agent-id")
89
+
90
+
91
+ def test_project_init_json_returns_agent_review_link(monkeypatch, tmp_path) -> None:
92
+ agent_id = "11111111-1111-4111-8111-111111111111"
93
+ credentials = SimpleNamespace(
94
+ base_url="https://api.halios.ai",
95
+ ui_base_url="https://app.halios.ai",
96
+ otlp_token="stored-token",
97
+ )
98
+
99
+ class InitApi:
100
+ def __init__(self, _credentials: object) -> None:
101
+ pass
102
+
103
+ def __enter__(self):
104
+ return self
105
+
106
+ def __exit__(self, *_args):
107
+ return None
108
+
109
+ def request(self, method: str, path: str, **_kwargs):
110
+ if method == "GET" and path == "/api/v1/ai/capability":
111
+ return {"evaluation_available": True}
112
+ if method == "POST" and path == "/api/v1/agents":
113
+ return {"id": agent_id, "name": "Support agent", "slug": "support-agent"}
114
+ if path == f"/api/v1/agents/{agent_id}/evaluation-suite":
115
+ return {
116
+ "revision": 0,
117
+ "digest": "",
118
+ "eval": {},
119
+ "scenarios": {"version": 1, "scenarios": []},
120
+ }
121
+ raise AssertionError(f"Unexpected request: {method} {path}")
122
+
123
+ monkeypatch.chdir(tmp_path)
124
+ monkeypatch.setattr(cli_project, "resolve_credentials", lambda *_args: credentials)
125
+ monkeypatch.setattr(cli_project, "ApiClient", InitApi)
126
+
127
+ result = CliRunner().invoke(
128
+ app,
129
+ [
130
+ "project",
131
+ "init",
132
+ "--agent",
133
+ "Support agent",
134
+ "--command",
135
+ "python adapter.py",
136
+ "--json",
137
+ ],
138
+ )
139
+
140
+ assert result.exit_code == 0
141
+ payload = json.loads(result.output)
142
+ assert payload["agent"]["id"] == agent_id
143
+ assert payload["links"]["agent"] == f"https://app.halios.ai/agents/{agent_id}"
55
144
 
56
145
 
57
146
  def test_trace_list_uses_backend_traffic_scope(monkeypatch) -> None:
@@ -100,6 +189,7 @@ def test_trace_verify_accepts_complete_root(monkeypatch) -> None:
100
189
  result = CliRunner().invoke(app, ["trace", "verify", trace_id, "--json"])
101
190
  assert result.exit_code == 0
102
191
  assert '"verified": true' in result.output
192
+ assert '"trace": "https://app.halios.ai/agents/agent-id/traces/' in result.output
103
193
 
104
194
 
105
195
  def test_cli_root_span_uses_standard_genai_message_parts() -> None:
@@ -1 +0,0 @@
1
- __version__ = "2.0.4"
File without changes
File without changes
File without changes