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,332 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ from pathlib import Path
17
+
18
+ import anyio
19
+ from rich.console import Console
20
+
21
+ from sanityops_cli.agents.scanner_agent.models.finding import FindingsResult
22
+ from sanityops_cli.agents.scanner_agent.prompts import (
23
+ # New analyzer prompts
24
+ ANALYZE_PARENT_PROMPT,
25
+ INSPECT_PARENT_PROMPT,
26
+ PROMPT_ANALYZER_PROMPT,
27
+ PROMPT_FINDER_RULES,
28
+ SKILL_ANALYZER_PROMPT,
29
+ SKILL_FINDER_RULES,
30
+ TOOL_ANALYZER_PROMPT,
31
+ TOOL_FINDER_RULES,
32
+ )
33
+ from sanityops_cli.exceptions.base_exceptions import ValidationError
34
+ from sanityops_cli.logging.logger import Logger
35
+
36
+ # ============================================================================
37
+ # ScannerAgent Class
38
+ # ============================================================================
39
+
40
+ console = Console()
41
+
42
+
43
+ class ScannerAgent:
44
+
45
+ def __init__(
46
+ self,
47
+ provider,
48
+ max_loops: int = 30,
49
+ timeout: int = 120,
50
+ token_budget: int = 200000,
51
+ verbose: bool = False,
52
+ console: Console | None = None,
53
+ logger: Logger | None = None,
54
+ ):
55
+ self.provider = provider
56
+ self.max_loops = max_loops
57
+ self.timeout = timeout
58
+ self.token_budget = token_budget
59
+ self.verbose = verbose
60
+ self.console = console or Console()
61
+ self.logger: Logger | None = logger
62
+
63
+ async def scan(
64
+ self,
65
+ directory: str,
66
+ depth: int = 5,
67
+ ) -> FindingsResult:
68
+ path = Path(directory).resolve()
69
+ if not path.exists() or not path.is_dir():
70
+ raise ValidationError(f"Directory does not exist: {directory}")
71
+
72
+ abs_directory = str(path)
73
+
74
+ from sanityops_agent.agents import AgentFactory
75
+ from sanityops_agent.core.agent import AgentConfig, TerminationReason
76
+ from sanityops_agent.hooks.base import HookExecutor
77
+ from sanityops_agent.tools import ToolRegistry
78
+ from sanityops_agent.tools.builtins import GlobTool, TaskTool
79
+
80
+ from sanityops_cli.agents.scanner_agent.tools.grep_tool import GrepTool
81
+ from sanityops_cli.agents.scanner_agent.tools.listfiles_tool import ListFilesTool
82
+ from sanityops_cli.agents.scanner_agent.tools.readfile_tool import FileReadTool
83
+ from sanityops_cli.agents.scanner_agent.tools.storefindings_tool import StoreFindingsTool
84
+
85
+ registry = ToolRegistry()
86
+ registry.register(FileReadTool())
87
+ registry.register(ListFilesTool())
88
+ registry.register(GlobTool())
89
+ registry.register(GrepTool())
90
+ registry.register(TaskTool())
91
+ registry.register(StoreFindingsTool())
92
+
93
+ StoreFindingsTool._findings.clear()
94
+
95
+ system_prompt = self._build_system_prompt(abs_directory, depth)
96
+
97
+ config = AgentConfig(
98
+ max_loops=self.max_loops,
99
+ total_timeout=self.timeout,
100
+ token_budget=self.token_budget,
101
+ system_prompt=system_prompt,
102
+ )
103
+
104
+ hook_executor = HookExecutor()
105
+ if self.verbose:
106
+ from sanityops_cli.agents.scanner_agent.hooks.progress_hook import ProgressHook
107
+ hook_executor.register(
108
+ ProgressHook(self.console, verbose=self.verbose, logger=self.logger)
109
+ )
110
+
111
+ factory = AgentFactory(
112
+ provider=self.provider,
113
+ config=config,
114
+ tool_registry=registry,
115
+ hook_executor=hook_executor,
116
+ )
117
+ agent = factory.create_parent_agent(system_prompt=system_prompt)
118
+
119
+ if self.verbose:
120
+ self.console.print()
121
+
122
+ result = await agent.run(f"Inspect {abs_directory}")
123
+
124
+ if self.verbose:
125
+ self.console.print()
126
+
127
+ if result.termination_reason != TerminationReason.END_TURN:
128
+ error_msg = result.error or f"Agent terminated: {result.termination_reason.value}"
129
+ if result.error_detail:
130
+ error_detail_str = "\n".join(
131
+ f" - {e.error_type}: {e.message}"
132
+ for e in result.error_detail
133
+ )
134
+ error_msg += f"\nError Detail:\n{error_detail_str}"
135
+ raise ValidationError(f"Agent execution failed: {error_msg}")
136
+
137
+ return self._build_result(abs_directory, result)
138
+
139
+ async def analyze_files(
140
+ self,
141
+ prompts: list[str],
142
+ tools: list[str],
143
+ skills: list[str],
144
+ ) -> FindingsResult:
145
+ """Analyze explicit artifact files and extract structured metadata.
146
+
147
+ Args:
148
+ prompts: List of absolute paths to prompt files
149
+ tools: List of absolute paths to tool files
150
+ skills: List of absolute paths to skill.md files
151
+
152
+ Returns:
153
+ FindingsResult with structured content for each artifact
154
+ """
155
+ from sanityops_agent.agents import AgentFactory
156
+ from sanityops_agent.core.agent import AgentConfig, TerminationReason
157
+ from sanityops_agent.hooks.base import HookExecutor
158
+ from sanityops_agent.tools import ToolRegistry
159
+ from sanityops_agent.tools.builtins import GlobTool, TaskTool
160
+
161
+ from sanityops_cli.agents.scanner_agent.tools.grep_tool import GrepTool
162
+ from sanityops_cli.agents.scanner_agent.tools.listfiles_tool import ListFilesTool
163
+ from sanityops_cli.agents.scanner_agent.tools.readfile_tool import FileReadTool
164
+ from sanityops_cli.agents.scanner_agent.tools.storefindings_tool import StoreFindingsTool
165
+
166
+ # Validate at least one artifact provided
167
+ if not prompts and not tools and not skills:
168
+ return FindingsResult(
169
+ directory="",
170
+ skills=[],
171
+ tools=[],
172
+ prompts=[],
173
+ meta={"time_elapsed": 0, "loops_used": 0, "tokens_used": 0}
174
+ )
175
+
176
+ # Validate all paths are absolute and exist
177
+ for path_list, name in [(prompts, "prompts"), (tools, "tools"), (skills, "skills")]:
178
+ for p in path_list:
179
+ path = Path(p)
180
+ if not path.is_absolute():
181
+ raise ValidationError(f"{name} path must be absolute: {p}")
182
+ if not path.exists():
183
+ raise ValidationError(f"{name} path does not exist: {p}")
184
+
185
+ # Build tool registry
186
+ registry = ToolRegistry()
187
+ registry.register(FileReadTool())
188
+ registry.register(ListFilesTool())
189
+ registry.register(GlobTool())
190
+ registry.register(GrepTool())
191
+ registry.register(TaskTool())
192
+ registry.register(StoreFindingsTool())
193
+
194
+ # Clear previous findings
195
+ StoreFindingsTool._findings.clear()
196
+
197
+ # Build system prompt
198
+ system_prompt = self._build_analyze_prompt(prompts, tools, skills)
199
+
200
+ config = AgentConfig(
201
+ max_loops=self.max_loops,
202
+ total_timeout=self.timeout,
203
+ token_budget=self.token_budget,
204
+ system_prompt=system_prompt,
205
+ )
206
+
207
+ # Setup hooks
208
+ hook_executor = HookExecutor()
209
+ if self.verbose:
210
+ from sanityops_cli.agents.scanner_agent.hooks.progress_hook import ProgressHook
211
+ hook_executor.register(
212
+ ProgressHook(self.console, verbose=self.verbose, logger=self.logger)
213
+ )
214
+
215
+ # Create and run agent
216
+ factory = AgentFactory(
217
+ provider=self.provider,
218
+ config=config,
219
+ tool_registry=registry,
220
+ hook_executor=hook_executor,
221
+ )
222
+ agent = factory.create_parent_agent(system_prompt=system_prompt)
223
+
224
+ if self.verbose:
225
+ self.console.print()
226
+
227
+ result = await agent.run("Analyze provided artifact files")
228
+
229
+ if self.verbose:
230
+ self.console.print()
231
+
232
+ if result.termination_reason != TerminationReason.END_TURN:
233
+ error_msg = result.error or f"Agent terminated: {result.termination_reason.value}"
234
+ if result.error_detail:
235
+ error_detail_str = "\n".join(
236
+ f" - {e.error_type}: {e.message}"
237
+ for e in result.error_detail
238
+ )
239
+ error_msg += f"\nError Detail:\n{error_detail_str}"
240
+ raise ValidationError(f"Agent execution failed: {error_msg}")
241
+
242
+ return self._build_result("", result)
243
+
244
+ def analyze_files_sync(
245
+ self,
246
+ prompts: list[str],
247
+ tools: list[str],
248
+ skills: list[str],
249
+ ) -> FindingsResult:
250
+ """Synchronous wrapper for analyze_files."""
251
+ return anyio.run(self.analyze_files, prompts, tools, skills)
252
+
253
+ def scan_sync(
254
+ self,
255
+ directory: str,
256
+ depth: int = 5,
257
+ ) -> FindingsResult:
258
+ return anyio.run(self.scan, directory, depth)
259
+
260
+ def _build_system_prompt(self, directory: str, depth: int) -> str:
261
+ formatted_skill_rules = SKILL_FINDER_RULES.format(
262
+ directory=directory, depth=depth
263
+ )
264
+ formatted_tool_rules = TOOL_FINDER_RULES.format(
265
+ directory=directory, depth=depth
266
+ )
267
+ formatted_prompt_rules = PROMPT_FINDER_RULES.format(
268
+ directory=directory, depth=depth
269
+ )
270
+ return INSPECT_PARENT_PROMPT.format(
271
+ directory=directory,
272
+ depth=depth,
273
+ SKILL_FINDER_RULES=formatted_skill_rules,
274
+ TOOL_FINDER_RULES=formatted_tool_rules,
275
+ PROMPT_FINDER_RULES=formatted_prompt_rules,
276
+ )
277
+
278
+ def _build_analyze_prompt(
279
+ self,
280
+ prompts: list[str],
281
+ tools: list[str],
282
+ skills: list[str],
283
+ ) -> str:
284
+ """Build system prompt for file analysis mode."""
285
+ # Format file lists as bullet points
286
+ skill_files_str = "\n".join(f"- {p}" for p in skills) if skills else "(none)"
287
+ tool_files_str = "\n".join(f"- {p}" for p in tools) if tools else "(none)"
288
+ prompt_files_str = "\n".join(f"- {p}" for p in prompts) if prompts else "(none)"
289
+
290
+ formatted_skill_analyzer = SKILL_ANALYZER_PROMPT.format(
291
+ skill_files=skill_files_str
292
+ )
293
+ formatted_tool_analyzer = TOOL_ANALYZER_PROMPT.format(
294
+ tool_files=tool_files_str
295
+ )
296
+ formatted_prompt_analyzer = PROMPT_ANALYZER_PROMPT.format(
297
+ prompt_files=prompt_files_str
298
+ )
299
+
300
+ return ANALYZE_PARENT_PROMPT.format(
301
+ skill_count=len(skills),
302
+ tool_count=len(tools),
303
+ prompt_count=len(prompts),
304
+ SKILL_ANALYZER_PROMPT=formatted_skill_analyzer,
305
+ TOOL_ANALYZER_PROMPT=formatted_tool_analyzer,
306
+ PROMPT_ANALYZER_PROMPT=formatted_prompt_analyzer,
307
+ )
308
+
309
+ def _build_result(
310
+ self,
311
+ directory: str,
312
+ agent_result,
313
+ ) -> FindingsResult:
314
+ from sanityops_cli.agents.scanner_agent.models.finding import FindingType
315
+ from sanityops_cli.agents.scanner_agent.tools.storefindings_tool import StoreFindingsTool
316
+
317
+ findings = StoreFindingsTool._findings
318
+ skills = [f for f in findings if f.type == FindingType.SKILL]
319
+ tools = [f for f in findings if f.type == FindingType.TOOL]
320
+ prompts = [f for f in findings if f.type == FindingType.PROMPT]
321
+
322
+ return FindingsResult(
323
+ directory=directory,
324
+ skills=skills,
325
+ tools=tools,
326
+ prompts=prompts,
327
+ meta={
328
+ "time_elapsed": agent_result.time_elapsed,
329
+ "loops_used": agent_result.loops_used,
330
+ "tokens_used": agent_result.tokens_used,
331
+ }
332
+ )
@@ -0,0 +1,152 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ from rich.console import Console
17
+ from rich.markup import escape
18
+ from rich.text import Text
19
+
20
+ from sanityops_cli.logging.logger import Logger
21
+
22
+
23
+ class ProgressHook:
24
+ """Hook to report agent progress to console in real-time."""
25
+
26
+ def __init__(
27
+ self,
28
+ console: Console,
29
+ verbose: bool = True,
30
+ logger: Logger | None = None,
31
+ ):
32
+ self.console = console
33
+ self.verbose = verbose
34
+ self.logger = logger
35
+ self._iteration = 0
36
+ self._tool_calls = 0
37
+ self._sub_agent_count = 0
38
+ # Will be set after lazy import
39
+ self._events = None
40
+
41
+ def _get_events(self):
42
+ """Lazy import of HookEvent."""
43
+ if self._events is None:
44
+ from sanityops_agent.hooks.base import HookEvent
45
+ self._events = HookEvent
46
+ return self._events
47
+
48
+ @property
49
+ def name(self) -> str:
50
+ return "progress"
51
+
52
+ @property
53
+ def description(self) -> str:
54
+ return "Reports agent progress to console"
55
+
56
+ @property
57
+ def priority(self) -> int:
58
+ return 5
59
+
60
+ @property
61
+ def events(self) -> list:
62
+ """Return events this hook subscribes to."""
63
+ events = self._get_events()
64
+ return [
65
+ events.BEFORE_LOOP,
66
+ events.BEFORE_LLM_CALL,
67
+ events.AFTER_LLM_CALL,
68
+ events.BEFORE_TOOL_EXEC,
69
+ events.AFTER_TOOL_EXEC,
70
+ events.ON_TERMINATION,
71
+ ]
72
+
73
+ def can_handle(self, event) -> bool:
74
+ """Whether this hook handles the given event."""
75
+ return event in self.events
76
+
77
+ def _emit(self, message: str) -> None:
78
+ """Print a progress line to console and mirror it to the logger at DEBUG."""
79
+ self.console.print(message)
80
+ if self.logger is not None:
81
+ self.logger.debug(Text.from_markup(message).plain.strip())
82
+
83
+ async def handle(self, ctx):
84
+ """Handle hook event and output progress."""
85
+ event = ctx.event
86
+ data = ctx.data
87
+
88
+ events = self._get_events()
89
+
90
+ if event == events.BEFORE_LOOP:
91
+ self._iteration = data.get("iteration", 0)
92
+ if self._iteration == 0:
93
+ self._emit("[bold cyan]▶ Agent started[/]")
94
+
95
+ elif event == events.BEFORE_LLM_CALL:
96
+ iteration = data.get("iteration", self._iteration)
97
+ self._emit(f" [dim]⟳ LLM call (iteration {iteration + 1})[/]")
98
+
99
+ elif event == events.AFTER_LLM_CALL:
100
+ response = data.get("response")
101
+ if response and hasattr(response, 'usage'):
102
+ usage = response.usage
103
+ if usage:
104
+ tokens = usage.get('total_tokens', 0)
105
+ self._emit(f" [dim]↓ received response (tokens: {tokens})[/]")
106
+
107
+ elif event == events.BEFORE_TOOL_EXEC:
108
+ tool_name = data.get("tool_name", "unknown")
109
+ tool_input = data.get("tool_input", {})
110
+ self._tool_calls += 1
111
+
112
+ if tool_name == "task":
113
+ goal = tool_input.get("goal", "unknown")[:50]
114
+ self._sub_agent_count += 1
115
+ self._emit(f" [bold yellow]◇ Starting sub Agent #{self._sub_agent_count}[/]: {escape(goal)}...")
116
+ else:
117
+ if self.verbose:
118
+ if tool_name == "bash":
119
+ cmd = tool_input.get("command", "")[:60]
120
+ self._emit(f" [green]⚡ {tool_name}[/]: {escape(cmd)}")
121
+ elif tool_name == "glob":
122
+ pattern = tool_input.get("pattern", "")
123
+ self._emit(f" [green]⚡ {tool_name}[/]: {escape(pattern)}")
124
+ elif tool_name == "grep":
125
+ pattern = tool_input.get("pattern", "")[:40]
126
+ path = tool_input.get("path", "")[:30]
127
+ self._emit(f" [green]⚡ {tool_name}[/]: '{escape(pattern)}' in {escape(path)}")
128
+ elif tool_name == "file_ops":
129
+ op = tool_input.get("operation", "")
130
+ path = tool_input.get("path", "")[:50]
131
+ self._emit(f" [green]⚡ {tool_name}[/]: {escape(op)} {escape(path)}")
132
+ else:
133
+ self._emit(f" [green]⚡ {tool_name}[/]")
134
+
135
+ elif event == events.AFTER_TOOL_EXEC:
136
+ tool_name = data.get("tool_name", "unknown")
137
+ is_error = ctx.is_error
138
+
139
+ if tool_name == "task":
140
+ if is_error:
141
+ self.console.print(" [red]✗ Sub Agent failed[/]")
142
+ if self.logger is not None:
143
+ self.logger.error("Sub Agent failed")
144
+ else:
145
+ self._emit(" [green]✓ Sub Agent completed[/]")
146
+
147
+ elif event == events.ON_TERMINATION:
148
+ reason = data.get("reason", "unknown")
149
+ self._emit(f"[bold]⏹ Agent terminated[/]: {escape(reason)}")
150
+ self._emit(f" [dim]Statistics: {self._tool_calls} tool calls, {self._sub_agent_count} sub Agents[/]")
151
+
152
+ return ctx
@@ -0,0 +1,120 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ from dataclasses import field
17
+ from enum import StrEnum
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
22
+
23
+
24
+ class FindingType(StrEnum):
25
+ SKILL = "skill"
26
+ TOOL = "tool"
27
+ PROMPT = "prompt"
28
+
29
+
30
+ class Section(BaseModel):
31
+ """A markdown section within a skill file."""
32
+ title: str = Field(description="Section heading text")
33
+ content: str = Field(description="Section body content")
34
+
35
+
36
+ class ToolSchema(BaseModel):
37
+ name: str
38
+ description: str = ""
39
+ parameters: dict = Field(default_factory=dict,description="Tool parameters, JSON Schema (Draft-07) format")
40
+
41
+
42
+ class SkillContent(BaseModel):
43
+ """Extracted content from a skill.md file."""
44
+ name: str = Field(description="Skill name from frontmatter")
45
+ description: str = Field(description="Skill description from frontmatter")
46
+ sections: list[Section] = Field(default_factory=list, description="Parsed markdown sections")
47
+
48
+ def to_markdown(self) -> str:
49
+ """Reconstruct sections back to markdown string.
50
+
51
+ Returns:
52
+ Markdown string with all sections joined by double newlines.
53
+ """
54
+ if not self.sections:
55
+ return ""
56
+ return "\n\n".join(
57
+ f"## {section.title}\n\n{section.content}"
58
+ for section in self.sections
59
+ )
60
+
61
+
62
+ class ToolContent(BaseModel):
63
+ """Extracted content from a tool file."""
64
+ name: str = Field(description="Tool name from definition")
65
+ description: str = Field(description="Tool description")
66
+ parameters: dict[str, Any] = Field(default_factory=dict, description="JSON Schema of parameters")
67
+
68
+
69
+ class PromptContent(BaseModel):
70
+ content: str
71
+
72
+
73
+ class Finding(BaseModel):
74
+ type: FindingType
75
+ relative: str
76
+ content: SkillContent | ToolContent | PromptContent | None = None
77
+
78
+ @field_validator("relative")
79
+ @classmethod
80
+ def validate_relative(cls, v: str, info: ValidationInfo) -> str:
81
+ ft = FindingType(info.data.get("type"))
82
+ path = Path(v)
83
+
84
+ if not path.is_absolute():
85
+ raise ValueError(f"relative must be an absolute path: {v}")
86
+
87
+ if not path.exists():
88
+ raise ValueError(f"path does not exist: {v}")
89
+
90
+ if ft == FindingType.TOOL:
91
+ if not path.is_file():
92
+ raise ValueError(f"Tool finding must be a file path: {v}")
93
+
94
+ elif ft == FindingType.SKILL:
95
+ # SKILL can be either a file (analyze_files mode) or directory (scan mode)
96
+ if not path.is_file() and not path.is_dir():
97
+ raise ValueError(f"Skill finding must be a file or directory path: {v}")
98
+
99
+ return v
100
+
101
+ @model_validator(mode="after")
102
+ def validate_content_binding(self) -> "Finding":
103
+ if self.type == FindingType.SKILL:
104
+ if self.content is not None and not isinstance(self.content, SkillContent):
105
+ raise ValueError("Skill finding content must be SkillContent or None")
106
+ elif self.type == FindingType.TOOL:
107
+ if self.content is not None and not isinstance(self.content, ToolContent):
108
+ raise ValueError("Tool finding content must be ToolContent or None")
109
+ elif self.type == FindingType.PROMPT:
110
+ if self.content is not None and not isinstance(self.content, PromptContent):
111
+ raise ValueError("Prompt finding content must be PromptContent or None")
112
+ return self
113
+
114
+
115
+ class FindingsResult(BaseModel):
116
+ directory: str
117
+ skills: list[Finding] = field(default_factory=list)
118
+ tools: list[Finding] = field(default_factory=list)
119
+ prompts: list[Finding] = field(default_factory=list)
120
+ meta: dict[str, Any] = field(default_factory=dict)