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