sanityops-cli 0.1.3__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.
- sanityops_cli/__init__.py +16 -0
- sanityops_cli/agents/__init__.py +14 -0
- sanityops_cli/agents/repair_agent/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/agent.py +230 -0
- sanityops_cli/agents/repair_agent/prompts.py +100 -0
- sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
- sanityops_cli/agents/scanner_agent/agent.py +332 -0
- sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
- sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
- sanityops_cli/agents/scanner_agent/prompts.py +316 -0
- sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
- sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
- sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
- sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
- sanityops_cli/api/__init__.py +16 -0
- sanityops_cli/api/client.py +403 -0
- sanityops_cli/commands/__init__.py +14 -0
- sanityops_cli/commands/config.py +312 -0
- sanityops_cli/commands/init.py +132 -0
- sanityops_cli/commands/inspect.py +646 -0
- sanityops_cli/constants/__init__.py +14 -0
- sanityops_cli/constants/config_defaults.py +22 -0
- sanityops_cli/constants/exit_codes.py +19 -0
- sanityops_cli/defect_checker/__init__.py +16 -0
- sanityops_cli/defect_checker/checker.py +100 -0
- sanityops_cli/defect_checker/llm_config.py +112 -0
- sanityops_cli/defect_checker/markdown_reporter.py +249 -0
- sanityops_cli/defect_checker/renderer.py +203 -0
- sanityops_cli/exceptions/__init__.py +14 -0
- sanityops_cli/exceptions/api_exceptions.py +60 -0
- sanityops_cli/exceptions/base_exceptions.py +25 -0
- sanityops_cli/help_panel.py +49 -0
- sanityops_cli/logging/__init__.py +18 -0
- sanityops_cli/logging/logger.py +108 -0
- sanityops_cli/main.py +123 -0
- sanityops_cli/progress/__init__.py +18 -0
- sanityops_cli/progress/tracker.py +159 -0
- sanityops_cli/renderers/__init__.py +14 -0
- sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
- sanityops_cli/templates/__init__.py +14 -0
- sanityops_cli/templates/inspect_config.yaml +55 -0
- sanityops_cli/utils/__init__.py +14 -0
- sanityops_cli/utils/artifact_packer.py +407 -0
- sanityops_cli/utils/config_loader.py +296 -0
- sanityops_cli/utils/config_resolver.py +358 -0
- sanityops_cli/utils/validators.py +117 -0
- sanityops_cli-0.1.3.dist-info/METADATA +213 -0
- sanityops_cli-0.1.3.dist-info/RECORD +53 -0
- sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
- sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
- sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
- sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""inspect command — Sanityops CLI Tool"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import platform
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import anyio
|
|
27
|
+
import typer
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.markup import escape
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
|
|
32
|
+
from sanityops_cli import __version__
|
|
33
|
+
from sanityops_cli.agents.scanner_agent.agent import ScannerAgent
|
|
34
|
+
from sanityops_cli.constants.config_defaults import DEFAULT_SERVER_BASE_URL
|
|
35
|
+
from sanityops_cli.constants.exit_codes import EXIT_FAILURE
|
|
36
|
+
from sanityops_cli.defect_checker.checker import DefectChecker
|
|
37
|
+
from sanityops_cli.defect_checker.llm_config import resolve_llm_config
|
|
38
|
+
from sanityops_cli.defect_checker.markdown_reporter import save_markdown_report
|
|
39
|
+
from sanityops_cli.defect_checker.renderer import DefectRenderer
|
|
40
|
+
from sanityops_cli.exceptions.base_exceptions import ValidationError
|
|
41
|
+
from sanityops_cli.logging.logger import Logger
|
|
42
|
+
from sanityops_cli.progress.tracker import ProgressTracker
|
|
43
|
+
from sanityops_cli.utils.config_loader import InspectConfigLoader, read_file_with_encoding_fallback
|
|
44
|
+
from sanityops_cli.utils.config_resolver import ConfigResolver
|
|
45
|
+
|
|
46
|
+
console = Console()
|
|
47
|
+
inspect_app = typer.Typer()
|
|
48
|
+
|
|
49
|
+
#: URL users attach log files to when reporting issues.
|
|
50
|
+
ISSUE_URL = "https://github.com/sanityops-org/sanityops-cli/issues"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_agent_config(config_path: Path) -> dict:
|
|
54
|
+
"""Read the optional `agent:` section from inspect_config.yaml.
|
|
55
|
+
|
|
56
|
+
Returns {} when the section is absent or malformed — callers then fall
|
|
57
|
+
back to their own defaults. Known keys: max_loops, token_budget, timeout.
|
|
58
|
+
"""
|
|
59
|
+
import yaml
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
content = read_file_with_encoding_fallback(config_path)
|
|
63
|
+
if not content:
|
|
64
|
+
return {}
|
|
65
|
+
data = yaml.safe_load(content)
|
|
66
|
+
section = (data or {}).get("agent")
|
|
67
|
+
if not isinstance(section, dict):
|
|
68
|
+
return {}
|
|
69
|
+
out: dict = {}
|
|
70
|
+
for key in ("max_loops", "token_budget", "timeout"):
|
|
71
|
+
value = section.get(key)
|
|
72
|
+
if isinstance(value, int) and value > 0:
|
|
73
|
+
out[key] = value
|
|
74
|
+
return out
|
|
75
|
+
except Exception:
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _auto_upload(
|
|
80
|
+
project_id: str | None,
|
|
81
|
+
scan_result: Any,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Auto upload scanned artifacts to the server.
|
|
84
|
+
|
|
85
|
+
Flow:
|
|
86
|
+
- If no API key configured: skip (no-op).
|
|
87
|
+
- If API key configured but no project bound: create a project first,
|
|
88
|
+
then push the artifacts.
|
|
89
|
+
- If API key configured and project bound: push directly.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
project_id: Resolved project ID (None if not bound / placeholder).
|
|
93
|
+
scan_result: ScannerAgent result containing skills, tools, prompts.
|
|
94
|
+
"""
|
|
95
|
+
from sanityops_cli.api.client import SanityopsClient
|
|
96
|
+
from sanityops_cli.utils.artifact_packer import pack_from_findings
|
|
97
|
+
|
|
98
|
+
# No API key configured -> cannot upload, skip silently
|
|
99
|
+
api_key = ConfigResolver("server.api_key", "SANITYOPS_API_KEY").resolve()
|
|
100
|
+
if not api_key:
|
|
101
|
+
console.print("[dim]API key not configured, skip auto upload.[/dim]")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
base_url = (
|
|
105
|
+
ConfigResolver("server.base_url", "SANITYOPS_BASE_URL", DEFAULT_SERVER_BASE_URL).resolve()
|
|
106
|
+
or DEFAULT_SERVER_BASE_URL
|
|
107
|
+
)
|
|
108
|
+
client = SanityopsClient(base_url, api_key)
|
|
109
|
+
|
|
110
|
+
# Pack artifacts from scan results
|
|
111
|
+
console.print("\n[bold]Packing artifacts...[/]")
|
|
112
|
+
try:
|
|
113
|
+
packed = pack_from_findings(
|
|
114
|
+
skills=scan_result.skills,
|
|
115
|
+
tools=scan_result.tools,
|
|
116
|
+
prompts=scan_result.prompts,
|
|
117
|
+
)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
console.print(f"[red]✗ Failed to pack artifacts: {escape(str(e))}[/]")
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
not packed.get("prompt_content")
|
|
124
|
+
and not packed.get("tools_schema")
|
|
125
|
+
and not packed.get("skill_file")
|
|
126
|
+
):
|
|
127
|
+
console.print("[yellow]No artifacts to upload.[/yellow]")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Create a project first if not bound yet
|
|
131
|
+
if not project_id:
|
|
132
|
+
default_name = Path.cwd().name or "my-project"
|
|
133
|
+
console.print(f"\n[bold]Creating new project [cyan]{default_name}[/]...[/]")
|
|
134
|
+
try:
|
|
135
|
+
result = client.create_project(default_name, None)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
console.print(f"[red]✗ Could not create project: {escape(str(e))}[/]")
|
|
138
|
+
console.print("[dim] Check your SANITYOPS_API_KEY and network connectivity.[/dim]")
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
new_project_id = result.get("project_id")
|
|
142
|
+
if not new_project_id:
|
|
143
|
+
console.print("[red]✗ Server returned no project ID[/]")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Persist the new project binding for subsequent pushes
|
|
147
|
+
ConfigResolver.set_project("project.id", new_project_id)
|
|
148
|
+
ConfigResolver.set_project("project.name", default_name)
|
|
149
|
+
|
|
150
|
+
project_id = new_project_id
|
|
151
|
+
console.print(f"[green]✓[/] Created project (id: {project_id})")
|
|
152
|
+
else:
|
|
153
|
+
console.print(f"\n[bold]Using existing project: [cyan]{project_id}[/][/")
|
|
154
|
+
|
|
155
|
+
# Push artifacts
|
|
156
|
+
message = f"Auto-push at {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
157
|
+
console.print("[bold]Uploading artifacts to server...[/]")
|
|
158
|
+
try:
|
|
159
|
+
result = client.upload_artifacts(
|
|
160
|
+
project_id=project_id,
|
|
161
|
+
prompt_content=packed.get("prompt_content"),
|
|
162
|
+
tools_schema=packed.get("tools_schema"),
|
|
163
|
+
skill_file=packed.get("skill_file"),
|
|
164
|
+
message=message,
|
|
165
|
+
)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
console.print(f"[red]✗ Could not upload artifacts: {escape(str(e))}[/]")
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
version_id = result.get("version_id") or result.get("id")
|
|
171
|
+
if version_id:
|
|
172
|
+
console.print(f"[green]✓[/] Created version [cyan]{version_id}[/]")
|
|
173
|
+
|
|
174
|
+
if scan_result.prompts:
|
|
175
|
+
console.print(f" Prompts: {len(scan_result.prompts)}")
|
|
176
|
+
if scan_result.tools:
|
|
177
|
+
console.print(f" Tools: {len(scan_result.tools)}")
|
|
178
|
+
if scan_result.skills:
|
|
179
|
+
console.print(f" Skills: {len(scan_result.skills)}")
|
|
180
|
+
console.print()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _banner_line() -> str:
|
|
184
|
+
"""Return a one-line run banner: program version, Python version, platform."""
|
|
185
|
+
return (
|
|
186
|
+
f"sanityops-cli v{__version__} | Python {platform.python_version()} "
|
|
187
|
+
f"| {sys.platform}/{platform.machine()}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _report_program_error(
|
|
192
|
+
logger: Logger, step_name: str, error: Exception, *, step_summary_shown: bool = False
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Print a concise program-error summary and point to the log file.
|
|
195
|
+
|
|
196
|
+
In verbose mode the failing step's ``StepContext`` already collapsed to a
|
|
197
|
+
``✗ {name} ({duration}s)`` line, so the step-error headline is skipped to
|
|
198
|
+
avoid reporting the same failure twice.
|
|
199
|
+
"""
|
|
200
|
+
if not step_summary_shown:
|
|
201
|
+
console.print(f"[red]✗ Error in step \"{escape(step_name)}\"[/]")
|
|
202
|
+
console.print(f" Message: {escape(str(error))}")
|
|
203
|
+
console.print(f"\nSee log for details: {logger.get_log_path()}")
|
|
204
|
+
console.print("To report this issue, attach the log file to:")
|
|
205
|
+
console.print(ISSUE_URL)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ============================================================================
|
|
209
|
+
# Helper functions for repair command
|
|
210
|
+
# ============================================================================
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _find_latest_report(report_dir: Path) -> Path | None:
|
|
214
|
+
"""Return the newest inspect-*.md report in report_dir, or None."""
|
|
215
|
+
if not report_dir.is_dir():
|
|
216
|
+
return None
|
|
217
|
+
candidates = sorted(report_dir.glob("inspect-*.md"), key=lambda p: p.stat().st_mtime)
|
|
218
|
+
return candidates[-1] if candidates else None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _write_repairs_markdown(
|
|
222
|
+
repairs: list[dict],
|
|
223
|
+
output_dir: Path,
|
|
224
|
+
*,
|
|
225
|
+
report_path: Path,
|
|
226
|
+
project_id: str | None,
|
|
227
|
+
) -> Path:
|
|
228
|
+
"""Persist repaired artifact content into a single markdown file."""
|
|
229
|
+
output_dir = output_dir.expanduser().resolve()
|
|
230
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
now = datetime.now()
|
|
233
|
+
timestamp = now.strftime("%Y%m%d-%H%M%S")
|
|
234
|
+
path = output_dir / f"repair-{timestamp}.md"
|
|
235
|
+
|
|
236
|
+
type_labels = {"skill": "Skill", "tool": "Tool", "prompt": "Prompt"}
|
|
237
|
+
lines: list[str] = [
|
|
238
|
+
"# Repair Report",
|
|
239
|
+
"",
|
|
240
|
+
"> **Disclaimer**: This tool lacks business context. The repair content is for reference only. Please verify and apply fixes carefully based on your business logic.",
|
|
241
|
+
"",
|
|
242
|
+
f"- **Generated**: {now.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
243
|
+
f"- **Source report**: {report_path}",
|
|
244
|
+
f"- **Project ID**: {project_id or 'not set'}",
|
|
245
|
+
f"- **Artifacts repaired**: {len(repairs)}",
|
|
246
|
+
"",
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
for index, repair in enumerate(repairs, start=1):
|
|
250
|
+
label = type_labels.get(repair.get("artifact_type", ""), "Artifact")
|
|
251
|
+
artifact_path = repair.get("artifact_path", "")
|
|
252
|
+
lines.extend(
|
|
253
|
+
[
|
|
254
|
+
"---",
|
|
255
|
+
"",
|
|
256
|
+
f"## {index}. {label} — {Path(artifact_path).name}",
|
|
257
|
+
"",
|
|
258
|
+
f"- **Source**: `{artifact_path}`",
|
|
259
|
+
f"- **Summary**: {repair.get('summary', '')}",
|
|
260
|
+
"",
|
|
261
|
+
"### Repaired content",
|
|
262
|
+
"",
|
|
263
|
+
"````markdown",
|
|
264
|
+
repair.get("repaired_content", ""),
|
|
265
|
+
"````",
|
|
266
|
+
"",
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
271
|
+
return path
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ============================================================================
|
|
275
|
+
# inspect repair — generate repaired artifacts from a local inspection report
|
|
276
|
+
# ============================================================================
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@inspect_app.command("repair")
|
|
280
|
+
def inspect_repair(
|
|
281
|
+
ctx: typer.Context,
|
|
282
|
+
# B008: Typer requires function calls in argument defaults for its CLI option
|
|
283
|
+
# annotation pattern. This is intentional and standard for Typer commands.
|
|
284
|
+
report: Path | None = typer.Option( # noqa: B008
|
|
285
|
+
None,
|
|
286
|
+
"--report", "-r",
|
|
287
|
+
help=(
|
|
288
|
+
"Path to the inspection report (.md) to repair from. "
|
|
289
|
+
"If omitted, uses the newest .sanityops/results/inspect-*.md."
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
config: str | None = typer.Option(
|
|
293
|
+
None,
|
|
294
|
+
"--config", "-c",
|
|
295
|
+
help="Path to inspect_config.yaml. If omitted, looks for .sanityops/inspect_config.yaml in cwd.",
|
|
296
|
+
),
|
|
297
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
|
|
298
|
+
timeout: int = typer.Option(
|
|
299
|
+
1800,
|
|
300
|
+
"--timeout",
|
|
301
|
+
help="Agent execution timeout in seconds (default: 1800, 30min).",
|
|
302
|
+
),
|
|
303
|
+
token_budget: int = typer.Option(
|
|
304
|
+
0,
|
|
305
|
+
"--token-budget",
|
|
306
|
+
help=(
|
|
307
|
+
"Token budget for repair agent (default: auto-scaled by artifact count). "
|
|
308
|
+
"Set explicitly for large projects."
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
model: str | None = typer.Option(None, "--model", "-m", help="LLM model ID override"),
|
|
312
|
+
provider: str | None = typer.Option(None, "--provider", "-p", help="LLM provider override"),
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Generate repaired artifacts locally from an inspection report.
|
|
315
|
+
|
|
316
|
+
Reads the latest inspection report (or --report), asks a local repair
|
|
317
|
+
agent to rewrite every defective artifact, and writes the repaired
|
|
318
|
+
content to `.sanityops/repairs/repair-<timestamp>.md`. Nothing is
|
|
319
|
+
uploaded and no source file is modified.
|
|
320
|
+
"""
|
|
321
|
+
if ctx.resilient_parsing:
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
logger = Logger()
|
|
325
|
+
tracker = ProgressTracker(console, logger, verbose=verbose)
|
|
326
|
+
|
|
327
|
+
# Step 1: Load and validate config
|
|
328
|
+
try:
|
|
329
|
+
with tracker.step("Loading configuration..."):
|
|
330
|
+
loader = InspectConfigLoader(config)
|
|
331
|
+
artifacts = loader.load()
|
|
332
|
+
except ValidationError as e:
|
|
333
|
+
console.print(f"[red]✗ Config error: {escape(str(e))}[/red]")
|
|
334
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
335
|
+
except Exception as e:
|
|
336
|
+
_report_program_error(logger, "Loading configuration...", e, step_summary_shown=verbose)
|
|
337
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
338
|
+
|
|
339
|
+
project_id = artifacts.get("project_id")
|
|
340
|
+
artifact_paths = {
|
|
341
|
+
"prompts": artifacts.get("prompts") or [],
|
|
342
|
+
"tools": artifacts.get("tools") or [],
|
|
343
|
+
"skills": artifacts.get("skills") or [],
|
|
344
|
+
}
|
|
345
|
+
total = sum(len(v) for v in artifact_paths.values())
|
|
346
|
+
if not total:
|
|
347
|
+
console.print("[red]✗ No artifacts configured in inspect_config.yaml[/red]")
|
|
348
|
+
raise typer.Exit(code=EXIT_FAILURE)
|
|
349
|
+
|
|
350
|
+
# Step 2: Resolve report path
|
|
351
|
+
results_dir = loader.config_path.parent / "results"
|
|
352
|
+
report_path: Path | None = None
|
|
353
|
+
if report:
|
|
354
|
+
report_path = report.expanduser().resolve()
|
|
355
|
+
if not report_path.is_file():
|
|
356
|
+
console.print(f"[red]✗ Report not found: {report}[/red]")
|
|
357
|
+
raise typer.Exit(code=EXIT_FAILURE)
|
|
358
|
+
else:
|
|
359
|
+
report_path = _find_latest_report(results_dir)
|
|
360
|
+
if report_path is None:
|
|
361
|
+
console.print(f"[red]✗ No inspection report found in {results_dir}[/red]")
|
|
362
|
+
console.print(" Run `sanityops-cli inspect` first, or pass --report <path>.")
|
|
363
|
+
raise typer.Exit(code=EXIT_FAILURE)
|
|
364
|
+
|
|
365
|
+
console.print()
|
|
366
|
+
console.print("[bold]Repairing artifacts from inspection report[/]")
|
|
367
|
+
console.print(f" Report: {report_path}")
|
|
368
|
+
console.print(
|
|
369
|
+
f" Artifacts: {total} ({len(artifact_paths['prompts'])}p, "
|
|
370
|
+
f"{len(artifact_paths['tools'])}t, {len(artifact_paths['skills'])}s)"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Step 3: Resolve LLM config
|
|
374
|
+
try:
|
|
375
|
+
with tracker.step("Resolving LLM configuration..."):
|
|
376
|
+
llm_config = resolve_llm_config(config)
|
|
377
|
+
# Apply CLI overrides
|
|
378
|
+
if provider:
|
|
379
|
+
llm_config["llm_provider"] = provider
|
|
380
|
+
if model:
|
|
381
|
+
llm_config["llm_model_id"] = model
|
|
382
|
+
except Exception as e:
|
|
383
|
+
console.print(f"[red]✗ Failed to resolve LLM configuration: {escape(str(e))}[/red]")
|
|
384
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
385
|
+
|
|
386
|
+
if not llm_config.get("llm_api_key", "").strip():
|
|
387
|
+
console.print("[red]✗ LLM configuration is incomplete (missing API key).[/red]")
|
|
388
|
+
console.print(
|
|
389
|
+
" Configure model.provider/api_key/model_id in .sanityops/inspect_config.yaml."
|
|
390
|
+
)
|
|
391
|
+
raise typer.Exit(code=EXIT_FAILURE)
|
|
392
|
+
|
|
393
|
+
from sanityops_agent.config import ProviderConfig
|
|
394
|
+
from sanityops_agent.llm.factory import ProviderFactory
|
|
395
|
+
|
|
396
|
+
provider_config = ProviderConfig(
|
|
397
|
+
LLM_PROVIDER=llm_config["llm_provider"],
|
|
398
|
+
API_KEY=llm_config["llm_api_key"],
|
|
399
|
+
MODEL_ID=llm_config["llm_model_id"],
|
|
400
|
+
BASE_URL=llm_config["llm_base_url"] or None,
|
|
401
|
+
)
|
|
402
|
+
llm_provider = ProviderFactory.create(provider_config)
|
|
403
|
+
|
|
404
|
+
# Step 4: Run the repair agent
|
|
405
|
+
from sanityops_cli.agents.repair_agent.agent import RepairAgent, _is_rate_limit_error
|
|
406
|
+
|
|
407
|
+
# Token budget auto-scaling
|
|
408
|
+
if token_budget > 0:
|
|
409
|
+
effective_budget = token_budget
|
|
410
|
+
else:
|
|
411
|
+
effective_budget = 300_000 + 150_000 * total
|
|
412
|
+
|
|
413
|
+
agent = RepairAgent(
|
|
414
|
+
provider=llm_provider,
|
|
415
|
+
max_loops=60,
|
|
416
|
+
timeout=timeout,
|
|
417
|
+
token_budget=effective_budget,
|
|
418
|
+
verbose=verbose,
|
|
419
|
+
console=console,
|
|
420
|
+
logger=logger,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
console.print(f" Provider: {llm_config['llm_provider']}")
|
|
424
|
+
console.print(f" Model: {llm_config['llm_model_id']}")
|
|
425
|
+
console.print(f" Budget: {effective_budget} tokens / {timeout}s")
|
|
426
|
+
console.print()
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
with tracker.step("Running repair agent..."):
|
|
430
|
+
repairs = agent.repair_sync(
|
|
431
|
+
report_path=str(report_path),
|
|
432
|
+
artifacts=artifact_paths,
|
|
433
|
+
project_root=str(loader.config_path.parent.parent),
|
|
434
|
+
)
|
|
435
|
+
except ValidationError as e:
|
|
436
|
+
if _is_rate_limit_error(e):
|
|
437
|
+
console.print("[red]✗ Repair failed: the LLM service is rate limited.[/red]")
|
|
438
|
+
console.print(
|
|
439
|
+
" The server-side model quota is exhausted."
|
|
440
|
+
)
|
|
441
|
+
console.print(" Wait a minute and re-run, or raise the backend quota.")
|
|
442
|
+
else:
|
|
443
|
+
console.print(f"[red]✗ Repair failed: {escape(str(e))}[/red]")
|
|
444
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
445
|
+
except Exception as e:
|
|
446
|
+
_report_program_error(logger, "Running repair agent...", e, step_summary_shown=verbose)
|
|
447
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
448
|
+
|
|
449
|
+
tracker.summary()
|
|
450
|
+
|
|
451
|
+
if not repairs:
|
|
452
|
+
console.print("[yellow]⚠[/] No repairs generated (report may contain no defects).")
|
|
453
|
+
raise typer.Exit()
|
|
454
|
+
|
|
455
|
+
# Step 5: Persist repairs
|
|
456
|
+
repairs_dir = loader.config_path.parent / "repairs"
|
|
457
|
+
try:
|
|
458
|
+
out_path = _write_repairs_markdown(
|
|
459
|
+
repairs,
|
|
460
|
+
repairs_dir,
|
|
461
|
+
report_path=report_path,
|
|
462
|
+
project_id=project_id,
|
|
463
|
+
)
|
|
464
|
+
console.print(f"[green]✓[/] Repaired {len(repairs)} artifact(s)")
|
|
465
|
+
console.print(f" Saved to: {out_path}")
|
|
466
|
+
except Exception as e:
|
|
467
|
+
console.print(f"[red]✗ Could not save repair report: {escape(str(e))}[/red]")
|
|
468
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@inspect_app.callback(invoke_without_command=True)
|
|
472
|
+
def inspect(
|
|
473
|
+
ctx: typer.Context,
|
|
474
|
+
config: str | None = typer.Option(
|
|
475
|
+
None,
|
|
476
|
+
"--config", "-c",
|
|
477
|
+
help="Path to inspect_config.yaml. If omitted, looks for .sanityops/inspect_config.yaml in cwd.",
|
|
478
|
+
),
|
|
479
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
|
|
480
|
+
check_level: str = typer.Option(
|
|
481
|
+
"L2",
|
|
482
|
+
"--check-level",
|
|
483
|
+
help="Defect check level: L1 (fast), L2 (standard), L3 (deep).",
|
|
484
|
+
),
|
|
485
|
+
skip_defect_check: bool = typer.Option(
|
|
486
|
+
False,
|
|
487
|
+
"--skip-defect-check",
|
|
488
|
+
help="Skip the defect check step and only run artifact analysis.",
|
|
489
|
+
),
|
|
490
|
+
timeout: int = typer.Option(
|
|
491
|
+
1800,
|
|
492
|
+
"--timeout",
|
|
493
|
+
help=(
|
|
494
|
+
"Agent execution timeout in seconds (default: 1800, 30min). "
|
|
495
|
+
"Increase for large projects; decrease for fast-fail in CI."
|
|
496
|
+
),
|
|
497
|
+
),
|
|
498
|
+
token_budget: int = typer.Option(
|
|
499
|
+
200000,
|
|
500
|
+
"--token-budget",
|
|
501
|
+
help="Token budget for agent execution (default: 200000). Increase for large projects.",
|
|
502
|
+
),
|
|
503
|
+
):
|
|
504
|
+
"""Inspect and defect-check the configured artifacts."""
|
|
505
|
+
# Skip callback execution when a subcommand is invoked
|
|
506
|
+
if ctx.invoked_subcommand is not None:
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
if check_level not in {"L1", "L2", "L3"}:
|
|
510
|
+
console.print(f"[red]✗ Invalid check level: {escape(check_level)} (must be L1/L2/L3)[/red]")
|
|
511
|
+
raise typer.Exit(code=EXIT_FAILURE)
|
|
512
|
+
|
|
513
|
+
logger = Logger()
|
|
514
|
+
tracker = ProgressTracker(console, logger, verbose=verbose)
|
|
515
|
+
|
|
516
|
+
# Step 1: Load and validate config
|
|
517
|
+
try:
|
|
518
|
+
with tracker.step("Loading configuration..."):
|
|
519
|
+
loader = InspectConfigLoader(config)
|
|
520
|
+
artifacts = loader.load()
|
|
521
|
+
except ValidationError as e:
|
|
522
|
+
console.print(f"[red]✗ Config error: {escape(str(e))}[/red]")
|
|
523
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
524
|
+
except Exception as e:
|
|
525
|
+
_report_program_error(logger, "Loading configuration...", e, step_summary_shown=verbose)
|
|
526
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
527
|
+
|
|
528
|
+
project_id = artifacts["project_id"]
|
|
529
|
+
prompt_files = artifacts["prompts"]
|
|
530
|
+
tool_files = artifacts["tools"]
|
|
531
|
+
skill_files = artifacts["skills"]
|
|
532
|
+
|
|
533
|
+
# Display resolved artifacts
|
|
534
|
+
table = Table(title="[bold]Inspect Artifacts[/]", border_style="blue")
|
|
535
|
+
table.add_column("Type", style="bold cyan", no_wrap=True)
|
|
536
|
+
table.add_column("Absolute Path", style="white")
|
|
537
|
+
for f in prompt_files:
|
|
538
|
+
table.add_row("Prompt", f)
|
|
539
|
+
for f in tool_files:
|
|
540
|
+
table.add_row("Tool", f)
|
|
541
|
+
for f in skill_files:
|
|
542
|
+
table.add_row("Skill", f)
|
|
543
|
+
console.print(table)
|
|
544
|
+
console.print(f"[dim]{_banner_line()}[/dim]")
|
|
545
|
+
console.print(f"[dim]Project ID: {project_id or 'not set'}[/dim]")
|
|
546
|
+
console.print(
|
|
547
|
+
f"[dim]Total: {len(prompt_files)} prompts, "
|
|
548
|
+
f"{len(tool_files)} tools, {len(skill_files)} skills[/dim]\n"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Step 2: Run ScannerAgent analysis
|
|
552
|
+
try:
|
|
553
|
+
with tracker.step("Analyzing artifacts...") as step:
|
|
554
|
+
# Resolve LLM config (config file model section or env vars)
|
|
555
|
+
llm_config = resolve_llm_config(config)
|
|
556
|
+
|
|
557
|
+
from sanityops_agent.config import ProviderConfig
|
|
558
|
+
from sanityops_agent.llm.factory import ProviderFactory
|
|
559
|
+
|
|
560
|
+
provider_config = ProviderConfig(
|
|
561
|
+
LLM_PROVIDER=llm_config["llm_provider"],
|
|
562
|
+
API_KEY=llm_config["llm_api_key"],
|
|
563
|
+
MODEL_ID=llm_config["llm_model_id"],
|
|
564
|
+
BASE_URL=llm_config["llm_base_url"] or None,
|
|
565
|
+
)
|
|
566
|
+
provider = ProviderFactory.create(provider_config)
|
|
567
|
+
|
|
568
|
+
# Load optional agent config section for timeout/token_budget overrides
|
|
569
|
+
agent_cfg = _load_agent_config(loader.config_path)
|
|
570
|
+
|
|
571
|
+
# Resolve effective timeout and token budget
|
|
572
|
+
# CLI flag wins when it differs from default; otherwise config file wins
|
|
573
|
+
effective_timeout = timeout if timeout != 1800 else agent_cfg.get("timeout", timeout)
|
|
574
|
+
effective_budget = (
|
|
575
|
+
token_budget if token_budget != 200000 else max(agent_cfg.get("token_budget", 0), 200000)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if verbose:
|
|
579
|
+
console.print(f" Timeout: {effective_timeout}s")
|
|
580
|
+
console.print(f" Token budget: {effective_budget}")
|
|
581
|
+
|
|
582
|
+
agent = ScannerAgent(
|
|
583
|
+
provider=provider,
|
|
584
|
+
verbose=verbose,
|
|
585
|
+
console=step.console,
|
|
586
|
+
logger=logger,
|
|
587
|
+
timeout=effective_timeout,
|
|
588
|
+
token_budget=effective_budget,
|
|
589
|
+
)
|
|
590
|
+
result = agent.analyze_files_sync(
|
|
591
|
+
prompts=prompt_files,
|
|
592
|
+
tools=tool_files,
|
|
593
|
+
skills=skill_files,
|
|
594
|
+
)
|
|
595
|
+
except Exception as e:
|
|
596
|
+
_report_program_error(logger, "Analyzing artifacts...", e, step_summary_shown=verbose)
|
|
597
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
598
|
+
|
|
599
|
+
if not result.skills and not result.tools and not result.prompts:
|
|
600
|
+
console.print("[yellow]No artifacts found to check.[/yellow]")
|
|
601
|
+
tracker.summary()
|
|
602
|
+
raise typer.Exit()
|
|
603
|
+
|
|
604
|
+
# Step 3: Auto upload artifacts after scan completes
|
|
605
|
+
# - If api-key configured and project not bound: create project then push
|
|
606
|
+
# - If api-key configured and project bound: push directly
|
|
607
|
+
try:
|
|
608
|
+
_auto_upload(project_id, result)
|
|
609
|
+
except Exception as e:
|
|
610
|
+
# _auto_upload prints user-friendly error, just continue
|
|
611
|
+
# Upload failure does not block defect check
|
|
612
|
+
logger.debug(f"Auto-upload failed: {e}")
|
|
613
|
+
|
|
614
|
+
# Step 4: Run defect check and render (unless skipped)
|
|
615
|
+
if skip_defect_check:
|
|
616
|
+
console.print("[dim]Defect check skipped (--skip-defect-check).[/dim]")
|
|
617
|
+
tracker.summary()
|
|
618
|
+
raise typer.Exit()
|
|
619
|
+
|
|
620
|
+
async def run_check():
|
|
621
|
+
checker = DefectChecker(llm_config)
|
|
622
|
+
return await checker.check(result, check_level=check_level)
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
with tracker.step("Running defect check..."):
|
|
626
|
+
response = anyio.run(run_check)
|
|
627
|
+
except Exception as e:
|
|
628
|
+
_report_program_error(logger, "Running defect check...", e, step_summary_shown=verbose)
|
|
629
|
+
raise typer.Exit(code=EXIT_FAILURE) from None
|
|
630
|
+
|
|
631
|
+
tracker.summary()
|
|
632
|
+
|
|
633
|
+
# Save markdown report (best-effort)
|
|
634
|
+
report_path: Path | None = None
|
|
635
|
+
try:
|
|
636
|
+
report_dir = loader.config_path.parent / "results"
|
|
637
|
+
report_path = save_markdown_report(
|
|
638
|
+
response, report_dir,
|
|
639
|
+
project_id=project_id,
|
|
640
|
+
check_level=check_level,
|
|
641
|
+
)
|
|
642
|
+
except OSError as e:
|
|
643
|
+
console.print(f"[yellow]⚠ Could not save markdown report: {escape(str(e))}[/yellow]")
|
|
644
|
+
logger.debug(f"Markdown report save failed: {e}")
|
|
645
|
+
|
|
646
|
+
DefectRenderer(console).render(response, report_path=str(report_path) if report_path else None)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""Default values for sanityops configuration.
|
|
17
|
+
|
|
18
|
+
Central place for config key defaults so they can be adjusted in one spot.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Default sanityops SaaS backend base URL (server.base_url)
|
|
22
|
+
DEFAULT_SERVER_BASE_URL = "https://demo.sanityops.org"
|