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,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"]
|