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