haliosai-cli 2.0.0__py3-none-any.whl

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.
@@ -0,0 +1,404 @@
1
+ """Thin, explicit, idempotent project setup commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import pathlib
7
+ import re
8
+ import secrets
9
+ import shlex
10
+ import shutil
11
+ import tempfile
12
+ import urllib.parse
13
+ from typing import Any
14
+
15
+ import typer
16
+
17
+ from .cli_support import (
18
+ ApiClient,
19
+ ApiError,
20
+ evaluation_suite_digest,
21
+ git_provenance,
22
+ load_project_config,
23
+ load_yaml,
24
+ preserve_suite_recovery,
25
+ resolve_credentials,
26
+ save_agent_ingest_token,
27
+ write_suite_checkout,
28
+ write_yaml,
29
+ )
30
+
31
+ app = typer.Typer(help="Initialize and validate a Halios project.", no_args_is_help=True)
32
+
33
+
34
+ def _slug(value: str) -> str:
35
+ return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
36
+
37
+
38
+ def _write_once(path: pathlib.Path, content: str) -> bool:
39
+ if path.exists():
40
+ return False
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ with tempfile.NamedTemporaryFile(
43
+ "w", dir=path.parent, delete=False, encoding="utf-8"
44
+ ) as handle:
45
+ handle.write(content)
46
+ temporary = pathlib.Path(handle.name)
47
+ temporary.replace(path)
48
+ return True
49
+
50
+
51
+ def _find_or_create_agent(api: ApiClient, explicit_agent: str) -> dict[str, Any]:
52
+ """Create a fresh agent; retained name avoids breaking direct SDK imports."""
53
+ slug = _slug(explicit_agent)
54
+ if not slug:
55
+ raise typer.BadParameter("--agent must contain letters or numbers")
56
+ return api.request(
57
+ "POST",
58
+ "/api/v1/agents",
59
+ json={
60
+ "name": explicit_agent,
61
+ "slug": f"{slug[:240]}-{secrets.token_hex(3)}",
62
+ "description": (
63
+ f"Evaluation, simulation, and production observability for {explicit_agent}."
64
+ ),
65
+ },
66
+ )
67
+
68
+
69
+ def _link_existing_agent(api: ApiClient, agent_id: str) -> dict[str, Any]:
70
+ if not re.fullmatch(
71
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}",
72
+ agent_id,
73
+ ):
74
+ raise typer.BadParameter("--link-agent requires an agent UUID")
75
+ return api.request("GET", f"/api/v1/agents/{agent_id}")
76
+
77
+
78
+ def _evaluation_ai_capability(api: ApiClient) -> dict[str, Any]:
79
+ capability = api.request("GET", "/api/v1/ai/capability")
80
+ if not capability.get("evaluation_available"):
81
+ raise typer.BadParameter(capability.get("remediation") or "Evaluation AI is unavailable")
82
+ return capability
83
+
84
+
85
+ @app.command("init")
86
+ def init(
87
+ agent: str | None = typer.Option(None, "--agent", help="Name for a new Halios agent."),
88
+ link_agent: str | None = typer.Option(
89
+ None, "--link-agent", help="Explicit UUID of an existing Halios agent."
90
+ ),
91
+ profile: str = typer.Option("default", "--profile"),
92
+ command: str = typer.Option(
93
+ "", "--command", help="Project adapter command; may be added later by the coding agent."
94
+ ),
95
+ ) -> None:
96
+ """Create a fresh agent, or explicitly link one by UUID, and initialize the checkout."""
97
+ root = pathlib.Path.cwd().resolve()
98
+ config_path = root / ".halios" / "config.toml"
99
+ if bool(agent) == bool(link_agent) and not config_path.exists():
100
+ raise typer.BadParameter("Provide exactly one of --agent or --link-agent")
101
+ credentials = resolve_credentials(profile)
102
+ with ApiClient(credentials) as api:
103
+ _evaluation_ai_capability(api)
104
+ created_agent = False
105
+ if config_path.exists():
106
+ _, existing = load_project_config(root)
107
+ bound_id = str((existing.get("agent") or {}).get("id") or "")
108
+ if link_agent and link_agent != bound_id:
109
+ raise typer.BadParameter(f"Project is already bound to agent {bound_id}")
110
+ resolved_agent = api.request("GET", f"/api/v1/agents/{bound_id}")
111
+ if agent and agent != str(resolved_agent.get("name") or ""):
112
+ raise typer.BadParameter(
113
+ f"Project is already bound to {resolved_agent.get('name')} ({bound_id})"
114
+ )
115
+ elif link_agent:
116
+ resolved_agent = _link_existing_agent(api, link_agent)
117
+ else:
118
+ resolved_agent = _find_or_create_agent(api, str(agent))
119
+ created_agent = True
120
+ suite = api.request("GET", f"/api/v1/agents/{resolved_agent['id']}/evaluation-suite")
121
+ if created_agent and (
122
+ int(suite.get("revision") or 0) != 0
123
+ or (suite.get("eval") or {}).get("checks")
124
+ or (suite.get("scenarios") or {}).get("scenarios")
125
+ ):
126
+ raise typer.BadParameter("Fresh agent unexpectedly contains evaluation state")
127
+ bound_credentials = resolve_credentials(profile, str(resolved_agent["id"]))
128
+ if not bound_credentials.otlp_token:
129
+ ingest = api.request("POST", f"/api/v1/agents/{resolved_agent['id']}/otel-token")
130
+ save_agent_ingest_token(profile, str(resolved_agent["id"]), str(ingest["token"]))
131
+ halios_dir = root / ".halios"
132
+
133
+ escaped_command = command.replace("\\", "\\\\").replace('"', '\\"')
134
+ created: list[str] = []
135
+ if _write_once(
136
+ config_path,
137
+ "\n".join(
138
+ [
139
+ 'version = "1"',
140
+ f'profile = "{profile}"',
141
+ f'app_name = "{str(resolved_agent["name"]).replace(chr(34), chr(39))}"',
142
+ f'halios_url = "{credentials.base_url}"',
143
+ "",
144
+ "[agent]",
145
+ f'id = "{resolved_agent["id"]}"',
146
+ f'slug = "{resolved_agent["slug"]}"',
147
+ f'command = "{escaped_command}"',
148
+ 'protocol = "jsonl-v1"',
149
+ "",
150
+ "[suite]",
151
+ f"revision = {int(suite.get('revision') or 0)}",
152
+ f'digest = "{str(suite.get("digest") or "")}"',
153
+ "",
154
+ ]
155
+ ),
156
+ ):
157
+ created.append(".halios/config.toml")
158
+
159
+ eval_path = halios_dir / "eval.yml"
160
+ if not eval_path.exists():
161
+ write_yaml(eval_path, suite["eval"])
162
+ created.append(".halios/eval.yml")
163
+
164
+ scenarios_path = halios_dir / "scenarios.yml"
165
+ if not scenarios_path.exists():
166
+ write_yaml(scenarios_path, suite["scenarios"])
167
+ created.append(".halios/scenarios.yml")
168
+
169
+ 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:
174
+ 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"
178
+ )
179
+ typer.echo("Created: " + ", ".join(created) if created else "Project was already initialized.")
180
+
181
+
182
+ def _apply_suite_response(root: pathlib.Path, response: dict[str, Any]) -> None:
183
+ expected_digest = response.get("digest")
184
+ actual_digest = evaluation_suite_digest(response["eval"], response["scenarios"])
185
+ if int(response.get("revision") or 0) > 0 and expected_digest != actual_digest:
186
+ raise typer.BadParameter("Halios returned an evaluation suite with an invalid digest")
187
+ write_suite_checkout(
188
+ root,
189
+ eval_plan=response["eval"],
190
+ scenarios=response["scenarios"],
191
+ revision=int(response["revision"]),
192
+ digest=response.get("digest"),
193
+ )
194
+
195
+
196
+ @app.command("configure")
197
+ def configure(json_output: bool = typer.Option(False, "--json")) -> None:
198
+ """Atomically apply local eval and scenario working copies to Halios."""
199
+ root, config = load_project_config()
200
+ agent_id = str((config.get("agent") or {}).get("id") or "")
201
+ profile = str(config.get("profile") or "default")
202
+ expected_revision = int((config.get("suite") or {}).get("revision") or 0)
203
+ eval_plan = load_yaml(root / ".halios" / "eval.yml")
204
+ scenarios = load_yaml(root / ".halios" / "scenarios.yml")
205
+ from .cli_eval import _eval_schema_errors, _scenario_schema_errors
206
+
207
+ local_errors = [*_eval_schema_errors(eval_plan), *_scenario_schema_errors(scenarios)[1]]
208
+ if local_errors:
209
+ raise typer.BadParameter("Invalid evaluation suite:\n- " + "\n- ".join(local_errors))
210
+ credentials = resolve_credentials(profile, agent_id)
211
+ try:
212
+ with ApiClient(credentials) as api:
213
+ response = api.request(
214
+ "PUT",
215
+ f"/api/v1/agents/{agent_id}/evaluation-suite",
216
+ json={
217
+ "expected_revision": expected_revision,
218
+ "eval": eval_plan,
219
+ "scenarios": scenarios,
220
+ },
221
+ )
222
+ except ApiError as exc:
223
+ if exc.status_code != 409 or not isinstance(exc.detail, dict):
224
+ raise
225
+ current = exc.detail.get("current")
226
+ if not isinstance(current, dict):
227
+ raise
228
+ recovery = preserve_suite_recovery(
229
+ agent_id=agent_id,
230
+ eval_plan=eval_plan,
231
+ scenarios=scenarios,
232
+ )
233
+ _apply_suite_response(root, current)
234
+ raise typer.BadParameter(
235
+ f"Evaluation suite revision conflict. Refreshed local YAML to revision "
236
+ f"{current['revision']}; rejected edits were preserved at {recovery}"
237
+ ) from exc
238
+ verification = response.get("verification") or {}
239
+ if verification.get("verified") is not True:
240
+ raise typer.BadParameter("Halios did not verify the materialized evaluation suite")
241
+ _apply_suite_response(root, response)
242
+ result = {
243
+ "configured": True,
244
+ "revision": response["revision"],
245
+ "digest": response.get("digest"),
246
+ "verification": verification,
247
+ }
248
+ if json_output:
249
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
250
+ else:
251
+ typer.echo(
252
+ f"Evaluation suite revision {response['revision']} configured: "
253
+ f"{verification['check_count']} checks, {verification['rule_count']} rules, "
254
+ f"{verification['rubric_count']} rubrics, {verification['scenario_count']} scenarios"
255
+ )
256
+
257
+
258
+ @app.command("refresh")
259
+ def refresh() -> None:
260
+ """Replace both local YAML files with the authoritative Halios suite."""
261
+ root, config = load_project_config()
262
+ agent_id = str((config.get("agent") or {}).get("id") or "")
263
+ profile = str(config.get("profile") or "default")
264
+ with ApiClient(resolve_credentials(profile, agent_id)) as api:
265
+ response = api.request("GET", f"/api/v1/agents/{agent_id}/evaluation-suite")
266
+ _apply_suite_response(root, response)
267
+ typer.echo(f"Refreshed evaluation suite revision {response['revision']} from Halios")
268
+
269
+
270
+ @app.command("check")
271
+ def check(
272
+ profile: str | None = typer.Option(None, "--profile"),
273
+ json_output: bool = typer.Option(False, "--json"),
274
+ ) -> None:
275
+ """Validate local files, credentials, adapter, provenance, and AI capability."""
276
+ root, config = load_project_config()
277
+ selected_profile = profile or str(config.get("profile") or "default")
278
+ agent = config.get("agent") or {}
279
+ agent_id = str(agent.get("id") or "")
280
+ if not agent_id:
281
+ raise typer.BadParameter("config.toml is missing agent.id")
282
+ if agent.get("protocol") != "jsonl-v1":
283
+ raise typer.BadParameter("agent.protocol must be jsonl-v1")
284
+ local_eval = load_yaml(root / ".halios" / "eval.yml")
285
+ local_scenarios = load_yaml(root / ".halios" / "scenarios.yml")
286
+ credentials = resolve_credentials(selected_profile, agent_id)
287
+ if not credentials.otlp_token:
288
+ raise typer.BadParameter(
289
+ "Missing agent OTLP token; rerun `halios project init --agent ...`"
290
+ )
291
+
292
+ command = str(agent.get("command") or "").strip()
293
+ if not command:
294
+ raise typer.BadParameter("No agent.command in .halios/config.toml")
295
+ parts = shlex.split(command)
296
+ if not parts:
297
+ raise typer.BadParameter("agent.command must not be empty")
298
+ executable = parts[0]
299
+ if "/" in executable:
300
+ if not (root / executable).exists():
301
+ raise typer.BadParameter(f"Adapter executable does not exist: {executable}")
302
+ elif shutil.which(executable) is None:
303
+ raise typer.BadParameter(f"Adapter executable was not found: {executable}")
304
+ candidate = (
305
+ parts[1] if len(parts) > 1 and pathlib.Path(executable).name.startswith("python") else None
306
+ )
307
+ if candidate and candidate.endswith(".py") and not (root / candidate).exists():
308
+ raise typer.BadParameter(f"Adapter command target does not exist: {candidate}")
309
+
310
+ with ApiClient(credentials) as api:
311
+ api.request("GET", f"/api/v1/agents/{agent_id}")
312
+ suite = api.request("GET", f"/api/v1/agents/{agent_id}/evaluation-suite")
313
+ local_revision = int((config.get("suite") or {}).get("revision") or 0)
314
+ if int(suite.get("revision") or 0) != local_revision:
315
+ raise typer.BadParameter(
316
+ "Local evaluation suite checkout is stale; run `halios project refresh`"
317
+ )
318
+ if evaluation_suite_digest(local_eval, local_scenarios) != suite.get("digest"):
319
+ raise typer.BadParameter(
320
+ "Local evaluation suite has unconfigured edits; run "
321
+ "`halios project configure` or `halios project refresh`"
322
+ )
323
+ if not (suite.get("eval") or {}).get("checks") or not (suite.get("scenarios") or {}).get(
324
+ "scenarios"
325
+ ):
326
+ raise typer.BadParameter(
327
+ "Evaluation suite is not configured; author YAML and run `halios project configure`"
328
+ )
329
+ if (suite.get("verification") or {}).get("verified") is not True:
330
+ raise typer.BadParameter("Persistent evaluation suite verification failed")
331
+ capability = _evaluation_ai_capability(api)
332
+
333
+ provenance = git_provenance(root)
334
+ branch = provenance.get("branch") or "detached"
335
+ commit = str(provenance.get("commit_sha") or "unknown")[:12]
336
+ dirty = str(bool(provenance.get("dirty_worktree"))).lower()
337
+ mode = str(capability.get("execution_mode") or "managed")
338
+ model = capability.get("default_model")
339
+ evaluation_status = f"ready ({'Halios Managed' if mode == 'managed' else model})"
340
+ result = {
341
+ "ok": True,
342
+ "agent_id": agent_id,
343
+ "profile": selected_profile,
344
+ "adapter_protocol": "jsonl-v1",
345
+ "git": {"branch": branch, "commit": commit, "dirty": dirty == "true"},
346
+ "evaluation_ai": evaluation_status,
347
+ "suite": {"verified": True, "revision": suite["revision"]},
348
+ }
349
+ if json_output:
350
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
351
+ else:
352
+ typer.echo(f"Project: ok ({agent_id})")
353
+ typer.echo(f"Credentials: ok ({selected_profile})")
354
+ typer.echo("Adapter: ok (jsonl-v1)")
355
+ typer.echo(f"Git: {branch}@{commit} dirty={dirty}")
356
+ typer.echo(f"Evaluation AI: {evaluation_status}")
357
+ typer.echo(f"Evaluation suite: verified (revision {suite['revision']})")
358
+
359
+
360
+ @app.command("instrumentation")
361
+ def instrumentation(
362
+ profile: str | None = typer.Option(None, "--profile"),
363
+ environment: str = typer.Option("production", "--environment"),
364
+ show_secret: bool = typer.Option(
365
+ False,
366
+ "--show-secret",
367
+ help="Reveal the stored agent ingest token for manual secret-manager setup.",
368
+ ),
369
+ json_output: bool = typer.Option(False, "--json"),
370
+ ) -> None:
371
+ """Print deployment-safe OpenTelemetry configuration for the real agent runtime."""
372
+ _root, config = load_project_config()
373
+ selected_profile = profile or str(config.get("profile") or "default")
374
+ agent = config.get("agent") or {}
375
+ agent_id = str(agent.get("id") or "")
376
+ if not agent_id:
377
+ raise typer.BadParameter("config.toml is missing agent.id")
378
+ credentials = resolve_credentials(selected_profile, agent_id)
379
+ if not credentials.otlp_token:
380
+ raise typer.BadParameter(
381
+ "Missing agent OTLP token; rerun `halios project init --agent ...`"
382
+ )
383
+ environment_name = environment.strip()
384
+ if not environment_name:
385
+ raise typer.BadParameter("--environment must not be empty")
386
+ token = credentials.otlp_token if show_secret else "<stored-agent-token>"
387
+ authorization = urllib.parse.quote(f"Bearer {token}", safe="<>")
388
+ values = {
389
+ "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
390
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": f"{credentials.base_url}/v1/traces",
391
+ "OTEL_EXPORTER_OTLP_HEADERS": f"Authorization={authorization}",
392
+ "OTEL_SERVICE_NAME": str(config.get("app_name") or agent.get("slug") or "agent"),
393
+ "OTEL_RESOURCE_ATTRIBUTES": f"deployment.environment.name={environment_name}",
394
+ }
395
+ if json_output:
396
+ typer.echo(json.dumps(values, indent=2, sort_keys=True))
397
+ else:
398
+ for key, value in values.items():
399
+ typer.echo(f"{key}={value}")
400
+ if not show_secret:
401
+ typer.echo(
402
+ "Token hidden. Run this command yourself with --show-secret, then copy it "
403
+ "directly into your deployment secret manager."
404
+ )
@@ -0,0 +1,96 @@
1
+ """Git-owned scenario authoring and inspection commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from .cli_support import ApiClient, load_project_config, load_yaml, resolve_credentials, write_yaml
10
+
11
+ app = typer.Typer(help="Generate and inspect durable test scenarios.", no_args_is_help=True)
12
+
13
+
14
+ def _local_suite():
15
+ root, config = load_project_config()
16
+ path = root / ".halios" / "scenarios.yml"
17
+ payload = load_yaml(path)
18
+ scenarios = payload.get("scenarios") or []
19
+ if not isinstance(scenarios, list):
20
+ raise typer.BadParameter("scenarios.yml scenarios must be a list")
21
+ return root, config, path, payload, scenarios
22
+
23
+
24
+ @app.command("generate")
25
+ def generate(
26
+ from_trace: str | None = typer.Option(None, "--from-trace"),
27
+ count: int = typer.Option(12, "--count", min=1, max=100),
28
+ max_turns: int = typer.Option(6, "--max-turns", min=1, max=20),
29
+ json_output: bool = typer.Option(False, "--json"),
30
+ ) -> None:
31
+ """Generate scenarios into Git, optionally from one production failure."""
32
+ _root, config, path, payload, scenarios = _local_suite()
33
+ agent_id = str((config.get("agent") or {}).get("id") or "")
34
+ credentials = resolve_credentials(str(config.get("profile") or "default"), agent_id)
35
+ with ApiClient(credentials) as api:
36
+ if from_trace:
37
+ drafted = api.request(
38
+ "POST",
39
+ f"/api/v1/scenarios/draft-from-trace/{from_trace}",
40
+ json={"agent_id": agent_id, "max_turns": max_turns},
41
+ )["draft"]
42
+ scenario = {
43
+ "id": f"regression-{from_trace[:12]}",
44
+ **drafted,
45
+ "source_trace_id": from_trace,
46
+ "generation_mode": "simulation",
47
+ }
48
+ generated = [scenario]
49
+ else:
50
+ response = api.request(
51
+ "POST",
52
+ "/api/v1/scenarios/generate",
53
+ json={
54
+ "agent_id": agent_id,
55
+ "scenario_count": count,
56
+ "generation_mode": "simulation-with-arc-hint",
57
+ "max_turns": max_turns,
58
+ },
59
+ )
60
+ generated = response.get("scenarios") or []
61
+
62
+ existing_ids = {str(item.get("id")) for item in scenarios}
63
+ added = [item for item in generated if str(item.get("id")) not in existing_ids]
64
+ payload["version"] = 1
65
+ payload["scenarios"] = [*scenarios, *added]
66
+ write_yaml(path, payload)
67
+ result = {"added": len(added), "path": str(path), "scenarios": added}
68
+ typer.echo(
69
+ json.dumps(result, indent=2, sort_keys=True)
70
+ if json_output
71
+ else f"Added {len(added)} scenarios to {path}"
72
+ )
73
+
74
+
75
+ @app.command("list")
76
+ def list_scenarios(json_output: bool = typer.Option(False, "--json")) -> None:
77
+ """List scenarios from the current Git branch."""
78
+ _root, _config, _path, _payload, scenarios = _local_suite()
79
+ if json_output:
80
+ typer.echo(json.dumps(scenarios, indent=2, sort_keys=True))
81
+ return
82
+ for scenario in scenarios:
83
+ typer.echo(f"{scenario.get('id')}\t{scenario.get('title') or scenario.get('goal') or ''}")
84
+
85
+
86
+ @app.command("show")
87
+ def show(scenario_id: str, json_output: bool = typer.Option(False, "--json")) -> None:
88
+ """Show one local scenario by stable id."""
89
+ _root, _config, _path, _payload, scenarios = _local_suite()
90
+ scenario = next((item for item in scenarios if str(item.get("id")) == scenario_id), None)
91
+ if not scenario:
92
+ raise typer.BadParameter(f"Scenario not found: {scenario_id}")
93
+ if json_output:
94
+ typer.echo(json.dumps(scenario, indent=2, sort_keys=True))
95
+ else:
96
+ typer.echo(f"{scenario_id}: {scenario.get('title') or ''}\n{scenario.get('goal') or ''}")