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,358 @@
|
|
|
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
|
+
"""Generic config resolver with precedence chain: project > global > env > default."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from sanityops_cli.utils.config_loader import read_file_with_encoding_fallback
|
|
28
|
+
|
|
29
|
+
# Default config file locations
|
|
30
|
+
DEFAULT_PROJECT_CONFIG_DIR = ".sanityops"
|
|
31
|
+
DEFAULT_PROJECT_CONFIG_FILE = "inspect_config.yaml"
|
|
32
|
+
GLOBAL_CONFIG_DIR = ".sanityops"
|
|
33
|
+
GLOBAL_CONFIG_FILE = "config"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_global_config_path() -> Path:
|
|
37
|
+
"""Return the global config file path, cross-platform.
|
|
38
|
+
|
|
39
|
+
Uses Path.home() which correctly resolves:
|
|
40
|
+
- macOS/Linux: $HOME
|
|
41
|
+
- Windows: %USERPROFILE%
|
|
42
|
+
"""
|
|
43
|
+
return Path.home() / GLOBAL_CONFIG_DIR / GLOBAL_CONFIG_FILE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_project_config_path() -> Path:
|
|
47
|
+
"""Return the project-level config file path in current working directory."""
|
|
48
|
+
return Path.cwd() / DEFAULT_PROJECT_CONFIG_DIR / DEFAULT_PROJECT_CONFIG_FILE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_yaml_file(path: Path) -> dict[str, Any]:
|
|
52
|
+
"""Read a YAML file with encoding fallback (UTF-8 -> GBK for Windows).
|
|
53
|
+
|
|
54
|
+
Returns empty dict if file doesn't exist or is unreadable.
|
|
55
|
+
"""
|
|
56
|
+
if not path.exists() or not path.is_file():
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
content = read_file_with_encoding_fallback(path)
|
|
60
|
+
if content is None:
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
data = yaml.safe_load(content)
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
return {}
|
|
67
|
+
return data
|
|
68
|
+
except yaml.YAMLError:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_global_config_path(path: Path) -> bool:
|
|
73
|
+
"""Check if the path is the global config file path."""
|
|
74
|
+
return path == get_global_config_path()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ensure_restricted_permissions(path: Path, is_file: bool = True) -> None:
|
|
78
|
+
"""Ensure restricted permissions for global config paths.
|
|
79
|
+
|
|
80
|
+
For directories: 0o700 (owner-only access)
|
|
81
|
+
For files: 0o600 (owner-only read/write)
|
|
82
|
+
|
|
83
|
+
No-op for non-global paths. Best-effort on non-POSIX filesystems.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
if is_file:
|
|
87
|
+
path.chmod(0o600)
|
|
88
|
+
else:
|
|
89
|
+
path.chmod(0o700)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass # Non-POSIX filesystem (e.g., Windows)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_yaml_file(path: Path, data: dict[str, Any]) -> None:
|
|
95
|
+
"""Write data to a YAML file with atomic replacement.
|
|
96
|
+
|
|
97
|
+
Creates parent directories if needed.
|
|
98
|
+
Uses an unpredictable temp file + rename to prevent corruption on
|
|
99
|
+
interruption and symlink attacks. The temp file is created with
|
|
100
|
+
owner-only permissions (0o600) from the start (via O_EXCL), so sensitive
|
|
101
|
+
values are never written to a world-readable file.
|
|
102
|
+
"""
|
|
103
|
+
is_global = _is_global_config_path(path)
|
|
104
|
+
|
|
105
|
+
# Create parent directory
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
# For global config, restrict directory permissions first
|
|
109
|
+
if is_global:
|
|
110
|
+
_ensure_restricted_permissions(path.parent, is_file=False)
|
|
111
|
+
|
|
112
|
+
# Create an unpredictable temp file in the same directory with restrictive
|
|
113
|
+
# permissions. mkstemp() creates the file with mode 0o600 by default, meaning
|
|
114
|
+
# the file is owner-only from creation and symlink attacks are prevented.
|
|
115
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
116
|
+
dir=path.parent,
|
|
117
|
+
prefix=f".{path.name}.tmp-",
|
|
118
|
+
text=True,
|
|
119
|
+
)
|
|
120
|
+
tmp_path = Path(tmp_name)
|
|
121
|
+
try:
|
|
122
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
123
|
+
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
|
|
124
|
+
# Atomic on POSIX, near-atomic on Windows
|
|
125
|
+
os.replace(tmp_path, path)
|
|
126
|
+
# Ensure restrictive permissions on the destination file after atomic
|
|
127
|
+
# replace. os.replace() may preserve existing destination permissions
|
|
128
|
+
# if the file already existed with looser permissions.
|
|
129
|
+
if is_global:
|
|
130
|
+
_ensure_restricted_permissions(path, is_file=True)
|
|
131
|
+
except BaseException:
|
|
132
|
+
# Close fd if os.fdopen() failed before wrapping it (the fd would
|
|
133
|
+
# otherwise leak). If fdopen succeeded, the file is already closed and
|
|
134
|
+
# os.close() raises OSError (EBADF), which we swallow.
|
|
135
|
+
try:
|
|
136
|
+
os.close(fd)
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
# Clean up temp file on failure
|
|
140
|
+
if tmp_path.exists():
|
|
141
|
+
tmp_path.unlink(missing_ok=True)
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_nested_value(data: dict[str, Any], key: str) -> Any:
|
|
146
|
+
"""Get a nested value from a dict using dot-separated key.
|
|
147
|
+
|
|
148
|
+
Example: _get_nested_value({"server": {"base_url": "x"}}, "server.base_url")
|
|
149
|
+
Returns None if any intermediate key is missing or not a dict.
|
|
150
|
+
"""
|
|
151
|
+
parts = key.split(".")
|
|
152
|
+
current: Any = data
|
|
153
|
+
for part in parts:
|
|
154
|
+
if not isinstance(current, dict):
|
|
155
|
+
return None
|
|
156
|
+
current = current.get(part)
|
|
157
|
+
if current is None:
|
|
158
|
+
return None
|
|
159
|
+
return current
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _set_nested_value(data: dict[str, Any], key: str, value: Any) -> None:
|
|
163
|
+
"""Set a nested value in a dict using dot-separated key.
|
|
164
|
+
|
|
165
|
+
Creates intermediate dicts as needed.
|
|
166
|
+
Example: _set_nested_value(data, "server.base_url", "x")
|
|
167
|
+
"""
|
|
168
|
+
parts = key.split(".")
|
|
169
|
+
current = data
|
|
170
|
+
for part in parts[:-1]:
|
|
171
|
+
if part not in current or not isinstance(current[part], dict):
|
|
172
|
+
current[part] = {}
|
|
173
|
+
current = current[part]
|
|
174
|
+
current[parts[-1]] = value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _unset_nested_value(data: dict[str, Any], key: str) -> bool:
|
|
178
|
+
"""Remove a nested key from a dict using dot-separated key.
|
|
179
|
+
|
|
180
|
+
Returns True if key was present and removed, False otherwise.
|
|
181
|
+
Cleans up empty intermediate dicts after removal.
|
|
182
|
+
"""
|
|
183
|
+
parts = key.split(".")
|
|
184
|
+
current = data
|
|
185
|
+
|
|
186
|
+
# Track path for cleanup of empty parents
|
|
187
|
+
path_stack: list[tuple[dict[str, Any], str]] = []
|
|
188
|
+
|
|
189
|
+
# Navigate to parent
|
|
190
|
+
for part in parts[:-1]:
|
|
191
|
+
if not isinstance(current, dict) or part not in current:
|
|
192
|
+
return False
|
|
193
|
+
path_stack.append((current, part))
|
|
194
|
+
current = current[part]
|
|
195
|
+
|
|
196
|
+
if not isinstance(current, dict):
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
final_key = parts[-1]
|
|
200
|
+
if final_key in current:
|
|
201
|
+
del current[final_key]
|
|
202
|
+
else:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
# Clean up empty intermediate dicts (from deepest to shallowest)
|
|
206
|
+
for parent_dict, child_key in reversed(path_stack):
|
|
207
|
+
child = parent_dict[child_key]
|
|
208
|
+
if isinstance(child, dict) and not child:
|
|
209
|
+
del parent_dict[child_key]
|
|
210
|
+
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ConfigResolver:
|
|
215
|
+
"""Generic config resolver with precedence chain.
|
|
216
|
+
|
|
217
|
+
Precedence: project-level > global > environment variable > default.
|
|
218
|
+
|
|
219
|
+
Attributes:
|
|
220
|
+
config_key: Dot-separated config key (e.g., "server.base_url").
|
|
221
|
+
env_var: Environment variable name (e.g., "SANITYOPS_BASE_URL").
|
|
222
|
+
default: Default value if none of the above provide a value.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self,
|
|
227
|
+
config_key: str,
|
|
228
|
+
env_var: str | None = None,
|
|
229
|
+
default: str | None = None,
|
|
230
|
+
):
|
|
231
|
+
self.config_key = config_key
|
|
232
|
+
self.env_var = env_var
|
|
233
|
+
self.default = default
|
|
234
|
+
|
|
235
|
+
def resolve(self) -> str | None:
|
|
236
|
+
"""Resolve config value following precedence chain.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The resolved value, or None if not found and no default.
|
|
240
|
+
"""
|
|
241
|
+
# 1. Project-level config
|
|
242
|
+
project_config = _read_yaml_file(get_project_config_path())
|
|
243
|
+
value = _get_nested_value(project_config, self.config_key)
|
|
244
|
+
if value is not None:
|
|
245
|
+
return str(value) if not isinstance(value, str) else value
|
|
246
|
+
|
|
247
|
+
# 2. Global config
|
|
248
|
+
global_config = _read_yaml_file(get_global_config_path())
|
|
249
|
+
value = _get_nested_value(global_config, self.config_key)
|
|
250
|
+
if value is not None:
|
|
251
|
+
return str(value) if not isinstance(value, str) else value
|
|
252
|
+
|
|
253
|
+
# 3. Environment variable
|
|
254
|
+
if self.env_var:
|
|
255
|
+
env_value = os.environ.get(self.env_var)
|
|
256
|
+
if env_value is not None:
|
|
257
|
+
return env_value
|
|
258
|
+
|
|
259
|
+
# 4. Default
|
|
260
|
+
return self.default
|
|
261
|
+
|
|
262
|
+
def resolve_all_sources(self) -> dict[str, str | None]:
|
|
263
|
+
"""Resolve value from each source for display purposes.
|
|
264
|
+
|
|
265
|
+
Returns dict with keys: 'project', 'global', 'env', 'default'.
|
|
266
|
+
Useful for --list output showing where each value comes from.
|
|
267
|
+
"""
|
|
268
|
+
result: dict[str, str | None] = {}
|
|
269
|
+
|
|
270
|
+
# Project-level
|
|
271
|
+
project_config = _read_yaml_file(get_project_config_path())
|
|
272
|
+
project_value = _get_nested_value(project_config, self.config_key)
|
|
273
|
+
result["project"] = str(project_value) if project_value is not None else None
|
|
274
|
+
|
|
275
|
+
# Global
|
|
276
|
+
global_config = _read_yaml_file(get_global_config_path())
|
|
277
|
+
global_value = _get_nested_value(global_config, self.config_key)
|
|
278
|
+
result["global"] = str(global_value) if global_value is not None else None
|
|
279
|
+
|
|
280
|
+
# Env
|
|
281
|
+
result["env"] = os.environ.get(self.env_var) if self.env_var else None
|
|
282
|
+
|
|
283
|
+
# Default
|
|
284
|
+
result["default"] = self.default
|
|
285
|
+
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def set_global(key: str, value: str) -> None:
|
|
290
|
+
"""Set a value in the global config file.
|
|
291
|
+
|
|
292
|
+
Creates the file if it doesn't exist. Merges with existing content.
|
|
293
|
+
"""
|
|
294
|
+
path = get_global_config_path()
|
|
295
|
+
data = _read_yaml_file(path)
|
|
296
|
+
_set_nested_value(data, key, value)
|
|
297
|
+
_write_yaml_file(path, data)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def set_project(key: str, value: str) -> None:
|
|
301
|
+
"""Set a value in the project-level config file.
|
|
302
|
+
|
|
303
|
+
Creates the file if it doesn't exist. Merges with existing content.
|
|
304
|
+
"""
|
|
305
|
+
path = get_project_config_path()
|
|
306
|
+
data = _read_yaml_file(path)
|
|
307
|
+
_set_nested_value(data, key, value)
|
|
308
|
+
_write_yaml_file(path, data)
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def unset_global(key: str) -> bool:
|
|
312
|
+
"""Remove a key from the global config file.
|
|
313
|
+
|
|
314
|
+
Returns True if key was present and removed, False otherwise.
|
|
315
|
+
If the file becomes empty after removal, it is deleted.
|
|
316
|
+
"""
|
|
317
|
+
path = get_global_config_path()
|
|
318
|
+
data = _read_yaml_file(path)
|
|
319
|
+
if not data:
|
|
320
|
+
return False
|
|
321
|
+
removed = _unset_nested_value(data, key)
|
|
322
|
+
if removed:
|
|
323
|
+
if data:
|
|
324
|
+
_write_yaml_file(path, data)
|
|
325
|
+
else:
|
|
326
|
+
# File is now empty; remove it entirely
|
|
327
|
+
path.unlink(missing_ok=True)
|
|
328
|
+
return removed
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def unset_project(key: str) -> bool:
|
|
332
|
+
"""Remove a key from the project-level config file.
|
|
333
|
+
|
|
334
|
+
Returns True if key was present and removed, False otherwise.
|
|
335
|
+
If the file becomes empty after removal, it is deleted.
|
|
336
|
+
"""
|
|
337
|
+
path = get_project_config_path()
|
|
338
|
+
data = _read_yaml_file(path)
|
|
339
|
+
if not data:
|
|
340
|
+
return False
|
|
341
|
+
removed = _unset_nested_value(data, key)
|
|
342
|
+
if removed:
|
|
343
|
+
if data:
|
|
344
|
+
_write_yaml_file(path, data)
|
|
345
|
+
else:
|
|
346
|
+
# File is now empty; remove it entirely
|
|
347
|
+
path.unlink(missing_ok=True)
|
|
348
|
+
return removed
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def list_global() -> dict[str, Any]:
|
|
352
|
+
"""List all config values from global config file."""
|
|
353
|
+
return _read_yaml_file(get_global_config_path())
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def list_project() -> dict[str, Any]:
|
|
357
|
+
"""List all config values from project-level config file."""
|
|
358
|
+
return _read_yaml_file(get_project_config_path())
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
"""local validators for sanityops-cli"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from sanityops_cli.exceptions.base_exceptions import ValidationError
|
|
23
|
+
|
|
24
|
+
# Exclude directories
|
|
25
|
+
EXCLUDED_DIRS = {
|
|
26
|
+
".git",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
"dist",
|
|
30
|
+
"build",
|
|
31
|
+
".venv",
|
|
32
|
+
"venv",
|
|
33
|
+
".idea",
|
|
34
|
+
".vscode",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def validate_inspect_directory(directory: str) -> None:
|
|
38
|
+
"""validate inspect directory"""
|
|
39
|
+
import os
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
path = Path(directory)
|
|
43
|
+
|
|
44
|
+
# folder exists
|
|
45
|
+
if not path.exists():
|
|
46
|
+
raise ValidationError(f"Folder does not exist: {directory}")
|
|
47
|
+
|
|
48
|
+
# is a directory
|
|
49
|
+
if not path.is_dir():
|
|
50
|
+
raise ValidationError(f"Path is not a directory: {directory}")
|
|
51
|
+
|
|
52
|
+
# folder is readable
|
|
53
|
+
if not os.access(path, os.R_OK | os.X_OK):
|
|
54
|
+
raise ValidationError(f"Folder is not readable: {directory}")
|
|
55
|
+
|
|
56
|
+
# folder is not empty
|
|
57
|
+
try:
|
|
58
|
+
items = list(path.iterdir())
|
|
59
|
+
# Exclude certain directories from the list of items
|
|
60
|
+
items = [i for i in items if i.name not in EXCLUDED_DIRS]
|
|
61
|
+
if not items:
|
|
62
|
+
raise ValidationError(f"Folder is empty: {directory}")
|
|
63
|
+
except PermissionError:
|
|
64
|
+
raise ValidationError(f"Folder is not readable: {directory}") from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_artifact_inputs(
|
|
68
|
+
skills: list[str],
|
|
69
|
+
tools: list[str],
|
|
70
|
+
prompts: list[str],
|
|
71
|
+
level: str,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""validate artifact inputs for inspect command"""
|
|
74
|
+
if not skills and not tools and not prompts:
|
|
75
|
+
raise ValidationError("at least one of (--skill / --tools / --prompt) must be provided")
|
|
76
|
+
|
|
77
|
+
for label, paths in [("Skill", skills), ("Tools Schema", tools), ("System Prompt", prompts)]:
|
|
78
|
+
for path_str in paths:
|
|
79
|
+
p = Path(path_str)
|
|
80
|
+
if not p.exists():
|
|
81
|
+
raise ValidationError(f"Path does not exist: {path_str}")
|
|
82
|
+
|
|
83
|
+
if label == "Skill":
|
|
84
|
+
if p.is_file():
|
|
85
|
+
if not os.access(p, os.R_OK):
|
|
86
|
+
raise ValidationError(f"File is not readable: {path_str}")
|
|
87
|
+
if p.stat().st_size == 0:
|
|
88
|
+
raise ValidationError(f"File is empty: {path_str}")
|
|
89
|
+
elif p.is_dir():
|
|
90
|
+
if not os.access(p, os.R_OK):
|
|
91
|
+
raise ValidationError(f"Directory is not readable: {path_str}")
|
|
92
|
+
else:
|
|
93
|
+
raise ValidationError(f"Skill path must be a file or directory: {path_str}")
|
|
94
|
+
else:
|
|
95
|
+
if not p.is_file():
|
|
96
|
+
raise ValidationError(f"Path is not a file: {path_str}")
|
|
97
|
+
if not os.access(p, os.R_OK):
|
|
98
|
+
raise ValidationError(f"File is not readable: {path_str}")
|
|
99
|
+
if p.stat().st_size == 0:
|
|
100
|
+
raise ValidationError(f"File is empty: {path_str}")
|
|
101
|
+
|
|
102
|
+
for path_str in tools:
|
|
103
|
+
try:
|
|
104
|
+
content = Path(path_str).read_text(encoding="utf-8")
|
|
105
|
+
except Exception:
|
|
106
|
+
raise ValidationError(f"File is not readable: {path_str}") from None
|
|
107
|
+
try:
|
|
108
|
+
parsed = json.loads(content)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
raise ValidationError(f"Tools Schema is not valid JSON: {path_str}") from None
|
|
111
|
+
if not isinstance(parsed, (dict, list)):
|
|
112
|
+
raise ValidationError(f"Tools Schema must be a JSON object or array: {path_str}")
|
|
113
|
+
|
|
114
|
+
if level.upper() not in ("L1", "L2", "L3"):
|
|
115
|
+
raise ValidationError(f"Level must be one of L1/L2/L3, current value: {level}")
|
|
116
|
+
|
|
117
|
+
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sanityops-cli
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: CLI tool for static defect inspection of AI logical artifacts
|
|
5
|
+
Project-URL: Homepage, https://sanityops.org
|
|
6
|
+
Project-URL: Documentation, https://sanityops.org
|
|
7
|
+
Project-URL: Repository, https://github.com/sanityops-org/sanityops-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/sanityops-org/sanityops-cli/issues
|
|
9
|
+
Author: Sanityops Team
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
13
|
+
Keywords: ai,cli,defect-detection,llm,static-analysis
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: anyio>=4.0
|
|
26
|
+
Requires-Dist: defect-check
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Requires-Dist: rich>=13.0
|
|
30
|
+
Requires-Dist: sanityops-agent>=0.0.4
|
|
31
|
+
Requires-Dist: tomli-w>=1.0
|
|
32
|
+
Requires-Dist: tomli>=2.0
|
|
33
|
+
Requires-Dist: typer>=0.12
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'test'
|
|
38
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Sanityops CLI
|
|
42
|
+
|
|
43
|
+
A command-line tool for static defect inspection of AI logical artifacts (System Prompts, Skills, Tool Schemas). Designed for local development and CI/CD integration.
|
|
44
|
+
|
|
45
|
+
**[Documentation](https://sanityops.org)** | **[Report Bug](https://github.com/sanityops-org/sanityops-cli/issues)** | **[Request Feature](https://github.com/sanityops-org/sanityops-cli/issues)**
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- **Multi-level inspection depth** — Choose between L1 (fast), L2 (standard), or L3 (deep) check levels
|
|
50
|
+
- **Multiple artifact types** — Inspect System Prompts, Skills, and Tool Schemas
|
|
51
|
+
- **CI/CD ready** — Designed for automated pipeline integration
|
|
52
|
+
- **Configurable rules** — Customize inspection behavior via YAML configuration
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
### From Curl (Recommended)
|
|
57
|
+
|
|
58
|
+
**macOS and Linux:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
curl -fsSL https://downloads.sanityops.org/sanityops-cli/install.sh | bash
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Windows PowerShell:**
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
irm https://downloads.sanityops.org/sanityops-cli/install.ps1 | iex
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
For a reviewable PowerShell installation, download the script first:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
irm https://downloads.sanityops.org/sanityops-cli/install.ps1 -OutFile install.ps1
|
|
74
|
+
Get-Content .\install.ps1
|
|
75
|
+
.\install.ps1
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Both installers verify the downloaded binary against `checksums.txt` before installing. They default to user-writable locations (`~/.local/bin` on macOS/Linux and `%USERPROFILE%\.sanityops\bin` on Windows), then add the Windows directory to the user PATH if needed.
|
|
79
|
+
|
|
80
|
+
### From PyPI
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install sanityops-cli
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### From Source
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git clone https://github.com/sanityops-org/sanityops-cli.git
|
|
90
|
+
cd sanityops-cli
|
|
91
|
+
pip install -e .
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Quick Start
|
|
95
|
+
|
|
96
|
+
### 1. Initialize Configuration
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
sanityops-cli init
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This creates `.sanityops/inspect_config.yaml` in your current directory with a
|
|
103
|
+
freshly generated project UUID. The generated file matches the template below —
|
|
104
|
+
edit the artifact paths to point at your prompts, tools, and skills.
|
|
105
|
+
|
|
106
|
+
### 2. Configure Artifacts
|
|
107
|
+
|
|
108
|
+
Edit `.sanityops/inspect_config.yaml` to specify your artifacts:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
project:
|
|
112
|
+
id: <your-project-id> # required: UUID
|
|
113
|
+
|
|
114
|
+
# Optional: omit to use LLM_* environment variables
|
|
115
|
+
# model:
|
|
116
|
+
# provider: anthropic
|
|
117
|
+
# api_key: sk-ant-...
|
|
118
|
+
# model_id: claude-sonnet-4-20250514
|
|
119
|
+
# base_url: ""
|
|
120
|
+
|
|
121
|
+
prompts:
|
|
122
|
+
- file: prompts/system_prompt.md
|
|
123
|
+
|
|
124
|
+
tools:
|
|
125
|
+
- file: tools/search_tools.json
|
|
126
|
+
|
|
127
|
+
skills:
|
|
128
|
+
- file: skills/code_review.md # file only, not directories
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 3. Run Inspection
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Standard inspection (L2 depth)
|
|
135
|
+
sanityops-cli inspect
|
|
136
|
+
|
|
137
|
+
# Fast inspection (L1 depth)
|
|
138
|
+
sanityops-cli inspect --check-level L1
|
|
139
|
+
|
|
140
|
+
# Deep inspection (L3 depth)
|
|
141
|
+
sanityops-cli inspect --check-level L3
|
|
142
|
+
|
|
143
|
+
# Skip defect check, only analyze artifacts
|
|
144
|
+
sanityops-cli inspect --skip-defect-check
|
|
145
|
+
|
|
146
|
+
# Use custom config file
|
|
147
|
+
sanityops-cli inspect --config path/to/config.yaml
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Check Levels
|
|
151
|
+
|
|
152
|
+
| Level | Description | Use Case |
|
|
153
|
+
|-------|-------------|----------|
|
|
154
|
+
| L1 | Fast | Quick validation during development |
|
|
155
|
+
| L2 | Standard | Default, balanced depth for regular use |
|
|
156
|
+
| L3 | Deep | Comprehensive analysis for production releases |
|
|
157
|
+
|
|
158
|
+
## Documentation
|
|
159
|
+
|
|
160
|
+
Full documentation is available at [sanityops.org](https://sanityops.org).
|
|
161
|
+
|
|
162
|
+
## Requirements
|
|
163
|
+
|
|
164
|
+
- Python >= 3.11
|
|
165
|
+
- OpenAI API key (or compatible LLM provider) for defect checking
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
### Setup
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Clone the repository
|
|
173
|
+
git clone https://github.com/sanityops-org/sanityops-cli.git
|
|
174
|
+
cd sanityops-cli
|
|
175
|
+
|
|
176
|
+
# Create virtual environment
|
|
177
|
+
python -m venv .venv
|
|
178
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
179
|
+
|
|
180
|
+
# Install development dependencies
|
|
181
|
+
pip install -e ".[dev,test]"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Run Tests
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pytest
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Code Style
|
|
191
|
+
|
|
192
|
+
This project uses [Ruff](https://docs.astral.sh/ruff/) for linting. Run:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
ruff check .
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Contributing
|
|
199
|
+
|
|
200
|
+
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
|
|
201
|
+
|
|
202
|
+
- Development setup
|
|
203
|
+
- Code style guidelines
|
|
204
|
+
- Pull request process
|
|
205
|
+
- Commit message conventions
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
|
210
|
+
|
|
211
|
+
## Acknowledgments
|
|
212
|
+
|
|
213
|
+
Built with [Typer](https://typer.tiangolo.com/) and [Rich](https://github.com/Textualize/rich).
|