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,203 @@
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
+ """Render defect-check results in the terminal, grouped by artifact type."""
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from rich.console import Console
23
+ from rich.markup import escape
24
+ from rich.panel import Panel
25
+ from rich.table import Table
26
+
27
+ #: URL advertised in the footer for the full experience.
28
+ FULL_EXPERIENCE_URL = "https://www.sanityops.org"
29
+
30
+ #: Map defect-check module names to display group labels.
31
+ _MODULE_LABELS: dict[str, str] = {
32
+ "QDS": "Skills",
33
+ "QDT": "Tools",
34
+ "QDP": "Prompts",
35
+ "CROSS": "Cross",
36
+ }
37
+
38
+ #: Map module names to singular type labels for panel titles.
39
+ _MODULE_TYPE_LABELS: dict[str, str] = {
40
+ "QDS": "Skill",
41
+ "QDT": "Tool",
42
+ "QDP": "Prompt",
43
+ }
44
+
45
+
46
+ def _resolve_artifact_names(result: dict[str, Any]) -> str | None:
47
+ """Resolve artifact names from defect's artifact_refs.
48
+
49
+ 1. Collects artifact_refs from all defects
50
+ 2. Maps refs to artifact names via artifacts[].id -> artifacts[].name
51
+ 3. Returns comma-separated names or None
52
+ """
53
+ artifacts = [a for a in result.get("artifacts") or [] if isinstance(a, dict)]
54
+ by_id = {a.get("id"): a for a in artifacts if isinstance(a.get("id"), str)}
55
+
56
+ names: list[str] = []
57
+ refs: list[str] = []
58
+ for defect in result.get("defects") or []:
59
+ if not isinstance(defect, dict):
60
+ continue
61
+ for ref in defect.get("artifact_refs") or []:
62
+ if isinstance(ref, str) and ref not in refs:
63
+ refs.append(ref)
64
+
65
+ seen: set[str] = set()
66
+ for ref in refs:
67
+ artifact = by_id.get(ref)
68
+ name = artifact.get("name") if artifact else None
69
+ if isinstance(name, str) and name and name not in seen:
70
+ names.append(name)
71
+ seen.add(name)
72
+
73
+ if names:
74
+ return ", ".join(names)
75
+ if len(artifacts) == 1:
76
+ name = artifacts[0].get("name")
77
+ if isinstance(name, str) and name:
78
+ return name
79
+ return None
80
+
81
+
82
+ def resolve_artifact_title(result: dict[str, Any]) -> str | None:
83
+ """Generate panel title for QDS/QDT/QDP/CROSS results.
84
+
85
+ Returns:
86
+ - "Skill morning-report" (type + name) if artifacts resolvable
87
+ - "Skills" (plural label) if not resolvable
88
+ - "Cross" for CROSS module
89
+ - None for unknown modules
90
+ """
91
+ module = result.get("module")
92
+
93
+ if module == "CROSS":
94
+ return "Cross"
95
+
96
+ fallback_label = _MODULE_LABELS.get(module)
97
+ if fallback_label is None:
98
+ return None
99
+
100
+ artifact_names = _resolve_artifact_names(result)
101
+ type_label = _MODULE_TYPE_LABELS.get(module)
102
+ if type_label and artifact_names:
103
+ return f"{type_label} {artifact_names}"
104
+ return fallback_label
105
+
106
+
107
+ #: Maximum number of defects rendered per group.
108
+ MAX_DEFECTS_PER_GROUP = 2
109
+
110
+
111
+ class DefectRenderer:
112
+ """Renders a defect-check response dict for the terminal (English output)."""
113
+
114
+ def __init__(self, console: Console):
115
+ self.console = console
116
+
117
+ def render(self, result: dict, *, report_path: str | None = None) -> None:
118
+ """Render summary, per-group defects, and the footer."""
119
+ self._render_summary(result.get("summary", {}))
120
+ self._render_artifact_groups(result.get("results", []))
121
+ self._render_footer(report_path)
122
+
123
+ # ------------------------------------------------------------------
124
+ # Summary
125
+ # ------------------------------------------------------------------
126
+
127
+ def _render_summary(self, summary: dict[str, Any]) -> None:
128
+ total = summary.get("total_defects", 0)
129
+ p0 = summary.get("p0_count", 0)
130
+ p1 = summary.get("p1_count", 0)
131
+ p2 = summary.get("p2_count", 0)
132
+ gate = summary.get("gate_result", "PASS")
133
+
134
+ table = Table(title="Defect Check Summary", border_style="blue")
135
+ table.add_column("Metric", style="bold")
136
+ table.add_column("Value")
137
+ table.add_row("Total defects", str(total))
138
+ table.add_row("Severity", f"P0: {p0} P1: {p1} P2: {p2}")
139
+ gate_style = "red bold" if gate == "FAIL" else "green bold"
140
+ table.add_row("Gate", f"[{gate_style}]{gate}[/]")
141
+ self.console.print(table)
142
+ self.console.print()
143
+
144
+ # ------------------------------------------------------------------
145
+ # Artifact groups
146
+ # ------------------------------------------------------------------
147
+
148
+ def _render_artifact_groups(self, results: list[dict[str, Any]]) -> None:
149
+ for result in results:
150
+ title = resolve_artifact_title(result)
151
+ if title is None:
152
+ continue
153
+ defects = result.get("defects", [])
154
+ if not defects:
155
+ continue
156
+ self._render_group(title, defects)
157
+ self.console.print()
158
+
159
+ def _render_group(self, label: str, defects: list[dict[str, Any]]) -> None:
160
+ lines: list[str] = []
161
+ shown = defects[:MAX_DEFECTS_PER_GROUP]
162
+ for defect in shown:
163
+ lines.append(self._format_defect(defect))
164
+
165
+ hidden = len(defects) - len(shown)
166
+ if hidden > 0:
167
+ lines.append(f"[dim]... {hidden} more defects not shown[/dim]")
168
+
169
+ body = "\n".join(lines)
170
+ self.console.print(Panel(body, title=label, border_style="cyan"))
171
+
172
+ def _format_defect(self, defect: dict[str, Any]) -> str:
173
+ severity = defect.get("severity", "NONE")
174
+ defect_id = defect.get("id") or "defect"
175
+ lines = [f"[bold red]✗ [{severity}] {defect_id}[/]"]
176
+ if defect.get("location"):
177
+ lines.append(f" [dim]Location[/] : {defect['location']}")
178
+ if defect.get("impact"):
179
+ lines.append(f" [dim]Impact[/] : {defect['impact']}")
180
+ if defect.get("fix_suggestion"):
181
+ lines.append(f" [dim]Fix[/] : {defect['fix_suggestion']}")
182
+ return "\n".join(lines)
183
+
184
+ # ------------------------------------------------------------------
185
+ # Footer
186
+ # ------------------------------------------------------------------
187
+
188
+ def _render_footer(self, report_path: str | None = None) -> None:
189
+ lines: list[str] = []
190
+ if report_path:
191
+ lines.append(f"Report saved to [magenta]{escape(report_path)}[/magenta]")
192
+ lines.extend(
193
+ [
194
+ "For the full experience, visit",
195
+ FULL_EXPERIENCE_URL,
196
+ ]
197
+ )
198
+ self.console.print(
199
+ Panel(
200
+ "\n".join(lines),
201
+ border_style="green",
202
+ )
203
+ )
@@ -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,60 @@
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
+ """API exception definitions for sanityops CLI."""
17
+
18
+ from sanityops_cli.exceptions.base_exceptions import SanityopsError
19
+
20
+
21
+ class APIError(SanityopsError):
22
+ """Base exception for API-related errors."""
23
+
24
+ exit_code: int = 5
25
+
26
+
27
+ class AuthenticationError(APIError):
28
+ """Authentication failed (401/403).
29
+
30
+ The API key is invalid, expired, or lacks required permissions.
31
+ """
32
+
33
+ exit_code: int = 5
34
+
35
+
36
+ class ProjectNotFoundError(APIError):
37
+ """Project not found (404).
38
+
39
+ The specified project ID does not exist or user lacks access.
40
+ """
41
+
42
+ exit_code: int = 6
43
+
44
+
45
+ class NetworkError(APIError):
46
+ """Network-related error.
47
+
48
+ Connection timeout, DNS failure, or other network issues.
49
+ """
50
+
51
+ exit_code: int = 7
52
+
53
+
54
+ class APIValidationError(APIError):
55
+ """Validation error (422).
56
+
57
+ Request parameters failed validation.
58
+ """
59
+
60
+ exit_code: int = 2
@@ -0,0 +1,25 @@
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
+ """Sanityops CLI Error Definitions"""
17
+
18
+
19
+ class SanityopsError(Exception):
20
+ """Sanityops CLI Base Exception Class"""
21
+ exit_code: int = 2
22
+
23
+ class ValidationError(SanityopsError):
24
+ """Validation Error Exception Class"""
25
+ exit_code: int = 2
@@ -0,0 +1,49 @@
1
+ """Getting Started and Advanced Usage panels for CLI help output."""
2
+
3
+ from rich.panel import Panel
4
+ from rich.text import Text
5
+
6
+ GETTING_STARTED_TEXT = """\
7
+ 1. Run 'sanityops-cli init' to create the default configuration
8
+ 2. Edit .sanityops/inspect_config.yaml to configure your project
9
+ 3. Run 'sanityops-cli inspect' to start the inspection
10
+ 4. Run 'sanityops-cli inspect repair' to generate fixes (optional)
11
+ """
12
+
13
+ ADVANCED_USAGE_TEXT = """\
14
+ CI/CD Integration:
15
+ 1. Commit .sanityops/inspect_config.yaml to the repository
16
+ 2. Set LLM API keys via environment variables (never commit sensitive keys)
17
+ 3. Run 'sanityops-cli inspect' in the pipeline
18
+
19
+ Config Command (sanityops-cli config --help):
20
+ View detailed configuration options and usage examples
21
+ """
22
+
23
+
24
+ def get_getting_started_panel() -> Panel:
25
+ """Create the Getting Started panel for help output.
26
+
27
+ Returns:
28
+ A Rich Panel containing the 4-step Getting Started workflow.
29
+ """
30
+ return Panel(
31
+ Text(GETTING_STARTED_TEXT, justify="left"),
32
+ title="Getting Started",
33
+ border_style="blue",
34
+ padding=(0, 1),
35
+ )
36
+
37
+
38
+ def get_advanced_usage_panel() -> Panel:
39
+ """Create the Advanced Usage panel for help output.
40
+
41
+ Returns:
42
+ A Rich Panel containing CI/CD integration and config reference.
43
+ """
44
+ return Panel(
45
+ Text(ADVANCED_USAGE_TEXT, justify="left"),
46
+ title="Advanced Usage",
47
+ border_style="cyan",
48
+ padding=(0, 1),
49
+ )
@@ -0,0 +1,18 @@
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
+ from sanityops_cli.logging.logger import Logger
17
+
18
+ __all__ = ["Logger"]
@@ -0,0 +1,108 @@
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
+ """File-based logging for sanityops-cli operations."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import re
22
+ from datetime import date
23
+ from pathlib import Path
24
+
25
+ #: Patterns used to redact secrets before writing to disk.
26
+ _SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
27
+ (re.compile(r"sk-[A-Za-z0-9_-]{8,}"), "sk-***"),
28
+ (re.compile(r"(api[_-]?key\s*[:=]\s*)[^\s\"',}]+", re.IGNORECASE), r"\1***"),
29
+ ]
30
+
31
+ __all__ = ["Logger"]
32
+
33
+
34
+ class Logger:
35
+ """Appends timestamped, leveled messages to a dated log file.
36
+
37
+ Each instance owns its own stdlib logger and file handler so multiple
38
+ instances (e.g. in tests) do not share handlers.
39
+
40
+ Process-scoped usage (the CLI) intentionally relies on interpreter exit to
41
+ flush and close the handler; ``close()`` is provided for long-lived callers
42
+ and tests to release the handle explicitly.
43
+ """
44
+
45
+ def __init__(self, log_dir: Path | None = None) -> None:
46
+ if log_dir is not None:
47
+ self.log_dir = Path(log_dir)
48
+ else:
49
+ self.log_dir = Path.home() / ".sanityops" / "logs"
50
+ self.log_dir.mkdir(parents=True, exist_ok=True)
51
+ self.log_file = self.log_dir / f"sanityops-cli-{date.today().isoformat()}.log"
52
+ self._logger = logging.getLogger(f"sanityops_cli.{id(self)}")
53
+ self._logger.setLevel(logging.DEBUG)
54
+ self._logger.propagate = False
55
+ handler = logging.FileHandler(self.log_file, encoding="utf-8")
56
+ handler.setFormatter(
57
+ logging.Formatter(
58
+ "%(asctime)s [%(levelname)s] %(message)s",
59
+ datefmt="%Y-%m-%d %H:%M:%S",
60
+ )
61
+ )
62
+ self._logger.addHandler(handler)
63
+
64
+ def debug(self, message: str) -> None:
65
+ self._logger.debug(Logger.redact(message))
66
+
67
+ def info(self, message: str) -> None:
68
+ self._logger.info(Logger.redact(message))
69
+
70
+ def warning(self, message: str) -> None:
71
+ self._logger.warning(Logger.redact(message))
72
+
73
+ def error(self, message: str) -> None:
74
+ self._logger.error(Logger.redact(message))
75
+
76
+ def step_started(self, step_name: str) -> None:
77
+ self.info(f"Step started: {step_name}")
78
+
79
+ def step_completed(self, step_name: str, duration: float) -> None:
80
+ self.info(f"Step completed: {step_name} ({duration:.2f}s)")
81
+
82
+ def step_failed(self, step_name: str, error: str) -> None:
83
+ self.error(f"Step failed: {step_name} - {error}")
84
+
85
+ def get_log_path(self) -> Path:
86
+ return self.log_file
87
+
88
+ def close(self) -> None:
89
+ """Close and remove the file handler, releasing the open handle.
90
+
91
+ Safe to call more than once; after closing, further log calls are
92
+ dropped (no handler remains to write them).
93
+ """
94
+ for handler in self._logger.handlers[:]:
95
+ handler.close()
96
+ self._logger.removeHandler(handler)
97
+
98
+ @staticmethod
99
+ def redact(message: str) -> str:
100
+ """Mask API keys and secret-bearing substrings before logging.
101
+
102
+ Coverage is intentionally limited to OpenAI-style ``sk-...`` keys and
103
+ ``api_key=`` / ``api-key:`` assignments; other credential formats
104
+ (e.g. AWS, Azure) are not matched and should not be logged.
105
+ """
106
+ for pattern, repl in _SECRET_PATTERNS:
107
+ message = pattern.sub(repl, message)
108
+ return message
sanityops_cli/main.py ADDED
@@ -0,0 +1,123 @@
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
+ """Sanityops CLI Entrance — Typer Application"""
17
+
18
+ import sys
19
+
20
+ # Windows consoles default to GBK/cp936 in zh-CN locales, which cannot encode
21
+ # symbols like ✓/✗/⚠ used throughout the CLI output. Force UTF-8 streams early
22
+ # so output never crashes regardless of terminal code page.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ if hasattr(_stream, "reconfigure"):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (ValueError, OSError):
28
+ pass
29
+
30
+ import typer # noqa: E402 - must come after stdout reconfiguration
31
+ from rich.console import Console # noqa: E402
32
+
33
+ from sanityops_cli import __version__ # noqa: E402
34
+ from sanityops_cli.commands.config import config_app # noqa: E402
35
+ from sanityops_cli.commands.init import init_config # noqa: E402
36
+ from sanityops_cli.commands.inspect import inspect_app # noqa: E402
37
+ from sanityops_cli.exceptions.base_exceptions import ValidationError # noqa: E402
38
+ from sanityops_cli.help_panel import ( # noqa: E402
39
+ get_advanced_usage_panel,
40
+ get_getting_started_panel,
41
+ )
42
+
43
+ app = typer.Typer(
44
+ name="Sanityops-cli",
45
+ help="Sanityops CLI Tool — A cli tool for sanityops",
46
+ no_args_is_help=True,
47
+ add_completion=False,
48
+ )
49
+
50
+
51
+ def version_callback(value: bool) -> None:
52
+ if value:
53
+ typer.echo(f"sanityops-cli v{__version__}")
54
+ typer.echo("Copyright (C) 2026 zipsonken / Sanity AI Labs")
55
+ typer.echo("License: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)")
56
+ raise typer.Exit()
57
+
58
+
59
+ @app.callback()
60
+ def callback(
61
+ _version: bool = typer.Option(
62
+ False,
63
+ "--version",
64
+ "-V",
65
+ help="Show version and exit",
66
+ is_eager=True,
67
+ callback=version_callback,
68
+ ),
69
+ ):
70
+ """Sanityops CLI callback."""
71
+
72
+ app.add_typer(inspect_app, name="inspect", help="Static defect inspection of logical artifacts (System Prompts, Skills, Tool Schemas), suitable for local development or CI/CD integration")
73
+ app.add_typer(
74
+ config_app,
75
+ name="config",
76
+ help="Get and set sanityops configuration (server base URL, API key, etc.)",
77
+ )
78
+ app.command("init")(init_config)
79
+
80
+
81
+ def main():
82
+ """Entrance function for the Sanityops CLI application."""
83
+ # Show panels only when --help is the sole flag or there are no args.
84
+ # This narrow trigger avoids showing panels on usage errors (-h, --bogus).
85
+ #
86
+ # Note: --version/-V doesn't need explicit handling here because:
87
+ # - `--version` won't match the exact `["--help"]` check
88
+ # - Single-arg `--version` has len > 1, so is_no_args_help is False
89
+ # - It falls through to the normal app() path, which prints version and exits.
90
+ is_only_help = sys.argv[1:] == ["--help"]
91
+ is_no_args_help = len(sys.argv) == 1
92
+
93
+ if is_only_help or is_no_args_help:
94
+ # Use a custom console to capture and extend help output
95
+ console = Console()
96
+ try:
97
+ app()
98
+ except SystemExit as e:
99
+ # Swallow the help exit so the panels can be printed after it.
100
+ # --help exits 0; no_args_is_help triggers Typer's help which
101
+ # exits 2 (Click's UsageError code). We catch both and continue
102
+ # to print panels, resulting in exit 0 for both cases.
103
+ if e.code not in (0, 2):
104
+ raise
105
+ # Print the Getting Started and Advanced Usage panels
106
+ console.print()
107
+ console.print(get_getting_started_panel())
108
+ console.print()
109
+ console.print(get_advanced_usage_panel())
110
+ return
111
+
112
+ try:
113
+ app()
114
+ except ValidationError as e:
115
+ print(f"Validation Error: {e}", file=sys.stderr)
116
+ sys.exit(1)
117
+ except Exception as e:
118
+ print(f"Unknown Error: {e}", file=sys.stderr)
119
+ sys.exit(1)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,18 @@
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
+ from sanityops_cli.progress.tracker import ProgressTracker, StepContext
17
+
18
+ __all__ = ["ProgressTracker", "StepContext"]