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,407 @@
|
|
|
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
|
+
"""Artifact packer — prepare artifacts for upload.
|
|
17
|
+
|
|
18
|
+
This module handles:
|
|
19
|
+
- Merging multiple prompt files into a single text
|
|
20
|
+
- Merging multiple tool schema files into a JSON array
|
|
21
|
+
- Packing skill directories/files into a ZIP archive
|
|
22
|
+
- Packing from ScannerAgent findings (extracted content)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import warnings
|
|
29
|
+
import zipfile
|
|
30
|
+
from io import BytesIO
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from sanityops_cli.agents.scanner_agent.models.finding import (
|
|
35
|
+
Finding,
|
|
36
|
+
PromptContent,
|
|
37
|
+
SkillContent,
|
|
38
|
+
ToolContent,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Module-level exclusion set for files/dirs skipped when packing skill directories.
|
|
42
|
+
EXCLUDED_DIRS = {"__pycache__", "node_modules", ".git"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_excluded_path(file_path: Path) -> bool:
|
|
46
|
+
"""Return True if any path component is an excluded directory.
|
|
47
|
+
|
|
48
|
+
Compares directory names (not just the final filename), so paths like
|
|
49
|
+
``project/__pycache__/module.pyc`` are correctly skipped even though
|
|
50
|
+
``file_path.name`` is ``module.pyc``.
|
|
51
|
+
"""
|
|
52
|
+
return any(part in EXCLUDED_DIRS for part in file_path.parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ArtifactPacker:
|
|
56
|
+
"""Prepare artifacts for upload to the sanityops server.
|
|
57
|
+
|
|
58
|
+
The server expects:
|
|
59
|
+
- prompt_content: Single text string (merged from all prompt files)
|
|
60
|
+
- tools_schema: JSON string of tool definitions array
|
|
61
|
+
- skill_file: ZIP file containing skill directories/files
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
prompts: list[str],
|
|
67
|
+
tools: list[str],
|
|
68
|
+
skills: list[str],
|
|
69
|
+
):
|
|
70
|
+
"""Initialize the packer with artifact file paths.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
prompts: List of absolute paths to prompt files.
|
|
74
|
+
tools: List of absolute paths to tool schema files.
|
|
75
|
+
skills: List of absolute paths to skill files or directories.
|
|
76
|
+
"""
|
|
77
|
+
self.prompts = prompts
|
|
78
|
+
self.tools = tools
|
|
79
|
+
self.skills = skills
|
|
80
|
+
|
|
81
|
+
def pack(self) -> dict[str, Any]:
|
|
82
|
+
"""Pack all artifacts into upload format.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Dict with keys:
|
|
86
|
+
- prompt_content: str or None
|
|
87
|
+
- tools_schema: str (JSON) or None
|
|
88
|
+
- skill_file: tuple(filename, bytes) or None
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
OSError: If files cannot be read.
|
|
92
|
+
ValueError: If tools schema is invalid JSON.
|
|
93
|
+
"""
|
|
94
|
+
result: dict[str, Any] = {}
|
|
95
|
+
|
|
96
|
+
# Pack prompts
|
|
97
|
+
result["prompt_content"] = self._pack_prompts()
|
|
98
|
+
|
|
99
|
+
# Pack tools
|
|
100
|
+
result["tools_schema"] = self._pack_tools()
|
|
101
|
+
|
|
102
|
+
# Pack skills
|
|
103
|
+
result["skill_file"] = self._pack_skills()
|
|
104
|
+
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
def _pack_prompts(self) -> str | None:
|
|
108
|
+
"""Merge all prompt files into a single text.
|
|
109
|
+
|
|
110
|
+
Each file's content is separated by a marker comment.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Merged prompt text, or None if no prompts.
|
|
114
|
+
"""
|
|
115
|
+
if not self.prompts:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
parts: list[str] = []
|
|
119
|
+
for path_str in self.prompts:
|
|
120
|
+
path = Path(path_str)
|
|
121
|
+
content = path.read_text(encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
# Add a header comment to identify the source file
|
|
124
|
+
filename = path.name
|
|
125
|
+
parts.append(f"<!-- BEGIN: {filename} -->")
|
|
126
|
+
parts.append(content.strip())
|
|
127
|
+
parts.append(f"<!-- END: {filename} -->")
|
|
128
|
+
parts.append("") # Empty line between files
|
|
129
|
+
|
|
130
|
+
return "\n".join(parts).strip() if parts else None
|
|
131
|
+
|
|
132
|
+
def _pack_tools(self) -> str | None:
|
|
133
|
+
"""Merge all tool schema files into a JSON array.
|
|
134
|
+
|
|
135
|
+
Handles both:
|
|
136
|
+
- Single tool definition objects
|
|
137
|
+
- Arrays of tool definitions
|
|
138
|
+
|
|
139
|
+
Tools are deduplicated by 'name' field.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
JSON string of tools array, or None if no tools.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If a tool file contains invalid JSON.
|
|
146
|
+
ValueError: If tool definitions conflict on name.
|
|
147
|
+
"""
|
|
148
|
+
if not self.tools:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
all_tools: dict[str, dict[str, Any]] = {} # name -> definition
|
|
152
|
+
|
|
153
|
+
for path_str in self.tools:
|
|
154
|
+
path = Path(path_str)
|
|
155
|
+
content = path.read_text(encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
data = json.loads(content)
|
|
159
|
+
except json.JSONDecodeError as e:
|
|
160
|
+
raise ValueError(f"Invalid JSON in tool file {path}: {e}") from e
|
|
161
|
+
|
|
162
|
+
# Handle both single object and array
|
|
163
|
+
if isinstance(data, dict):
|
|
164
|
+
tools_list = [data]
|
|
165
|
+
elif isinstance(data, list):
|
|
166
|
+
tools_list = data
|
|
167
|
+
else:
|
|
168
|
+
raise ValueError(f"Tool file {path} must contain an object or array")
|
|
169
|
+
|
|
170
|
+
# Deduplicate by name
|
|
171
|
+
for tool_def in tools_list:
|
|
172
|
+
if not isinstance(tool_def, dict):
|
|
173
|
+
continue
|
|
174
|
+
name = tool_def.get("name")
|
|
175
|
+
if not name:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
all_tools[name] = tool_def
|
|
179
|
+
|
|
180
|
+
if not all_tools:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return json.dumps(list(all_tools.values()), ensure_ascii=False)
|
|
184
|
+
|
|
185
|
+
def _pack_skills(self) -> tuple[str, bytes] | None:
|
|
186
|
+
"""Pack skill directories/files into a ZIP archive.
|
|
187
|
+
|
|
188
|
+
The ZIP preserves the directory structure relative to the skill root.
|
|
189
|
+
If multiple skills are provided, they're all packed into one ZIP.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Tuple of (filename, bytes) for the ZIP, or None if no skills.
|
|
193
|
+
"""
|
|
194
|
+
if not self.skills:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
buffer = BytesIO()
|
|
198
|
+
|
|
199
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
200
|
+
for skill_path_str in self.skills:
|
|
201
|
+
skill_path = Path(skill_path_str)
|
|
202
|
+
|
|
203
|
+
if skill_path.is_file():
|
|
204
|
+
# Single skill file
|
|
205
|
+
self._add_file_to_zip(zf, skill_path, skill_path.name)
|
|
206
|
+
|
|
207
|
+
elif skill_path.is_dir():
|
|
208
|
+
# Skill directory - add all files recursively
|
|
209
|
+
for file_path in skill_path.rglob("*"):
|
|
210
|
+
if file_path.is_file():
|
|
211
|
+
# Skip hidden files and excluded directories
|
|
212
|
+
if any(part.startswith(".") for part in file_path.parts):
|
|
213
|
+
continue
|
|
214
|
+
if _is_excluded_path(file_path):
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Preserve relative path from skill directory
|
|
218
|
+
rel_path = file_path.relative_to(skill_path)
|
|
219
|
+
self._add_file_to_zip(zf, file_path, str(rel_path))
|
|
220
|
+
|
|
221
|
+
# Return ZIP with a descriptive name
|
|
222
|
+
return ("skills.zip", buffer.getvalue())
|
|
223
|
+
|
|
224
|
+
def _add_file_to_zip(self, zf: zipfile.ZipFile, file_path: Path, archive_name: str) -> None:
|
|
225
|
+
"""Add a file to the ZIP archive.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
zf: ZipFile object.
|
|
229
|
+
file_path: Path to the file on disk.
|
|
230
|
+
archive_name: Name/path to use in the ZIP archive.
|
|
231
|
+
"""
|
|
232
|
+
# Read file content
|
|
233
|
+
try:
|
|
234
|
+
content = file_path.read_bytes()
|
|
235
|
+
zf.writestr(archive_name, content)
|
|
236
|
+
except OSError as e:
|
|
237
|
+
warnings.warn(f"Could not read file for ZIP archive: {file_path}: {e}", stacklevel=2)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def pack_from_findings(
|
|
241
|
+
skills: list[Finding],
|
|
242
|
+
tools: list[Finding],
|
|
243
|
+
prompts: list[Finding],
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Pack artifacts from ScannerAgent findings (extracted content).
|
|
246
|
+
|
|
247
|
+
This function uses the content already extracted by ScannerAgent,
|
|
248
|
+
avoiding redundant file reads. It supports:
|
|
249
|
+
- Prompts extracted from YAML/Python files (e.g., system_prompt field)
|
|
250
|
+
- Tools extracted from code or schema files
|
|
251
|
+
- Skills from directories or SKILL.md files
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
skills: List of skill findings from ScannerAgent.
|
|
255
|
+
tools: List of tool findings from ScannerAgent.
|
|
256
|
+
prompts: List of prompt findings from ScannerAgent.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Dict with keys:
|
|
260
|
+
- prompt_content: str or None
|
|
261
|
+
- tools_schema: str (JSON) or None
|
|
262
|
+
- skill_file: tuple(filename, bytes) or None
|
|
263
|
+
"""
|
|
264
|
+
result: dict[str, Any] = {}
|
|
265
|
+
|
|
266
|
+
# Pack prompts from findings
|
|
267
|
+
result["prompt_content"] = _pack_prompts_from_findings(prompts)
|
|
268
|
+
|
|
269
|
+
# Pack tools from findings
|
|
270
|
+
result["tools_schema"] = _pack_tools_from_findings(tools)
|
|
271
|
+
|
|
272
|
+
# Pack skills from findings
|
|
273
|
+
result["skill_file"] = _pack_skills_from_findings(skills)
|
|
274
|
+
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _pack_prompts_from_findings(prompts: list[Finding]) -> str | None:
|
|
279
|
+
"""Merge prompt content from findings.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Merged prompt text, or None if no prompts.
|
|
283
|
+
"""
|
|
284
|
+
if not prompts:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
parts: list[str] = []
|
|
288
|
+
for finding in prompts:
|
|
289
|
+
if finding.content is None:
|
|
290
|
+
continue
|
|
291
|
+
if not isinstance(finding.content, PromptContent):
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
content = finding.content.content
|
|
295
|
+
if not content:
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Add source identifier
|
|
299
|
+
source = Path(finding.relative).name
|
|
300
|
+
parts.append(f"<!-- BEGIN: {source} -->")
|
|
301
|
+
parts.append(content.strip())
|
|
302
|
+
parts.append(f"<!-- END: {source} -->")
|
|
303
|
+
parts.append("")
|
|
304
|
+
|
|
305
|
+
return "\n".join(parts).strip() if parts else None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _pack_tools_from_findings(tools: list[Finding]) -> str | None:
|
|
309
|
+
"""Merge tool schemas from findings into OpenAI function calling format.
|
|
310
|
+
|
|
311
|
+
Output format follows OpenAI function calling schema:
|
|
312
|
+
[
|
|
313
|
+
{
|
|
314
|
+
"type": "function",
|
|
315
|
+
"function": {
|
|
316
|
+
"name": "...",
|
|
317
|
+
"description": "...",
|
|
318
|
+
"parameters": {...}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
JSON string of tools array, or None if no tools.
|
|
325
|
+
"""
|
|
326
|
+
if not tools:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
all_tools: list[dict[str, Any]] = []
|
|
330
|
+
for finding in tools:
|
|
331
|
+
if finding.content is None:
|
|
332
|
+
continue
|
|
333
|
+
if not isinstance(finding.content, ToolContent):
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
# Build OpenAI function calling format
|
|
337
|
+
tool_def = {
|
|
338
|
+
"type": "function",
|
|
339
|
+
"function": {
|
|
340
|
+
"name": finding.content.name,
|
|
341
|
+
"description": finding.content.description,
|
|
342
|
+
"parameters": finding.content.parameters,
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
all_tools.append(tool_def)
|
|
346
|
+
|
|
347
|
+
if not all_tools:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
return json.dumps(all_tools, ensure_ascii=False)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _pack_skills_from_findings(skills: list[Finding]) -> tuple[str, bytes] | None:
|
|
354
|
+
"""Pack skills from findings.
|
|
355
|
+
|
|
356
|
+
For skill findings with content (from analyze_files mode), creates a
|
|
357
|
+
SKILL.md file in the ZIP. For directory findings (from scan mode),
|
|
358
|
+
reads files from disk.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Tuple of (filename, bytes) for the ZIP, or None if no skills.
|
|
362
|
+
"""
|
|
363
|
+
if not skills:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
buffer = BytesIO()
|
|
367
|
+
|
|
368
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
369
|
+
for i, finding in enumerate(skills):
|
|
370
|
+
skill_path = Path(finding.relative)
|
|
371
|
+
|
|
372
|
+
# Determine skill name
|
|
373
|
+
if finding.content and isinstance(finding.content, SkillContent):
|
|
374
|
+
skill_name = finding.content.name
|
|
375
|
+
else:
|
|
376
|
+
skill_name = skill_path.name if skill_path.is_dir() else f"skill_{i}"
|
|
377
|
+
|
|
378
|
+
if skill_path.is_file():
|
|
379
|
+
# Single skill file - read from disk
|
|
380
|
+
try:
|
|
381
|
+
text_content = skill_path.read_text(encoding="utf-8")
|
|
382
|
+
zf.writestr(f"{skill_name}/SKILL.md", text_content)
|
|
383
|
+
except OSError as e:
|
|
384
|
+
warnings.warn(
|
|
385
|
+
f"Could not read skill file for ZIP archive: {skill_path}: {e}", stacklevel=2
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
elif skill_path.is_dir():
|
|
389
|
+
# Skill directory - add all files recursively
|
|
390
|
+
for file_path in skill_path.rglob("*"):
|
|
391
|
+
if file_path.is_file():
|
|
392
|
+
# Skip hidden files and excluded directories
|
|
393
|
+
if any(part.startswith(".") for part in file_path.parts):
|
|
394
|
+
continue
|
|
395
|
+
if _is_excluded_path(file_path):
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
rel_path = file_path.relative_to(skill_path)
|
|
399
|
+
try:
|
|
400
|
+
binary_content = file_path.read_bytes()
|
|
401
|
+
zf.writestr(f"{skill_name}/{rel_path}", binary_content)
|
|
402
|
+
except OSError as e:
|
|
403
|
+
warnings.warn(
|
|
404
|
+
f"Could not read file for ZIP archive: {file_path}: {e}", stacklevel=2
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return ("skills.zip", buffer.getvalue())
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""Inspect config loader - load and validate inspect_config.yaml"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from sanityops_cli.exceptions.base_exceptions import ValidationError
|
|
25
|
+
|
|
26
|
+
# Default config file location
|
|
27
|
+
DEFAULT_CONFIG_DIR = ".sanityops"
|
|
28
|
+
DEFAULT_CONFIG_FILE = "inspect_config.yaml"
|
|
29
|
+
|
|
30
|
+
# Sentinel project.id meaning "not yet bound". Kept as-is by init so that
|
|
31
|
+
# the first inspect upload auto-creates the project and writes the real
|
|
32
|
+
# id/name back to the config file.
|
|
33
|
+
PROJECT_ID_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_file_with_encoding_fallback(path: Path) -> str | None:
|
|
37
|
+
"""Read a file with UTF-8 encoding, falling back to GBK for Windows compatibility.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to the file to read
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
File content as string, or None if file cannot be read with either encoding.
|
|
44
|
+
"""
|
|
45
|
+
# Try UTF-8 first (most common)
|
|
46
|
+
try:
|
|
47
|
+
return path.read_text(encoding="utf-8")
|
|
48
|
+
except (UnicodeDecodeError, OSError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# Fall back to GBK (Windows Chinese locale)
|
|
52
|
+
try:
|
|
53
|
+
return path.read_text(encoding="gbk")
|
|
54
|
+
except (UnicodeDecodeError, OSError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InspectConfigLoader:
|
|
61
|
+
"""Load and validate inspect_config.yaml, resolve all artifact paths to absolute."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, config_path: str | None = None):
|
|
64
|
+
self._config_path = self._resolve_config_path(config_path)
|
|
65
|
+
self._config = self._load_yaml()
|
|
66
|
+
self._root_dir = Path.cwd()
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Public API
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def config_path(self) -> Path:
|
|
74
|
+
"""Return the resolved config file path."""
|
|
75
|
+
return self._config_path
|
|
76
|
+
|
|
77
|
+
def load(self) -> dict[str, Any]:
|
|
78
|
+
"""Load config, validate, and return resolved absolute paths.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
{
|
|
82
|
+
"project_id": str | None, # None if placeholder (not bound)
|
|
83
|
+
"prompts": List[str], # absolute paths
|
|
84
|
+
"tools": List[str], # absolute paths
|
|
85
|
+
"skills": List[str], # absolute paths
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Note:
|
|
89
|
+
project.name in the config file is not returned here; it is
|
|
90
|
+
display metadata written back by the auto-upload flow.
|
|
91
|
+
"""
|
|
92
|
+
self._validate_structure()
|
|
93
|
+
|
|
94
|
+
prompts = self._process_prompts()
|
|
95
|
+
tools = self._process_tools()
|
|
96
|
+
skills = self._process_skills()
|
|
97
|
+
|
|
98
|
+
if not prompts and not tools and not skills:
|
|
99
|
+
raise ValidationError(
|
|
100
|
+
"At least one of (prompts / tools / skills) must be provided in config"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# project_id may be None/placeholder if not bound
|
|
104
|
+
project_id = self._config["project"]["id"]
|
|
105
|
+
if project_id == PROJECT_ID_PLACEHOLDER:
|
|
106
|
+
project_id = None
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"project_id": project_id,
|
|
110
|
+
"prompts": prompts,
|
|
111
|
+
"tools": tools,
|
|
112
|
+
"skills": skills,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Config path resolution
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _resolve_config_path(self, config_path: str | None) -> Path:
|
|
120
|
+
"""Resolve config file path: explicit -> .sanityops/inspect_config.yaml -> error."""
|
|
121
|
+
if config_path:
|
|
122
|
+
p = Path(config_path).expanduser()
|
|
123
|
+
if not p.exists():
|
|
124
|
+
raise ValidationError(f"Config file not found: {config_path}")
|
|
125
|
+
if not p.is_file():
|
|
126
|
+
raise ValidationError(f"Config path is not a file: {config_path}")
|
|
127
|
+
return p.resolve()
|
|
128
|
+
|
|
129
|
+
# Fall back to default location in cwd
|
|
130
|
+
default_path = Path.cwd() / DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_FILE
|
|
131
|
+
if default_path.exists() and default_path.is_file():
|
|
132
|
+
return default_path.resolve()
|
|
133
|
+
|
|
134
|
+
raise ValidationError(
|
|
135
|
+
f"Config file not found. Provide --config <path>, "
|
|
136
|
+
f"or create one at .sanityops/{DEFAULT_CONFIG_FILE}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# YAML loading & structure validation
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def _load_yaml(self) -> dict:
|
|
144
|
+
try:
|
|
145
|
+
with open(self._config_path, encoding="utf-8") as f:
|
|
146
|
+
data = yaml.safe_load(f)
|
|
147
|
+
except yaml.YAMLError as e:
|
|
148
|
+
raise ValidationError(f"Invalid YAML in config file: {e}") from e
|
|
149
|
+
except OSError as e:
|
|
150
|
+
raise ValidationError(f"Cannot read config file: {e}") from e
|
|
151
|
+
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ValidationError("Config file must contain a YAML object at top level")
|
|
154
|
+
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
def _validate_structure(self) -> None:
|
|
158
|
+
# project.id is required
|
|
159
|
+
project = self._config.get("project")
|
|
160
|
+
if not isinstance(project, dict):
|
|
161
|
+
raise ValidationError("Config must contain 'project' section")
|
|
162
|
+
project_id = project.get("id")
|
|
163
|
+
if not project_id:
|
|
164
|
+
raise ValidationError("'project.id' is required (UUID)")
|
|
165
|
+
# validate UUID format
|
|
166
|
+
uuid_re = re.compile(
|
|
167
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
|
168
|
+
r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
169
|
+
)
|
|
170
|
+
if not uuid_re.match(str(project_id)):
|
|
171
|
+
raise ValidationError(f"'project.id' is not a valid UUID: {project_id}")
|
|
172
|
+
|
|
173
|
+
# Validate model section if present
|
|
174
|
+
self._validate_model_section()
|
|
175
|
+
|
|
176
|
+
def _validate_model_section(self) -> None:
|
|
177
|
+
"""Validate model section if present.
|
|
178
|
+
|
|
179
|
+
Rules:
|
|
180
|
+
- If 'model' key absent: OK (no validation)
|
|
181
|
+
- If 'model' present but not a dict: ValidationError
|
|
182
|
+
- Required fields: provider, api_key, model_id (non-empty strings)
|
|
183
|
+
- Optional: base_url (string, may be empty)
|
|
184
|
+
"""
|
|
185
|
+
model = self._config.get("model")
|
|
186
|
+
if model is None:
|
|
187
|
+
return # model section is optional
|
|
188
|
+
|
|
189
|
+
if not isinstance(model, dict):
|
|
190
|
+
raise ValidationError("'model' section must be a mapping (dict)")
|
|
191
|
+
|
|
192
|
+
# Required fields - must be non-empty strings
|
|
193
|
+
required_fields = ["provider", "api_key", "model_id"]
|
|
194
|
+
for field in required_fields:
|
|
195
|
+
value = model.get(field)
|
|
196
|
+
if value is None:
|
|
197
|
+
raise ValidationError(
|
|
198
|
+
f"'model.{field}' is required when model section is present. "
|
|
199
|
+
"Fill it in .sanityops/inspect_config.yaml, or remove the "
|
|
200
|
+
"'model' section to use LLM_* environment variables instead."
|
|
201
|
+
)
|
|
202
|
+
if not isinstance(value, str):
|
|
203
|
+
raise ValidationError(f"'model.{field}' must be a string")
|
|
204
|
+
if value.strip() == "":
|
|
205
|
+
raise ValidationError(
|
|
206
|
+
f"'model.{field}' cannot be empty. "
|
|
207
|
+
"Fill it in .sanityops/inspect_config.yaml, or remove the "
|
|
208
|
+
"'model' section to use LLM_* environment variables instead."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Optional field - just check it's a string if present
|
|
212
|
+
base_url = model.get("base_url")
|
|
213
|
+
if base_url is not None and not isinstance(base_url, str):
|
|
214
|
+
raise ValidationError("'model.base_url' must be a string")
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
# Path resolution helper
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _resolve_file_path(self, file_str: str) -> Path:
|
|
221
|
+
"""Resolve a file path to absolute, handling relative paths against root dir."""
|
|
222
|
+
p = Path(file_str).expanduser()
|
|
223
|
+
if not p.is_absolute():
|
|
224
|
+
p = self._root_dir / p
|
|
225
|
+
return p.resolve()
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Prompts processing
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _process_prompts(self) -> list[str]:
|
|
232
|
+
"""Validate prompt files and return absolute paths."""
|
|
233
|
+
entries = self._config.get("prompts", [])
|
|
234
|
+
if entries is None:
|
|
235
|
+
entries = []
|
|
236
|
+
return self._process_file_entries(entries, "prompts")
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Tools processing
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _process_tools(self) -> list[str]:
|
|
243
|
+
"""Validate tool files and return absolute paths."""
|
|
244
|
+
entries = self._config.get("tools", [])
|
|
245
|
+
if entries is None:
|
|
246
|
+
entries = []
|
|
247
|
+
return self._process_file_entries(entries, "tools")
|
|
248
|
+
|
|
249
|
+
def _process_file_entries(self, entries: list, label: str) -> list[str]:
|
|
250
|
+
"""Common logic for prompts, tools, and skills: each entry must be a readable file."""
|
|
251
|
+
result: list[str] = []
|
|
252
|
+
for i, entry in enumerate(entries):
|
|
253
|
+
file_str = self._extract_file_value(entry, label, i)
|
|
254
|
+
abs_path = self._resolve_file_path(file_str)
|
|
255
|
+
if not abs_path.exists():
|
|
256
|
+
raise ValidationError(
|
|
257
|
+
f"[{label}] file not found: {file_str} (resolved: {abs_path})"
|
|
258
|
+
)
|
|
259
|
+
if not abs_path.is_file():
|
|
260
|
+
raise ValidationError(
|
|
261
|
+
f"[{label}] path is not a file: {file_str} (resolved: {abs_path})"
|
|
262
|
+
)
|
|
263
|
+
result.append(str(abs_path))
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# Skills processing
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _process_skills(self) -> list[str]:
|
|
271
|
+
"""Validate skill files and return absolute paths (files only)."""
|
|
272
|
+
entries = self._config.get("skills", [])
|
|
273
|
+
if entries is None:
|
|
274
|
+
entries = []
|
|
275
|
+
return self._process_file_entries(entries, "skills")
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# Entry parsing helper
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _extract_file_value(entry, label: str, index: int) -> str:
|
|
283
|
+
"""Extract 'file' value from a YAML list entry."""
|
|
284
|
+
if isinstance(entry, dict):
|
|
285
|
+
file_val = entry.get("file")
|
|
286
|
+
if not file_val:
|
|
287
|
+
raise ValidationError(
|
|
288
|
+
f"[{label}] entry #{index} missing 'file' key"
|
|
289
|
+
)
|
|
290
|
+
return str(file_val)
|
|
291
|
+
elif isinstance(entry, str):
|
|
292
|
+
return entry
|
|
293
|
+
else:
|
|
294
|
+
raise ValidationError(
|
|
295
|
+
f"[{label}] entry #{index} must be a string or object with 'file' key"
|
|
296
|
+
)
|