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,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).