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.
Files changed (53) hide show
  1. sanityops_cli/__init__.py +16 -0
  2. sanityops_cli/agents/__init__.py +14 -0
  3. sanityops_cli/agents/repair_agent/__init__.py +19 -0
  4. sanityops_cli/agents/repair_agent/agent.py +230 -0
  5. sanityops_cli/agents/repair_agent/prompts.py +100 -0
  6. sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
  7. sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
  8. sanityops_cli/agents/scanner_agent/agent.py +332 -0
  9. sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
  10. sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
  11. sanityops_cli/agents/scanner_agent/prompts.py +316 -0
  12. sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
  13. sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
  14. sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
  15. sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
  16. sanityops_cli/api/__init__.py +16 -0
  17. sanityops_cli/api/client.py +403 -0
  18. sanityops_cli/commands/__init__.py +14 -0
  19. sanityops_cli/commands/config.py +312 -0
  20. sanityops_cli/commands/init.py +132 -0
  21. sanityops_cli/commands/inspect.py +646 -0
  22. sanityops_cli/constants/__init__.py +14 -0
  23. sanityops_cli/constants/config_defaults.py +22 -0
  24. sanityops_cli/constants/exit_codes.py +19 -0
  25. sanityops_cli/defect_checker/__init__.py +16 -0
  26. sanityops_cli/defect_checker/checker.py +100 -0
  27. sanityops_cli/defect_checker/llm_config.py +112 -0
  28. sanityops_cli/defect_checker/markdown_reporter.py +249 -0
  29. sanityops_cli/defect_checker/renderer.py +203 -0
  30. sanityops_cli/exceptions/__init__.py +14 -0
  31. sanityops_cli/exceptions/api_exceptions.py +60 -0
  32. sanityops_cli/exceptions/base_exceptions.py +25 -0
  33. sanityops_cli/help_panel.py +49 -0
  34. sanityops_cli/logging/__init__.py +18 -0
  35. sanityops_cli/logging/logger.py +108 -0
  36. sanityops_cli/main.py +123 -0
  37. sanityops_cli/progress/__init__.py +18 -0
  38. sanityops_cli/progress/tracker.py +159 -0
  39. sanityops_cli/renderers/__init__.py +14 -0
  40. sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
  41. sanityops_cli/templates/__init__.py +14 -0
  42. sanityops_cli/templates/inspect_config.yaml +55 -0
  43. sanityops_cli/utils/__init__.py +14 -0
  44. sanityops_cli/utils/artifact_packer.py +407 -0
  45. sanityops_cli/utils/config_loader.py +296 -0
  46. sanityops_cli/utils/config_resolver.py +358 -0
  47. sanityops_cli/utils/validators.py +117 -0
  48. sanityops_cli-0.1.3.dist-info/METADATA +213 -0
  49. sanityops_cli-0.1.3.dist-info/RECORD +53 -0
  50. sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
  51. sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
  52. sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
  53. 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"