agentops-accelerator 0.3.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.
Files changed (142) hide show
  1. agentops/__init__.py +10 -0
  2. agentops/__main__.py +6 -0
  3. agentops/agent/__init__.py +12 -0
  4. agentops/agent/_legacy_ids.py +92 -0
  5. agentops/agent/analyzer.py +207 -0
  6. agentops/agent/checks/__init__.py +1 -0
  7. agentops/agent/checks/catalog.py +880 -0
  8. agentops/agent/checks/errors.py +279 -0
  9. agentops/agent/checks/foundry_config.py +75 -0
  10. agentops/agent/checks/latency.py +84 -0
  11. agentops/agent/checks/opex.py +157 -0
  12. agentops/agent/checks/opex_workspace.py +874 -0
  13. agentops/agent/checks/posture.py +36 -0
  14. agentops/agent/checks/posture_rules/__init__.py +53 -0
  15. agentops/agent/checks/posture_rules/content_filter.py +59 -0
  16. agentops/agent/checks/posture_rules/diagnostics.py +74 -0
  17. agentops/agent/checks/posture_rules/local_auth.py +55 -0
  18. agentops/agent/checks/posture_rules/managed_identity.py +59 -0
  19. agentops/agent/checks/posture_rules/network.py +68 -0
  20. agentops/agent/checks/regression.py +78 -0
  21. agentops/agent/checks/release_readiness.py +182 -0
  22. agentops/agent/checks/safety.py +247 -0
  23. agentops/agent/checks/spec_conformance.py +375 -0
  24. agentops/agent/cockpit.py +5159 -0
  25. agentops/agent/config.py +240 -0
  26. agentops/agent/findings.py +113 -0
  27. agentops/agent/history.py +142 -0
  28. agentops/agent/knowledge/__init__.py +182 -0
  29. agentops/agent/knowledge/waf-checklist.csv +39 -0
  30. agentops/agent/llm_assist/__init__.py +16 -0
  31. agentops/agent/llm_assist/_base.py +124 -0
  32. agentops/agent/llm_assist/_bundle_rule.py +154 -0
  33. agentops/agent/llm_assist/_client.py +347 -0
  34. agentops/agent/llm_assist/_dataset_rules.py +191 -0
  35. agentops/agent/llm_assist/_engine.py +106 -0
  36. agentops/agent/llm_assist/_prompt_rules.py +291 -0
  37. agentops/agent/llm_assist/_spec_rules.py +235 -0
  38. agentops/agent/production_telemetry.py +430 -0
  39. agentops/agent/report.py +207 -0
  40. agentops/agent/server/__init__.py +1 -0
  41. agentops/agent/server/app.py +84 -0
  42. agentops/agent/server/auth.py +94 -0
  43. agentops/agent/server/chat.py +44 -0
  44. agentops/agent/server/protocol.py +72 -0
  45. agentops/agent/sources/__init__.py +1 -0
  46. agentops/agent/sources/azure_monitor.py +523 -0
  47. agentops/agent/sources/azure_resources.py +602 -0
  48. agentops/agent/sources/foundry_control.py +174 -0
  49. agentops/agent/sources/results_history.py +494 -0
  50. agentops/agent/sources/spec_detectors/__init__.py +42 -0
  51. agentops/agent/sources/spec_detectors/_base.py +58 -0
  52. agentops/agent/sources/spec_detectors/agents_md.py +75 -0
  53. agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
  54. agentops/agent/time_range.py +117 -0
  55. agentops/cli/__init__.py +1 -0
  56. agentops/cli/app.py +4823 -0
  57. agentops/core/__init__.py +1 -0
  58. agentops/core/agentops_config.py +592 -0
  59. agentops/core/config_loader.py +22 -0
  60. agentops/core/evaluators.py +480 -0
  61. agentops/core/release_evidence.py +56 -0
  62. agentops/core/results.py +117 -0
  63. agentops/mcp/__init__.py +10 -0
  64. agentops/mcp/server.py +232 -0
  65. agentops/pipeline/__init__.py +8 -0
  66. agentops/pipeline/cloud_results.py +189 -0
  67. agentops/pipeline/cloud_runner.py +901 -0
  68. agentops/pipeline/comparison.py +108 -0
  69. agentops/pipeline/diagnostics.py +51 -0
  70. agentops/pipeline/invocations.py +535 -0
  71. agentops/pipeline/official_eval.py +414 -0
  72. agentops/pipeline/orchestrator.py +775 -0
  73. agentops/pipeline/prompt_deploy.py +377 -0
  74. agentops/pipeline/publisher.py +121 -0
  75. agentops/pipeline/reporter.py +202 -0
  76. agentops/pipeline/runtime.py +409 -0
  77. agentops/pipeline/thresholds.py +84 -0
  78. agentops/services/__init__.py +1 -0
  79. agentops/services/cicd.py +720 -0
  80. agentops/services/eval_analysis.py +848 -0
  81. agentops/services/evidence_pack.py +757 -0
  82. agentops/services/initializer.py +86 -0
  83. agentops/services/preflight.py +470 -0
  84. agentops/services/setup_wizard.py +709 -0
  85. agentops/services/skills.py +643 -0
  86. agentops/services/trace_promotion.py +300 -0
  87. agentops/services/workflow_analysis.py +1129 -0
  88. agentops/templates/.gitignore +15 -0
  89. agentops/templates/__init__.py +1 -0
  90. agentops/templates/agent-server/Dockerfile +23 -0
  91. agentops/templates/agent-server/README.md +61 -0
  92. agentops/templates/agent-server/main.bicep +94 -0
  93. agentops/templates/agent.yaml +87 -0
  94. agentops/templates/agentops.yaml +58 -0
  95. agentops/templates/foundry.svg +71 -0
  96. agentops/templates/icon.png +0 -0
  97. agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
  98. agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
  99. agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
  100. agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
  101. agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
  102. agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
  103. agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
  104. agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
  105. agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
  106. agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
  107. agentops/templates/project.gitignore +36 -0
  108. agentops/templates/sample-traces.jsonl +3 -0
  109. agentops/templates/skills/agentops-agent/SKILL.md +137 -0
  110. agentops/templates/skills/agentops-config/SKILL.md +113 -0
  111. agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
  112. agentops/templates/skills/agentops-eval/SKILL.md +189 -0
  113. agentops/templates/skills/agentops-report/SKILL.md +71 -0
  114. agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
  115. agentops/templates/smoke.jsonl +3 -0
  116. agentops/templates/waf-checklist.README.md +84 -0
  117. agentops/templates/waf-checklist.csv +22 -0
  118. agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
  119. agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
  120. agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
  121. agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
  122. agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
  123. agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
  124. agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
  125. agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
  126. agentops/templates/workflows/agentops-pr.yml +148 -0
  127. agentops/templates/workflows/agentops-watchdog.yml +122 -0
  128. agentops/utils/__init__.py +1 -0
  129. agentops/utils/azd_env.py +435 -0
  130. agentops/utils/azure_endpoints.py +62 -0
  131. agentops/utils/colors.py +47 -0
  132. agentops/utils/dotenv_loader.py +105 -0
  133. agentops/utils/foundry_discovery.py +229 -0
  134. agentops/utils/logging.py +59 -0
  135. agentops/utils/telemetry.py +554 -0
  136. agentops/utils/yaml.py +36 -0
  137. agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
  138. agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
  139. agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
  140. agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
  141. agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
  142. agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
agentops/cli/app.py ADDED
@@ -0,0 +1,4823 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from html import escape as html_escape
14
+ from pathlib import Path
15
+ from textwrap import wrap
16
+ from collections.abc import Sequence
17
+ from typing import Annotated, Optional
18
+
19
+ import typer
20
+
21
+ from agentops.utils.colors import style
22
+ from agentops.utils.logging import get_logger, setup_logging
23
+
24
+ app = typer.Typer(
25
+ name="agentops",
26
+ help="AgentOps - standardized evaluation workflows for AI projects.",
27
+ add_completion=False,
28
+ )
29
+ eval_app = typer.Typer(
30
+ help=(
31
+ "Evaluation sub-commands. "
32
+ "Use `agentops eval run --help` to see run options like "
33
+ "`--config` (`-c`) and `--output` (`-o`)."
34
+ )
35
+ )
36
+ report_app = typer.Typer(help="Reporting commands.")
37
+ workflow_app = typer.Typer(help="CI/CD workflow commands.")
38
+ skills_app = typer.Typer(help="Coding agent skills management.")
39
+ mcp_app = typer.Typer(help="MCP (Model Context Protocol) server commands.")
40
+ agent_app = typer.Typer(
41
+ help=(
42
+ "Agent server commands (host AgentOps as a Copilot SDK agent). "
43
+ "Use `agentops doctor` for the local diagnostic analyzer."
44
+ )
45
+ )
46
+ doctor_app = typer.Typer(
47
+ help=(
48
+ "Diagnose MLOps / security / responsible-AI gaps in this workspace. "
49
+ "Use `agentops doctor explain` for the long-form manual."
50
+ ),
51
+ invoke_without_command=True,
52
+ no_args_is_help=False,
53
+ )
54
+ init_app = typer.Typer(
55
+ help=(
56
+ "Initialise an AgentOps workspace and configure endpoints. "
57
+ "Use `agentops init show` to inspect the current configuration. "
58
+ "Use `agentops init explain` for the long-form manual."
59
+ ),
60
+ invoke_without_command=True,
61
+ no_args_is_help=False,
62
+ )
63
+ app.add_typer(eval_app, name="eval")
64
+ app.add_typer(report_app, name="report")
65
+ app.add_typer(workflow_app, name="workflow")
66
+ app.add_typer(skills_app, name="skills")
67
+ app.add_typer(mcp_app, name="mcp")
68
+ app.add_typer(agent_app, name="agent")
69
+ app.add_typer(doctor_app, name="doctor")
70
+ app.add_typer(init_app, name="init")
71
+
72
+ log = get_logger(__name__)
73
+ DEFAULT_REPORT_INPUT = Path(".agentops/results/latest/results.json")
74
+ DOCTOR_EXPLAIN_WRAP_WIDTH = 88
75
+
76
+
77
+ def _cli_heading(text: str) -> str:
78
+ return style(text, "bold", "cyan")
79
+
80
+
81
+ def _cli_label(text: str) -> str:
82
+ return style(text, "bold", "cyan")
83
+
84
+
85
+ def _cli_path(path: Path | str) -> str:
86
+ return style(str(path), "cyan")
87
+
88
+
89
+ def _cli_command(command: str) -> str:
90
+ return style(command, "bold")
91
+
92
+
93
+ def _cli_ok(text: str) -> str:
94
+ return style(text, "green")
95
+
96
+
97
+ def _cli_warn(text: str) -> str:
98
+ return style(text, "yellow")
99
+
100
+
101
+ def _cli_error(text: str) -> str:
102
+ return style(text, "red")
103
+
104
+
105
+ def _cli_created(path: Path | str) -> str:
106
+ return f" {_cli_ok('+')} {_cli_ok('created')} {_cli_path(path)}"
107
+
108
+
109
+ def _cli_updated(path: Path | str) -> str:
110
+ return f" {_cli_ok('✓')} {_cli_ok('updated')} {_cli_path(path)}"
111
+
112
+
113
+ def _cli_overwritten(path: Path | str) -> str:
114
+ return f" {_cli_warn('~')} {_cli_warn('overwritten')} {_cli_path(path)}"
115
+
116
+
117
+ def _cli_skipped(path: Path | str, suffix: str = "") -> str:
118
+ return f" {style('-', 'dim')} {style('skipped', 'dim')} {_cli_path(path)}{suffix}"
119
+
120
+
121
+ def _cli_value(text: str) -> str:
122
+ lowered = text.lower()
123
+ if lowered in {"ready", "no"} or lowered.startswith("low -") or "ready to run" in lowered:
124
+ return _cli_ok(text)
125
+ if lowered in {"invalid", "not_found", "missing", "missing_input_column"}:
126
+ return _cli_error(text)
127
+ if lowered.startswith("high -") or "needs skill" in lowered:
128
+ return _cli_warn(text)
129
+ if lowered.startswith("medium -") or lowered in {"yes", "unknown", "incomplete"}:
130
+ return _cli_warn(text)
131
+ if lowered in {"azd", "prompt-agent", "placeholder", "auto"} or "(auto default)" in lowered:
132
+ return style(text, "bold")
133
+ return text
134
+
135
+
136
+ class _CliStatusIndicator:
137
+ """Small terminal heartbeat for long-running CLI phases."""
138
+
139
+ _FRAMES = ("|", "/", "-", "\\")
140
+
141
+ def __init__(
142
+ self,
143
+ message: str,
144
+ *,
145
+ err: bool = True,
146
+ interval_seconds: float = 0.25,
147
+ ) -> None:
148
+ self._message = message
149
+ self._err = err
150
+ self._interval_seconds = interval_seconds
151
+ self._done = threading.Event()
152
+ self._lock = threading.Lock()
153
+ self._thread: threading.Thread | None = None
154
+ self._stream = sys.stderr if err else sys.stdout
155
+ self._interactive = _stream_is_interactive(self._stream)
156
+ self._started = 0.0
157
+ self._last_render_length = 0
158
+
159
+ def __enter__(self) -> "_CliStatusIndicator":
160
+ self._started = time.perf_counter()
161
+ if self._interactive:
162
+ self._thread = threading.Thread(
163
+ target=self._render_loop,
164
+ name="agentops-cli-status",
165
+ daemon=True,
166
+ )
167
+ self._thread.start()
168
+ else:
169
+ typer.echo(self._message, err=self._err)
170
+ return self
171
+
172
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
173
+ self._done.set()
174
+ if self._thread is not None:
175
+ self._thread.join(timeout=1.0)
176
+ if self._interactive:
177
+ self._clear()
178
+
179
+ def update(self, message: str) -> None:
180
+ with self._lock:
181
+ if message == self._message:
182
+ return
183
+ self._message = message
184
+ if not self._interactive:
185
+ typer.echo(message, err=self._err)
186
+
187
+ def _render_loop(self) -> None:
188
+ frame_index = 0
189
+ while not self._done.is_set():
190
+ self._render(self._FRAMES[frame_index % len(self._FRAMES)])
191
+ frame_index += 1
192
+ self._done.wait(self._interval_seconds)
193
+
194
+ def _render(self, frame: str) -> None:
195
+ with self._lock:
196
+ message = self._message
197
+ elapsed_seconds = int(time.perf_counter() - self._started)
198
+ text = f"\r{frame} {message} ({elapsed_seconds}s elapsed)"
199
+ padding = " " * max(0, self._last_render_length - len(text))
200
+ try:
201
+ self._stream.write(text + padding)
202
+ self._stream.flush()
203
+ except (OSError, ValueError):
204
+ self._done.set()
205
+ return
206
+ self._last_render_length = max(self._last_render_length, len(text))
207
+
208
+ def _clear(self) -> None:
209
+ try:
210
+ self._stream.write("\r" + (" " * self._last_render_length) + "\r")
211
+ self._stream.flush()
212
+ except (OSError, ValueError):
213
+ return
214
+
215
+
216
+ def _stream_is_interactive(stream: object) -> bool:
217
+ if os.environ.get("CI") or os.environ.get("AGENTOPS_NO_PROGRESS"):
218
+ return False
219
+ isatty = getattr(stream, "isatty", None)
220
+ if not callable(isatty):
221
+ return False
222
+ try:
223
+ return bool(isatty())
224
+ except (OSError, ValueError):
225
+ return False
226
+
227
+
228
+ def _doctor_findings_summary_lines(findings: Sequence[object]) -> list[str]:
229
+ if not findings:
230
+ return [f"{_cli_label('Findings')}: {_cli_ok('0')}"]
231
+
232
+ counts = {"critical": 0, "warning": 0, "info": 0}
233
+ for finding in findings:
234
+ severity = getattr(getattr(finding, "severity", ""), "value", "info")
235
+ counts[severity] = counts.get(severity, 0) + 1
236
+
237
+ count_parts = []
238
+ if counts.get("critical"):
239
+ count_parts.append(_cli_error(f"{counts['critical']} critical"))
240
+ if counts.get("warning"):
241
+ count_parts.append(_cli_warn(f"{counts['warning']} warning"))
242
+ if counts.get("info"):
243
+ count_parts.append(_cli_ok(f"{counts['info']} info"))
244
+ count_text = " · ".join(count_parts)
245
+
246
+ lines = [
247
+ f"{_cli_label('Findings')}: {len(findings)} ({count_text})",
248
+ f"{_cli_label('Finding summary')}:",
249
+ ]
250
+ max_items = 20
251
+ for index, finding in enumerate(findings[:max_items], start=1):
252
+ severity = getattr(getattr(finding, "severity", ""), "value", "info")
253
+ category = getattr(getattr(finding, "category", ""), "value", "")
254
+ category = category.replace("_", " ") if category else "uncategorized"
255
+ finding_id = getattr(finding, "id", "unknown")
256
+ title = getattr(finding, "title", "")
257
+ tone = (
258
+ "red"
259
+ if severity == "critical"
260
+ else "yellow"
261
+ if severity == "warning"
262
+ else "green"
263
+ )
264
+ plain_prefix = f" {index}. {severity} [{category}] {finding_id} - "
265
+ marker = style(f"{index}.", "dim")
266
+ severity_label = style(severity, "bold", tone)
267
+ head = (
268
+ f" {marker} {severity_label} "
269
+ f"[{category}] {style(finding_id, 'bold')} - "
270
+ )
271
+ title_lines = wrap(title, width=max(32, 110 - len(plain_prefix)))
272
+ if title_lines:
273
+ lines.append(f"{head}{title_lines[0]}")
274
+ continuation_indent = " " * len(plain_prefix)
275
+ for continuation in title_lines[1:]:
276
+ lines.append(f"{continuation_indent}{continuation}")
277
+ else:
278
+ lines.append(head.rstrip())
279
+ remaining = len(findings) - max_items
280
+ if remaining > 0:
281
+ lines.append(f" ... {remaining} more finding(s) in the Doctor report.")
282
+ return lines
283
+
284
+
285
+ def _workflow_eval_runner_label(eval_runner: str) -> str:
286
+ if eval_runner == "agentops-cloud":
287
+ return "AgentOps cloud eval in Foundry"
288
+ if eval_runner == "official-ai-agent-evaluation":
289
+ return "Microsoft Foundry AI Agent Evaluation"
290
+ if eval_runner == "agentops-local":
291
+ return "AgentOps local eval"
292
+ return eval_runner
293
+
294
+
295
+ def _workflow_environment_names(kinds: list[str]) -> list[str]:
296
+ environments: list[str] = []
297
+ if any(kind in kinds for kind in ("pr", "doctor", "dev")):
298
+ environments.append("dev")
299
+ if "qa" in kinds:
300
+ environments.append("qa")
301
+ if "prod" in kinds:
302
+ environments.append("production")
303
+ return environments
304
+
305
+
306
+ def _colorize_analysis_text(text: str) -> str:
307
+ """Apply restrained terminal color to text analysis output only."""
308
+ lines: list[str] = []
309
+ section = ""
310
+ for line in text.splitlines():
311
+ stripped = line.strip()
312
+ if not stripped:
313
+ lines.append(line)
314
+ continue
315
+ if stripped in {"Warnings", "Warnings:"}:
316
+ section = "Warnings"
317
+ lines.append(_cli_warn(line))
318
+ continue
319
+ if stripped in {
320
+ "AgentOps eval analysis",
321
+ "AgentOps trace-to-dataset preview",
322
+ "AgentOps workflow analysis",
323
+ "Workflow decision checklist:",
324
+ "Recommendation",
325
+ "Readiness",
326
+ "Detected signals:",
327
+ "Signals",
328
+ "Foundry eval checks:",
329
+ "Foundry eval",
330
+ "Recommended skills:",
331
+ "Recommended skills",
332
+ "Copilot handoff:",
333
+ "Copilot handoff",
334
+ "Recommended commands:",
335
+ "Commands",
336
+ "Pipeline stages:",
337
+ "Pipeline plan",
338
+ "Next steps:",
339
+ "Next",
340
+ "Sample rows:",
341
+ "Sample rows",
342
+ "Summary",
343
+ }:
344
+ section = stripped.rstrip(":")
345
+ lines.append(_cli_heading(line))
346
+ continue
347
+ if section == "Commands" and stripped.startswith("agentops "):
348
+ prefix = line[: len(line) - len(line.lstrip())]
349
+ lines.append(f"{prefix}{_cli_command(stripped)}")
350
+ continue
351
+ status = stripped.split(maxsplit=1)[0].lower() if stripped else ""
352
+ if status in {"ok", "hint", "todo", "warn"}:
353
+ prefix = line[: len(line) - len(line.lstrip())]
354
+ body = line[len(prefix) :]
355
+ rest = body[len(status) :]
356
+ if status == "ok":
357
+ rendered_status = _cli_ok(body[: len(status)])
358
+ elif status == "warn":
359
+ rendered_status = _cli_warn(body[: len(status)])
360
+ else:
361
+ rendered_status = style(body[: len(status)], "bold", "yellow")
362
+ lines.append(f"{prefix}{rendered_status}{rest}")
363
+ continue
364
+ if stripped.startswith("- "):
365
+ bullet = style("-", "dim")
366
+ body = stripped[2:]
367
+ prefix = line[: len(line) - len(line.lstrip())]
368
+ if section == "Warnings":
369
+ body = _cli_warn(body)
370
+ elif section == "Recommended commands" and body.startswith("agentops "):
371
+ body = _cli_command(body)
372
+ elif section == "Recommended skills" and body.startswith("/"):
373
+ body = style(body, "bold", "cyan")
374
+ lines.append(f"{prefix}{bullet} {body}")
375
+ continue
376
+ if ": " in stripped:
377
+ label, value = stripped.split(": ", 1)
378
+ prefix = line[: len(line) - len(line.lstrip())]
379
+ if label == "Copilot skills installed" and value.lower() == "no":
380
+ rendered_value = _cli_warn(value)
381
+ elif label == "Skill-assisted setup" and value.lower() == "no":
382
+ rendered_value = _cli_ok(value)
383
+ else:
384
+ rendered_value = _cli_value(value)
385
+ lines.append(f"{prefix}{_cli_label(label)}: {rendered_value}")
386
+ continue
387
+ lines.append(line)
388
+ return "\n".join(lines)
389
+
390
+
391
+ # Auto-load .agentops/.env at import time so downstream code (Foundry
392
+ # discovery, telemetry, doctor checks) can rely on the values the user
393
+ # captured via `agentops init` without having to `export` them in every
394
+ # shell session. The loader never overrides variables already present in
395
+ # the process environment, matching dotenv/direnv/azd semantics.
396
+ try:
397
+ from agentops.utils.dotenv_loader import load_workspace_dotenv
398
+
399
+ load_workspace_dotenv(Path.cwd())
400
+ except Exception: # noqa: BLE001
401
+ # Loading is best-effort; never crash the CLI on a malformed .env.
402
+ pass
403
+
404
+
405
+ @dataclass(frozen=True)
406
+ class ExplainPage:
407
+ title: str
408
+ command: str
409
+ synopsis: tuple[str, ...]
410
+ summary: tuple[str, ...]
411
+ how_it_works: tuple[str, ...] = ()
412
+ architecture: tuple[str, ...] = ()
413
+ inputs: tuple[str, ...] = ()
414
+ outputs: tuple[str, ...] = ()
415
+ examples: tuple[str, ...] = ()
416
+ see_also: tuple[str, ...] = ()
417
+ children: tuple[str, ...] = field(default_factory=tuple)
418
+
419
+
420
+ EXPLAIN_PAGES: dict[tuple[str, ...], ExplainPage] = {
421
+ (): ExplainPage(
422
+ title="AgentOps CLI",
423
+ command="agentops",
424
+ synopsis=(
425
+ "agentops [OPTIONS] COMMAND [ARGS]...",
426
+ "agentops explain [COMMAND...] [--format text|markdown|html] [--out PATH] [--open]",
427
+ ),
428
+ summary=(
429
+ "AgentOps Toolkit — a CLI, local Cockpit, and agent skills "
430
+ "that help teams answer two release questions for Microsoft "
431
+ "Foundry agents: can we ship it, and where is the proof?",
432
+ "The CLI runs reproducible release gates for agents and models, "
433
+ "writes stable artifacts, regenerates reports, and installs "
434
+ "AgentOps skills into GitHub Copilot or Claude Code. The Cockpit "
435
+ "brings your project, Foundry, and Azure Monitor together in a "
436
+ "local browser view. Doctor adds readiness analysis grouped by "
437
+ "the Microsoft AI Well-Architected Framework.",
438
+ "Foundry runs the agent. AgentOps proves the release is ready: "
439
+ "CLI configuration, CI gates, normalized artifacts, Doctor "
440
+ "diagnostics, release evidence, and links to the right Foundry "
441
+ "or Azure Monitor surface for runtime drilldown.",
442
+ "Use `--help` for the terse syntax. Use `explain` for the "
443
+ "bigger picture — what each command does, what it reads, what "
444
+ "it writes, and where it takes you.",
445
+ ),
446
+ how_it_works=(
447
+ "`init` creates a reproducible workspace layout: config, starter data, local env values, and result folders.",
448
+ "`eval run` executes local or Foundry-backed evaluation workflows and writes stable `results.json` plus `report.md` artifacts.",
449
+ "`doctor` collects local history, workspace configuration, Foundry control-plane metadata, Azure telemetry, and Azure resource posture, then emits actionable readiness findings grouped by WAF-AI pillar.",
450
+ "`cockpit` opens a local browser cockpit: Foundry connection, one-click links to Foundry Monitor, Evaluations, Traces, Red Teaming, and App Insights, an observability readiness checklist, Doctor findings, local eval history, and recommended next actions.",
451
+ "`workflow analyze`, `workflow generate`, `skills install`, `report generate`, and `mcp serve` wire the same workflow into CI, coding agents, reports, and MCP clients.",
452
+ ),
453
+ inputs=(
454
+ "Command path after `explain`, for example `eval run`, `doctor`, or `cockpit`.",
455
+ "`--format text|markdown|html` to choose terminal text, README-style Markdown, or printable HTML.",
456
+ "`--out PATH` to write the selected format to disk.",
457
+ "`--open` to generate a temporary HTML page and open it in the default browser.",
458
+ ),
459
+ outputs=(
460
+ "Terminal manual by default.",
461
+ "Markdown or HTML file when `--out` is provided.",
462
+ "Temporary browser copy when `--open` is used without an explicit HTML output path.",
463
+ ),
464
+ examples=(
465
+ "agentops --help",
466
+ "agentops explain",
467
+ "agentops explain eval run --open",
468
+ "agentops explain cockpit --format markdown --out cockpit.md",
469
+ ),
470
+ children=("init", "eval", "report", "workflow", "skills", "mcp", "agent", "doctor", "cockpit"),
471
+ ),
472
+ ("init",): ExplainPage(
473
+ title="Initialize workspace and configure endpoints",
474
+ command="agentops init",
475
+ synopsis=(
476
+ "agentops init [--force] [--dir PATH]",
477
+ "agentops init [--no-prompt] [--reconfigure] [--no-appinsights] [--azd-env NAME]",
478
+ "agentops init [--project-endpoint URL] [--agent REF] [--dataset PATH] [--appinsights-connection-string STR]",
479
+ "agentops init show [--reveal-secrets]",
480
+ "agentops init explain",
481
+ ),
482
+ summary=(
483
+ "Bootstraps an AgentOps workspace and walks the user through the "
484
+ "values needed to evaluate, observe, and analyze a Foundry agent.",
485
+ "It is the single entrypoint for setting up a project: it "
486
+ "scaffolds `agentops.yaml` plus the `.agentops/` starter files, "
487
+ "and runs a question loop that fills in project endpoint, "
488
+ "agent, and dataset.",
489
+ "Every answer is persisted as soon as it is validated, so a "
490
+ "Ctrl+C mid-wizard never loses values that were already entered. "
491
+ "Re-running `agentops init` is idempotent: questions whose values "
492
+ "are already configured are skipped with a one-line confirmation. "
493
+ "Pass `--reconfigure` to re-ask every question.",
494
+ ),
495
+ how_it_works=(
496
+ "Scaffolds the minimal workspace files via packaged templates: "
497
+ "`agentops.yaml`, `.agentops/data/smoke.jsonl`, and a starter "
498
+ "project `.gitignore` (when one does not already exist). Existing "
499
+ "files are preserved unless `--force` is provided.",
500
+ "Reads current effective values from `agentops.yaml`, the active "
501
+ "azd env when one already exists, `.agentops/.env`, and the "
502
+ "process environment. Each question shows the current value as "
503
+ "its default; pressing Enter keeps it.",
504
+ "Persists `agent` and `dataset` to `agentops.yaml` (declarative, "
505
+ "version-controlled). Persists the Foundry project endpoint to "
506
+ "`.agentops/.env` by default, or to `.azure/<env>/.env` when an "
507
+ "azd environment already exists or `--azd-env` is provided. App "
508
+ "Insights is not asked in the wizard; runtime commands try to "
509
+ "discover the Foundry project's "
510
+ "attached resource through the Azure AI Projects SDK, and "
511
+ "`--appinsights-connection-string` remains available when you need "
512
+ "to force a value explicitly. Canonical Azure variable names "
513
+ "(`AZURE_*`, `APPLICATIONINSIGHTS_*`) are preserved so Azure SDKs "
514
+ "and azd templates read them directly. Only AgentOps-specific "
515
+ "knobs use the `AGENTOPS_` prefix.",
516
+ "Supports a fully scripted mode through `--project-endpoint`, "
517
+ "`--agent`, `--dataset`, `--appinsights-connection-string`, and "
518
+ "`--azd-env` flags. The wizard is skipped automatically when any "
519
+ "of those flags is provided, or when `--no-prompt` is passed.",
520
+ "`agentops init show` prints the active configuration: azd "
521
+ "environment when present, AgentOps local env, agentops.yaml "
522
+ "fields, and each managed variable with its source and whether it "
523
+ "is set.",
524
+ ),
525
+ inputs=(
526
+ "Workspace directory (defaults to the current directory).",
527
+ "User answers entered interactively, or values supplied via flags.",
528
+ ),
529
+ outputs=(
530
+ "`agentops.yaml` — version, agent, dataset.",
531
+ "`.agentops/` — starter data and asset folders.",
532
+ "`.agentops/.env` — local AgentOps env values when no azd env is active.",
533
+ "`.azure/<env>/.env` — only when an azd env already exists or `--azd-env` is provided.",
534
+ ),
535
+ examples=(
536
+ "agentops init",
537
+ "agentops init --no-prompt",
538
+ "agentops init --reconfigure",
539
+ "agentops init show",
540
+ "agentops init --no-appinsights",
541
+ "agentops init --azd-env dev --project-endpoint https://acct.services.ai.azure.com/api/projects/p --agent my-bot:2 --dataset .agentops/data/smoke.jsonl",
542
+ ),
543
+ see_also=(
544
+ "agentops explain eval run",
545
+ "agentops explain doctor",
546
+ "agentops explain cockpit",
547
+ "agentops explain skills install",
548
+ ),
549
+ ),
550
+ ("eval",): ExplainPage(
551
+ title="Evaluation commands",
552
+ command="agentops eval",
553
+ synopsis=("agentops eval COMMAND [ARGS]...", "agentops eval explain"),
554
+ summary=(
555
+ "Contains commands that analyze and execute standardized evaluation runs from AgentOps configuration.",
556
+ "`analyze` is the read-only setup triage; `run` is the deterministic executor that loads config, invokes the target, evaluates rows, and writes normalized outputs. `promote-traces` turns reviewed production trace exports into regression dataset candidates.",
557
+ ),
558
+ children=("analyze", "run", "promote-traces"),
559
+ examples=("agentops eval analyze", "agentops eval run --config agentops.yaml", "agentops eval promote-traces --source traces.jsonl --apply", "agentops explain eval run --open"),
560
+ ),
561
+ ("eval", "analyze"): ExplainPage(
562
+ title="Analyze evaluation setup",
563
+ command="agentops eval analyze",
564
+ synopsis=("agentops eval analyze [--dir PATH] [--format text|markdown|json] [--out PATH]", "agentops eval analyze explain"),
565
+ summary=(
566
+ "Inspects the local repository and explains whether evaluation setup is ready for `agentops eval run`.",
567
+ "Use it after `agentops init` and before the first run, especially for copied accelerators or apps where target, dataset, or evaluator scenario is not obvious.",
568
+ ),
569
+ how_it_works=(
570
+ "Scans local files only; it does not call Azure, Foundry, Copilot, or any model.",
571
+ "Reads `agentops.yaml` when present, classifies the target kind, checks the dataset reference, and samples JSONL columns.",
572
+ "Looks for structural hints such as Foundry SDK usage, HTTP/containerized apps, RAG/retrieval code, tool calls, direct model APIs, and azd projects.",
573
+ "Reports a scenario hint and complexity level. If deterministic inference is not enough, it recommends the AgentOps skills to use with Copilot, such as `agentops-config`, `agentops-dataset`, and `agentops-eval`.",
574
+ "The boundary is intentional: `eval analyze` is read-only triage, `agentops init` writes the base config, `agentops eval run` executes a configured eval, and Doctor checks readiness after runs/config exist.",
575
+ ),
576
+ outputs=("Human-readable eval setup analysis or stable JSON with `version: 1`",),
577
+ examples=(
578
+ "agentops eval analyze",
579
+ "agentops eval analyze --format markdown --out agentops-eval-plan.md",
580
+ "agentops eval analyze --format json",
581
+ ),
582
+ see_also=("agentops explain eval run", "agentops explain workflow analyze", "agentops explain skills install"),
583
+ ),
584
+ ("eval", "run"): ExplainPage(
585
+ title="Run evaluation",
586
+ command="agentops eval run",
587
+ synopsis=("agentops eval run [--config PATH] [--output DIR] [--baseline PATH] [--format md|html|all]", "agentops eval run explain"),
588
+ summary=(
589
+ "Runs the evaluation workflow described by `agentops.yaml` and writes stable outputs for humans and CI.",
590
+ "This command is the core quality gate: it produces machine-readable results, a Markdown report, and an exit code that pipelines can enforce.",
591
+ ),
592
+ how_it_works=(
593
+ "Loads and validates the flat AgentOps config.",
594
+ "Resolves the target backend and dataset from the config.",
595
+ "Runs evaluation rows, collects metric scores, and applies thresholds.",
596
+ "Writes outputs to a timestamped `.agentops/results/<timestamp>/` folder unless `--output` is set.",
597
+ "Mirrors the run to `.agentops/results/latest/` for reports, cockpit, and Doctor history.",
598
+ "Returns exit code `0` when all thresholds pass, `2` when a threshold fails, or `1` for runtime or configuration errors — so CI pipelines can gate on the result.",
599
+ ),
600
+ inputs=("`agentops.yaml`", "Dataset rows referenced by the config", "Optional baseline `results.json`"),
601
+ outputs=("`results.json`", "`report.md`", "Optional latest mirror under `.agentops/results/latest/`"),
602
+ examples=("agentops eval run", "agentops eval run -c agentops.yaml -o .agentops/results/manual"),
603
+ see_also=("agentops explain report generate", "agentops explain cockpit"),
604
+ ),
605
+ ("eval", "promote-traces"): ExplainPage(
606
+ title="Promote traces into dataset candidates",
607
+ command="agentops eval promote-traces",
608
+ synopsis=("agentops eval promote-traces --source traces.jsonl [--out .agentops/data/trace-regression.jsonl] [--max-rows N] [--label-mode self-similarity|pending] [--apply]", "agentops eval promote-traces explain"),
609
+ summary=(
610
+ "Converts an exported Foundry/App Insights trace JSON or JSONL file into reviewable AgentOps regression dataset rows.",
611
+ "By default it only previews the candidate rows. Use `--apply` to write the dataset and provenance manifest under `.agentops/data/`.",
612
+ ),
613
+ how_it_works=(
614
+ "Reads local trace exports only; it does not query Azure or Foundry.",
615
+ "Extracts common input/response fields from each trace and writes AgentOps JSONL rows.",
616
+ "`--label-mode self-similarity` stores the production response as `expected` for drift detection; this is not human-verified truth.",
617
+ "`--label-mode pending` leaves expected values blank and marks rows for human labeling.",
618
+ "Writes `trace-regression-manifest.json` beside the dataset when `--apply` is used so Doctor and evidence packs can show trace-to-dataset readiness.",
619
+ ),
620
+ outputs=("Preview text by default", "JSONL dataset plus `trace-regression-manifest.json` when `--apply` is used"),
621
+ examples=(
622
+ "agentops eval promote-traces --source traces.jsonl",
623
+ "agentops eval promote-traces --source traces.jsonl --label-mode pending --apply",
624
+ ),
625
+ see_also=("agentops explain eval run", "agentops explain doctor"),
626
+ ),
627
+ ("report",): ExplainPage(
628
+ title="Reporting commands",
629
+ command="agentops report",
630
+ synopsis=("agentops report COMMAND [ARGS]...", "agentops report explain"),
631
+ summary=("Contains commands that turn existing evaluation outputs back into human-readable reports.",),
632
+ children=("generate",),
633
+ examples=("agentops report generate --in .agentops/results/latest/results.json",),
634
+ ),
635
+ ("report", "generate"): ExplainPage(
636
+ title="Generate report",
637
+ command="agentops report generate",
638
+ synopsis=("agentops report generate [--in results.json] [--out report.md] [--format md]", "agentops report generate explain"),
639
+ summary=(
640
+ "Regenerates `report.md` from an existing AgentOps `results.json` without re-running the target or evaluators.",
641
+ "Use it when you changed report rendering or need a report copy in a different location.",
642
+ ),
643
+ how_it_works=(
644
+ "Loads `.agentops/results/latest/results.json` by default, or the path passed with `--in`.",
645
+ "Validates that the file is an AgentOps 1.0 results payload.",
646
+ "Renders the report through the flat pipeline reporter and writes it next to results unless `--out` is set.",
647
+ ),
648
+ inputs=("`results.json`",),
649
+ outputs=("`report.md`",),
650
+ examples=("agentops report generate", "agentops report generate --in .agentops/results/latest/results.json --out report.md"),
651
+ ),
652
+ ("workflow",): ExplainPage(
653
+ title="Workflow commands",
654
+ command="agentops workflow",
655
+ synopsis=("agentops workflow COMMAND [ARGS]...", "agentops workflow explain"),
656
+ summary=("Contains commands that analyze and generate CI/CD workflow files for AgentOps evaluation gates and deployment stages.",),
657
+ children=("analyze", "generate"),
658
+ ),
659
+ ("workflow", "analyze"): ExplainPage(
660
+ title="Analyze CI/CD workflow shape",
661
+ command="agentops workflow analyze",
662
+ synopsis=("agentops workflow analyze [--dir PATH] [--format text|markdown|json] [--out PATH]", "agentops workflow analyze explain"),
663
+ summary=(
664
+ "Inspects the local repository and recommends how AgentOps should fit into CI/CD without replacing Foundry, azd, or landing-zone deployment.",
665
+ "Use it before generating workflows for copied accelerators, azd projects, AI Landing Zone topologies, or repos with existing build/deploy pipelines.",
666
+ ),
667
+ how_it_works=(
668
+ "Scans local files only; it does not call Azure, Foundry, GitHub, Azure DevOps, or azd.",
669
+ "Treats structural signals such as `azure.yaml`, Bicep files, AgentOps prompt-agent config, landing-zone manifests, private-network terms, Dockerfiles, and existing CI folders as the main evidence.",
670
+ "Treats README accelerator matches as hints, not hard truth.",
671
+ "Returns the same deploy-mode recommendation used by `workflow generate --deploy-mode auto` so analysis and generation stay aligned.",
672
+ "Explains the recommended pipeline stages: AgentOps eval/Doctor gates, azd app/infra deployment when present, Foundry prompt-agent candidate deployment when applicable, or project-specific placeholders when adaptation is required.",
673
+ ),
674
+ outputs=("Human-readable workflow analysis or stable JSON with `version: 1`",),
675
+ examples=(
676
+ "agentops workflow analyze",
677
+ "agentops workflow analyze --format markdown --out agentops-workflow-plan.md",
678
+ "agentops workflow analyze --format json",
679
+ ),
680
+ see_also=("agentops explain workflow generate", "agentops explain doctor", "agentops explain cockpit"),
681
+ ),
682
+ ("workflow", "generate"): ExplainPage(
683
+ title="Generate CI/CD workflows",
684
+ command="agentops workflow generate",
685
+ synopsis=("agentops workflow generate [--force] [--dir PATH] [--kinds pr,dev,qa,prod,doctor] [--platform github|azure-devops] [--deploy-mode auto|placeholder|azd|prompt-agent] [--doctor-gate critical|warning|none]", "agentops workflow generate explain"),
686
+ summary=(
687
+ "Writes CI/CD workflow templates that run AgentOps gates in pull requests and environment deployments.",
688
+ "Deployment mode defaults to `auto`. Deployment is azd-first when the repo already has `azure.yaml`: generated deploy workflows call `azd provision` / `azd deploy` instead of asking AgentOps to own infrastructure. Repos without `azure.yaml` can use prompt-agent mode when `agentops.yaml` targets a Foundry prompt agent, or placeholders for custom stacks.",
689
+ "PR-gate Doctor severity defaults to `critical`: PRs are blocked on critical Doctor findings such as regression drops, even when eval thresholds still pass. Use `--doctor-gate warning` to also block on warnings, or `--doctor-gate none` to restore the pre-1.x advisory behavior.",
690
+ ),
691
+ how_it_works=(
692
+ "Selects the target platform and workflow kinds.",
693
+ "When `--deploy-mode` is omitted, auto-detects `azure.yaml` first, then Foundry prompt-agent configs, and picks azd, prompt-agent, or placeholder deploy templates. Override with `--deploy-mode`.",
694
+ "Substitutes the chosen `--doctor-gate` value into the PR template's `agentops doctor --severity-fail` argument. Deploy templates always run with `--severity-fail critical`.",
695
+ "Copies packaged templates into `.github/workflows/` or `.azuredevops/pipelines/`.",
696
+ "Skips existing files unless `--force` is set.",
697
+ "Prints required identity, environment, and branch-protection next steps.",
698
+ ),
699
+ outputs=("CI/CD YAML workflow files",),
700
+ examples=(
701
+ "agentops workflow generate",
702
+ "agentops workflow generate --kinds pr,dev --platform github --deploy-mode prompt-agent --force",
703
+ "agentops workflow generate --doctor-gate warning",
704
+ "agentops workflow generate --doctor-gate none",
705
+ ),
706
+ ),
707
+ ("skills",): ExplainPage(
708
+ title="Coding agent skills",
709
+ command="agentops skills",
710
+ synopsis=("agentops skills COMMAND [ARGS]...", "agentops skills explain"),
711
+ summary=("Contains commands that install workflow-oriented AgentOps skills for coding agents such as GitHub Copilot and Claude Code.",),
712
+ children=("install",),
713
+ ),
714
+ ("skills", "install"): ExplainPage(
715
+ title="Install coding-agent skills",
716
+ command="agentops skills install",
717
+ synopsis=("agentops skills install [--platform copilot|claude] [--from SOURCE] [--prompt] [--force] [--dir PATH]", "agentops skills install explain"),
718
+ summary=(
719
+ "Installs AgentOps skill files into the current repository so coding agents can guide users through eval setup, dataset creation, reporting, regressions, tracing, monitoring, and workflows.",
720
+ "By default it auto-detects the coding-agent platform and falls back to GitHub Copilot when nothing is detected.",
721
+ ),
722
+ how_it_works=(
723
+ "Resolves target platforms from `--platform`, workspace detection, or the default.",
724
+ "Copies bundled skills or installs a community skill from GitHub when `--from` is used.",
725
+ "Registers skills in platform-specific instruction files when supported.",
726
+ ),
727
+ outputs=("`.github/skills/agentops-*` for Copilot", "`.claude/commands/agentops-*` for Claude Code"),
728
+ examples=("agentops skills install", "agentops skills install --platform copilot", "agentops skills install --from github:org/repo@v1"),
729
+ ),
730
+ ("mcp",): ExplainPage(
731
+ title="MCP commands",
732
+ command="agentops mcp",
733
+ synopsis=("agentops mcp COMMAND [ARGS]...", "agentops mcp explain"),
734
+ summary=("Contains commands that expose AgentOps workflows over the Model Context Protocol for MCP-aware coding agents.",),
735
+ children=("serve",),
736
+ ),
737
+ ("mcp", "serve"): ExplainPage(
738
+ title="Serve MCP tools",
739
+ command="agentops mcp serve",
740
+ synopsis=("agentops mcp serve", "agentops mcp serve explain"),
741
+ summary=(
742
+ "Starts the AgentOps MCP server on stdio so an MCP client can call AgentOps tools directly.",
743
+ "This is intended for coding-agent integrations, not for an HTTP browser workflow.",
744
+ ),
745
+ how_it_works=(
746
+ "Imports the optional MCP server package.",
747
+ "Registers AgentOps workflow tools such as init, eval run, reporting, results summaries, dataset operations, and workflow generation.",
748
+ "Serves over stdin/stdout until the MCP client exits.",
749
+ ),
750
+ inputs=("MCP client stdio messages",),
751
+ examples=("agentops mcp serve",),
752
+ ),
753
+ ("agent",): ExplainPage(
754
+ title="Agent server commands",
755
+ command="agentops agent",
756
+ synopsis=("agentops agent COMMAND [ARGS]...", "agentops agent explain"),
757
+ summary=("Contains commands that host AgentOps Doctor as an HTTP agent/Copilot Extension surface.",),
758
+ children=("serve",),
759
+ ),
760
+ ("agent", "serve"): ExplainPage(
761
+ title="Serve AgentOps as an HTTP agent",
762
+ command="agentops agent serve",
763
+ synopsis=("agentops agent serve [--host HOST] [--port PORT] [--workspace PATH] [--config PATH] [--no-verify] [--workers N]", "agentops agent serve explain"),
764
+ summary=(
765
+ "Hosts AgentOps Doctor behind an HTTP API compatible with Copilot Extensions.",
766
+ "It exposes message handling and health endpoints so AgentOps diagnostics can be used from a chat-based agent surface.",
767
+ ),
768
+ how_it_works=(
769
+ "Loads `.agentops/agent.yaml` or the explicit `--config` path.",
770
+ "Creates the FastAPI app from the agent server module.",
771
+ "Runs Uvicorn with signature verification enabled by default.",
772
+ ),
773
+ inputs=("`.agentops/agent.yaml`", "Copilot Extensions HTTP requests"),
774
+ outputs=("HTTP endpoints: `POST /agents/messages`, `GET /healthz`, `GET /`",),
775
+ examples=("agentops agent serve", "agentops agent serve --host 127.0.0.1 --port 8080 --no-verify"),
776
+ see_also=("agentops explain doctor",),
777
+ ),
778
+ ("doctor",): ExplainPage(
779
+ title="Doctor diagnostics",
780
+ command="agentops doctor",
781
+ synopsis=("agentops doctor [OPTIONS] [--evidence-pack] [--evidence-out PATH]", "agentops doctor explain [--format text|markdown|html] [--out PATH] [--open]"),
782
+ summary=(
783
+ "Runs the local diagnostic analyzer for AgentOps workspaces, Foundry, Azure telemetry, and WAF-AI gaps.",
784
+ "With `--evidence-pack`, Doctor also writes a production-readiness evidence pack that summarizes eval, workflow, Foundry, monitoring, AI Landing Zone, and trace-regression signals for release review.",
785
+ ),
786
+ see_also=("agentops doctor explain", "agentops explain cockpit"),
787
+ ),
788
+ ("cockpit",): ExplainPage(
789
+ title="AgentOps Cockpit",
790
+ command="agentops cockpit",
791
+ synopsis=("agentops cockpit [--host HOST] [--port PORT] [--workspace PATH] [--no-preflight]", "agentops cockpit explain"),
792
+ summary=(
793
+ "The local browser Cockpit. Shows the Foundry connection, "
794
+ "one-click jumps to Monitor, Evaluations, Traces, Red Teaming, "
795
+ "and App Insights, an observability readiness checklist, Doctor "
796
+ "findings, local eval history, and the next things to do.",
797
+ "It brings your project, Foundry, and Azure Monitor into one "
798
+ "view — so you can see what's wired up, what's missing, and "
799
+ "where to click when you need to dig deeper.",
800
+ "Read-only and local. Binds to `127.0.0.1` by default so you "
801
+ "can keep it open during reviews and pipeline work without "
802
+ "touching anything in the cloud.",
803
+ ),
804
+ how_it_works=(
805
+ "Runs pre-flight checks unless `--no-preflight` is used.",
806
+ "Reads eval results, Doctor history, reports, workflows, and telemetry metadata.",
807
+ "Resolves Foundry, App Insights, tenant, and RBAC context when configured.",
808
+ "Renders focused sections: connection, launchpad, observability, Doctor, eval gates, quality gates, production signal, CI/CD, and next actions.",
809
+ "Starts a localhost Uvicorn server and opens a browser tab.",
810
+ ),
811
+ inputs=(
812
+ "`.agentops/results/` evaluation history and latest report",
813
+ "`.agentops/agent/history.jsonl` Doctor history",
814
+ "GitHub Actions or Azure DevOps workflow files when present",
815
+ "`AZURE_AI_FOUNDRY_PROJECT_ENDPOINT` and Application Insights connection metadata when configured",
816
+ ),
817
+ outputs=(
818
+ "Local web server, default `http://127.0.0.1:8090`",
819
+ "Cockpit page with connection, launchpad, readiness, Doctor, eval, telemetry, CI/CD, and next actions",
820
+ ),
821
+ examples=("agentops cockpit", "agentops cockpit --port 8091", "agentops cockpit --no-preflight", "agentops cockpit explain"),
822
+ see_also=("agentops explain doctor", "agentops explain eval run", "agentops explain workflow generate"),
823
+ ),
824
+ }
825
+
826
+
827
+ def _resolve_platforms(
828
+ directory: Path,
829
+ explicit: list[str] | None,
830
+ prompt: bool,
831
+ ) -> list[str]:
832
+ """Resolve target platforms: explicit > auto-detect > fallback."""
833
+ from agentops.services.skills import detect_platforms
834
+
835
+ if explicit:
836
+ return explicit
837
+
838
+ detected = detect_platforms(directory)
839
+ if detected:
840
+ typer.echo(
841
+ f"{_cli_label('Detected coding agent platform(s)')}: {', '.join(detected)}"
842
+ )
843
+ return detected
844
+
845
+ if prompt:
846
+ install = typer.confirm(
847
+ "No coding agent platform detected. Install skills for GitHub Copilot?",
848
+ default=True,
849
+ )
850
+ return ["copilot"] if install else []
851
+
852
+ return ["copilot"]
853
+
854
+
855
+ def _print_skills_result(result: object) -> None:
856
+ """Print skills installation summary."""
857
+ platforms = getattr(result, "platforms", [])
858
+ if platforms:
859
+ typer.echo(f"{_cli_label('Skills platforms')}: {', '.join(platforms)}")
860
+ for created in result.created_files: # type: ignore[attr-defined]
861
+ typer.echo(_cli_created(created))
862
+ for overwritten in result.overwritten_files: # type: ignore[attr-defined]
863
+ typer.echo(_cli_overwritten(overwritten))
864
+ for skipped in result.skipped_files: # type: ignore[attr-defined]
865
+ typer.echo(_cli_skipped(skipped, " (use --force to overwrite)"))
866
+
867
+
868
+ def _print_registration_result(result: object) -> None:
869
+ """Print skill registration summary."""
870
+ registered = getattr(result, "registered_files", [])
871
+ for path in registered:
872
+ typer.echo(f" {_cli_ok('*')} {_cli_ok('registered skills in')} {_cli_path(path)}")
873
+
874
+
875
+ # ---------------------------------------------------------------------------
876
+ # Global callback - configures logging before any command runs
877
+ # ---------------------------------------------------------------------------
878
+
879
+
880
+ def _version_callback(value: bool) -> None:
881
+ if value:
882
+ from agentops import __version__
883
+
884
+ typer.echo(f"agentops {__version__}")
885
+ raise typer.Exit()
886
+
887
+
888
+ def _normalize_explain_path(parts: list[str] | None) -> tuple[str, ...]:
889
+ if not parts:
890
+ return ()
891
+ normalized = tuple(part.strip().lower() for part in parts if part.strip())
892
+ if normalized and normalized[-1] == "explain":
893
+ normalized = normalized[:-1]
894
+ return normalized
895
+
896
+
897
+ def _build_registered_explain_markdown(path: tuple[str, ...]) -> str:
898
+ page = EXPLAIN_PAGES.get(path)
899
+ if page is None:
900
+ known = ", ".join(
901
+ " ".join(key) if key else "agentops" for key in sorted(EXPLAIN_PAGES)
902
+ )
903
+ raise ValueError(
904
+ f"unknown command path: {' '.join(path) or 'agentops'}. Known: {known}"
905
+ )
906
+
907
+ lines: list[str] = [
908
+ f"# {page.title}",
909
+ "",
910
+ "## NAME",
911
+ "",
912
+ f"`{page.command}` - {page.summary[0]}",
913
+ "",
914
+ "## SYNOPSIS",
915
+ "",
916
+ "```text",
917
+ *page.synopsis,
918
+ "```",
919
+ "",
920
+ "## DESCRIPTION",
921
+ "",
922
+ *page.summary,
923
+ ]
924
+ if page.children:
925
+ lines.extend(
926
+ [
927
+ "",
928
+ "## COMMANDS",
929
+ "",
930
+ "| Command | Detailed docs |",
931
+ "|---|---|",
932
+ ]
933
+ )
934
+ for child in page.children:
935
+ child_path = (*path, child) if path else (child,)
936
+ child_page = EXPLAIN_PAGES.get(child_path)
937
+ label = child_page.command if child_page else f"{page.command} {child}"
938
+ lines.append(f"| `{label}` | `agentops explain {' '.join(child_path)}` |")
939
+ _extend_explain_section(lines, "HOW IT WORKS", page.how_it_works, numbered=True)
940
+ _extend_explain_section(lines, "ARCHITECTURE", page.architecture)
941
+ _extend_explain_section(lines, "INPUTS", page.inputs)
942
+ _extend_explain_section(lines, "OUTPUTS", page.outputs)
943
+ if page.examples:
944
+ lines.extend(["", "## EXAMPLES", "", "```text", *page.examples, "```"])
945
+ if page.see_also:
946
+ lines.extend(["", "## SEE ALSO", ""])
947
+ lines.extend(f"- `{item}`" if item.startswith("agentops") else f"- {item}" for item in page.see_also)
948
+ lines.append("")
949
+ return "\n".join(lines)
950
+
951
+
952
+ def _extend_explain_section(
953
+ lines: list[str],
954
+ title: str,
955
+ items: tuple[str, ...],
956
+ *,
957
+ numbered: bool = False,
958
+ ) -> None:
959
+ if not items:
960
+ return
961
+ lines.extend(["", f"## {title}", ""])
962
+ for index, item in enumerate(items, start=1):
963
+ marker = f"{index}." if numbered else "-"
964
+ lines.append(f"{marker} {item}")
965
+
966
+
967
+ def _registered_explain_text(path: tuple[str, ...]) -> str:
968
+ page = EXPLAIN_PAGES.get(path)
969
+ if page is None:
970
+ raise ValueError(f"unknown command path: {' '.join(path) or 'agentops'}")
971
+ lines: list[str] = _manual_banner(page.title, page.summary[0])
972
+
973
+ def section(title: str) -> None:
974
+ _manual_section(lines, title)
975
+
976
+ section("NAME")
977
+ _emit_name_line(lines, page.command, page.summary[0])
978
+ section("SYNOPSIS")
979
+ lines.extend(f" {style('$', 'dim')} {style(entry, 'bold')}" for entry in page.synopsis)
980
+ section("DESCRIPTION")
981
+ description_paragraphs = page.summary[1:] if len(page.summary) > 1 else page.summary
982
+ lines.extend(_manual_paragraphs(*description_paragraphs))
983
+ if page.children:
984
+ section("COMMANDS")
985
+ rows: list[tuple[str, str]] = []
986
+ for child in page.children:
987
+ child_path = (*path, child) if path else (child,)
988
+ child_page = EXPLAIN_PAGES.get(child_path)
989
+ label = child_page.command if child_page else f"{page.command} {child}"
990
+ if child_page:
991
+ rows.append((label, child_page.summary[0]))
992
+ else:
993
+ rows.append((label, ""))
994
+ lines.extend(_manual_command_rows(rows))
995
+ _extend_text_section(lines, "HOW IT WORKS", page.how_it_works, numbered=True)
996
+ _extend_text_section(lines, "ARCHITECTURE", page.architecture)
997
+ _extend_text_section(lines, "INPUTS", page.inputs)
998
+ _extend_text_section(lines, "OUTPUTS", page.outputs)
999
+ if page.examples:
1000
+ section("EXAMPLES")
1001
+ lines.extend(f" {style('$', 'dim')} {style(entry, 'bold')}" for entry in page.examples)
1002
+ if page.see_also:
1003
+ section("SEE ALSO")
1004
+ lines.extend(f" {entry}" for entry in page.see_also)
1005
+ return "\n".join(lines) + "\n"
1006
+
1007
+
1008
+ def _extend_text_section(
1009
+ lines: list[str],
1010
+ title: str,
1011
+ items: tuple[str, ...],
1012
+ *,
1013
+ numbered: bool = False,
1014
+ ) -> None:
1015
+ if not items:
1016
+ return
1017
+ _manual_section(lines, title)
1018
+ for index, item in enumerate(items, start=1):
1019
+ prefix = f"{index}. " if numbered else "- "
1020
+ lines.extend(_manual_item_lines(prefix, item))
1021
+
1022
+
1023
+ _ASCII_TRANSLITERATION: dict[int, str] = {
1024
+ ord("\u2014"): "-", # em dash
1025
+ ord("\u2013"): "-", # en dash
1026
+ ord("\u2212"): "-", # minus sign
1027
+ ord("\u2018"): "'", # left single quote
1028
+ ord("\u2019"): "'", # right single quote
1029
+ ord("\u201c"): '"', # left double quote
1030
+ ord("\u201d"): '"', # right double quote
1031
+ ord("\u2026"): "...", # horizontal ellipsis
1032
+ ord("\u00a0"): " ", # non-breaking space
1033
+ ord("\u2192"): "->", # rightwards arrow
1034
+ ord("\u2197"): "^", # north-east arrow
1035
+ ord("\u2022"): "*", # bullet
1036
+ ord("\u00b7"): "*", # middle dot
1037
+ }
1038
+
1039
+
1040
+ def _downgrade_to_ascii(text: str) -> str:
1041
+ """Replace common typographic Unicode with ASCII equivalents.
1042
+
1043
+ Used when the terminal cannot render UTF-8 (e.g. legacy Windows
1044
+ code pages) to avoid mojibake like ``ù`` in the place of an em dash.
1045
+ Only typographic punctuation/arrows are downgraded - box-drawing
1046
+ characters were already gated by ``_terminal_unicode_enabled()``.
1047
+ """
1048
+ if not text:
1049
+ return text
1050
+ return text.translate(_ASCII_TRANSLITERATION)
1051
+
1052
+
1053
+ def _emit_manual_output(
1054
+ *,
1055
+ text: str,
1056
+ markdown: str,
1057
+ title: str,
1058
+ no_pager: bool,
1059
+ format_: str,
1060
+ out: Path | None,
1061
+ open_browser: bool,
1062
+ ) -> None:
1063
+ import click
1064
+
1065
+ format_ = format_.lower()
1066
+ if format_ not in {"text", "markdown", "html"}:
1067
+ typer.echo(
1068
+ f"{_cli_error('Invalid --format')}. Use one of: text, markdown, html.",
1069
+ err=True,
1070
+ )
1071
+ raise typer.Exit(code=1)
1072
+
1073
+ html = _build_explain_html(markdown, title=title)
1074
+ output = {"text": text, "markdown": markdown, "html": html}[format_]
1075
+ if out is not None:
1076
+ out.parent.mkdir(parents=True, exist_ok=True)
1077
+ out.write_text(output, encoding="utf-8")
1078
+ typer.echo(f"{_cli_label('Wrote')}: {_cli_path(out)}")
1079
+
1080
+ if open_browser:
1081
+ browser_path = out if out is not None and format_ == "html" else None
1082
+ if browser_path is None:
1083
+ with tempfile.NamedTemporaryFile(
1084
+ "w",
1085
+ encoding="utf-8",
1086
+ suffix=".html",
1087
+ prefix="agentops-explain-",
1088
+ delete=False,
1089
+ ) as temp:
1090
+ temp.write(html)
1091
+ browser_path = Path(temp.name)
1092
+ typer.echo(f"{_cli_label('Opened browser copy')}: {_cli_path(browser_path)}")
1093
+ webbrowser.open(browser_path.resolve().as_uri())
1094
+
1095
+ if out is not None or open_browser:
1096
+ return
1097
+
1098
+ if format_ == "text" and not _terminal_unicode_enabled():
1099
+ output = _downgrade_to_ascii(output)
1100
+
1101
+ if format_ != "text":
1102
+ if no_pager:
1103
+ typer.echo(output, color=True)
1104
+ return
1105
+ click.echo_via_pager(output, color=True)
1106
+ return
1107
+
1108
+ if no_pager:
1109
+ _emit_manual_to_terminal(output)
1110
+ return
1111
+ if _useful_pager_available():
1112
+ click.echo_via_pager(output, color=True)
1113
+ return
1114
+ _emit_manual_with_internal_pager(output)
1115
+
1116
+
1117
+ def _emit_registered_explain(
1118
+ path: tuple[str, ...],
1119
+ *,
1120
+ no_pager: bool,
1121
+ format_: str,
1122
+ out: Path | None,
1123
+ open_browser: bool,
1124
+ ) -> None:
1125
+ try:
1126
+ text = _registered_explain_text(path)
1127
+ markdown = _build_registered_explain_markdown(path)
1128
+ except ValueError as exc:
1129
+ typer.echo(f"{_cli_error('Error')}: {exc}", err=True)
1130
+ raise typer.Exit(code=1) from exc
1131
+ page = EXPLAIN_PAGES[path]
1132
+ _emit_manual_output(
1133
+ text=text,
1134
+ markdown=markdown,
1135
+ title=page.title,
1136
+ no_pager=no_pager,
1137
+ format_=format_,
1138
+ out=out,
1139
+ open_browser=open_browser,
1140
+ )
1141
+
1142
+
1143
+ def _maybe_explain_leaf(
1144
+ path: tuple[str, ...],
1145
+ explain: str | None,
1146
+ ) -> bool:
1147
+ if explain is None:
1148
+ return False
1149
+ if explain.lower() != "explain":
1150
+ typer.echo(f"{_cli_error('Error')}: unexpected argument {explain!r}.", err=True)
1151
+ raise typer.Exit(code=1)
1152
+ _emit_registered_explain(
1153
+ path,
1154
+ no_pager=True,
1155
+ format_="text",
1156
+ out=None,
1157
+ open_browser=False,
1158
+ )
1159
+ return True
1160
+
1161
+
1162
+ @app.callback()
1163
+ def _main(
1164
+ verbose: Annotated[
1165
+ bool,
1166
+ typer.Option("--verbose", "-v", help="Enable DEBUG logging."),
1167
+ ] = False,
1168
+ version: Annotated[
1169
+ bool,
1170
+ typer.Option(
1171
+ "--version",
1172
+ help="Show version and exit.",
1173
+ callback=_version_callback,
1174
+ is_eager=True,
1175
+ ),
1176
+ ] = False,
1177
+ ) -> None:
1178
+ setup_logging(verbose=verbose)
1179
+
1180
+
1181
+ @app.command("explain", context_settings={"allow_extra_args": True})
1182
+ def cmd_explain(
1183
+ ctx: typer.Context,
1184
+ command_path: Annotated[
1185
+ list[str] | None,
1186
+ typer.Argument(help="Optional command path, for example: eval run."),
1187
+ ] = None,
1188
+ no_pager: Annotated[
1189
+ bool,
1190
+ typer.Option("--no-pager", help="Print directly instead of opening the pager."),
1191
+ ] = False,
1192
+ format_: Annotated[
1193
+ str,
1194
+ typer.Option("--format", "-f", help="Output format: text, markdown, or html."),
1195
+ ] = "text",
1196
+ out: Annotated[
1197
+ Path | None,
1198
+ typer.Option("--out", "-o", help="Write the manual to a file."),
1199
+ ] = None,
1200
+ open_browser: Annotated[
1201
+ bool,
1202
+ typer.Option("--open", help="Open a browser-friendly HTML copy."),
1203
+ ] = False,
1204
+ ) -> None:
1205
+ """Open detailed documentation for AgentOps or one command."""
1206
+ parts = list(command_path or [])
1207
+ parts.extend(ctx.args)
1208
+ _emit_registered_explain(
1209
+ _normalize_explain_path(parts),
1210
+ no_pager=no_pager,
1211
+ format_=format_,
1212
+ out=out,
1213
+ open_browser=open_browser,
1214
+ )
1215
+
1216
+
1217
+ def _make_group_explain(path: tuple[str, ...]):
1218
+ def _cmd(
1219
+ no_pager: Annotated[
1220
+ bool,
1221
+ typer.Option("--no-pager", help="Print directly instead of opening the pager."),
1222
+ ] = False,
1223
+ format_: Annotated[
1224
+ str,
1225
+ typer.Option("--format", "-f", help="Output format: text, markdown, or html."),
1226
+ ] = "text",
1227
+ out: Annotated[
1228
+ Path | None,
1229
+ typer.Option("--out", "-o", help="Write the manual to a file."),
1230
+ ] = None,
1231
+ open_browser: Annotated[
1232
+ bool,
1233
+ typer.Option("--open", help="Open a browser-friendly HTML copy."),
1234
+ ] = False,
1235
+ ) -> None:
1236
+ _emit_registered_explain(
1237
+ path,
1238
+ no_pager=no_pager,
1239
+ format_=format_,
1240
+ out=out,
1241
+ open_browser=open_browser,
1242
+ )
1243
+
1244
+ _cmd.__name__ = "cmd_" + "_".join((*path, "explain"))
1245
+ _cmd.__doc__ = "Open detailed documentation for this command group."
1246
+ return _cmd
1247
+
1248
+
1249
+ eval_app.command("explain")(_make_group_explain(("eval",)))
1250
+ report_app.command("explain")(_make_group_explain(("report",)))
1251
+ workflow_app.command("explain")(_make_group_explain(("workflow",)))
1252
+ skills_app.command("explain")(_make_group_explain(("skills",)))
1253
+ mcp_app.command("explain")(_make_group_explain(("mcp",)))
1254
+ agent_app.command("explain")(_make_group_explain(("agent",)))
1255
+
1256
+
1257
+ # ---------------------------------------------------------------------------
1258
+ # agentops init (group: callback = scaffold + bootstrap + wizard)
1259
+ # ---------------------------------------------------------------------------
1260
+
1261
+
1262
+ @init_app.callback(invoke_without_command=True)
1263
+ def cmd_init(
1264
+ ctx: typer.Context,
1265
+ force: Annotated[
1266
+ bool,
1267
+ typer.Option(
1268
+ "--force",
1269
+ help="Overwrite starter files if they exist.",
1270
+ ),
1271
+ ] = False,
1272
+ directory: Annotated[
1273
+ Path,
1274
+ typer.Option(
1275
+ "--dir",
1276
+ "--path",
1277
+ help="Workspace directory to initialise (defaults to the current directory).",
1278
+ ),
1279
+ ] = Path("."),
1280
+ no_prompt: Annotated[
1281
+ bool,
1282
+ typer.Option(
1283
+ "--no-prompt",
1284
+ help="Skip the interactive question loop (scaffold-only mode).",
1285
+ ),
1286
+ ] = False,
1287
+ reconfigure: Annotated[
1288
+ bool,
1289
+ typer.Option(
1290
+ "--reconfigure",
1291
+ help="Re-ask every wizard question, even when a value is already set.",
1292
+ ),
1293
+ ] = False,
1294
+ no_appinsights: Annotated[
1295
+ bool,
1296
+ typer.Option(
1297
+ "--no-appinsights",
1298
+ help=(
1299
+ "Deprecated no-op; App Insights is no longer asked in the "
1300
+ "interactive wizard."
1301
+ ),
1302
+ ),
1303
+ ] = False,
1304
+ project_endpoint: Annotated[
1305
+ Optional[str],
1306
+ typer.Option(
1307
+ "--project-endpoint",
1308
+ help="Set the Foundry project endpoint non-interactively.",
1309
+ ),
1310
+ ] = None,
1311
+ agent: Annotated[
1312
+ Optional[str],
1313
+ typer.Option(
1314
+ "--agent",
1315
+ help="Set the agent identifier non-interactively (name:version, model:deployment, or URL).",
1316
+ ),
1317
+ ] = None,
1318
+ dataset: Annotated[
1319
+ Optional[str],
1320
+ typer.Option(
1321
+ "--dataset",
1322
+ help="Set the dataset path non-interactively.",
1323
+ ),
1324
+ ] = None,
1325
+ appinsights_connection_string: Annotated[
1326
+ Optional[str],
1327
+ typer.Option(
1328
+ "--appinsights-connection-string",
1329
+ help="Set the App Insights connection string non-interactively.",
1330
+ ),
1331
+ ] = None,
1332
+ azd_env_name: Annotated[
1333
+ Optional[str],
1334
+ typer.Option(
1335
+ "--azd-env",
1336
+ help=(
1337
+ "Opt into writing local Azure values to the named azd environment."
1338
+ ),
1339
+ ),
1340
+ ] = None,
1341
+ ) -> None:
1342
+ """Initialise an AgentOps workspace and configure endpoints.
1343
+
1344
+ Scaffolds the minimal workspace layout (``agentops.yaml`` plus a tiny
1345
+ seed dataset under ``.agentops/data/``), then walks the user through a
1346
+ question loop to fill in the values AgentOps needs to evaluate, observe,
1347
+ and analyze a Foundry agent.
1348
+
1349
+ ``agent`` and ``dataset`` land in ``agentops.yaml`` (version-
1350
+ controlled). The Foundry project endpoint lands in ``.agentops/.env`` by
1351
+ default. If the workspace already has an active azd environment, or the
1352
+ user passes ``--azd-env``, AgentOps writes the endpoint to
1353
+ ``.azure/<env>/.env`` instead. App Insights can be supplied explicitly
1354
+ with ``--appinsights-connection-string`` if runtime discovery is not
1355
+ enough.
1356
+
1357
+ The wizard persists each answer immediately as it is validated, so a
1358
+ Ctrl+C mid-wizard never discards what the user already entered.
1359
+ """
1360
+ if ctx.invoked_subcommand is not None:
1361
+ return
1362
+
1363
+ from agentops.services.initializer import initialize_flat_workspace
1364
+ from agentops.services.setup_wizard import (
1365
+ AGENT_TITLE,
1366
+ DATASET_TITLE,
1367
+ PROJECT_ENDPOINT_TITLE,
1368
+ WizardAnswers,
1369
+ apply_answers,
1370
+ discover_defaults,
1371
+ run_wizard,
1372
+ validate_agent,
1373
+ validate_dataset,
1374
+ validate_project_endpoint,
1375
+ )
1376
+ from agentops.utils.azd_env import ensure_azd_env, set_default_azd_env
1377
+
1378
+ workspace = directory.resolve()
1379
+ if not workspace.exists():
1380
+ try:
1381
+ workspace.mkdir(parents=True, exist_ok=True)
1382
+ except Exception as exc: # noqa: BLE001
1383
+ typer.echo(
1384
+ f"{_cli_error('Error')}: could not create workspace directory "
1385
+ f"{_cli_path(workspace)}: {exc}",
1386
+ err=True,
1387
+ )
1388
+ raise typer.Exit(code=1) from exc
1389
+
1390
+ log.debug(
1391
+ "cmd_init called force=%s dir=%s no_prompt=%s no_appinsights=%s any_flag=%s",
1392
+ force,
1393
+ workspace,
1394
+ no_prompt,
1395
+ no_appinsights,
1396
+ any(
1397
+ v is not None
1398
+ for v in (project_endpoint, agent, dataset, appinsights_connection_string)
1399
+ ),
1400
+ )
1401
+
1402
+ # The cherry on top: greet the user with the AgentOps brand banner
1403
+ # so `agentops init` feels like a first-class onboarding moment
1404
+ # rather than a silent scaffolding step.
1405
+ #
1406
+ # Emit via :func:`_emit_manual_to_terminal` (the same helper the
1407
+ # explain pages use when no real pager is available). It writes the
1408
+ # raw UTF-8 banner bytes straight to ``sys.stdout.buffer``, bypassing
1409
+ # Click's Windows console writer (``click._winconsole.ConsoleStream``
1410
+ # → ``WriteConsoleW``), which on TTY stdouts is the path that
1411
+ # mangles the 24-bit RGB gradient (``ESC[38;2;R;G;Bm``) into the
1412
+ # scrambled colored-block rendering seen in earlier builds. We also
1413
+ # gate on :func:`_terminal_color_enabled` so colorless CI runs and
1414
+ # ``CliRunner``-based tests still flow through ``typer.echo`` and
1415
+ # remain easy to assert on.
1416
+ _emit_init_banner()
1417
+
1418
+ # ----- Phase 1: scaffold the .agentops/ workspace ---------------------
1419
+ try:
1420
+ result = initialize_flat_workspace(directory=workspace, force=force)
1421
+ except Exception as exc:
1422
+ typer.echo(
1423
+ f"{_cli_error('Error')}: failed to initialize workspace: {exc}",
1424
+ err=True,
1425
+ )
1426
+ raise typer.Exit(code=1) from exc
1427
+
1428
+ typer.echo(_cli_ok("Initialized AgentOps workspace."))
1429
+ for created in result.created_files:
1430
+ typer.echo(_cli_created(created))
1431
+ for overwritten in result.overwritten_files:
1432
+ typer.echo(_cli_overwritten(overwritten))
1433
+ for skipped in result.skipped_files:
1434
+ typer.echo(_cli_skipped(skipped))
1435
+
1436
+ config_path = workspace / "agentops.yaml"
1437
+ config_seeded_this_run = (
1438
+ config_path in result.created_files
1439
+ or config_path in result.overwritten_files
1440
+ )
1441
+
1442
+ # ----- Phase 2: use azd only when it is explicit or already present ----
1443
+ target_env_name = azd_env_name or "dev"
1444
+ if azd_env_name:
1445
+ # User explicitly named an azd env — create it if missing and promote
1446
+ # it to the active default. Without this flag, AgentOps no longer
1447
+ # bootstraps .azure just to hold its own local values.
1448
+ try:
1449
+ location = ensure_azd_env(workspace, azd_env_name)
1450
+ set_default_azd_env(workspace, azd_env_name)
1451
+ typer.echo(
1452
+ f" {_cli_ok('+')} {_cli_ok('prepared azd environment')} "
1453
+ f"'{azd_env_name}' at {_cli_path(location.env_path.parent)}"
1454
+ if location.env_path is not None
1455
+ else f" {_cli_ok('+')} {_cli_ok('prepared azd environment')} "
1456
+ f"'{azd_env_name}'"
1457
+ )
1458
+ except Exception as exc: # noqa: BLE001
1459
+ typer.echo(
1460
+ f" {_cli_warn('!')} {_cli_warn('could not prepare')} "
1461
+ f"{_cli_path(f'.azure/{azd_env_name}')}: {exc}",
1462
+ err=True,
1463
+ )
1464
+
1465
+ # ----- Phase 3: collect values (scripted, interactive, or skip) ------
1466
+ flag_values = {
1467
+ "project_endpoint": project_endpoint,
1468
+ "agent": agent,
1469
+ "dataset": dataset,
1470
+ "appinsights_connection_string": appinsights_connection_string,
1471
+ }
1472
+ any_flag = any(v is not None for v in flag_values.values())
1473
+
1474
+ answers: Optional[WizardAnswers] = None
1475
+
1476
+ if any_flag:
1477
+ # Scripted mode — validate then apply.
1478
+ if project_endpoint is not None:
1479
+ err = validate_project_endpoint(project_endpoint)
1480
+ if err:
1481
+ typer.echo(f"{_cli_error('Error')}: --project-endpoint: {err}", err=True)
1482
+ raise typer.Exit(code=1)
1483
+ if agent is not None:
1484
+ err = validate_agent(agent)
1485
+ if err:
1486
+ typer.echo(f"{_cli_error('Error')}: --agent: {err}", err=True)
1487
+ raise typer.Exit(code=1)
1488
+ if dataset is not None:
1489
+ err = validate_dataset(dataset, workspace)
1490
+ if err:
1491
+ typer.echo(f"{_cli_error('Error')}: --dataset: {err}", err=True)
1492
+ raise typer.Exit(code=1)
1493
+ answers = WizardAnswers(
1494
+ project_endpoint=project_endpoint,
1495
+ agent=agent,
1496
+ dataset=dataset,
1497
+ appinsights_connection_string=appinsights_connection_string,
1498
+ )
1499
+ elif no_prompt or not sys.stdin.isatty():
1500
+ # Scaffold-only mode: either the user explicitly asked for it via
1501
+ # --no-prompt, or stdin is not a TTY (CI, piped stdin, etc.) and
1502
+ # the interactive wizard cannot run. Print the next-step hints and
1503
+ # exit cleanly.
1504
+ typer.echo("")
1505
+ typer.echo(_cli_heading("Workspace ready. Next steps:"))
1506
+ typer.echo(f" {_cli_command('agentops init')} # interactive wizard to set endpoints")
1507
+ typer.echo(f" {_cli_command('agentops init show')} # inspect the active configuration")
1508
+ typer.echo(f" {_cli_command('agentops eval analyze')} # inspect eval setup before running")
1509
+ typer.echo(f" {_cli_command('agentops eval run')} # run a configured evaluation")
1510
+ typer.echo(f" {_cli_command('agentops skills install')} # install coding agent skills")
1511
+ if not no_prompt and not sys.stdin.isatty():
1512
+ typer.echo("")
1513
+ typer.echo(
1514
+ f"{_cli_warn('Tip')}: stdin is not a TTY, so the interactive wizard was skipped. "
1515
+ "Re-run with --project-endpoint, --agent, --dataset and/or "
1516
+ "--appinsights-connection-string to script the configuration."
1517
+ )
1518
+ return
1519
+ else:
1520
+ # Interactive mode — TTY confirmed.
1521
+ typer.echo("")
1522
+ unicode_ok = _terminal_unicode_enabled()
1523
+ typer.echo(_cli_heading("AgentOps configuration"))
1524
+ typer.echo(style("──────────────────────" if unicode_ok else "----------------------", "dim"))
1525
+
1526
+ defaults = discover_defaults(workspace)
1527
+
1528
+ # Only show the prompt hint when at least one question will actually
1529
+ # be asked. When everything is already configured (idempotent re-run),
1530
+ # the wizard emits compact confirmation lines instead.
1531
+ force_prompt_fields = {"agent", "dataset"} if config_seeded_this_run else set()
1532
+ prompt_values = [
1533
+ defaults.project_endpoint,
1534
+ defaults.agent,
1535
+ defaults.dataset,
1536
+ ]
1537
+ will_prompt = reconfigure or bool(force_prompt_fields) or any(
1538
+ v is None or not str(v).strip()
1539
+ for v in prompt_values
1540
+ )
1541
+ if will_prompt:
1542
+ typer.echo(style("Press Enter to accept the value in brackets.", "dim"))
1543
+
1544
+ def _prompt(question: str, default: Optional[str]) -> str:
1545
+ return typer.prompt(question, default=default or "", show_default=bool(default))
1546
+
1547
+ # Incremental persistence: each answer is written as soon as the
1548
+ # wizard validates it, so Ctrl+C never throws away progress.
1549
+ bullet = "·" if unicode_ok else "-"
1550
+ question_titles = {
1551
+ PROJECT_ENDPOINT_TITLE,
1552
+ AGENT_TITLE,
1553
+ DATASET_TITLE,
1554
+ }
1555
+
1556
+ def _on_answer(field_name: str, value: str) -> None:
1557
+ partial = WizardAnswers(**{field_name: value})
1558
+ try:
1559
+ partial_result = apply_answers(
1560
+ workspace,
1561
+ partial,
1562
+ default_env_name=target_env_name,
1563
+ azd_env_name=azd_env_name,
1564
+ )
1565
+ except Exception as exc: # noqa: BLE001
1566
+ typer.echo(
1567
+ f" {_cli_warn('!')} {_cli_warn('could not persist')} "
1568
+ f"{field_name}: {exc}",
1569
+ err=True,
1570
+ )
1571
+ return
1572
+ if partial_result.yaml_updated and field_name in partial_result.yaml_fields:
1573
+ typer.echo(f" {bullet} {_cli_ok('saved to')} {_cli_path(partial_result.yaml_path)}")
1574
+ if partial_result.env_updated and partial_result.env_path is not None:
1575
+ typer.echo(f" {bullet} {_cli_ok('saved to')} {_cli_path(partial_result.env_path)}")
1576
+
1577
+ def _wizard_echo(msg: str) -> None:
1578
+ if msg in question_titles:
1579
+ typer.echo(style(msg, "bold", "cyan"))
1580
+ else:
1581
+ typer.echo(msg)
1582
+
1583
+ answers = run_wizard(
1584
+ workspace,
1585
+ prompt=_prompt,
1586
+ echo=_wizard_echo,
1587
+ on_answer=_on_answer,
1588
+ reconfigure=reconfigure,
1589
+ force_prompt_fields=force_prompt_fields,
1590
+ )
1591
+
1592
+ # ----- Phase 4: apply (idempotent — covers scripted mode and any
1593
+ # residual fields the wizard returned). The wizard's own on_answer
1594
+ # already wrote each value, but applying the full set again is safe
1595
+ # and ensures scripted callers (which skip on_answer) still persist.
1596
+ if answers is None:
1597
+ return
1598
+
1599
+ try:
1600
+ final_result = apply_answers(
1601
+ workspace,
1602
+ answers,
1603
+ default_env_name=target_env_name,
1604
+ azd_env_name=azd_env_name,
1605
+ )
1606
+ except RuntimeError as exc:
1607
+ typer.echo(f"{_cli_error('Error')}: {exc}", err=True)
1608
+ raise typer.Exit(code=1)
1609
+
1610
+ typer.echo("")
1611
+ unicode_ok = _terminal_unicode_enabled()
1612
+ ok = "✓" if unicode_ok else "*"
1613
+ bullet = "·" if unicode_ok else "-"
1614
+ if final_result.azd_env_created and final_result.env_path is not None:
1615
+ typer.echo(
1616
+ f" {ok} {_cli_ok('created azd environment')} "
1617
+ f"'{final_result.azd_env_name}' at {_cli_path(final_result.env_path.parent)}"
1618
+ )
1619
+ if final_result.yaml_updated:
1620
+ typer.echo(_cli_updated(final_result.yaml_path))
1621
+ for name in final_result.yaml_fields:
1622
+ typer.echo(f" {bullet} {_cli_label(name)}")
1623
+ if final_result.env_updated and final_result.env_path is not None:
1624
+ typer.echo(_cli_updated(final_result.env_path))
1625
+ for name in final_result.env_keys:
1626
+ typer.echo(f" {bullet} {_cli_label(name)}")
1627
+ if (
1628
+ not final_result.yaml_updated
1629
+ and not final_result.env_updated
1630
+ and not final_result.azd_env_created
1631
+ ):
1632
+ typer.echo(_cli_ok("No configuration changes — every value was already up to date."))
1633
+
1634
+ typer.echo("")
1635
+ typer.echo(_cli_heading("Next steps:"))
1636
+ typer.echo(f" {_cli_command('agentops init show')} # inspect the active configuration")
1637
+ typer.echo(f" {_cli_command('agentops eval analyze')} # inspect eval setup before running")
1638
+ typer.echo(f" {_cli_command('agentops doctor')} # validate the workspace")
1639
+ typer.echo(f" {_cli_command('agentops eval run')} # run a configured evaluation")
1640
+ typer.echo(f" {_cli_command('agentops cockpit')} # open the local cockpit")
1641
+ typer.echo(f" {_cli_command('agentops skills install')} # install coding agent skills")
1642
+
1643
+
1644
+ @init_app.command("show")
1645
+ def cmd_init_show(
1646
+ directory: Annotated[
1647
+ Path,
1648
+ typer.Option(
1649
+ "--dir",
1650
+ "--path",
1651
+ help="Workspace directory to inspect (defaults to the current directory).",
1652
+ ),
1653
+ ] = Path("."),
1654
+ reveal_secrets: Annotated[
1655
+ bool,
1656
+ typer.Option(
1657
+ "--reveal-secrets",
1658
+ help="Print secrets in full instead of masking them. Use with care.",
1659
+ ),
1660
+ ] = False,
1661
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
1662
+ ) -> None:
1663
+ """Show the active AgentOps configuration and where each value comes from."""
1664
+ if _maybe_explain_leaf(("init", "show"), explain):
1665
+ return
1666
+
1667
+ from agentops.services.setup_wizard import collect_snapshot, mask_secret
1668
+
1669
+ workspace = directory.resolve()
1670
+ if not workspace.exists():
1671
+ typer.echo(
1672
+ f"{_cli_error('Error')}: workspace directory does not exist: "
1673
+ f"{_cli_path(workspace)}",
1674
+ err=True,
1675
+ )
1676
+ raise typer.Exit(code=1)
1677
+
1678
+ snapshot = collect_snapshot(workspace)
1679
+ unicode_ok = _terminal_unicode_enabled()
1680
+ ok = "✓" if unicode_ok else "*"
1681
+ miss = "✗" if unicode_ok else "x"
1682
+ warn = "!" if unicode_ok else "!"
1683
+
1684
+ def _config_label(text: str) -> str:
1685
+ return _cli_label(text)
1686
+
1687
+ typer.echo("")
1688
+ typer.echo(_config_label("AgentOps configuration"))
1689
+ typer.echo(style("──────────────────────" if unicode_ok else "----------------------", "dim"))
1690
+ typer.echo(f"{_config_label('Workspace')}: {snapshot.workspace}")
1691
+
1692
+ # AgentOps local env block --------------------------------------------
1693
+ typer.echo("")
1694
+ typer.echo(_config_label("AgentOps local env"))
1695
+ if snapshot.agentops_env_path is not None:
1696
+ typer.echo(f" {ok} {snapshot.agentops_env_path}")
1697
+ else:
1698
+ typer.echo(" - not created yet")
1699
+ typer.echo(" created when AgentOps needs local env values and no azd env is active")
1700
+
1701
+ # azd environment block ------------------------------------------------
1702
+ typer.echo("")
1703
+ typer.echo(_config_label("azd environment"))
1704
+ if snapshot.azd_env_name and snapshot.azd_env_path is not None:
1705
+ marker = ok if snapshot.azd_status == "ok" else warn
1706
+ typer.echo(f" {marker} {_config_label('name')}: {snapshot.azd_env_name}")
1707
+ typer.echo(f" {_config_label('path')}: {snapshot.azd_env_path}")
1708
+ typer.echo(f" {_config_label('status')}: {snapshot.azd_status}")
1709
+ else:
1710
+ typer.echo(" - no azd environment found")
1711
+ if snapshot.azd_reason:
1712
+ typer.echo(f" {_config_label('reason')}: {snapshot.azd_reason}")
1713
+ typer.echo(" pass `--azd-env <name>` to opt into azd env storage")
1714
+
1715
+ # agentops.yaml block --------------------------------------------------
1716
+ typer.echo("")
1717
+ typer.echo(_config_label("agentops.yaml"))
1718
+ if snapshot.yaml_present:
1719
+ typer.echo(f" {ok} {snapshot.yaml_path}")
1720
+ typer.echo(f" {_config_label('agent')}: {snapshot.yaml_agent or '(not set)'}")
1721
+ typer.echo(f" {_config_label('dataset')}: {snapshot.yaml_dataset or '(not set)'}")
1722
+ if snapshot.yaml_project_endpoint:
1723
+ typer.echo(
1724
+ f" {_config_label('project_endpoint')} (legacy yaml override): "
1725
+ f"{snapshot.yaml_project_endpoint}"
1726
+ )
1727
+ else:
1728
+ typer.echo(f" {warn} {snapshot.yaml_path} does not exist")
1729
+ typer.echo(" run `agentops init` to scaffold the workspace")
1730
+
1731
+ # environment variables block -----------------------------------------
1732
+ typer.echo("")
1733
+ typer.echo(_config_label("Environment variables"))
1734
+ for var in snapshot.variables:
1735
+ if var.value:
1736
+ display = var.value if (not var.secret or reveal_secrets) else mask_secret(var.value)
1737
+ marker = ok
1738
+ else:
1739
+ display = "(not set)"
1740
+ marker = miss if var.required else warn
1741
+ required_label = style(" [required]", "yellow") if var.required else ""
1742
+ typer.echo(f" {marker} {_config_label(var.key)}{required_label}")
1743
+ typer.echo(f" {_config_label('value')}: {display}")
1744
+ typer.echo(f" {_config_label('source')}: {var.source}")
1745
+ if var.description:
1746
+ typer.echo(f" {_config_label('info')}: {var.description}")
1747
+
1748
+ # Exit code: 1 if a required variable is missing.
1749
+ if snapshot.missing_required:
1750
+ typer.echo("")
1751
+ typer.echo(
1752
+ f"{_cli_error('Missing required values')}: "
1753
+ f"{', '.join(snapshot.missing_required)}. "
1754
+ "Run `agentops init` to provide them.",
1755
+ err=True,
1756
+ )
1757
+ raise typer.Exit(code=1)
1758
+
1759
+
1760
+ @init_app.command("explain")
1761
+ def cmd_init_explain(
1762
+ no_pager: Annotated[
1763
+ bool, typer.Option("--no-pager", help="Print directly to stdout.")
1764
+ ] = False,
1765
+ fmt: Annotated[
1766
+ str,
1767
+ typer.Option(
1768
+ "--format",
1769
+ "-f",
1770
+ help="Output format: text (default), markdown, or html.",
1771
+ ),
1772
+ ] = "text",
1773
+ out: Annotated[
1774
+ Path | None,
1775
+ typer.Option("--out", "-o", help="Write the explanation to PATH instead of stdout."),
1776
+ ] = None,
1777
+ open_browser: Annotated[
1778
+ bool,
1779
+ typer.Option(
1780
+ "--open",
1781
+ help="When --format html, open the rendered file in the default browser.",
1782
+ ),
1783
+ ] = False,
1784
+ ) -> None:
1785
+ """Long-form manual for `agentops init` and `agentops init show`."""
1786
+ _emit_registered_explain(
1787
+ ("init",),
1788
+ no_pager=no_pager,
1789
+ format_=fmt,
1790
+ out=out,
1791
+ open_browser=open_browser,
1792
+ )
1793
+
1794
+
1795
+ # ---------------------------------------------------------------------------
1796
+ # agentops eval analyze / run
1797
+ # ---------------------------------------------------------------------------
1798
+
1799
+
1800
+ @eval_app.command("analyze")
1801
+ def cmd_eval_analyze(
1802
+ directory: Path = typer.Option(
1803
+ Path("."),
1804
+ "--dir",
1805
+ help="Target repository root directory.",
1806
+ ),
1807
+ output_format: str = typer.Option(
1808
+ "text",
1809
+ "--format",
1810
+ "-f",
1811
+ help="Output format: text, markdown, or json.",
1812
+ ),
1813
+ out: Path | None = typer.Option(
1814
+ None,
1815
+ "--out",
1816
+ "-o",
1817
+ help="Write the analysis to a file instead of stdout.",
1818
+ ),
1819
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
1820
+ ) -> None:
1821
+ """Analyze this repo's evaluation setup before running eval."""
1822
+ if _maybe_explain_leaf(("eval", "analyze"), explain):
1823
+ return
1824
+
1825
+ from agentops.services.eval_analysis import analyze_eval_project, render_eval_analysis
1826
+
1827
+ normalized_format = output_format.lower()
1828
+ if normalized_format not in {"text", "markdown", "json"}:
1829
+ typer.echo(
1830
+ f"{_cli_error('Error')}: --format must be text, markdown, or json.",
1831
+ err=True,
1832
+ )
1833
+ raise typer.Exit(code=1)
1834
+
1835
+ try:
1836
+ analysis = analyze_eval_project(directory)
1837
+ rendered = render_eval_analysis(analysis, normalized_format)
1838
+ except Exception as exc:
1839
+ typer.echo(
1840
+ f"{_cli_error('Error')}: failed to analyze evaluation setup: {exc}",
1841
+ err=True,
1842
+ )
1843
+ raise typer.Exit(code=1) from exc
1844
+
1845
+ if out is not None:
1846
+ out.parent.mkdir(parents=True, exist_ok=True)
1847
+ out.write_text(rendered, encoding="utf-8")
1848
+ typer.echo(f"{_cli_label('Wrote')}: {_cli_path(out)}")
1849
+ return
1850
+
1851
+ if normalized_format == "text":
1852
+ rendered = _colorize_analysis_text(rendered)
1853
+ typer.echo(rendered, color=True)
1854
+
1855
+
1856
+ @eval_app.command("run")
1857
+ def cmd_eval_run(
1858
+ config: Annotated[
1859
+ Path | None,
1860
+ typer.Option(
1861
+ "--config",
1862
+ "-c",
1863
+ help="Path to agentops.yaml. Defaults to ./agentops.yaml.",
1864
+ ),
1865
+ ] = None,
1866
+ output: Annotated[
1867
+ Path | None,
1868
+ typer.Option("--output", "-o", help="Output directory for results."),
1869
+ ] = None,
1870
+ baseline: Annotated[
1871
+ Path | None,
1872
+ typer.Option(
1873
+ "--baseline",
1874
+ help="Path to a previous results.json to compare this run against.",
1875
+ ),
1876
+ ] = None,
1877
+ report_format: Annotated[
1878
+ str, typer.Option("--format", "-f", help="Report format: md, html, or all.")
1879
+ ] = "md",
1880
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
1881
+ ) -> None:
1882
+ """Run an evaluation defined in agentops.yaml."""
1883
+ if _maybe_explain_leaf(("eval", "run"), explain):
1884
+ return
1885
+
1886
+ if report_format not in ("md", "html", "all"):
1887
+ typer.echo(
1888
+ f"{_cli_error('Error')}: --format must be md, html, or all.",
1889
+ err=True,
1890
+ )
1891
+ raise typer.Exit(code=1)
1892
+
1893
+ config_path = _resolve_eval_config_path(config)
1894
+ log.debug(
1895
+ "cmd_eval_run called config=%s output=%s format=%s baseline=%s",
1896
+ config_path,
1897
+ output,
1898
+ report_format,
1899
+ baseline,
1900
+ )
1901
+
1902
+ if not config_path.exists():
1903
+ typer.echo(
1904
+ f"{_cli_error('Error')}: config not found at {_cli_path(config_path)}. "
1905
+ "Run `agentops init` to scaffold a starter agentops.yaml.",
1906
+ err=True,
1907
+ )
1908
+ raise typer.Exit(code=1)
1909
+
1910
+ _run_flat_schema_eval(
1911
+ config_path=config_path,
1912
+ output=output,
1913
+ baseline=baseline,
1914
+ )
1915
+
1916
+
1917
+ @eval_app.command("promote-traces")
1918
+ def cmd_eval_promote_traces(
1919
+ source: Annotated[
1920
+ Path,
1921
+ typer.Option("--source", "-s", help="JSON or JSONL trace export to convert."),
1922
+ ],
1923
+ out: Annotated[
1924
+ Path,
1925
+ typer.Option(
1926
+ "--out",
1927
+ "-o",
1928
+ help="Dataset JSONL path to write when --apply is used.",
1929
+ ),
1930
+ ] = Path(".agentops/data/trace-regression.jsonl"),
1931
+ max_rows: Annotated[
1932
+ int,
1933
+ typer.Option("--max-rows", help="Maximum candidate rows to keep."),
1934
+ ] = 50,
1935
+ label_mode: Annotated[
1936
+ str,
1937
+ typer.Option(
1938
+ "--label-mode",
1939
+ help="How to label expected values: self-similarity or pending.",
1940
+ ),
1941
+ ] = "self-similarity",
1942
+ apply: Annotated[
1943
+ bool,
1944
+ typer.Option("--apply", help="Write the dataset and manifest instead of previewing only."),
1945
+ ] = False,
1946
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
1947
+ ) -> None:
1948
+ """Promote trace exports into reviewable regression dataset rows."""
1949
+ if _maybe_explain_leaf(("eval", "promote-traces"), explain):
1950
+ return
1951
+
1952
+ from agentops.services.trace_promotion import (
1953
+ promote_traces,
1954
+ render_trace_promotion_preview,
1955
+ )
1956
+
1957
+ normalized_mode = label_mode.lower()
1958
+ if normalized_mode not in {"self-similarity", "pending"}:
1959
+ typer.echo(
1960
+ f"{_cli_error('Error')}: --label-mode must be self-similarity or pending.",
1961
+ err=True,
1962
+ )
1963
+ raise typer.Exit(code=1)
1964
+
1965
+ try:
1966
+ preview = promote_traces(
1967
+ source=source,
1968
+ output_path=out,
1969
+ max_rows=max_rows,
1970
+ label_mode=normalized_mode, # type: ignore[arg-type]
1971
+ apply=apply,
1972
+ )
1973
+ except Exception as exc:
1974
+ typer.echo(f"{_cli_error('Error')}: trace promotion failed: {exc}", err=True)
1975
+ raise typer.Exit(code=1) from exc
1976
+
1977
+ typer.echo(_colorize_analysis_text(render_trace_promotion_preview(preview)), color=True)
1978
+ if apply:
1979
+ typer.echo(f"{_cli_label('Wrote dataset')}: {_cli_path(preview.output_path)}")
1980
+ typer.echo(f"{_cli_label('Wrote manifest')}: {_cli_path(preview.manifest_path)}")
1981
+ else:
1982
+ typer.echo(
1983
+ f"{_cli_warn('Preview only')}: re-run with `{_cli_command('--apply')}` to write files."
1984
+ )
1985
+
1986
+
1987
+ def _resolve_eval_config_path(config: Path | None) -> Path:
1988
+ if config is not None:
1989
+ return config
1990
+ return Path("agentops.yaml")
1991
+
1992
+
1993
+ def _run_flat_schema_eval(
1994
+ *,
1995
+ config_path: Path,
1996
+ output: Path | None,
1997
+ baseline: Path | None,
1998
+ ) -> None:
1999
+ from agentops.core.config_loader import load_agentops_config
2000
+ from agentops.pipeline.orchestrator import (
2001
+ RunOptions,
2002
+ exit_code_from,
2003
+ run_evaluation,
2004
+ )
2005
+
2006
+ try:
2007
+ config_obj = load_agentops_config(config_path)
2008
+ except Exception as exc:
2009
+ typer.echo(
2010
+ f"{_cli_error('Error')}: failed to load {_cli_path(config_path)}: {exc}",
2011
+ err=True,
2012
+ )
2013
+ raise typer.Exit(code=1) from exc
2014
+
2015
+ use_default_layout = output is None
2016
+ if use_default_layout:
2017
+ output_dir: Path = _default_flat_output_dir(config_path)
2018
+ else:
2019
+ assert output is not None
2020
+ output_dir = output
2021
+
2022
+ options = RunOptions(
2023
+ config_path=config_path.resolve(),
2024
+ output_dir=output_dir,
2025
+ baseline_path=baseline.resolve() if baseline else None,
2026
+ progress=lambda msg: typer.echo(msg),
2027
+ )
2028
+
2029
+ try:
2030
+ result = run_evaluation(config_obj, options=options)
2031
+ except Exception as exc:
2032
+ typer.echo(f"{_cli_error('Error')}: evaluation failed: {exc}", err=True)
2033
+ raise typer.Exit(code=1) from exc
2034
+
2035
+ latest_dir = config_path.parent / ".agentops" / "results" / "latest"
2036
+ if output_dir.resolve() != latest_dir.resolve():
2037
+ try:
2038
+ _mirror_to_latest(output_dir, latest_dir)
2039
+ except Exception as exc: # pragma: no cover - mirror failures shouldn't fail the run
2040
+ typer.echo(
2041
+ f"{_cli_warn('Warning')}: failed to update {_cli_path(latest_dir)}: {exc}",
2042
+ err=True,
2043
+ )
2044
+ latest_dir = None # type: ignore[assignment]
2045
+ else:
2046
+ latest_dir = None # type: ignore[assignment]
2047
+
2048
+ typer.echo(f"{_cli_label('Evaluation output directory')}: {_cli_path(output_dir)}")
2049
+ typer.echo(f"{_cli_label('results.json')}: {_cli_path(output_dir / 'results.json')}")
2050
+ typer.echo(f"{_cli_label('report.md')}: {_cli_path(output_dir / 'report.md')}")
2051
+ if latest_dir is not None:
2052
+ typer.echo(f"{_cli_label('latest/')}: {_cli_path(latest_dir)}")
2053
+ if result.summary.overall_passed:
2054
+ typer.echo(f"{_cli_label('Threshold status')}: {style('PASSED', 'bold', 'green')}")
2055
+ return
2056
+ typer.echo(f"{_cli_label('Threshold status')}: {style('FAILED', 'bold', 'red')}")
2057
+ raise typer.Exit(code=exit_code_from(result))
2058
+
2059
+
2060
+ def _default_flat_output_dir(config_path: Path) -> Path:
2061
+ base = config_path.parent / ".agentops" / "results"
2062
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
2063
+ return base / timestamp
2064
+
2065
+
2066
+ def _mirror_to_latest(source: Path, latest: Path) -> None:
2067
+ """Replace ``latest`` with a copy of ``source``."""
2068
+ if latest.exists():
2069
+ if latest.is_symlink() or latest.is_file():
2070
+ latest.unlink()
2071
+ else:
2072
+ shutil.rmtree(latest)
2073
+ shutil.copytree(source, latest)
2074
+
2075
+
2076
+ def _is_flat_results(results_path: Path) -> bool:
2077
+ """Return True when results.json was produced by the flat pipeline."""
2078
+ if not results_path.exists():
2079
+ return False
2080
+ try:
2081
+ import json as _json
2082
+ data = _json.loads(results_path.read_text(encoding="utf-8"))
2083
+ except Exception:
2084
+ return False
2085
+ if not isinstance(data, dict):
2086
+ return False
2087
+ target = data.get("target")
2088
+ return (
2089
+ data.get("version") == 1
2090
+ and isinstance(target, dict)
2091
+ and "kind" in target
2092
+ and "bundle" not in data
2093
+ )
2094
+
2095
+
2096
+ def _regenerate_flat_report(
2097
+ *,
2098
+ results_path: Path,
2099
+ output_path: Path | None,
2100
+ report_format: str,
2101
+ ) -> Path:
2102
+ """Render report.md from a flat-pipeline results.json."""
2103
+ import json as _json
2104
+
2105
+ from agentops.core.results import RunResult
2106
+ from agentops.pipeline import reporter as flat_reporter
2107
+
2108
+ if report_format not in ("md", "all"):
2109
+ raise ValueError(
2110
+ "Only --format md is supported (got %r)" % report_format
2111
+ )
2112
+ payload = _json.loads(results_path.read_text(encoding="utf-8"))
2113
+ result = RunResult.model_validate(payload)
2114
+ target = output_path or (results_path.parent / "report.md")
2115
+ target.write_text(flat_reporter.render(result), encoding="utf-8")
2116
+ return target
2117
+
2118
+
2119
+
2120
+ # ---------------------------------------------------------------------------
2121
+ # agentops report generate
2122
+ # ---------------------------------------------------------------------------
2123
+
2124
+
2125
+ @report_app.command("generate")
2126
+ def cmd_report_generate(
2127
+ results_in: Annotated[
2128
+ Path | None,
2129
+ typer.Option(
2130
+ "--in",
2131
+ help=(
2132
+ "Path to results.json. "
2133
+ "If omitted, uses .agentops/results/latest/results.json"
2134
+ ),
2135
+ ),
2136
+ ] = None,
2137
+ report_out: Annotated[
2138
+ Path | None,
2139
+ typer.Option("--out", help="Output path for report."),
2140
+ ] = None,
2141
+ report_format: Annotated[
2142
+ str, typer.Option("--format", "-f", help="Report format: md (default).")
2143
+ ] = "md",
2144
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
2145
+ ) -> None:
2146
+ """Regenerate report.md from a results.json file."""
2147
+ if _maybe_explain_leaf(("report", "generate"), explain):
2148
+ return
2149
+
2150
+ if report_format not in ("md", "all"):
2151
+ typer.echo(f"{_cli_error('Error')}: --format must be md or all.", err=True)
2152
+ raise typer.Exit(code=1)
2153
+
2154
+ resolved_results_in = results_in or DEFAULT_REPORT_INPUT
2155
+ log.debug(
2156
+ "cmd_report_generate called in=%s out=%s format=%s",
2157
+ resolved_results_in,
2158
+ report_out,
2159
+ report_format,
2160
+ )
2161
+
2162
+ if not resolved_results_in.exists():
2163
+ typer.echo(
2164
+ f"{_cli_error('Error')}: results not found at "
2165
+ f"{_cli_path(resolved_results_in)}.",
2166
+ err=True,
2167
+ )
2168
+ raise typer.Exit(code=1)
2169
+
2170
+ if not _is_flat_results(resolved_results_in):
2171
+ typer.echo(
2172
+ f"{_cli_error('Error')}: {_cli_path(resolved_results_in)} is not "
2173
+ "an AgentOps 1.0 results.json. "
2174
+ "Re-run `agentops eval run` to regenerate it.",
2175
+ err=True,
2176
+ )
2177
+ raise typer.Exit(code=1)
2178
+
2179
+ try:
2180
+ output_path = _regenerate_flat_report(
2181
+ results_path=resolved_results_in,
2182
+ output_path=report_out,
2183
+ report_format=report_format,
2184
+ )
2185
+ except Exception as exc:
2186
+ typer.echo(f"{_cli_error('Error')}: report generation failed: {exc}", err=True)
2187
+ raise typer.Exit(code=1) from exc
2188
+
2189
+ typer.echo(f"{_cli_label('Loaded results')}: {_cli_path(resolved_results_in)}")
2190
+ typer.echo(f"{_cli_label('Generated report')}: {_cli_path(output_path)}")
2191
+
2192
+
2193
+ # ---------------------------------------------------------------------------
2194
+ # agentops workflow analyze / generate
2195
+ # ---------------------------------------------------------------------------
2196
+
2197
+
2198
+ @workflow_app.command("analyze")
2199
+ def cmd_workflow_analyze(
2200
+ directory: Path = typer.Option(
2201
+ Path("."),
2202
+ "--dir",
2203
+ help="Target repository root directory.",
2204
+ ),
2205
+ output_format: str = typer.Option(
2206
+ "text",
2207
+ "--format",
2208
+ "-f",
2209
+ help="Output format: text, markdown, or json.",
2210
+ ),
2211
+ out: Path | None = typer.Option(
2212
+ None,
2213
+ "--out",
2214
+ "-o",
2215
+ help="Write the analysis to a file instead of stdout.",
2216
+ ),
2217
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
2218
+ ) -> None:
2219
+ """Analyze this repo's CI/CD shape before generating workflows."""
2220
+ if _maybe_explain_leaf(("workflow", "analyze"), explain):
2221
+ return
2222
+
2223
+ from agentops.services.workflow_analysis import (
2224
+ analyze_workflow_project,
2225
+ render_workflow_analysis,
2226
+ )
2227
+
2228
+ normalized_format = output_format.lower()
2229
+ if normalized_format not in {"text", "markdown", "json"}:
2230
+ typer.echo(
2231
+ f"{_cli_error('Error')}: --format must be text, markdown, or json.",
2232
+ err=True,
2233
+ )
2234
+ raise typer.Exit(code=1)
2235
+
2236
+ try:
2237
+ analysis = analyze_workflow_project(directory)
2238
+ rendered = render_workflow_analysis(analysis, normalized_format)
2239
+ except Exception as exc:
2240
+ typer.echo(
2241
+ f"{_cli_error('Error')}: failed to analyze CI/CD workflow shape: {exc}",
2242
+ err=True,
2243
+ )
2244
+ raise typer.Exit(code=1) from exc
2245
+
2246
+ if out is not None:
2247
+ out.parent.mkdir(parents=True, exist_ok=True)
2248
+ out.write_text(rendered, encoding="utf-8")
2249
+ typer.echo(f"{_cli_label('Wrote')}: {_cli_path(out)}")
2250
+ return
2251
+
2252
+ if normalized_format == "text":
2253
+ rendered = _colorize_analysis_text(rendered)
2254
+ typer.echo(rendered, color=True)
2255
+
2256
+
2257
+ @workflow_app.command("generate")
2258
+ def cmd_workflow_generate(
2259
+ force: bool = typer.Option(
2260
+ False, "--force", help="Overwrite existing workflow files."
2261
+ ),
2262
+ directory: Path = typer.Option(
2263
+ Path("."),
2264
+ "--dir",
2265
+ help="Target repository root directory.",
2266
+ ),
2267
+ kinds: str = typer.Option(
2268
+ "",
2269
+ "--kinds",
2270
+ help=(
2271
+ "Comma-separated subset of workflow kinds to generate. "
2272
+ "Valid values: pr, dev, qa, prod, doctor. "
2273
+ "Default (empty) generates pr, dev, qa, prod."
2274
+ ),
2275
+ ),
2276
+ platform: str = typer.Option(
2277
+ "github",
2278
+ "--platform",
2279
+ "-p",
2280
+ help=(
2281
+ "CI/CD platform. 'github' (default) writes "
2282
+ "`.github/workflows/*.yml`; 'azure-devops' writes "
2283
+ "`.azuredevops/pipelines/*.yml`."
2284
+ ),
2285
+ ),
2286
+ deploy_mode: str = typer.Option(
2287
+ "auto",
2288
+ "--deploy-mode",
2289
+ help=(
2290
+ "Deployment template mode. Default is 'auto': uses azd when azure.yaml exists "
2291
+ "or prompt-agent when agentops.yaml targets a Foundry prompt agent; "
2292
+ "'azd' forces azd provision/deploy templates, 'prompt-agent' "
2293
+ "creates/evaluates a Foundry prompt candidate, and 'placeholder' "
2294
+ "keeps stack-agnostic placeholders."
2295
+ ),
2296
+ ),
2297
+ doctor_gate: str = typer.Option(
2298
+ "critical",
2299
+ "--doctor-gate",
2300
+ help=(
2301
+ "Severity floor for the PR-gate Doctor step. 'critical' (default) "
2302
+ "blocks the PR on critical Doctor findings such as regression drops "
2303
+ "even when eval thresholds still pass; 'warning' blocks on warning "
2304
+ "or higher; 'none' keeps Doctor advisory (pre-1.x behavior). "
2305
+ "Only the PR template uses this; deploy templates always run with "
2306
+ "--severity-fail critical."
2307
+ ),
2308
+ ),
2309
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
2310
+ ) -> None:
2311
+ """Generate the AgentOps GitFlow CI/CD workflows.
2312
+
2313
+ By default writes the four templates that map to a classic GitFlow
2314
+ setup with three deploy environments (dev, qa, production):
2315
+
2316
+ - agentops-pr (PR gate; PRs to develop, release/**, main)
2317
+ - agentops-deploy-dev (push to develop -> environment: dev)
2318
+ - agentops-deploy-qa (push to release/** -> environment: qa)
2319
+ - agentops-deploy-prod (push to main -> environment: production)
2320
+
2321
+ Use --kinds to opt into a subset (e.g. --kinds pr,dev). Add
2322
+ --kinds doctor only when you want a scheduled Doctor run separate from
2323
+ the PR/release gates. --platform targets either GitHub Actions or Azure
2324
+ DevOps Pipelines. The conceptual workflows are identical across platforms.
2325
+ """
2326
+ if _maybe_explain_leaf(("workflow", "generate"), explain):
2327
+ return
2328
+
2329
+ from agentops.services.cicd import (
2330
+ ALL_KINDS,
2331
+ DEPLOY_MODES,
2332
+ DOCTOR_GATES,
2333
+ LEGACY_KIND_ALIASES,
2334
+ PLATFORMS,
2335
+ generate_cicd_workflows,
2336
+ normalize_workflow_kind,
2337
+ )
2338
+
2339
+ log.debug(
2340
+ "cmd_workflow_generate called force=%s dir=%s kinds=%r platform=%s doctor_gate=%s",
2341
+ force, directory, kinds, platform, doctor_gate,
2342
+ )
2343
+
2344
+ if platform not in PLATFORMS:
2345
+ typer.echo(
2346
+ f"{_cli_error('Error')}: unknown --platform value {platform!r}. "
2347
+ f"Valid: {', '.join(PLATFORMS)}.",
2348
+ err=True,
2349
+ )
2350
+ raise typer.Exit(code=1)
2351
+ if deploy_mode not in DEPLOY_MODES:
2352
+ typer.echo(
2353
+ f"{_cli_error('Error')}: unknown --deploy-mode value {deploy_mode!r}. "
2354
+ f"Valid: {', '.join(DEPLOY_MODES)}.",
2355
+ err=True,
2356
+ )
2357
+ raise typer.Exit(code=1)
2358
+ if doctor_gate not in DOCTOR_GATES:
2359
+ typer.echo(
2360
+ f"{_cli_error('Error')}: unknown --doctor-gate value {doctor_gate!r}. "
2361
+ f"Valid: {', '.join(DOCTOR_GATES)}.",
2362
+ err=True,
2363
+ )
2364
+ raise typer.Exit(code=1)
2365
+
2366
+ selected: list[str] | None = None
2367
+ if kinds.strip():
2368
+ requested = [k.strip() for k in kinds.split(",") if k.strip()]
2369
+ valid_inputs = set(ALL_KINDS) | set(LEGACY_KIND_ALIASES)
2370
+ invalid = [k for k in requested if k not in valid_inputs]
2371
+ if invalid:
2372
+ typer.echo(
2373
+ f"{_cli_error('Error')}: unknown --kinds value(s): "
2374
+ f"{', '.join(invalid)}. "
2375
+ f"Valid: {', '.join(ALL_KINDS)}.",
2376
+ err=True,
2377
+ )
2378
+ raise typer.Exit(code=1)
2379
+ selected = [normalize_workflow_kind(k) for k in requested]
2380
+
2381
+ try:
2382
+ result = generate_cicd_workflows(
2383
+ directory=directory,
2384
+ force=force,
2385
+ kinds=selected,
2386
+ platform=platform,
2387
+ deploy_mode=deploy_mode,
2388
+ doctor_gate=doctor_gate,
2389
+ )
2390
+ except Exception as exc:
2391
+ typer.echo(
2392
+ f"{_cli_error('Error')}: failed to generate CI/CD workflows: {exc}",
2393
+ err=True,
2394
+ )
2395
+ raise typer.Exit(code=1) from exc
2396
+
2397
+ typer.echo(f"{_cli_label('Platform')}: {result.platform}")
2398
+ deploy_kinds = [kind for kind in result.kinds if kind in {"dev", "qa", "prod"}]
2399
+ deploy_mode_note = result.deploy_mode
2400
+ if deploy_mode == "auto":
2401
+ deploy_mode_note = f"{deploy_mode_note} (auto default)"
2402
+ if not deploy_kinds:
2403
+ deploy_mode_note = f"{deploy_mode_note}; used only by deploy workflows"
2404
+ typer.echo(f"{_cli_label('Deploy mode')}: {_cli_value(deploy_mode_note)}")
2405
+ typer.echo(
2406
+ f"{_cli_label('Eval runner')}: "
2407
+ f"{_cli_value(_workflow_eval_runner_label(result.eval_runner))}"
2408
+ )
2409
+ doctor_gate_note = result.doctor_gate
2410
+ if "pr" in result.kinds:
2411
+ if doctor_gate_note == "none":
2412
+ doctor_gate_note = f"{doctor_gate_note} (advisory; PR not blocked)"
2413
+ else:
2414
+ doctor_gate_note = f"{doctor_gate_note} (PR blocks on {doctor_gate_note} findings)"
2415
+ else:
2416
+ doctor_gate_note = f"{doctor_gate_note}; PR template not generated"
2417
+ typer.echo(f"{_cli_label('Doctor gate')}: {_cli_value(doctor_gate_note)}")
2418
+ for created in result.created_files:
2419
+ typer.echo(_cli_created(created))
2420
+ for overwritten in result.overwritten_files:
2421
+ typer.echo(_cli_overwritten(overwritten))
2422
+ for skipped in result.skipped_files:
2423
+ typer.echo(_cli_skipped(skipped, " (use --force to overwrite)"))
2424
+
2425
+ if result.created_files or result.overwritten_files:
2426
+ typer.echo("")
2427
+ typer.echo(_cli_heading("Next"))
2428
+ if result.platform == "github":
2429
+ environments = _workflow_environment_names(result.kinds)
2430
+ typer.echo(" repo publish this folder before CI can run")
2431
+ typer.echo(" If this is not a GitHub repo yet:")
2432
+ typer.echo(f" {_cli_command('git init')}")
2433
+ typer.echo(f" {_cli_command('git add .')}")
2434
+ typer.echo(
2435
+ " "
2436
+ + _cli_command('git commit -m "Add AgentOps workflows"')
2437
+ )
2438
+ typer.echo(
2439
+ f" {_cli_command('gh repo create <repo-name> --source . --private --push')}"
2440
+ )
2441
+ typer.echo(" Copilot smoother path: use the AgentOps workflow skill")
2442
+ typer.echo(
2443
+ f" {_cli_command('agentops skills install --platform copilot')}"
2444
+ )
2445
+ typer.echo(" In Copilot, run /skills and confirm agentops-workflow loaded.")
2446
+ typer.echo(
2447
+ " Ask it to wire GitHub, Azure OIDC, variables, "
2448
+ "environments, and branch rules."
2449
+ )
2450
+ typer.echo(
2451
+ " CI vars AZURE_CLIENT_ID, AZURE_TENANT_ID, "
2452
+ "AZURE_SUBSCRIPTION_ID"
2453
+ )
2454
+ typer.echo(
2455
+ " AZURE_AI_FOUNDRY_PROJECT_ENDPOINT, "
2456
+ "AZURE_OPENAI_DEPLOYMENT"
2457
+ )
2458
+ if environments:
2459
+ typer.echo(
2460
+ " envs create GitHub environment"
2461
+ f"{'' if len(environments) == 1 else 's'}: "
2462
+ f"{', '.join(environments)}"
2463
+ )
2464
+ if "production" in environments:
2465
+ typer.echo(
2466
+ " add required reviewers to production before "
2467
+ "enabling prod deploys"
2468
+ )
2469
+ else:
2470
+ environments = _workflow_environment_names(result.kinds)
2471
+ typer.echo(" repo publish this folder before pipelines can run")
2472
+ typer.echo(" service create service connection: agentops-azure")
2473
+ typer.echo(" vars create variable group: agentops")
2474
+ if environments:
2475
+ typer.echo(
2476
+ " envs create Azure DevOps environment"
2477
+ f"{'' if len(environments) == 1 else 's'}: "
2478
+ f"{', '.join(environments)}"
2479
+ )
2480
+ if "production" in environments:
2481
+ typer.echo(" add approval checks to production")
2482
+ if result.deploy_mode == "azd":
2483
+ if deploy_kinds:
2484
+ typer.echo(" azd commit azure.yaml, infra/, and azd hooks")
2485
+ typer.echo(
2486
+ " set AZURE_ENV_NAME/AZURE_LOCATION if env names differ"
2487
+ )
2488
+ elif result.deploy_mode == "prompt-agent":
2489
+ if deploy_kinds:
2490
+ typer.echo(" prompt commit a prompt/instructions file")
2491
+ typer.echo(
2492
+ " set prompt_file or AGENTOPS_AGENT_PROMPT_FILE in CI"
2493
+ )
2494
+ typer.echo(
2495
+ " deploy evaluates that exact candidate version first"
2496
+ )
2497
+ else:
2498
+ typer.echo(" deploy not needed yet; PR gate can run first")
2499
+ typer.echo(
2500
+ " add deploy workflows when you are ready to deploy"
2501
+ )
2502
+ else:
2503
+ if deploy_kinds:
2504
+ typer.echo(" deploy placeholder workflows need project-specific edits")
2505
+ typer.echo(
2506
+ " ask your coding agent to wire azd or prompt-agent deploy"
2507
+ )
2508
+ if "pr" in result.kinds:
2509
+ typer.echo(" gate after the first run, require the AgentOps PR check")
2510
+ typer.echo(" guide docs/ci-github-actions.md")
2511
+ elif result.skipped_files:
2512
+ typer.echo(_cli_warn("No files written. Use --force to overwrite existing workflows."))
2513
+
2514
+
2515
+ # ---------------------------------------------------------------------------
2516
+ # agentops skills install
2517
+ # ---------------------------------------------------------------------------
2518
+
2519
+
2520
+ @skills_app.command("install")
2521
+ def cmd_skills_install(
2522
+ platform: Annotated[
2523
+ list[str] | None,
2524
+ typer.Option(
2525
+ "--platform",
2526
+ "-p",
2527
+ help="Target platform(s): copilot, claude.",
2528
+ ),
2529
+ ] = None,
2530
+ from_github: Annotated[
2531
+ str | None,
2532
+ typer.Option(
2533
+ "--from",
2534
+ help=(
2535
+ "Install a community skill from GitHub. "
2536
+ "Format: org/repo or github:org/repo[@ref]. "
2537
+ "Example: --from donlee/pptx-designer"
2538
+ ),
2539
+ ),
2540
+ ] = None,
2541
+ force: bool = typer.Option(
2542
+ False,
2543
+ "--force",
2544
+ help="Deprecated - skills are always overwritten with the latest version.",
2545
+ ),
2546
+ prompt: bool = typer.Option(
2547
+ False,
2548
+ "--prompt",
2549
+ help="Ask before installing skills when no coding agent platform is detected.",
2550
+ ),
2551
+ directory: Path = typer.Option(
2552
+ Path("."),
2553
+ "--dir",
2554
+ help="Target repository root directory.",
2555
+ ),
2556
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
2557
+ ) -> None:
2558
+ """Install AgentOps coding agent skills into the target project.
2559
+
2560
+ Use --from to install a community skill from GitHub:
2561
+
2562
+ agentops skills install --from donlee/pptx-designer
2563
+
2564
+ agentops skills install --from github:org/repo@v1.0
2565
+ """
2566
+ if _maybe_explain_leaf(("skills", "install"), explain):
2567
+ return
2568
+
2569
+ log.debug(
2570
+ "cmd_skills_install called platform=%s from=%s force=%s prompt=%s dir=%s",
2571
+ platform,
2572
+ from_github,
2573
+ force,
2574
+ prompt,
2575
+ directory,
2576
+ )
2577
+ resolved_platforms = _resolve_platforms(
2578
+ directory=directory, explicit=platform, prompt=prompt
2579
+ )
2580
+ if not resolved_platforms:
2581
+ typer.echo(_cli_warn("No platforms selected. Skipping skill installation."))
2582
+ return
2583
+
2584
+ if from_github:
2585
+ # GitHub-based skill installation
2586
+ from agentops.services.skills import install_github_skill
2587
+
2588
+ typer.echo(f"{_cli_label('Installing skill from GitHub')}: {from_github}")
2589
+ try:
2590
+ result = install_github_skill(
2591
+ source=from_github,
2592
+ directory=directory,
2593
+ platforms=resolved_platforms,
2594
+ force=True,
2595
+ )
2596
+ except ValueError as exc:
2597
+ typer.echo(f"{_cli_error('Error')}: {exc}", err=True)
2598
+ raise typer.Exit(code=1) from exc
2599
+ except Exception as exc:
2600
+ typer.echo(f"{_cli_error('Error')}: failed to install skill: {exc}", err=True)
2601
+ raise typer.Exit(code=1) from exc
2602
+
2603
+ _print_skills_result(result)
2604
+ return
2605
+
2606
+ # Bundled skills installation
2607
+ from agentops.services.skills import install_skills
2608
+
2609
+ try:
2610
+ result = install_skills(
2611
+ directory=directory, platforms=resolved_platforms, force=True
2612
+ )
2613
+ except Exception as exc:
2614
+ typer.echo(f"{_cli_error('Error')}: failed to install skills: {exc}", err=True)
2615
+ raise typer.Exit(code=1) from exc
2616
+
2617
+ _print_skills_result(result)
2618
+
2619
+ from agentops.services.skills import register_skills
2620
+
2621
+ try:
2622
+ reg_result = register_skills(directory=directory, platforms=resolved_platforms)
2623
+ except Exception as exc:
2624
+ typer.echo(f"{_cli_warn('Warning')}: failed to register skills: {exc}", err=True)
2625
+ else:
2626
+ _print_registration_result(reg_result)
2627
+
2628
+
2629
+ # ---------------------------------------------------------------------------
2630
+ # agentops mcp serve
2631
+ # ---------------------------------------------------------------------------
2632
+
2633
+
2634
+ @mcp_app.command("serve")
2635
+ def cmd_mcp_serve(
2636
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
2637
+ ) -> None:
2638
+ """Start the AgentOps MCP server on stdio.
2639
+
2640
+ Exposes the AgentOps workflow (init, eval run, report show, results
2641
+ summary, dataset add, list runs, workflow init) as MCP tools so that
2642
+ MCP-aware coding agents can drive AgentOps directly.
2643
+
2644
+ Requires the optional ``mcp`` extra:
2645
+
2646
+ pip install agentops-accelerator[mcp]
2647
+ """
2648
+ if _maybe_explain_leaf(("mcp", "serve"), explain):
2649
+ return
2650
+
2651
+ try:
2652
+ from agentops.mcp.server import serve_stdio
2653
+ except RuntimeError as exc:
2654
+ typer.echo(f"{_cli_error('Error')}: {exc}", err=True)
2655
+ raise typer.Exit(code=1) from exc
2656
+
2657
+ try:
2658
+ serve_stdio()
2659
+ except RuntimeError as exc:
2660
+ typer.echo(f"{_cli_error('Error')}: {exc}", err=True)
2661
+ raise typer.Exit(code=1) from exc
2662
+
2663
+
2664
+ # ---------------------------------------------------------------------------
2665
+ # `agentops agent` commands
2666
+ # ---------------------------------------------------------------------------
2667
+
2668
+
2669
+ def _resolve_agent_config_path(workspace: Path, explicit: Path | None) -> Path | None:
2670
+ if explicit is not None:
2671
+ return explicit
2672
+ candidate = workspace / ".agentops" / "agent.yaml"
2673
+ return candidate if candidate.exists() else None
2674
+
2675
+
2676
+ def _port_in_use(host: str, port: int) -> bool:
2677
+ """Return True when ``(host, port)`` is already accepting connections."""
2678
+ import socket
2679
+
2680
+ try:
2681
+ with socket.create_connection((host, port), timeout=0.5):
2682
+ return True
2683
+ except OSError:
2684
+ return False
2685
+
2686
+
2687
+ def _existing_agentops_cockpit(host: str, port: int) -> bool:
2688
+ """Heuristic: hit ``/healthz`` and verify it looks like our cockpit.
2689
+
2690
+ The cockpit exposes ``GET /healthz`` returning ``{"status": "ok"}``.
2691
+ Any non-200 / non-matching body means a different process owns the
2692
+ port and we should not assume it's safe to point the browser at it.
2693
+ """
2694
+ import json
2695
+ from urllib import error, request
2696
+
2697
+ try:
2698
+ req = request.Request(
2699
+ f"http://{host}:{port}/healthz",
2700
+ headers={"Accept": "application/json"},
2701
+ )
2702
+ with request.urlopen(req, timeout=1.0) as resp: # noqa: S310
2703
+ if resp.status != 200:
2704
+ return False
2705
+ body = resp.read(256)
2706
+ parsed = json.loads(body)
2707
+ return isinstance(parsed, dict) and parsed.get("status") == "ok"
2708
+ except (error.URLError, ValueError, OSError, TimeoutError):
2709
+ return False
2710
+
2711
+
2712
+ def _summarize_cockpit_connection(workspace: Path) -> list[tuple[str, str]]:
2713
+ """Return label/value pairs describing where the cockpit is pointed.
2714
+
2715
+ Surfaces the resolved Foundry project endpoint and agent identifier so
2716
+ the operator knows which project they are analyzing before the browser
2717
+ opens.
2718
+
2719
+ Best-effort: any read failure (missing file, malformed YAML, no env
2720
+ var) yields a "not configured" line with a single concrete next step
2721
+ instead of raising.
2722
+ """
2723
+
2724
+ project_endpoint: str | None = None
2725
+ agent_id: str | None = None
2726
+
2727
+ def _read_yaml(path: Path) -> dict:
2728
+ try:
2729
+ import yaml # type: ignore[import-not-found]
2730
+
2731
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
2732
+ except Exception: # noqa: BLE001
2733
+ return {}
2734
+ return data if isinstance(data, dict) else {}
2735
+
2736
+ # 1) Flat schema: agentops.yaml at the project root (1.0 layout) or
2737
+ # the legacy location under .agentops/.
2738
+ candidates = [workspace / "agentops.yaml", workspace / ".agentops" / "agentops.yaml"]
2739
+ for path in candidates:
2740
+ if not path.exists():
2741
+ continue
2742
+ data = _read_yaml(path)
2743
+ if not data:
2744
+ continue
2745
+ if not agent_id:
2746
+ value = data.get("agent")
2747
+ if isinstance(value, str) and value.strip():
2748
+ agent_id = value.strip()
2749
+ if not project_endpoint:
2750
+ value = data.get("project_endpoint")
2751
+ if isinstance(value, str) and value.strip():
2752
+ project_endpoint = value.strip()
2753
+ if agent_id and project_endpoint:
2754
+ break
2755
+
2756
+ # 2) Layered schema: .agentops/run.yaml or run.yaml at the root.
2757
+ if not agent_id or not project_endpoint:
2758
+ for path in (workspace / ".agentops" / "run.yaml", workspace / "run.yaml"):
2759
+ if not path.exists():
2760
+ continue
2761
+ data = _read_yaml(path)
2762
+ target = data.get("target") if isinstance(data, dict) else None
2763
+ endpoint = (target or {}).get("endpoint") if isinstance(target, dict) else None
2764
+ if isinstance(endpoint, dict):
2765
+ if not agent_id:
2766
+ value = endpoint.get("agent_id")
2767
+ if isinstance(value, str) and value.strip():
2768
+ agent_id = value.strip()
2769
+ if not project_endpoint:
2770
+ value = endpoint.get("project_endpoint")
2771
+ if isinstance(value, str) and value.strip():
2772
+ project_endpoint = value.strip()
2773
+ if agent_id and project_endpoint:
2774
+ break
2775
+
2776
+ # 3) Env-var fallback for the project endpoint.
2777
+ if not project_endpoint:
2778
+ env_value = os.environ.get("AZURE_AI_FOUNDRY_PROJECT_ENDPOINT")
2779
+ if env_value and env_value.strip():
2780
+ project_endpoint = env_value.strip()
2781
+
2782
+ rows: list[tuple[str, str]] = []
2783
+
2784
+ if project_endpoint:
2785
+ rows.append(("Foundry project", project_endpoint))
2786
+ else:
2787
+ rows.append(
2788
+ (
2789
+ "Foundry project",
2790
+ "not configured — set AZURE_AI_FOUNDRY_PROJECT_ENDPOINT or "
2791
+ "`project_endpoint` in agentops.yaml",
2792
+ )
2793
+ )
2794
+
2795
+ if agent_id:
2796
+ rows.append(("agent", agent_id))
2797
+ else:
2798
+ rows.append(("agent", "not configured — set `agent` in agentops.yaml"))
2799
+
2800
+ return rows
2801
+
2802
+
2803
+ @doctor_app.callback(invoke_without_command=True)
2804
+ def cmd_doctor(
2805
+ ctx: typer.Context,
2806
+ workspace: Annotated[
2807
+ Path,
2808
+ typer.Option(
2809
+ "--workspace",
2810
+ "-w",
2811
+ help="Project root containing `.agentops/`.",
2812
+ ),
2813
+ ] = Path("."),
2814
+ config_path: Annotated[
2815
+ Path | None,
2816
+ typer.Option(
2817
+ "--config",
2818
+ "-c",
2819
+ help="Path to `agent.yaml` (default: `.agentops/agent.yaml`).",
2820
+ ),
2821
+ ] = None,
2822
+ out: Annotated[
2823
+ Path,
2824
+ typer.Option(
2825
+ "--out",
2826
+ "-o",
2827
+ help="Where to write the Markdown report.",
2828
+ ),
2829
+ ] = Path(".agentops/agent/report.md"),
2830
+ lookback_days: Annotated[
2831
+ int | None,
2832
+ typer.Option(
2833
+ "--lookback-days",
2834
+ help="Override the lookback window for production telemetry.",
2835
+ ),
2836
+ ] = None,
2837
+ severity_fail: Annotated[
2838
+ str,
2839
+ typer.Option(
2840
+ "--severity-fail",
2841
+ help=(
2842
+ "Exit 2 when a finding at or above this severity is produced "
2843
+ "(info, warning, critical, or none)."
2844
+ ),
2845
+ ),
2846
+ ] = "critical",
2847
+ categories: Annotated[
2848
+ str | None,
2849
+ typer.Option(
2850
+ "--categories",
2851
+ help=(
2852
+ "Comma-separated list of categories to include "
2853
+ "(quality, performance, reliability, "
2854
+ "operational_excellence, security, responsible_ai). "
2855
+ "Default: include all."
2856
+ ),
2857
+ ),
2858
+ ] = None,
2859
+ exclude_rules: Annotated[
2860
+ str | None,
2861
+ typer.Option(
2862
+ "--exclude-rules",
2863
+ help=(
2864
+ "Comma-separated list of posture rule ids to skip "
2865
+ "(for example `waf.security.diagnostic_settings`)."
2866
+ ),
2867
+ ),
2868
+ ] = None,
2869
+ no_preflight: Annotated[
2870
+ bool,
2871
+ typer.Option(
2872
+ "--no-preflight",
2873
+ help="Skip the pre-flight connectivity checks.",
2874
+ ),
2875
+ ] = False,
2876
+ strict_preflight: Annotated[
2877
+ bool,
2878
+ typer.Option(
2879
+ "--strict-preflight",
2880
+ help="Exit non-zero if any pre-flight check fails or warns.",
2881
+ ),
2882
+ ] = False,
2883
+ evidence_pack: Annotated[
2884
+ bool,
2885
+ typer.Option(
2886
+ "--evidence-pack",
2887
+ help="Write `.agentops/release/latest/evidence.json` and `evidence.md`.",
2888
+ ),
2889
+ ] = False,
2890
+ evidence_out: Annotated[
2891
+ Path | None,
2892
+ typer.Option(
2893
+ "--evidence-out",
2894
+ help="Directory for release evidence artifacts (default: `.agentops/release/latest`).",
2895
+ ),
2896
+ ] = None,
2897
+ ) -> None:
2898
+ """Diagnose local AgentOps, Foundry, Azure telemetry, and WAF-AI gaps."""
2899
+ # When a subcommand was provided (e.g. `doctor explain`), defer to it
2900
+ # and don't run the analyzer. Group-level options on the callback
2901
+ # are still parsed (Typer requires this) but are ignored here -
2902
+ # subcommands declare their own option surface.
2903
+ if ctx.invoked_subcommand is not None:
2904
+ return
2905
+
2906
+ _run_doctor_analyze(
2907
+ workspace=workspace,
2908
+ config_path=config_path,
2909
+ out=out,
2910
+ lookback_days=lookback_days,
2911
+ severity_fail=severity_fail,
2912
+ categories=categories,
2913
+ exclude_rules=exclude_rules,
2914
+ no_preflight=no_preflight,
2915
+ strict_preflight=strict_preflight,
2916
+ evidence_pack=evidence_pack,
2917
+ evidence_out=evidence_out,
2918
+ )
2919
+
2920
+
2921
+ def _run_doctor_analyze(
2922
+ *,
2923
+ workspace: Path,
2924
+ config_path: Path | None,
2925
+ out: Path,
2926
+ lookback_days: int | None,
2927
+ severity_fail: str,
2928
+ categories: str | None,
2929
+ exclude_rules: str | None,
2930
+ no_preflight: bool,
2931
+ strict_preflight: bool,
2932
+ evidence_pack: bool,
2933
+ evidence_out: Path | None,
2934
+ ) -> None:
2935
+ """Run the doctor analyzer pipeline.
2936
+
2937
+ Extracted from the Typer callback so behavior is identical whether
2938
+ the user invokes ``agentops doctor`` directly or any future entry
2939
+ point that wants to reuse the same orchestration.
2940
+ """
2941
+ from agentops.agent.analyzer import analyze
2942
+ from agentops.agent.config import load_agent_config
2943
+ from agentops.agent.findings import Severity
2944
+ from agentops.agent.history import append_analysis, build_record
2945
+ from agentops.agent.report import render_report
2946
+ from agentops.services.preflight import (
2947
+ format_report,
2948
+ run_preflight,
2949
+ )
2950
+ from agentops.utils import telemetry
2951
+
2952
+ workspace = workspace.resolve()
2953
+ resolved_config = _resolve_agent_config_path(workspace, config_path)
2954
+
2955
+ if evidence_pack and categories:
2956
+ typer.echo(
2957
+ f"{_cli_error('Error')}: --evidence-pack requires a full doctor run; omit --categories.",
2958
+ err=True,
2959
+ )
2960
+ raise typer.Exit(code=1)
2961
+
2962
+ if not no_preflight:
2963
+ with _CliStatusIndicator(
2964
+ "doctor: running pre-flight checks (workspace, Azure auth, Foundry discovery)"
2965
+ ):
2966
+ report = run_preflight(workspace, scope="doctor")
2967
+ typer.echo(format_report(report, show_ok_details=True), err=True)
2968
+ if report.has_failures or (strict_preflight and report.has_warnings):
2969
+ typer.echo(
2970
+ f"{_cli_error('Pre-flight failed')}. Resolve the issues above or re-run "
2971
+ "with `--no-preflight` to bypass.",
2972
+ err=True,
2973
+ )
2974
+ raise typer.Exit(code=1)
2975
+
2976
+ try:
2977
+ config = load_agent_config(resolved_config)
2978
+ except Exception as exc:
2979
+ typer.echo(f"{_cli_error('Error loading agent config')}: {exc}", err=True)
2980
+ raise typer.Exit(code=1) from exc
2981
+
2982
+ if lookback_days is not None:
2983
+ config = config.model_copy(update={"lookback_days": lookback_days})
2984
+
2985
+ severity_fail_normalized = severity_fail.strip().lower()
2986
+ if severity_fail_normalized == "none":
2987
+ severity_floor: Severity | None = None
2988
+ else:
2989
+ try:
2990
+ severity_floor = Severity(severity_fail_normalized)
2991
+ except ValueError as exc:
2992
+ typer.echo(
2993
+ f"{_cli_error('Error')}: invalid --severity-fail '{severity_fail}'. "
2994
+ "Use one of: info, warning, critical, none.",
2995
+ err=True,
2996
+ )
2997
+ raise typer.Exit(code=1) from exc
2998
+
2999
+ telemetry.init_tracing()
3000
+ started_perf = time.perf_counter()
3001
+ try:
3002
+ with telemetry.agent_analyze_span(
3003
+ workspace=str(workspace),
3004
+ lookback_days=config.lookback_days,
3005
+ ) as analyze_span:
3006
+ try:
3007
+ with _CliStatusIndicator(
3008
+ "doctor: collecting local history, Azure Monitor, and Foundry control plane"
3009
+ ) as status:
3010
+ result = analyze(
3011
+ workspace,
3012
+ config,
3013
+ categories=(
3014
+ [c for c in categories.split(",") if c.strip()]
3015
+ if categories
3016
+ else None
3017
+ ),
3018
+ exclude_rules=(
3019
+ [r for r in exclude_rules.split(",") if r.strip()]
3020
+ if exclude_rules
3021
+ else None
3022
+ ),
3023
+ progress=status.update,
3024
+ )
3025
+ except Exception as exc: # pragma: no cover
3026
+ typer.echo(f"{_cli_error('Error running analyzer')}: {exc}", err=True)
3027
+ raise typer.Exit(code=1) from exc
3028
+
3029
+ duration_seconds = time.perf_counter() - started_perf
3030
+
3031
+ # Persist the analysis history (always - works without Azure).
3032
+ sources_enabled = _sources_enabled(config)
3033
+ record = build_record(
3034
+ result.findings,
3035
+ sources_enabled=sources_enabled,
3036
+ lookback_days=config.lookback_days,
3037
+ duration_seconds=duration_seconds,
3038
+ )
3039
+ try:
3040
+ history_file = append_analysis(workspace, record)
3041
+ except OSError as exc: # pragma: no cover - best effort
3042
+ history_file = None
3043
+ log.debug("could not append agent history: %s", exc)
3044
+
3045
+ telemetry.set_agent_analyze_result(
3046
+ analyze_span,
3047
+ findings_total=record.findings_total,
3048
+ by_severity=record.findings_by_severity,
3049
+ by_category=record.findings_by_category,
3050
+ max_severity=record.max_severity,
3051
+ sources_enabled=sources_enabled,
3052
+ )
3053
+ for finding in result.findings:
3054
+ telemetry.record_agent_finding_span(finding)
3055
+ finally:
3056
+ telemetry.shutdown()
3057
+
3058
+ out_path = out if out.is_absolute() else workspace / out
3059
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3060
+ out_path.write_text(render_report(result), encoding="utf-8")
3061
+
3062
+ typer.echo(f"{_cli_label('Wrote')}: {_cli_path(out_path)}")
3063
+ if evidence_pack:
3064
+ from agentops.services.evidence_pack import write_release_evidence
3065
+
3066
+ try:
3067
+ evidence_result = write_release_evidence(
3068
+ workspace=workspace,
3069
+ analysis=result,
3070
+ out_dir=evidence_out,
3071
+ )
3072
+ except Exception as exc:
3073
+ typer.echo(
3074
+ f"{_cli_error('Error writing evidence pack')}: {exc}",
3075
+ err=True,
3076
+ )
3077
+ raise typer.Exit(code=1) from exc
3078
+ evidence = evidence_result.evidence
3079
+ status_tone = (
3080
+ "green"
3081
+ if evidence.status == "ready"
3082
+ else "yellow"
3083
+ if evidence.status == "ready_with_warnings"
3084
+ else "red"
3085
+ )
3086
+ typer.echo(
3087
+ f"{_cli_label('Release readiness')}: {style(evidence.status, 'bold', status_tone)}"
3088
+ )
3089
+ typer.echo(
3090
+ f"{_cli_label('Evidence pack')}: {_cli_path(evidence_result.json_path)}"
3091
+ )
3092
+ typer.echo(
3093
+ f"{_cli_label('Evidence report')}: {_cli_path(evidence_result.markdown_path)}"
3094
+ )
3095
+ if history_file is not None:
3096
+ typer.echo(f"{_cli_label('Appended history')}: {_cli_path(history_file)}")
3097
+ finding_lines = _doctor_findings_summary_lines(result.findings)
3098
+ if finding_lines:
3099
+ typer.echo(finding_lines[0])
3100
+ if result.max_severity is not None:
3101
+ severity = result.max_severity.value
3102
+ tone = "red" if severity == "critical" else "yellow" if severity == "warning" else "green"
3103
+ typer.echo(f"{_cli_label('Max severity')}: {style(severity, 'bold', tone)}")
3104
+ for line in finding_lines[1:]:
3105
+ typer.echo(line)
3106
+
3107
+ if severity_floor is None:
3108
+ typer.echo(f"{_cli_label('Finding gate')}: disabled (--severity-fail none)")
3109
+ return
3110
+
3111
+ if result.max_severity is not None and result.max_severity >= severity_floor:
3112
+ raise typer.Exit(code=2)
3113
+
3114
+
3115
+ @doctor_app.command("explain")
3116
+ def cmd_doctor_explain(
3117
+ no_pager: Annotated[
3118
+ bool,
3119
+ typer.Option(
3120
+ "--no-pager",
3121
+ help="Print directly instead of opening the pager.",
3122
+ ),
3123
+ ] = False,
3124
+ format_: Annotated[
3125
+ str,
3126
+ typer.Option(
3127
+ "--format",
3128
+ "-f",
3129
+ help="Output format: text, markdown, or html.",
3130
+ ),
3131
+ ] = "text",
3132
+ out: Annotated[
3133
+ Path | None,
3134
+ typer.Option(
3135
+ "--out",
3136
+ "-o",
3137
+ help="Write the manual to a file instead of only printing it.",
3138
+ ),
3139
+ ] = None,
3140
+ open_browser: Annotated[
3141
+ bool,
3142
+ typer.Option(
3143
+ "--open",
3144
+ help="Open a browser-friendly HTML copy for reading or printing.",
3145
+ ),
3146
+ ] = False,
3147
+ ) -> None:
3148
+ """Open a paged manual explaining what Doctor does and how it works."""
3149
+ _emit_doctor_explain(
3150
+ no_pager=no_pager,
3151
+ format_=format_,
3152
+ out=out,
3153
+ open_browser=open_browser,
3154
+ )
3155
+
3156
+
3157
+ def _emit_doctor_explain(
3158
+ *,
3159
+ no_pager: bool,
3160
+ format_: str,
3161
+ out: Path | None,
3162
+ open_browser: bool,
3163
+ ) -> None:
3164
+ import click
3165
+
3166
+ from agentops.agent.checks.catalog import (
3167
+ CATEGORY_DESCRIPTIONS,
3168
+ CATEGORY_ORDER,
3169
+ SOURCE_DESCRIPTIONS,
3170
+ SOURCE_LABELS,
3171
+ by_category,
3172
+ all_checks,
3173
+ reference_url_for,
3174
+ )
3175
+
3176
+ format_ = format_.lower()
3177
+ if format_ not in {"text", "markdown", "html"}:
3178
+ typer.echo(
3179
+ f"{_cli_error('Invalid --format')}. Use one of: text, markdown, html.",
3180
+ err=True,
3181
+ )
3182
+ raise typer.Exit(code=1)
3183
+
3184
+ text = _build_doctor_explain_text(
3185
+ category_descriptions=CATEGORY_DESCRIPTIONS,
3186
+ category_order=CATEGORY_ORDER,
3187
+ source_labels=SOURCE_LABELS,
3188
+ source_descriptions=SOURCE_DESCRIPTIONS,
3189
+ grouped_checks=by_category(all_checks()),
3190
+ reference_url_for=reference_url_for,
3191
+ )
3192
+ markdown = _build_doctor_explain_markdown(
3193
+ category_descriptions=CATEGORY_DESCRIPTIONS,
3194
+ category_order=CATEGORY_ORDER,
3195
+ source_labels=SOURCE_LABELS,
3196
+ source_descriptions=SOURCE_DESCRIPTIONS,
3197
+ grouped_checks=by_category(all_checks()),
3198
+ reference_url_for=reference_url_for,
3199
+ )
3200
+ html = _build_explain_html(markdown, title="AgentOps Doctor manual")
3201
+
3202
+ output = {"text": text, "markdown": markdown, "html": html}[format_]
3203
+ if out is not None:
3204
+ out.parent.mkdir(parents=True, exist_ok=True)
3205
+ out.write_text(output, encoding="utf-8")
3206
+ typer.echo(f"{_cli_label('Wrote')}: {_cli_path(out)}")
3207
+
3208
+ if open_browser:
3209
+ browser_path = out if out is not None and format_ == "html" else None
3210
+ if browser_path is None:
3211
+ with tempfile.NamedTemporaryFile(
3212
+ "w",
3213
+ encoding="utf-8",
3214
+ suffix=".html",
3215
+ prefix="agentops-doctor-",
3216
+ delete=False,
3217
+ ) as temp:
3218
+ temp.write(html)
3219
+ browser_path = Path(temp.name)
3220
+ typer.echo(f"{_cli_label('Opened browser copy')}: {_cli_path(browser_path)}")
3221
+ webbrowser.open(browser_path.resolve().as_uri())
3222
+
3223
+ if out is not None or open_browser:
3224
+ return
3225
+
3226
+ if format_ == "text" and not _terminal_unicode_enabled():
3227
+ output = _downgrade_to_ascii(output)
3228
+
3229
+ if format_ != "text":
3230
+ if no_pager:
3231
+ typer.echo(output, color=True)
3232
+ return
3233
+ click.echo_via_pager(output, color=True)
3234
+ return
3235
+
3236
+ if no_pager:
3237
+ _emit_manual_to_terminal(output)
3238
+ return
3239
+ if _useful_pager_available():
3240
+ click.echo_via_pager(output, color=True)
3241
+ return
3242
+ _emit_manual_with_internal_pager(output)
3243
+
3244
+
3245
+ def _build_doctor_explain_text(
3246
+ *,
3247
+ category_descriptions: dict,
3248
+ category_order: tuple,
3249
+ source_labels: dict,
3250
+ source_descriptions: dict,
3251
+ grouped_checks: dict,
3252
+ reference_url_for,
3253
+ ) -> str:
3254
+ """Build the Linux-style manual shown by `agentops doctor explain`."""
3255
+ source_order = [
3256
+ "workspace",
3257
+ "spec_workspace",
3258
+ "results_history",
3259
+ "foundry_control",
3260
+ "azure_monitor",
3261
+ "azure_resources",
3262
+ "judge_model",
3263
+ ]
3264
+ lines: list[str] = _manual_banner(
3265
+ "AgentOps Doctor",
3266
+ "Diagnose AgentOps workspaces, Foundry projects, Azure telemetry, and WAF-AI gaps.",
3267
+ )
3268
+
3269
+ def section(title: str) -> None:
3270
+ _manual_section(lines, title)
3271
+
3272
+ section("NAME")
3273
+ _emit_name_line(
3274
+ lines,
3275
+ "agentops doctor",
3276
+ "Diagnose AgentOps workspaces, Foundry projects, Azure telemetry, and WAF-AI gaps.",
3277
+ )
3278
+
3279
+ section("SYNOPSIS")
3280
+ lines.extend(
3281
+ [
3282
+ f" {style('$', 'dim')} {style('agentops doctor [OPTIONS]', 'bold')}",
3283
+ f" {style('$', 'dim')} {style('agentops doctor explain [--no-pager]', 'bold')}",
3284
+ ]
3285
+ )
3286
+
3287
+ section("DESCRIPTION")
3288
+ lines.extend(
3289
+ _manual_paragraphs(
3290
+ "Doctor is the local readiness analyzer in AgentOps. It "
3291
+ "collects the sources you've configured — workspace files, "
3292
+ "Foundry control plane, Azure telemetry, Azure resources — "
3293
+ "runs deterministic and optional LLM-judged checks, groups "
3294
+ "findings by Microsoft AI Well-Architected Framework pillars, "
3295
+ "writes a Markdown report, and returns CI-friendly exit codes "
3296
+ "so quality gates fail fast.",
3297
+ "It surfaces what's missing across the project, the CI, and "
3298
+ "the Foundry wiring: workspace and config drift, missing CI "
3299
+ "gates, telemetry wiring, RBAC posture, and readiness for the "
3300
+ "practices described in the Microsoft AI Well-Architected "
3301
+ "Framework — with concrete next actions for each finding.",
3302
+ "Use `agentops doctor --help` for the terse syntax. Use "
3303
+ "`agentops doctor explain` for this longer manual-style overview.",
3304
+ )
3305
+ )
3306
+
3307
+ section("DATA SOURCES")
3308
+ lines.extend(
3309
+ _manual_paragraphs(
3310
+ "Doctor is source-driven. Local sources work without Azure access. "
3311
+ "The main Azure sources are enabled by default and fail-open: if "
3312
+ "Doctor cannot authenticate, infer the deployed environment, or "
3313
+ "read a resource, it records a source diagnostic with the reason "
3314
+ "and the next configuration step instead of crashing the run."
3315
+ )
3316
+ )
3317
+ for source in source_order:
3318
+ label = source_labels.get(source, source)
3319
+ description = source_descriptions.get(source, "")
3320
+ lines.append("")
3321
+ lines.append(f" {source} ({label})")
3322
+ lines.extend(_manual_paragraphs(description, indent=" "))
3323
+
3324
+ section("AZD AND DEPLOYED ENVIRONMENTS")
3325
+ lines.extend(
3326
+ _manual_paragraphs(
3327
+ "For ARM posture checks, Doctor first looks for the active AZD "
3328
+ "environment under `.azure/<env>/.env`. It uses `AZURE_ENV_NAME`, "
3329
+ "then `.azure/config.json` `defaultEnvironment`, then the only "
3330
+ "environment folder when there is exactly one. From that file it "
3331
+ "uses `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, and common "
3332
+ "AI account variables when present.",
3333
+ "If AZD metadata is not enough, Doctor uses the Foundry project "
3334
+ "endpoint to identify the backing Azure AI / Cognitive Services "
3335
+ "account. It scans the subscription for matching account endpoints "
3336
+ "and only proceeds automatically when the match is unambiguous. "
3337
+ "Explicit `sources.azure_resources` values in `.agentops/agent.yaml` "
3338
+ "still work and take precedence.",
3339
+ "For production telemetry, configure `sources.azure_monitor` with "
3340
+ "`app_insights_resource_id` or `log_analytics_workspace_id`. For "
3341
+ "Foundry control-plane checks, configure "
3342
+ "`AZURE_AI_FOUNDRY_PROJECT_ENDPOINT` or "
3343
+ "`sources.foundry_control.project_endpoint`.",
3344
+ )
3345
+ )
3346
+
3347
+ section("HOW IT WORKS")
3348
+ lines.extend(
3349
+ [
3350
+ *_manual_item_lines("1. ", "Resolve `.agentops/agent.yaml`."),
3351
+ *_manual_item_lines("2. ", "Run pre-flight checks unless `--no-preflight` is used."),
3352
+ *_manual_item_lines(
3353
+ "3. ",
3354
+ "Collect enabled sources: local files, eval history, Foundry, Azure Monitor, ARM resources.",
3355
+ ),
3356
+ *_manual_item_lines("4. ", "Run Doctor checks and optional LLM-judged rules."),
3357
+ *_manual_item_lines("5. ", "Sort findings by severity and WAF-AI pillar."),
3358
+ *_manual_item_lines("6. ", "Write `.agentops/agent/report.md` (or `--out <path>`)."),
3359
+ *_manual_item_lines("7. ", "Append analysis history for cockpit / trend views."),
3360
+ *_manual_item_lines("8. ", "Exit with 0, 1, or 2."),
3361
+ ]
3362
+ )
3363
+
3364
+ section("CHECK CATEGORIES")
3365
+ lines.extend(
3366
+ [
3367
+ " quality Eval metric regressions.",
3368
+ " performance Eval and production latency.",
3369
+ " reliability Error rates, failed runs, rate limits, missing telemetry.",
3370
+ " operational_excellence CI hygiene, config drift, stale tasks, spec conformance.",
3371
+ " security Azure AI account auth, identity, and diagnostics posture.",
3372
+ " responsible_ai Content safety, prompt guardrails, dataset risk, continuous eval.",
3373
+ ]
3374
+ )
3375
+
3376
+ section("FINDING QUALITY BAR")
3377
+ lines.extend(
3378
+ _manual_paragraphs(
3379
+ "Doctor findings are intentionally high-signal. The default catalog "
3380
+ "focuses on quality regressions, stale or flaky evaluations, "
3381
+ "production latency/errors, runtime safety signals, security posture, "
3382
+ "CI gates, and spec/config drift that changes what is actually "
3383
+ "evaluated.",
3384
+ "Process-only or backend-assumption checks stay out of the default "
3385
+ "finding list. For example, a reachable Foundry project with zero "
3386
+ "agents is source context, not a warning, because the agent may run "
3387
+ "on HTTP, Container Apps, AKS, or another runtime. Tiny specs, "
3388
+ "missing changelogs, and Copilot-instructions housekeeping are also "
3389
+ "not emitted as default findings.",
3390
+ )
3391
+ )
3392
+
3393
+ section("CHECK CATALOG")
3394
+ lines.extend(
3395
+ _manual_paragraphs(
3396
+ "This is the catalog of findings Doctor can emit. Each entry "
3397
+ "shows the finding id, whether it is LLM-judged or source-based, "
3398
+ "the short purpose, required sources, and a public reference URL.",
3399
+ "`[LLM Judge]` means the check calls the configured judge model "
3400
+ "(opt-in and may use tokens). `[Source-based]` means no judge "
3401
+ "model is called; Doctor evaluates local files, eval history, "
3402
+ "Foundry, App Insights, or Azure resource metadata.",
3403
+ )
3404
+ )
3405
+ for category in category_order:
3406
+ checks = grouped_checks.get(category, [])
3407
+ if not checks:
3408
+ continue
3409
+ lines.append("")
3410
+ heading = category.value.replace("_", " ").title()
3411
+ description = category_descriptions.get(category)
3412
+ lines.append(f" {heading}")
3413
+ if description:
3414
+ lines.extend(_manual_paragraphs(description, indent=" "))
3415
+ for spec in checks:
3416
+ requires = ", ".join(
3417
+ source_labels.get(source, source) for source in spec.requires
3418
+ )
3419
+ severities = ", ".join(severity.value for severity in spec.severities)
3420
+ if spec.is_llm_judged:
3421
+ mode_badge = style("[LLM Judge]", "bold", "magenta")
3422
+ mode_text = "LLM Judge (opt-in; uses configured judge model)"
3423
+ else:
3424
+ mode_badge = style("[Source-based]", "dim")
3425
+ mode_text = "Source-based (no judge model call)"
3426
+ lines.append("")
3427
+ lines.append(f" - {mode_badge} {spec.id}")
3428
+ lines.extend(_manual_paragraphs(spec.title, indent=" "))
3429
+ lines.extend(_manual_paragraphs(spec.summary, indent=" "))
3430
+ lines.append(f" mode: {mode_text}")
3431
+ lines.append(f" severity: {severities}")
3432
+ if requires:
3433
+ lines.extend(_manual_paragraphs(f"requires: {requires}", indent=" "))
3434
+ docs_url = reference_url_for(spec)
3435
+ if docs_url:
3436
+ lines.append(f" learn more: {docs_url}")
3437
+
3438
+ section("EXIT CODES")
3439
+ lines.extend(
3440
+ [
3441
+ " 0 Analyzer ran successfully and no finding met `--severity-fail`, or `--severity-fail none` disabled the finding gate.",
3442
+ " 1 Runtime or configuration error.",
3443
+ " 2 Analyzer ran successfully, but at least one finding met `--severity-fail`.",
3444
+ ]
3445
+ )
3446
+
3447
+ section("EXAMPLES")
3448
+ lines.extend(
3449
+ [
3450
+ f" {style('$', 'dim')} {style('agentops doctor', 'bold')}",
3451
+ f" {style('$', 'dim')} {style('agentops doctor --categories security,responsible_ai', 'bold')}",
3452
+ f" {style('$', 'dim')} {style('agentops doctor --severity-fail warning', 'bold')}",
3453
+ f" {style('$', 'dim')} {style('agentops doctor --severity-fail none --evidence-pack', 'bold')}",
3454
+ f" {style('$', 'dim')} {style('agentops doctor explain --no-pager', 'bold')}",
3455
+ ]
3456
+ )
3457
+
3458
+ section("SEE ALSO")
3459
+ lines.extend(
3460
+ [
3461
+ " docs/doctor-checks.md",
3462
+ " docs/doctor-explained.md",
3463
+ " https://learn.microsoft.com/azure/well-architected/ai/",
3464
+ ]
3465
+ )
3466
+ return "\n".join(lines) + "\n"
3467
+
3468
+
3469
+ def _build_doctor_explain_markdown(
3470
+ *,
3471
+ category_descriptions: dict,
3472
+ category_order: tuple,
3473
+ source_labels: dict,
3474
+ source_descriptions: dict,
3475
+ grouped_checks: dict,
3476
+ reference_url_for,
3477
+ ) -> str:
3478
+ """Build a Markdown manual for browser/print workflows."""
3479
+ source_order = [
3480
+ "workspace",
3481
+ "spec_workspace",
3482
+ "results_history",
3483
+ "foundry_control",
3484
+ "azure_monitor",
3485
+ "azure_resources",
3486
+ "judge_model",
3487
+ ]
3488
+ lines: list[str] = [
3489
+ "# AgentOps Doctor manual",
3490
+ "",
3491
+ "## NAME",
3492
+ "",
3493
+ "`agentops doctor` - diagnose AgentOps, Foundry, Azure telemetry, and WAF-AI gaps.",
3494
+ "",
3495
+ "## SYNOPSIS",
3496
+ "",
3497
+ "```text",
3498
+ "agentops doctor [OPTIONS]",
3499
+ "agentops doctor explain [--no-pager] [--format text|markdown|html] [--out PATH] [--open]",
3500
+ "```",
3501
+ "",
3502
+ "## DESCRIPTION",
3503
+ "",
3504
+ "Doctor is the local diagnostic analyzer for AgentOps workspaces. It collects configured sources, runs deterministic and optional LLM-judged checks, groups findings by Microsoft WAF-AI pillars, writes a Markdown report, and returns CI-friendly exit codes.",
3505
+ "",
3506
+ "Use `agentops doctor --help` for terse syntax and options. Use `agentops doctor explain` for the terminal manual, `--format markdown --out doctor.md` for a document, or `--open` for a browser-friendly copy you can print.",
3507
+ "",
3508
+ "## DATA SOURCES",
3509
+ "",
3510
+ "Doctor is source-driven. Local sources work without Azure access. The main Azure sources are enabled by default and fail open: if Doctor cannot authenticate, infer the deployed environment, or read a resource, it records a source diagnostic with the reason and next setup step instead of crashing the run.",
3511
+ "",
3512
+ "| Source | Label | What it reads |",
3513
+ "|---|---|---|",
3514
+ ]
3515
+ for source in source_order:
3516
+ lines.append(
3517
+ "| "
3518
+ + " | ".join(
3519
+ [
3520
+ f"`{source}`",
3521
+ source_labels.get(source, source),
3522
+ source_descriptions.get(source, "").replace("|", "\\|"),
3523
+ ]
3524
+ )
3525
+ + " |"
3526
+ )
3527
+
3528
+ lines.extend(
3529
+ [
3530
+ "",
3531
+ "## AZD AND DEPLOYED ENVIRONMENTS",
3532
+ "",
3533
+ "For ARM posture checks, Doctor first looks for the active AZD environment under `.azure/<env>/.env`. It uses `AZURE_ENV_NAME`, then `.azure/config.json` `defaultEnvironment`, then the only environment folder when there is exactly one. From that file it uses `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, and common AI account variables when present.",
3534
+ "",
3535
+ "If AZD metadata is not enough, Doctor uses the Foundry project endpoint to identify the backing Azure AI / Cognitive Services account. It scans the subscription for matching account endpoints and only proceeds automatically when the match is unambiguous. Explicit `sources.azure_resources` values in `.agentops/agent.yaml` still work and take precedence.",
3536
+ "",
3537
+ "For production telemetry, configure `sources.azure_monitor` with `app_insights_resource_id` or `log_analytics_workspace_id`. For Foundry control-plane checks, configure `AZURE_AI_FOUNDRY_PROJECT_ENDPOINT` or `sources.foundry_control.project_endpoint`.",
3538
+ "",
3539
+ "## HOW IT WORKS",
3540
+ "",
3541
+ "1. Resolve `.agentops/agent.yaml`.",
3542
+ "2. Run pre-flight checks unless `--no-preflight` is used.",
3543
+ "3. Collect enabled sources: local files, eval history, Foundry, Azure Monitor, ARM resources.",
3544
+ "4. Run Doctor checks and optional LLM-judged rules.",
3545
+ "5. Sort findings by severity and WAF-AI pillar.",
3546
+ "6. Write `.agentops/agent/report.md` or `--out <path>`.",
3547
+ "7. Append analysis history for cockpit / trend views.",
3548
+ "8. Exit with 0, 1, or 2.",
3549
+ "",
3550
+ "## CHECK CATEGORIES",
3551
+ "",
3552
+ "| Category | Purpose |",
3553
+ "|---|---|",
3554
+ "| `quality` | Eval metric regressions. |",
3555
+ "| `performance` | Eval and production latency. |",
3556
+ "| `reliability` | Error rates, failed runs, rate limits, missing telemetry. |",
3557
+ "| `operational_excellence` | CI hygiene, config drift, stale tasks, spec conformance. |",
3558
+ "| `security` | Azure AI account auth, identity, and diagnostics posture. |",
3559
+ "| `responsible_ai` | Content safety, prompt guardrails, dataset risk, continuous eval. |",
3560
+ "",
3561
+ "## FINDING QUALITY BAR",
3562
+ "",
3563
+ "Doctor findings are intentionally high-signal. The default catalog focuses on quality regressions, stale or flaky evaluations, production latency/errors, runtime safety signals, security posture, CI gates, and spec/config drift that changes what is actually evaluated.",
3564
+ "",
3565
+ "Process-only or backend-assumption checks stay out of the default finding list. For example, a reachable Foundry project with zero agents is source context, not a warning, because the agent may run on HTTP, Container Apps, AKS, or another runtime. Tiny specs, missing changelogs, and Copilot-instructions housekeeping are also not emitted as default findings.",
3566
+ "",
3567
+ "## CHECK CATALOG",
3568
+ "",
3569
+ "This is the catalog of findings Doctor can emit. Each entry shows the finding id, whether it is LLM-judged or source-based, the short purpose, required sources, and a public reference URL.",
3570
+ "",
3571
+ "**Legend:** `LLM Judge` calls the configured judge model (opt-in and may use tokens). `Source-based` does not call a judge model; it evaluates local files, eval history, Foundry, App Insights, or Azure resource metadata.",
3572
+ ]
3573
+ )
3574
+
3575
+ for category in category_order:
3576
+ checks = grouped_checks.get(category, [])
3577
+ if not checks:
3578
+ continue
3579
+ heading = category.value.replace("_", " ").title()
3580
+ lines.extend(["", f"### {heading}", ""])
3581
+ description = category_descriptions.get(category)
3582
+ if description:
3583
+ lines.extend([description, ""])
3584
+ for spec in checks:
3585
+ requires = ", ".join(
3586
+ source_labels.get(source, source) for source in spec.requires
3587
+ )
3588
+ severities = ", ".join(severity.value for severity in spec.severities)
3589
+ docs_url = reference_url_for(spec)
3590
+ mode = (
3591
+ "LLM Judge (opt-in; uses configured judge model)"
3592
+ if spec.is_llm_judged
3593
+ else "Source-based (no judge model call)"
3594
+ )
3595
+ lines.extend(
3596
+ [
3597
+ f"#### {'[LLM Judge] ' if spec.is_llm_judged else '[Source-based] '}`{spec.id}`",
3598
+ "",
3599
+ spec.title,
3600
+ "",
3601
+ spec.summary,
3602
+ "",
3603
+ f"**Mode:** {mode}",
3604
+ "",
3605
+ f"**Severity:** {severities}",
3606
+ ]
3607
+ )
3608
+ if requires:
3609
+ lines.append(f"**Requires:** {requires}")
3610
+ if docs_url:
3611
+ lines.append(f"**Learn more:** [{docs_url}]({docs_url})")
3612
+ lines.append("")
3613
+
3614
+ lines.extend(
3615
+ [
3616
+ "## EXIT CODES",
3617
+ "",
3618
+ "| Code | Meaning |",
3619
+ "|---|---|",
3620
+ "| `0` | Analyzer ran successfully and no finding met `--severity-fail`, or `--severity-fail none` disabled the finding gate. |",
3621
+ "| `1` | Runtime or configuration error. |",
3622
+ "| `2` | Analyzer ran successfully, but at least one finding met `--severity-fail`. |",
3623
+ "",
3624
+ "## EXAMPLES",
3625
+ "",
3626
+ "```text",
3627
+ "agentops doctor",
3628
+ "agentops doctor --categories security,responsible_ai",
3629
+ "agentops doctor --severity-fail warning",
3630
+ "agentops doctor --severity-fail none --evidence-pack",
3631
+ "agentops doctor explain --no-pager",
3632
+ "agentops doctor explain --format markdown --out doctor.md",
3633
+ "agentops doctor explain --open",
3634
+ "```",
3635
+ "",
3636
+ "## SEE ALSO",
3637
+ "",
3638
+ "- `docs/doctor-checks.md`",
3639
+ "- `docs/doctor-explained.md`",
3640
+ "- https://learn.microsoft.com/azure/well-architected/ai/",
3641
+ "",
3642
+ ]
3643
+ )
3644
+ return "\n".join(lines)
3645
+
3646
+
3647
+ def _build_explain_html(markdown: str, *, title: str) -> str:
3648
+ """Render the generated Markdown subset as self-contained printable HTML."""
3649
+ lines = markdown.splitlines()
3650
+ hero_title = _extract_markdown_title(lines) or title
3651
+ hero_subtitle = _extract_first_paragraph(lines)
3652
+ body: list[str] = []
3653
+ in_ul = False
3654
+ in_ol = False
3655
+ in_table = False
3656
+ in_code = False
3657
+ in_card = False
3658
+ code_lines: list[str] = []
3659
+ table_header = True
3660
+
3661
+ def close_lists() -> None:
3662
+ nonlocal in_ul, in_ol
3663
+ if in_ul:
3664
+ body.append("</ul>")
3665
+ in_ul = False
3666
+ if in_ol:
3667
+ body.append("</ol>")
3668
+ in_ol = False
3669
+
3670
+ def close_table() -> None:
3671
+ nonlocal in_table, table_header
3672
+ if in_table:
3673
+ body.append("</tbody></table>")
3674
+ in_table = False
3675
+ table_header = True
3676
+
3677
+ def close_card() -> None:
3678
+ nonlocal in_card
3679
+ if in_card:
3680
+ body.append("</article>")
3681
+ in_card = False
3682
+
3683
+ for line in lines:
3684
+ stripped = line.strip()
3685
+ if stripped.startswith("```"):
3686
+ close_lists()
3687
+ close_table()
3688
+ if in_code:
3689
+ body.append("<pre><code>" + html_escape("\n".join(code_lines)) + "</code></pre>")
3690
+ code_lines = []
3691
+ in_code = False
3692
+ else:
3693
+ in_code = True
3694
+ continue
3695
+ if in_code:
3696
+ code_lines.append(line)
3697
+ continue
3698
+ if not stripped:
3699
+ close_lists()
3700
+ close_table()
3701
+ continue
3702
+ if stripped.startswith("|") and stripped.endswith("|"):
3703
+ cells = [c.strip() for c in stripped.strip("|").split("|")]
3704
+ if all(set(c) <= {"-", ":"} for c in cells):
3705
+ continue
3706
+ close_lists()
3707
+ if not in_table:
3708
+ body.append("<table>")
3709
+ in_table = True
3710
+ table_header = True
3711
+ if table_header:
3712
+ body.append(
3713
+ "<thead><tr>"
3714
+ + "".join(f"<th>{_inline_markdown(cell)}</th>" for cell in cells)
3715
+ + "</tr></thead><tbody>"
3716
+ )
3717
+ table_header = False
3718
+ else:
3719
+ body.append(
3720
+ "<tr>"
3721
+ + "".join(f"<td>{_inline_markdown(cell)}</td>" for cell in cells)
3722
+ + "</tr>"
3723
+ )
3724
+ continue
3725
+ close_table()
3726
+ if stripped.startswith("#"):
3727
+ close_lists()
3728
+ level = min(len(stripped) - len(stripped.lstrip("#")), 4)
3729
+ heading_title = stripped[level:].strip()
3730
+ if level <= 3:
3731
+ close_card()
3732
+ elif level == 4:
3733
+ close_card()
3734
+ body.append('<article class="check-card">')
3735
+ in_card = True
3736
+ body.append(f"<h{level}>{_inline_markdown(heading_title)}</h{level}>")
3737
+ continue
3738
+ if stripped.startswith("- "):
3739
+ if in_ol:
3740
+ body.append("</ol>")
3741
+ in_ol = False
3742
+ if not in_ul:
3743
+ body.append("<ul>")
3744
+ in_ul = True
3745
+ body.append(f"<li>{_inline_markdown(stripped[2:])}</li>")
3746
+ continue
3747
+ if re.match(r"^\d+\. ", stripped):
3748
+ if in_ul:
3749
+ body.append("</ul>")
3750
+ in_ul = False
3751
+ if not in_ol:
3752
+ body.append("<ol>")
3753
+ in_ol = True
3754
+ body.append(f"<li>{_inline_markdown(stripped.split('. ', 1)[1])}</li>")
3755
+ continue
3756
+ close_lists()
3757
+ body.append(f"<p>{_inline_markdown(stripped)}</p>")
3758
+
3759
+ close_lists()
3760
+ close_table()
3761
+ close_card()
3762
+ if in_code:
3763
+ body.append("<pre><code>" + html_escape("\n".join(code_lines)) + "</code></pre>")
3764
+
3765
+ return """<!doctype html>
3766
+ <html lang="en">
3767
+ <head>
3768
+ <meta charset="utf-8">
3769
+ <title>""" + html_escape(title) + """</title>
3770
+ <style>
3771
+ :root { color-scheme: light; font-family: "Segoe UI", Arial, sans-serif; }
3772
+ body { margin: 0; background: #f6f8fa; color: #24292f; line-height: 1.55; }
3773
+ main { max-width: 1040px; margin: 32px auto; padding: 44px; background: #fff; border: 1px solid #d8dee4; border-radius: 16px; box-shadow: 0 14px 40px rgba(27, 31, 36, 0.045); }
3774
+ .hero { position: relative; overflow: hidden; margin: -12px -12px 34px; padding: 30px 34px 26px; color: #fff; border-radius: 18px; background: radial-gradient(circle at 8% 20%, rgba(87, 200, 255, 0.95), transparent 24%), radial-gradient(circle at 78% 22%, rgba(255, 43, 170, 0.82), transparent 23%), linear-gradient(120deg, #0a66c2 0%, #7c3aed 48%, #ff7a00 100%); box-shadow: 0 18px 45px rgba(9, 105, 218, 0.18); }
3775
+ .hero::after { content: ""; position: absolute; inset: auto -8% -48% 36%; height: 150px; background: rgba(255,255,255,0.16); filter: blur(22px); transform: rotate(-8deg); }
3776
+ .hero-kicker { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 0.45rem; margin-bottom: 0.45rem; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.11em; text-transform: uppercase; opacity: 0.9; }
3777
+ .hero-mark { display: inline-grid; place-items: center; width: 1.45rem; height: 1.45rem; border-radius: 0.45rem; background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.28); }
3778
+ .hero h1 { position: relative; z-index: 1; margin: 0; color: #fff; font-size: clamp(2rem, 4vw, 3.25rem); line-height: 1; letter-spacing: -0.055em; }
3779
+ .hero p { position: relative; z-index: 1; max-width: 760px; margin: 0.8rem 0 0; color: rgba(255,255,255,0.92); font-size: 1.03rem; }
3780
+ h1 { margin-top: 0; font-size: 2.1rem; letter-spacing: -0.03em; }
3781
+ h2 { border-bottom: 1px solid #d0d7de; padding-bottom: 0.45rem; margin-top: 2.4rem; font-size: 1.45rem; letter-spacing: -0.02em; }
3782
+ h3 { margin: 2.1rem 0 0.85rem; padding: 0.35rem 0 0.4rem; border-bottom: 1px solid #eaeef2; color: #1f2328; font-size: 1.18rem; }
3783
+ h4 { margin: 0 0 0.85rem; font-size: 1rem; }
3784
+ h4 code { display: inline-flex; align-items: center; background: #eef6ff; color: #0550ae; border: 1px solid #b6d7ff; border-radius: 999px; padding: 0.22rem 0.58rem; font-size: 0.84rem; font-weight: 650; box-shadow: none; }
3785
+ code { background: #f6f8fa; padding: 0.12rem 0.3rem; border-radius: 4px; }
3786
+ pre { background: #f6f8fa; padding: 1rem; overflow-x: auto; border-radius: 8px; }
3787
+ .check-card { margin: 1rem 0 1.25rem; padding: 0.95rem 1.05rem 0.9rem; border: 1px solid #d8e3f0; border-left: 3px solid #8cbeff; border-radius: 12px; background: linear-gradient(180deg, #ffffff, #fbfdff); box-shadow: 0 4px 14px rgba(31, 35, 40, 0.035); }
3788
+ .check-card p { margin: 0.62rem 0; }
3789
+ .check-card p:first-of-type { font-size: 1.03rem; font-weight: 600; color: #1f2328; }
3790
+ .check-card strong { color: #1f2328; }
3791
+ .check-card a { overflow-wrap: anywhere; }
3792
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
3793
+ th, td { border: 1px solid #d0d7de; padding: 0.55rem 0.7rem; vertical-align: top; }
3794
+ th { background: #f6f8fa; text-align: left; }
3795
+ @media print {
3796
+ body { background: #fff; }
3797
+ main { margin: 0; padding: 0; border: 0; max-width: none; }
3798
+ a { color: #24292f; }
3799
+ }
3800
+ </style>
3801
+ </head>
3802
+ <body>
3803
+ <main>
3804
+ <section class="hero">
3805
+ <div class="hero-kicker"><span class="hero-mark">A</span> AgentOps explain</div>
3806
+ <h1>""" + html_escape(hero_title) + """</h1>
3807
+ <p>""" + html_escape(hero_subtitle) + """</p>
3808
+ </section>
3809
+ """ + "\n".join(body) + """
3810
+ </main>
3811
+ </body>
3812
+ </html>
3813
+ """
3814
+
3815
+
3816
+ def _inline_markdown(text: str) -> str:
3817
+ text = html_escape(text)
3818
+ text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
3819
+ text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
3820
+ text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
3821
+ return text
3822
+
3823
+
3824
+ def _extract_markdown_title(lines: list[str]) -> str | None:
3825
+ for line in lines:
3826
+ stripped = line.strip()
3827
+ if stripped.startswith("# "):
3828
+ return stripped[2:].strip()
3829
+ return None
3830
+
3831
+
3832
+ def _extract_first_paragraph(lines: list[str]) -> str:
3833
+ after_h1 = False
3834
+ for line in lines:
3835
+ stripped = line.strip()
3836
+ if not stripped:
3837
+ continue
3838
+ if stripped.startswith("# "):
3839
+ after_h1 = True
3840
+ continue
3841
+ if not after_h1:
3842
+ continue
3843
+ if stripped.startswith("#") or stripped.startswith("```") or stripped.startswith("|"):
3844
+ continue
3845
+ if stripped.startswith("- ") or re.match(r"^\d+\. ", stripped):
3846
+ continue
3847
+ return re.sub(r"[`*_]", "", stripped)
3848
+ return "Detailed local command documentation, architecture, workflow, and examples."
3849
+
3850
+
3851
+ _ANSI_RESET = "\x1b[0m"
3852
+
3853
+ # Synthwave-inspired horizontal palette used by the AGENTOPS block banner.
3854
+ # Sweeps teal -> blue -> violet -> hot pink -> sunset orange to give the
3855
+ # 80s neon feel while keeping every stop high-contrast on a dark terminal.
3856
+ _AGENTOPS_GRADIENT = (
3857
+ (0, 245, 212),
3858
+ (18, 183, 255),
3859
+ (168, 85, 247),
3860
+ (236, 72, 153),
3861
+ (255, 159, 67),
3862
+ )
3863
+
3864
+ # Fixed-width (8 cols x 5 rows) block glyphs for "AGENTOPS".
3865
+ # Concatenated with a single-space separator the full banner is 71 cols wide,
3866
+ # which centers comfortably inside DOCTOR_EXPLAIN_WRAP_WIDTH (88).
3867
+ _AGENTOPS_GLYPHS: dict[str, tuple[str, ...]] = {
3868
+ "A": (
3869
+ " █████ ",
3870
+ "██ ██ ",
3871
+ "███████ ",
3872
+ "██ ██ ",
3873
+ "██ ██ ",
3874
+ ),
3875
+ "G": (
3876
+ " ██████ ",
3877
+ "██ ",
3878
+ "██ ███ ",
3879
+ "██ ██ ",
3880
+ " ██████ ",
3881
+ ),
3882
+ "E": (
3883
+ "███████ ",
3884
+ "██ ",
3885
+ "█████ ",
3886
+ "██ ",
3887
+ "███████ ",
3888
+ ),
3889
+ "N": (
3890
+ "███ ██",
3891
+ "████ ██",
3892
+ "██ █ ██",
3893
+ "██ █ ██",
3894
+ "██ ███",
3895
+ ),
3896
+ "T": (
3897
+ "████████",
3898
+ " ██ ",
3899
+ " ██ ",
3900
+ " ██ ",
3901
+ " ██ ",
3902
+ ),
3903
+ "O": (
3904
+ " ██████ ",
3905
+ "██ ██",
3906
+ "██ ██",
3907
+ "██ ██",
3908
+ " ██████ ",
3909
+ ),
3910
+ "P": (
3911
+ "███████ ",
3912
+ "██ ██ ",
3913
+ "███████ ",
3914
+ "██ ",
3915
+ "██ ",
3916
+ ),
3917
+ "S": (
3918
+ " ██████ ",
3919
+ "██ ",
3920
+ " █████ ",
3921
+ " ██ ",
3922
+ "██████ ",
3923
+ ),
3924
+ }
3925
+
3926
+ # ASCII (figlet "Standard") fallback for terminals without UTF-8 support.
3927
+ _AGENTOPS_PLAIN_BANNER: tuple[str, ...] = (
3928
+ " _ ____ _____ _ _ _____ ___ ____ ____ ",
3929
+ " / \\ / ___| ____| \\ | |_ _/ _ \\| _ \\/ ___| ",
3930
+ " / _ \\| | _| _| | \\| | | || | | | |_) \\___ \\ ",
3931
+ " / ___ \\ |_| | |___| |\\ | | || |_| | __/ ___) |",
3932
+ "/_/ \\_\\____|_____|_| \\_| |_| \\___/|_| |____/ ",
3933
+ )
3934
+
3935
+
3936
+ def _terminal_unicode_enabled() -> bool:
3937
+ if os.environ.get("AGENTOPS_NO_UNICODE_BANNER"):
3938
+ return False
3939
+ if os.environ.get("AGENTOPS_UNICODE_BANNER"):
3940
+ return True
3941
+ try:
3942
+ encoding = (sys.stdout.encoding or "").lower()
3943
+ except Exception: # noqa: BLE001
3944
+ return False
3945
+ if not ("utf" in encoding or "65001" in encoding):
3946
+ return False
3947
+ # On Windows, sys.stdout.encoding may report utf-8 (e.g. when
3948
+ # PYTHONIOENCODING is set) while the underlying console host is
3949
+ # still on a legacy OEM code page such as cp1252 or cp850. In that
3950
+ # case the bytes for `█` and `━` are decoded as multiple Latin
3951
+ # glyphs and the banner renders as garbled mojibake. Detect the
3952
+ # real console output code page and only enable the unicode banner
3953
+ # when it is set to 65001 (UTF-8).
3954
+ if sys.platform == "win32":
3955
+ try:
3956
+ import ctypes # noqa: PLC0415
3957
+
3958
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
3959
+ if kernel32.GetConsoleOutputCP() != 65001:
3960
+ return False
3961
+ except Exception: # noqa: BLE001
3962
+ return False
3963
+ return True
3964
+
3965
+
3966
+ def _terminal_color_enabled() -> bool:
3967
+ if os.environ.get("NO_COLOR") or os.environ.get("AGENTOPS_NO_COLOR"):
3968
+ return False
3969
+ if (
3970
+ os.environ.get("WT_SESSION")
3971
+ or os.environ.get("TERM_PROGRAM")
3972
+ or os.environ.get("ANSICON")
3973
+ or os.environ.get("ConEmuANSI", "").upper() == "ON"
3974
+ ):
3975
+ return True
3976
+ try:
3977
+ return bool(sys.stdout.isatty())
3978
+ except Exception: # noqa: BLE001
3979
+ return False
3980
+
3981
+
3982
+ def _useful_pager_available() -> bool:
3983
+ """Return True when launching the pager produces a good experience.
3984
+
3985
+ The Windows default pager (``more.com``) paginates one screen at a time
3986
+ and prompts the user on every page, which is hostile for long manuals.
3987
+ We only opt into the pager when there is a "real" pager available
3988
+ (``less``, ``bat``, ...) selected explicitly via ``MANPAGER``/``PAGER``
3989
+ environment variables, or when running on a non-Windows OS where the
3990
+ default pager is typically ``less``.
3991
+ """
3992
+
3993
+ pager = os.environ.get("MANPAGER") or os.environ.get("PAGER")
3994
+ if pager:
3995
+ executable = pager.strip().split()[0] if pager.strip() else ""
3996
+ name = os.path.basename(executable).lower()
3997
+ if name in {"more", "more.com", "more.exe", ""}:
3998
+ return False
3999
+ return True
4000
+ if os.name == "nt":
4001
+ return False
4002
+ return True
4003
+
4004
+
4005
+ def _enable_windows_vt_mode() -> None:
4006
+ """Enable Virtual Terminal Processing on Windows stdout if available.
4007
+
4008
+ Modern Windows 10/11 conhost and Windows Terminal interpret ANSI VT
4009
+ escapes natively, but the flag is off by default on legacy consoles.
4010
+ Without VT mode, raw ANSI bytes would be printed as ``ESC[...m`` text.
4011
+ Failures are silently ignored — the helper is best-effort.
4012
+ """
4013
+
4014
+ if os.name != "nt":
4015
+ return
4016
+ try:
4017
+ import ctypes
4018
+
4019
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
4020
+ STD_OUTPUT_HANDLE = -11
4021
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
4022
+ handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
4023
+ if handle in (0, -1, None):
4024
+ return
4025
+ current = ctypes.c_ulong()
4026
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(current)):
4027
+ return
4028
+ kernel32.SetConsoleMode(
4029
+ handle, current.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
4030
+ )
4031
+ except Exception: # noqa: BLE001
4032
+ return
4033
+
4034
+
4035
+ def _emit_manual_to_terminal(output: str) -> None:
4036
+ """Write a manual page directly to stdout, preserving raw ANSI bytes.
4037
+
4038
+ ``click.echo`` and ``typer.echo`` on Windows wrap stdout with a
4039
+ colorama-compatible filter that does not understand 24-bit truecolor
4040
+ escapes (``ESC[38;2;R;G;Bm``); the filter splits the sequence and
4041
+ leaks the RGB digits as stray SGR codes, painting random background
4042
+ blocks onto the banner. Writing the bytes straight to the underlying
4043
+ binary buffer bypasses that filter and lets the terminal (Windows
4044
+ Terminal / VT-enabled conhost) render the escapes correctly.
4045
+ """
4046
+
4047
+ _enable_windows_vt_mode()
4048
+ payload = output if output.endswith("\n") else output + "\n"
4049
+ buffer = getattr(sys.stdout, "buffer", None)
4050
+ if buffer is None:
4051
+ try:
4052
+ sys.stdout.write(payload)
4053
+ sys.stdout.flush()
4054
+ except Exception: # noqa: BLE001
4055
+ typer.echo(output, color=True)
4056
+ return
4057
+ try:
4058
+ buffer.write(payload.encode("utf-8", errors="replace"))
4059
+ buffer.flush()
4060
+ except Exception: # noqa: BLE001
4061
+ typer.echo(output, color=True)
4062
+
4063
+
4064
+ def _read_single_key() -> str:
4065
+ """Read one keypress from stdin without requiring Enter.
4066
+
4067
+ Returns the character pressed, or an empty string on extended/unknown
4068
+ keys (arrow keys, function keys) and on any I/O error. Used by the
4069
+ built-in pager to advance pages on SPACE and quit on ``q``/Esc.
4070
+ """
4071
+
4072
+ if os.name == "nt":
4073
+ try:
4074
+ import msvcrt # type: ignore[import-not-found]
4075
+
4076
+ ch_win = msvcrt.getch() # type: ignore[attr-defined]
4077
+ if ch_win in (b"\xe0", b"\x00"):
4078
+ try:
4079
+ msvcrt.getch() # type: ignore[attr-defined]
4080
+ except Exception: # noqa: BLE001
4081
+ pass
4082
+ return ""
4083
+ try:
4084
+ return ch_win.decode("utf-8", errors="ignore")
4085
+ except Exception: # noqa: BLE001
4086
+ return ""
4087
+ except Exception: # noqa: BLE001
4088
+ return ""
4089
+ try:
4090
+ import termios # type: ignore[import-not-found]
4091
+ import tty # type: ignore[import-not-found]
4092
+
4093
+ fd = sys.stdin.fileno()
4094
+ old = termios.tcgetattr(fd) # type: ignore[attr-defined]
4095
+ try:
4096
+ tty.setraw(fd) # type: ignore[attr-defined]
4097
+ ch_posix = sys.stdin.read(1)
4098
+ finally:
4099
+ termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
4100
+ return ch_posix
4101
+ except Exception: # noqa: BLE001
4102
+ return ""
4103
+
4104
+
4105
+ def _emit_manual_with_internal_pager(output: str) -> None:
4106
+ """Paginate the manual one screenful at a time, preserving ANSI escapes.
4107
+
4108
+ The Windows default pager (``more.com``) prompts on every line and the
4109
+ Click/colorama text wrapper mangles 24-bit truecolor escapes. This
4110
+ function rolls a tiny internal pager that:
4111
+
4112
+ * measures the terminal height via :func:`shutil.get_terminal_size`
4113
+ * writes ``rows - 2`` lines per page as raw UTF-8 bytes to
4114
+ ``sys.stdout.buffer`` (so truecolor survives intact)
4115
+ * draws a ``-- More -- (NN%) -- SPACE next / q quit`` status line
4116
+ * waits for a single keypress (no Enter required) via
4117
+ :func:`_read_single_key`
4118
+ * stops on ``q``, ``Q``, ``Esc``, ``Ctrl-C``, or ``Ctrl-D``
4119
+
4120
+ When stdin is not a TTY (CI, piped invocation, test runner) we skip
4121
+ pagination entirely and dump the manual via
4122
+ :func:`_emit_manual_to_terminal`.
4123
+ """
4124
+
4125
+ try:
4126
+ stdin_is_tty = bool(sys.stdin.isatty())
4127
+ except Exception: # noqa: BLE001
4128
+ stdin_is_tty = False
4129
+ if not stdin_is_tty:
4130
+ _emit_manual_to_terminal(output)
4131
+ return
4132
+
4133
+ import shutil
4134
+
4135
+ columns, rows = shutil.get_terminal_size(fallback=(80, 24))
4136
+ page_size = max(rows - 2, 5)
4137
+ lines = output.split("\n")
4138
+ if len(lines) <= page_size:
4139
+ _emit_manual_to_terminal(output)
4140
+ return
4141
+
4142
+ _enable_windows_vt_mode()
4143
+ buffer = getattr(sys.stdout, "buffer", None)
4144
+
4145
+ def write_raw(text: str) -> None:
4146
+ if buffer is not None:
4147
+ try:
4148
+ buffer.write(text.encode("utf-8", errors="replace"))
4149
+ buffer.flush()
4150
+ return
4151
+ except Exception: # noqa: BLE001
4152
+ pass
4153
+ try:
4154
+ sys.stdout.write(text)
4155
+ sys.stdout.flush()
4156
+ except Exception: # noqa: BLE001
4157
+ pass
4158
+
4159
+ total = len(lines)
4160
+ idx = 0
4161
+ quit_keys = {"q", "Q", "\x03", "\x04", "\x1b"}
4162
+ try:
4163
+ while idx < total:
4164
+ end = min(idx + page_size, total)
4165
+ page = "\n".join(lines[idx:end])
4166
+ write_raw(page + "\n")
4167
+ idx = end
4168
+ if idx >= total:
4169
+ break
4170
+ percent = int(idx * 100 / total)
4171
+ prompt_text = (
4172
+ f" -- More -- ({percent}%) -- SPACE next page, q to quit "
4173
+ )
4174
+ write_raw(f"\x1b[7m{prompt_text}\x1b[0m")
4175
+ key = _read_single_key()
4176
+ write_raw("\r\x1b[2K")
4177
+ if key in quit_keys:
4178
+ break
4179
+ except KeyboardInterrupt:
4180
+ write_raw("\r\x1b[2K")
4181
+ return
4182
+
4183
+
4184
+ def _mix_palette(
4185
+ palette: tuple[tuple[int, int, int], ...],
4186
+ position: float,
4187
+ ) -> tuple[int, int, int]:
4188
+ """Linear-interpolate a palette stop for a normalised position in [0, 1]."""
4189
+
4190
+ if position <= 0:
4191
+ return palette[0]
4192
+ if position >= 1:
4193
+ return palette[-1]
4194
+ palette_position = position * (len(palette) - 1)
4195
+ left = int(palette_position)
4196
+ right = min(left + 1, len(palette) - 1)
4197
+ mix = palette_position - left
4198
+ return (
4199
+ round(palette[left][0] * (1 - mix) + palette[right][0] * mix),
4200
+ round(palette[left][1] * (1 - mix) + palette[right][1] * mix),
4201
+ round(palette[left][2] * (1 - mix) + palette[right][2] * mix),
4202
+ )
4203
+
4204
+
4205
+ def _gradient_text(text: str, palette: tuple[tuple[int, int, int], ...]) -> str:
4206
+ visible_chars = [char for char in text if char != " "]
4207
+ if not visible_chars:
4208
+ return text
4209
+ colored: list[str] = []
4210
+ color_index = 0
4211
+ denominator = max(1, len(visible_chars) - 1)
4212
+ for char in text:
4213
+ if char == " ":
4214
+ colored.append(char)
4215
+ continue
4216
+ rgb = _mix_palette(palette, color_index / denominator)
4217
+ colored.append(f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m{char}")
4218
+ color_index += 1
4219
+ colored.append(_ANSI_RESET)
4220
+ return "".join(colored)
4221
+
4222
+
4223
+ def _gradient_bar(width: int, palette: tuple[tuple[int, int, int], ...]) -> str:
4224
+ if not _terminal_color_enabled():
4225
+ return "=" * width
4226
+ chunks: list[str] = []
4227
+ denominator = max(1, width - 1)
4228
+ for index in range(width):
4229
+ rgb = _mix_palette(palette, index / denominator)
4230
+ chunks.append(f"\x1b[48;2;{rgb[0]};{rgb[1]};{rgb[2]}m ")
4231
+ chunks.append(_ANSI_RESET)
4232
+ return "".join(chunks)
4233
+
4234
+
4235
+ def _gradient_rule(width: int, palette: tuple[tuple[int, int, int], ...]) -> str:
4236
+ visible_width = min(width, 56)
4237
+ rule_char = "━" if _terminal_unicode_enabled() else "-"
4238
+ if not _terminal_color_enabled():
4239
+ return _center_text(rule_char * visible_width, width)
4240
+ denominator = max(1, visible_width - 1)
4241
+ chunks: list[str] = []
4242
+ last_color: tuple[int, int, int] | None = None
4243
+ for index in range(visible_width):
4244
+ rgb = _mix_palette(palette, index / denominator)
4245
+ if rgb != last_color:
4246
+ chunks.append(f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m")
4247
+ last_color = rgb
4248
+ chunks.append(rule_char)
4249
+ chunks.append(_ANSI_RESET)
4250
+ return _center_text("".join(chunks), width)
4251
+
4252
+
4253
+ def _center_text(text: str, width: int) -> str:
4254
+ visible = re.sub(r"\x1b\[[0-9;]*m", "", text)
4255
+ if len(visible) >= width:
4256
+ return text
4257
+ left = (width - len(visible)) // 2
4258
+ return " " * left + text
4259
+
4260
+
4261
+ def _agentops_block_rows() -> list[str]:
4262
+ """Render the 8-letter AGENTOPS block banner as 5 uncolored text rows."""
4263
+
4264
+ word = "AGENTOPS"
4265
+ rows = ["", "", "", "", ""]
4266
+ for index, letter in enumerate(word):
4267
+ glyph = _AGENTOPS_GLYPHS[letter]
4268
+ for row_index in range(5):
4269
+ rows[row_index] += glyph[row_index]
4270
+ if index < len(word) - 1:
4271
+ rows[row_index] += " "
4272
+ return rows
4273
+
4274
+
4275
+ def _colorize_block(
4276
+ rows: list[str],
4277
+ palette: tuple[tuple[int, int, int], ...],
4278
+ ) -> list[str]:
4279
+ """Apply a horizontal gradient across a multi-line block of glyphs.
4280
+
4281
+ Each non-space column gets a color picked from its X position so every row
4282
+ shares the same horizontal sweep, producing a continuous neon wash from
4283
+ left to right.
4284
+ """
4285
+
4286
+ width = max((len(row) for row in rows), default=0)
4287
+ denominator = max(1, width - 1)
4288
+ colored: list[str] = []
4289
+ for row in rows:
4290
+ chunks: list[str] = []
4291
+ current_color: tuple[int, int, int] | None = None
4292
+ for col, char in enumerate(row):
4293
+ if char == " ":
4294
+ if current_color is not None:
4295
+ chunks.append(_ANSI_RESET)
4296
+ current_color = None
4297
+ chunks.append(char)
4298
+ continue
4299
+ rgb = _mix_palette(palette, col / denominator)
4300
+ if rgb != current_color:
4301
+ chunks.append(f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m")
4302
+ current_color = rgb
4303
+ chunks.append(char)
4304
+ if current_color is not None:
4305
+ chunks.append(_ANSI_RESET)
4306
+ colored.append("".join(chunks))
4307
+ return colored
4308
+
4309
+
4310
+ # Single source of truth for the AgentOps brand tagline. Used by the
4311
+ # explain pages and by the `agentops init` startup banner so the slogan
4312
+ # never drifts. The unicode variant uses fancy separators; the ASCII
4313
+ # fallback keeps the same words on a single line.
4314
+ def _agentops_tagline() -> str:
4315
+ if _terminal_unicode_enabled():
4316
+ return "Evaluate · Observe · Diagnose · Ship — every Foundry agent."
4317
+ return "Evaluate :: Observe :: Diagnose :: Ship -- every Foundry agent."
4318
+
4319
+
4320
+ def _render_brand_block(
4321
+ *,
4322
+ width: int = DOCTOR_EXPLAIN_WRAP_WIDTH,
4323
+ eyebrow: str | None = None,
4324
+ ) -> list[str]:
4325
+ """Render the AgentOps banner (block art + tagline) as a list of lines.
4326
+
4327
+ Shared by ``agentops init`` (startup banner, no eyebrow) and every
4328
+ ``agentops explain`` page (with a centred breadcrumb eyebrow).
4329
+ """
4330
+ color_on = _terminal_color_enabled()
4331
+ unicode_on = _terminal_unicode_enabled()
4332
+
4333
+ if unicode_on:
4334
+ block_rows = _agentops_block_rows()
4335
+ else:
4336
+ block_rows = list(_AGENTOPS_PLAIN_BANNER)
4337
+
4338
+ if color_on:
4339
+ block_rows = _colorize_block(block_rows, _AGENTOPS_GRADIENT)
4340
+
4341
+ centered_block = [_center_text(row, width) for row in block_rows]
4342
+
4343
+ tagline = _agentops_tagline()
4344
+ if color_on:
4345
+ tagline_line = _center_text(
4346
+ _gradient_text(tagline, _AGENTOPS_GRADIENT), width
4347
+ )
4348
+ else:
4349
+ tagline_line = _center_text(tagline, width)
4350
+
4351
+ rule_line = _gradient_rule(width, _AGENTOPS_GRADIENT)
4352
+
4353
+ lines: list[str] = [""]
4354
+ lines.extend(centered_block)
4355
+ lines.append("")
4356
+ lines.append(tagline_line)
4357
+ lines.append(rule_line)
4358
+ if eyebrow:
4359
+ lines.append("")
4360
+ lines.append(_center_text(eyebrow, width))
4361
+ lines.append("")
4362
+ return lines
4363
+
4364
+
4365
+ def _manual_banner(title: str, subtitle: str) -> list[str]: # noqa: ARG001
4366
+ """Render the AgentOps explain banner (block art + breadcrumb).
4367
+
4368
+ ``subtitle`` is accepted for backward compatibility but no longer
4369
+ rendered: explain pages now derive the one-line summary from the
4370
+ NAME section to avoid repeating the same sentence in three places
4371
+ (banner subtitle, NAME, and the first paragraph of DESCRIPTION).
4372
+ """
4373
+ if _terminal_color_enabled():
4374
+ eyebrow = (
4375
+ style("AGENTOPS EXPLAIN", "bold", "magenta")
4376
+ + style(" / detailed command guide", "cyan")
4377
+ + style(" / ", "dim")
4378
+ + style(title, "bold")
4379
+ )
4380
+ else:
4381
+ eyebrow = f"AGENTOPS EXPLAIN / detailed command guide / {title}"
4382
+
4383
+ return _render_brand_block(eyebrow=eyebrow)
4384
+
4385
+
4386
+ def render_init_banner() -> str:
4387
+ """Return the full multi-line startup banner used by ``agentops init``.
4388
+
4389
+ Centered AGENTOPS block art on the brand gradient, followed by the
4390
+ one-line tagline and the gradient rule. No breadcrumb eyebrow.
4391
+ """
4392
+ return "\n".join(_render_brand_block())
4393
+
4394
+
4395
+ def _emit_init_banner() -> None:
4396
+ """Write the ``agentops init`` startup banner to the terminal.
4397
+
4398
+ When colors are enabled, the banner is emitted via
4399
+ :func:`_emit_manual_to_terminal`, which writes raw UTF-8 bytes to
4400
+ ``sys.stdout.buffer`` and enables Windows VT processing. That
4401
+ bypasses ``click._winconsole.ConsoleStream`` (the TTY path Click 8
4402
+ uses on Windows), which is what mangles the 24-bit gradient into
4403
+ the colored-block rendering observed otherwise. When colors are
4404
+ disabled (``NO_COLOR``/``AGENTOPS_NO_COLOR``, no TTY, CI capture,
4405
+ or :class:`click.testing.CliRunner` in tests) we fall back to
4406
+ :func:`typer.echo` so existing assertions on captured output stay
4407
+ stable.
4408
+ """
4409
+
4410
+ banner = render_init_banner()
4411
+ if _terminal_color_enabled():
4412
+ _emit_manual_to_terminal(banner)
4413
+ else:
4414
+ typer.echo(banner)
4415
+
4416
+
4417
+ def _manual_section(lines: list[str], title: str) -> None:
4418
+ if lines:
4419
+ lines.append("")
4420
+ lines.append(style(title, "bold", "cyan"))
4421
+ lines.append(style("-" * len(title), "dim"))
4422
+
4423
+
4424
+ def _manual_command_rows(rows: list[tuple[str, str]]) -> list[str]:
4425
+ if not rows:
4426
+ return []
4427
+ label_width = min(max(len(label) for label, _ in rows), 28)
4428
+ header = f" {'COMMAND'.ljust(label_width)} WHAT IT DOES"
4429
+ lines: list[str] = [style(header, "bold"), f" {'-' * label_width} {'-' * 42}"]
4430
+ for label, description in rows:
4431
+ if not description:
4432
+ lines.append(f" {style(label, 'bold', 'cyan')}")
4433
+ continue
4434
+ first_indent = f" {label.ljust(label_width)} "
4435
+ next_indent = " " * len(first_indent)
4436
+ wrapped = _wrap_hanging(
4437
+ description,
4438
+ first_indent=first_indent,
4439
+ next_indent=next_indent,
4440
+ )
4441
+ lines.extend(
4442
+ style(line[: 2 + label_width], "bold", "cyan") + line[2 + label_width :]
4443
+ if line.startswith(" ") and line.strip().startswith(label)
4444
+ else line
4445
+ for line in wrapped
4446
+ )
4447
+ lines.append("")
4448
+ if lines[-1] == "":
4449
+ lines.pop()
4450
+ return lines
4451
+
4452
+
4453
+ def _manual_item_lines(marker: str, text: str, *, indent: str = " ") -> list[str]:
4454
+ first_indent = f"{indent}{marker}"
4455
+ next_indent = " " * len(first_indent)
4456
+ return _wrap_hanging(text, first_indent=first_indent, next_indent=next_indent)
4457
+
4458
+
4459
+ def _manual_paragraphs(*paragraphs: str, indent: str = " ") -> list[str]:
4460
+ lines: list[str] = []
4461
+ for index, paragraph in enumerate(paragraphs):
4462
+ if index:
4463
+ lines.append("")
4464
+ lines.append(_wrap_text(paragraph, indent=indent))
4465
+ return lines
4466
+
4467
+
4468
+ def _emit_name_line(lines: list[str], command: str, tagline: str) -> None:
4469
+ """Render the NAME line with the same wrap width as DESCRIPTION.
4470
+
4471
+ The styled command and em-dash are injected into the first wrapped
4472
+ line after the fact so that ANSI escape sequences don't disturb the
4473
+ column measurement performed by :func:`textwrap.wrap`.
4474
+ """
4475
+
4476
+ em_dash = "\u2014"
4477
+ plain_prefix = f"{command} {em_dash} "
4478
+ plain_text = f"{plain_prefix}{tagline}"
4479
+ wrapped = wrap(
4480
+ plain_text,
4481
+ width=DOCTOR_EXPLAIN_WRAP_WIDTH,
4482
+ initial_indent=" ",
4483
+ subsequent_indent=" ",
4484
+ break_long_words=False,
4485
+ break_on_hyphens=False,
4486
+ ) or [f" {plain_text}"]
4487
+
4488
+ full_plain_prefix = f" {plain_prefix}"
4489
+ styled_prefix = (
4490
+ f" {style(command, 'bold', 'cyan')} {style(em_dash, 'dim')} "
4491
+ )
4492
+ first = wrapped[0]
4493
+ if first.startswith(full_plain_prefix):
4494
+ first = styled_prefix + first[len(full_plain_prefix):]
4495
+ lines.append(first)
4496
+ lines.extend(wrapped[1:])
4497
+
4498
+
4499
+ def _wrap_hanging(
4500
+ text: str,
4501
+ *,
4502
+ first_indent: str,
4503
+ next_indent: str,
4504
+ width: int = DOCTOR_EXPLAIN_WRAP_WIDTH,
4505
+ ) -> list[str]:
4506
+ effective_width = max(40, width - len(first_indent))
4507
+ wrapped = wrap(
4508
+ text,
4509
+ width=effective_width,
4510
+ break_long_words=False,
4511
+ break_on_hyphens=False,
4512
+ ) or [""]
4513
+ return [
4514
+ f"{first_indent}{line}" if index == 0 else f"{next_indent}{line}"
4515
+ for index, line in enumerate(wrapped)
4516
+ ]
4517
+
4518
+
4519
+ def _wrap_text(
4520
+ text: str,
4521
+ *,
4522
+ indent: str,
4523
+ width: int = DOCTOR_EXPLAIN_WRAP_WIDTH,
4524
+ ) -> str:
4525
+ """Wrap CLI prose with a stable hanging indent."""
4526
+ effective_width = max(40, width - len(indent))
4527
+ return "\n".join(
4528
+ f"{indent}{line}"
4529
+ for line in wrap(
4530
+ text,
4531
+ width=effective_width,
4532
+ break_long_words=False,
4533
+ break_on_hyphens=False,
4534
+ )
4535
+ )
4536
+
4537
+ def _sources_enabled(config) -> list:
4538
+ """Return the list of source names that were enabled in agent.yaml."""
4539
+ enabled: list = []
4540
+ sources = getattr(config, "sources", None)
4541
+ if sources is None:
4542
+ return enabled
4543
+ for name in ("results_history", "azure_monitor", "foundry_control", "azure_resources"):
4544
+ source = getattr(sources, name, None)
4545
+ if source is None:
4546
+ continue
4547
+ if getattr(source, "enabled", True):
4548
+ enabled.append(name)
4549
+ return enabled
4550
+
4551
+
4552
+ @agent_app.command("serve")
4553
+ def cmd_agent_serve(
4554
+ host: Annotated[
4555
+ str, typer.Option("--host", help="Bind host.")
4556
+ ] = "0.0.0.0",
4557
+ port: Annotated[
4558
+ int, typer.Option("--port", help="Bind port.")
4559
+ ] = 8080,
4560
+ workspace: Annotated[
4561
+ Path,
4562
+ typer.Option("--workspace", "-w", help="Project root for analysis."),
4563
+ ] = Path("."),
4564
+ config_path: Annotated[
4565
+ Path | None,
4566
+ typer.Option(
4567
+ "--config",
4568
+ "-c",
4569
+ help="Path to `agent.yaml` (default: `.agentops/agent.yaml`).",
4570
+ ),
4571
+ ] = None,
4572
+ no_verify: Annotated[
4573
+ bool,
4574
+ typer.Option(
4575
+ "--no-verify",
4576
+ help="Skip Copilot Extensions signature validation (dev only).",
4577
+ ),
4578
+ ] = False,
4579
+ workers: Annotated[
4580
+ int, typer.Option("--workers", help="Uvicorn worker count.")
4581
+ ] = 1,
4582
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
4583
+ ) -> None:
4584
+ """Start the AgentOps doctor as a Copilot Extension HTTP server.
4585
+
4586
+ Exposes ``POST /agents/messages`` (Copilot Extensions protocol),
4587
+ ``GET /healthz`` and ``GET /``. Requires the ``[agent]`` extra:
4588
+
4589
+ pip install agentops-accelerator[agent]
4590
+ """
4591
+ if _maybe_explain_leaf(("agent", "serve"), explain):
4592
+ return
4593
+
4594
+ try:
4595
+ import uvicorn
4596
+ except ImportError as exc:
4597
+ typer.echo(
4598
+ f"{_cli_error('Error')}: agent extras not installed. "
4599
+ "Run `pip install agentops-accelerator[agent]`.",
4600
+ err=True,
4601
+ )
4602
+ raise typer.Exit(code=1) from exc
4603
+
4604
+ from agentops.agent.config import load_agent_config
4605
+ from agentops.agent.server.app import create_app
4606
+
4607
+ workspace = workspace.resolve()
4608
+ resolved_config = _resolve_agent_config_path(workspace, config_path)
4609
+
4610
+ try:
4611
+ config = load_agent_config(resolved_config)
4612
+ except Exception as exc:
4613
+ typer.echo(f"{_cli_error('Error loading agent config')}: {exc}", err=True)
4614
+ raise typer.Exit(code=1) from exc
4615
+
4616
+ fastapi_app = create_app(
4617
+ workspace=workspace,
4618
+ config=config,
4619
+ verify_signature=not no_verify,
4620
+ )
4621
+
4622
+ if no_verify:
4623
+ typer.echo(
4624
+ f"{_cli_warn('WARNING')}: Copilot Extensions signature validation is disabled. "
4625
+ "Use only for local development."
4626
+ )
4627
+
4628
+ uvicorn.run(fastapi_app, host=host, port=port, workers=workers)
4629
+
4630
+
4631
+ @app.command("cockpit")
4632
+ def cmd_cockpit(
4633
+ host: Annotated[
4634
+ str, typer.Option("--host", help="Bind host (default: 127.0.0.1).")
4635
+ ] = "127.0.0.1",
4636
+ port: Annotated[
4637
+ int, typer.Option("--port", help="Bind port (default: 8090).")
4638
+ ] = 8090,
4639
+ workspace: Annotated[
4640
+ Path,
4641
+ typer.Option(
4642
+ "--workspace",
4643
+ "-w",
4644
+ help="Project root containing `.agentops/agent/history.jsonl`.",
4645
+ ),
4646
+ ] = Path("."),
4647
+ no_preflight: Annotated[
4648
+ bool,
4649
+ typer.Option(
4650
+ "--no-preflight",
4651
+ help="Skip the pre-flight connectivity checks.",
4652
+ ),
4653
+ ] = False,
4654
+ explain: Annotated[str | None, typer.Argument(hidden=True)] = None,
4655
+ ) -> None:
4656
+ """Open the local AgentOps cockpit."""
4657
+ if _maybe_explain_leaf(("cockpit",), explain):
4658
+ return
4659
+
4660
+ try:
4661
+ import uvicorn
4662
+ except ImportError as exc:
4663
+ typer.echo(
4664
+ f"{_cli_error('Error')}: cockpit requires the [agent] extra. "
4665
+ "Run `pip install agentops-accelerator[agent]`.",
4666
+ err=True,
4667
+ )
4668
+ raise typer.Exit(code=1) from exc
4669
+
4670
+ import threading
4671
+ import time as _time
4672
+ import webbrowser
4673
+
4674
+ from agentops.agent.cockpit import create_app as create_cockpit_app
4675
+ from agentops.services.preflight import format_report, run_preflight
4676
+
4677
+ workspace = workspace.resolve()
4678
+
4679
+ if not no_preflight:
4680
+ report = run_preflight(workspace, scope="cockpit")
4681
+ typer.echo(format_report(report), err=True)
4682
+ if report.has_failures:
4683
+ typer.echo(
4684
+ f"{_cli_error('Pre-flight failed')}. Resolve the issues above or re-run "
4685
+ "with `--no-preflight` to bypass.",
4686
+ err=True,
4687
+ )
4688
+ raise typer.Exit(code=1)
4689
+
4690
+ fastapi_app = create_cockpit_app(workspace=workspace)
4691
+ url = f"http://{host}:{port}"
4692
+
4693
+ # Friendly port-conflict handling. Without this the user gets a raw
4694
+ # uvicorn `[Errno 10048] only one usage of each socket address
4695
+ # normally permitted` traceback when they accidentally run
4696
+ # `agentops cockpit` twice. Probe the port: if an AgentOps
4697
+ # cockpit is already serving on it, just open the browser and
4698
+ # exit cleanly; otherwise tell the user how to pick a different port.
4699
+ if _port_in_use(host, port):
4700
+ if _existing_agentops_cockpit(host, port):
4701
+ typer.echo(
4702
+ f"{_cli_warn('AgentOps cockpit is already running')} on {_cli_path(url)} - "
4703
+ "opening browser. Stop the existing cockpit "
4704
+ "(Ctrl+C in its terminal) before starting a new one.",
4705
+ err=True,
4706
+ )
4707
+ try:
4708
+ webbrowser.open(url)
4709
+ except Exception: # noqa: BLE001 - best effort
4710
+ pass
4711
+ raise typer.Exit(code=0)
4712
+ typer.echo(
4713
+ f"{_cli_error('Port')} {port} is already in use by another process. "
4714
+ f"Pick a different port with `agentops cockpit --port <N>`.",
4715
+ err=True,
4716
+ )
4717
+ raise typer.Exit(code=1)
4718
+
4719
+ typer.echo(f"{_cli_heading('AgentOps cockpit')} → {_cli_path(url)}")
4720
+ connection_rows: list[tuple[str, str]] = [("workspace", str(workspace))]
4721
+ connection_rows.extend(_summarize_cockpit_connection(workspace))
4722
+ label_width = max(len(label) for label, _ in connection_rows)
4723
+ for label, value in connection_rows:
4724
+ padding = " " * (label_width - len(label))
4725
+ typer.echo(f"{_cli_label(label)}:{padding} {value}")
4726
+ typer.echo(
4727
+ f"Run {_cli_command('agentops doctor')} in another terminal to populate doctor findings."
4728
+ )
4729
+ typer.echo("")
4730
+ typer.echo(style("Press Enter (or Ctrl+C) to stop the cockpit.", "dim"))
4731
+
4732
+ # Silence uvicorn's own error logger so the friendly bind-failure
4733
+ # message below is not preceded by a red traceback line. The
4734
+ # access / info loggers stay at "warning" so legitimate startup
4735
+ # warnings still surface.
4736
+ log_config = {
4737
+ "version": 1,
4738
+ "disable_existing_loggers": False,
4739
+ "loggers": {
4740
+ "uvicorn": {"level": "CRITICAL", "handlers": []},
4741
+ "uvicorn.error": {"level": "CRITICAL", "handlers": []},
4742
+ "uvicorn.access": {"level": "CRITICAL", "handlers": []},
4743
+ },
4744
+ }
4745
+ config = uvicorn.Config(
4746
+ fastapi_app,
4747
+ host=host,
4748
+ port=port,
4749
+ log_level="warning",
4750
+ log_config=log_config,
4751
+ )
4752
+ server = uvicorn.Server(config)
4753
+
4754
+ # Carry exceptions from the daemon thread back to the main one so
4755
+ # we can render a friendly message instead of letting uvicorn's
4756
+ # default error path leak.
4757
+ bind_error: list[BaseException] = []
4758
+
4759
+ def _serve() -> None:
4760
+ try:
4761
+ server.run()
4762
+ except BaseException as exc: # noqa: BLE001
4763
+ bind_error.append(exc)
4764
+
4765
+ server_thread = threading.Thread(target=_serve, daemon=True)
4766
+ server_thread.start()
4767
+
4768
+ # Wait for uvicorn to actually bind before launching the browser so
4769
+ # the first GET does not race the server startup.
4770
+ for _ in range(40): # up to ~2s
4771
+ if getattr(server, "started", False) or bind_error:
4772
+ break
4773
+ _time.sleep(0.05)
4774
+
4775
+ if bind_error:
4776
+ bind_exc = bind_error[0]
4777
+ bind_errno = getattr(bind_exc, "errno", None)
4778
+ # WinError 10048 / EADDRINUSE / EACCES on the bind syscall.
4779
+ is_port_collision = (
4780
+ isinstance(bind_exc, OSError)
4781
+ and (
4782
+ getattr(bind_exc, "winerror", None) == 10048
4783
+ or (isinstance(bind_errno, int) and bind_errno in (48, 98, 13))
4784
+ )
4785
+ )
4786
+ if is_port_collision:
4787
+ typer.echo(
4788
+ f"{_cli_error('Port')} {port} is busy and the cockpit could not "
4789
+ "bind to it. Common causes:\n"
4790
+ f" • a previous `agentops cockpit` is still "
4791
+ "holding the socket (Windows TIME_WAIT, lasts up to "
4792
+ "~2 min after Ctrl+C)\n"
4793
+ " • another local service is listening on the same "
4794
+ "port\n"
4795
+ "Fixes (pick one):\n"
4796
+ f" • wait ~2 minutes and re-run\n"
4797
+ f" • pick another port: agentops cockpit --port "
4798
+ f"{port + 1}\n"
4799
+ f" • find the holder: PowerShell `Get-NetTCPConnection "
4800
+ f"-LocalPort {port}`",
4801
+ err=True,
4802
+ )
4803
+ raise typer.Exit(code=1)
4804
+ typer.echo(f"{_cli_error('Failed to start cockpit')}: {bind_exc}", err=True)
4805
+ raise typer.Exit(code=1)
4806
+
4807
+ try:
4808
+ webbrowser.open(url, new=2)
4809
+ except Exception: # noqa: BLE001 - never fail cockpit on a browser launch issue
4810
+ pass
4811
+
4812
+ try:
4813
+ input()
4814
+ except (EOFError, KeyboardInterrupt):
4815
+ pass
4816
+
4817
+ typer.echo(_cli_warn("Stopping cockpit…"))
4818
+ server.should_exit = True
4819
+ server_thread.join(timeout=5)
4820
+
4821
+
4822
+ def main() -> None:
4823
+ app()