ctrlcode 0.1.0__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.
- ctrlcode/__init__.py +8 -0
- ctrlcode/agents/__init__.py +29 -0
- ctrlcode/agents/cleanup.py +388 -0
- ctrlcode/agents/communication.py +439 -0
- ctrlcode/agents/observability.py +421 -0
- ctrlcode/agents/react_loop.py +297 -0
- ctrlcode/agents/registry.py +211 -0
- ctrlcode/agents/result_parser.py +242 -0
- ctrlcode/agents/workflow.py +723 -0
- ctrlcode/analysis/__init__.py +28 -0
- ctrlcode/analysis/ast_diff.py +163 -0
- ctrlcode/analysis/bug_detector.py +149 -0
- ctrlcode/analysis/code_graphs.py +329 -0
- ctrlcode/analysis/semantic.py +205 -0
- ctrlcode/analysis/static.py +183 -0
- ctrlcode/analysis/synthesizer.py +281 -0
- ctrlcode/analysis/tests.py +189 -0
- ctrlcode/cleanup/__init__.py +16 -0
- ctrlcode/cleanup/auto_merge.py +350 -0
- ctrlcode/cleanup/doc_gardening.py +388 -0
- ctrlcode/cleanup/pr_automation.py +330 -0
- ctrlcode/cleanup/scheduler.py +356 -0
- ctrlcode/config.py +380 -0
- ctrlcode/embeddings/__init__.py +6 -0
- ctrlcode/embeddings/embedder.py +192 -0
- ctrlcode/embeddings/vector_store.py +213 -0
- ctrlcode/fuzzing/__init__.py +24 -0
- ctrlcode/fuzzing/analyzer.py +280 -0
- ctrlcode/fuzzing/budget.py +112 -0
- ctrlcode/fuzzing/context.py +665 -0
- ctrlcode/fuzzing/context_fuzzer.py +506 -0
- ctrlcode/fuzzing/derived_orchestrator.py +732 -0
- ctrlcode/fuzzing/oracle_adapter.py +135 -0
- ctrlcode/linters/__init__.py +11 -0
- ctrlcode/linters/hand_rolled_utils.py +221 -0
- ctrlcode/linters/yolo_parsing.py +217 -0
- ctrlcode/metrics/__init__.py +6 -0
- ctrlcode/metrics/dashboard.py +283 -0
- ctrlcode/metrics/tech_debt.py +663 -0
- ctrlcode/paths.py +68 -0
- ctrlcode/permissions.py +179 -0
- ctrlcode/providers/__init__.py +15 -0
- ctrlcode/providers/anthropic.py +138 -0
- ctrlcode/providers/base.py +77 -0
- ctrlcode/providers/openai.py +197 -0
- ctrlcode/providers/parallel.py +104 -0
- ctrlcode/server.py +871 -0
- ctrlcode/session/__init__.py +6 -0
- ctrlcode/session/baseline.py +57 -0
- ctrlcode/session/manager.py +967 -0
- ctrlcode/skills/__init__.py +10 -0
- ctrlcode/skills/builtin/commit.toml +29 -0
- ctrlcode/skills/builtin/docs.toml +25 -0
- ctrlcode/skills/builtin/refactor.toml +33 -0
- ctrlcode/skills/builtin/review.toml +28 -0
- ctrlcode/skills/builtin/test.toml +28 -0
- ctrlcode/skills/loader.py +111 -0
- ctrlcode/skills/registry.py +139 -0
- ctrlcode/storage/__init__.py +19 -0
- ctrlcode/storage/history_db.py +708 -0
- ctrlcode/tools/__init__.py +220 -0
- ctrlcode/tools/bash.py +112 -0
- ctrlcode/tools/browser.py +352 -0
- ctrlcode/tools/executor.py +153 -0
- ctrlcode/tools/explore.py +486 -0
- ctrlcode/tools/mcp.py +108 -0
- ctrlcode/tools/observability.py +561 -0
- ctrlcode/tools/registry.py +193 -0
- ctrlcode/tools/todo.py +291 -0
- ctrlcode/tools/update.py +266 -0
- ctrlcode/tools/webfetch.py +147 -0
- ctrlcode-0.1.0.dist-info/METADATA +93 -0
- ctrlcode-0.1.0.dist-info/RECORD +75 -0
- ctrlcode-0.1.0.dist-info/WHEEL +4 -0
- ctrlcode-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""MCP tool system for ctrl-code."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .mcp import MCPClient, MCPTool
|
|
6
|
+
from .registry import ToolRegistry, BuiltinTool
|
|
7
|
+
from .executor import ToolExecutor, ToolCallResult
|
|
8
|
+
from .explore import ExploreTools, EXPLORE_TOOL_SCHEMAS
|
|
9
|
+
from .todo import TodoTools, TODO_TOOL_SCHEMAS
|
|
10
|
+
from .bash import BashTools, BASH_TOOL_SCHEMAS
|
|
11
|
+
from .webfetch import WebFetchTools, WEBFETCH_TOOL_SCHEMAS
|
|
12
|
+
from .update import UpdateFileTools, UPDATE_TOOL_SCHEMAS
|
|
13
|
+
from .observability import ObservabilityTools, OBSERVABILITY_TOOL_SCHEMAS
|
|
14
|
+
from .browser import BrowserTools, BROWSER_TOOL_SCHEMAS, get_browser_tools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def setup_explore_tools(registry: ToolRegistry, workspace_root: str | Path) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Register built-in exploration tools.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
registry: Tool registry to register tools in
|
|
23
|
+
workspace_root: Root directory for file exploration
|
|
24
|
+
"""
|
|
25
|
+
explore = ExploreTools(workspace_root)
|
|
26
|
+
|
|
27
|
+
# Register each tool with its schema and function
|
|
28
|
+
for schema in EXPLORE_TOOL_SCHEMAS:
|
|
29
|
+
tool_name = schema["name"]
|
|
30
|
+
|
|
31
|
+
# Map tool name to method
|
|
32
|
+
function = getattr(explore, tool_name)
|
|
33
|
+
|
|
34
|
+
registry.register_builtin(
|
|
35
|
+
name=tool_name,
|
|
36
|
+
description=schema["description"],
|
|
37
|
+
input_schema=schema["input_schema"],
|
|
38
|
+
function=function,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def setup_todo_tools(registry: ToolRegistry, data_dir: str | Path) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Register built-in todo tools.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
registry: Tool registry to register tools in
|
|
48
|
+
data_dir: Data directory for storing todos
|
|
49
|
+
"""
|
|
50
|
+
todo = TodoTools(data_dir)
|
|
51
|
+
|
|
52
|
+
# Register each tool with its schema and function
|
|
53
|
+
for schema in TODO_TOOL_SCHEMAS:
|
|
54
|
+
tool_name = schema["name"]
|
|
55
|
+
|
|
56
|
+
# Map tool name to method
|
|
57
|
+
function = getattr(todo, tool_name)
|
|
58
|
+
|
|
59
|
+
registry.register_builtin(
|
|
60
|
+
name=tool_name,
|
|
61
|
+
description=schema["description"],
|
|
62
|
+
input_schema=schema["input_schema"],
|
|
63
|
+
function=function,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Also register as task_* alias for user flexibility
|
|
67
|
+
task_alias = tool_name.replace("todo_", "task_")
|
|
68
|
+
registry.register_builtin(
|
|
69
|
+
name=task_alias,
|
|
70
|
+
description=schema["description"],
|
|
71
|
+
input_schema=schema["input_schema"],
|
|
72
|
+
function=function,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def setup_bash_tools(registry: ToolRegistry, workspace_root: str | Path) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Register built-in bash tools.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
registry: Tool registry to register tools in
|
|
82
|
+
workspace_root: Root directory for command execution
|
|
83
|
+
"""
|
|
84
|
+
bash = BashTools(workspace_root)
|
|
85
|
+
|
|
86
|
+
# Register each tool with its schema and function
|
|
87
|
+
for schema in BASH_TOOL_SCHEMAS:
|
|
88
|
+
tool_name = schema["name"]
|
|
89
|
+
|
|
90
|
+
# Map tool name to method
|
|
91
|
+
function = getattr(bash, tool_name)
|
|
92
|
+
|
|
93
|
+
registry.register_builtin(
|
|
94
|
+
name=tool_name,
|
|
95
|
+
description=schema["description"],
|
|
96
|
+
input_schema=schema["input_schema"],
|
|
97
|
+
function=function,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def setup_webfetch_tools(registry: ToolRegistry) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Register built-in web fetch tools.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
registry: Tool registry to register tools in
|
|
107
|
+
"""
|
|
108
|
+
webfetch = WebFetchTools()
|
|
109
|
+
|
|
110
|
+
# Register each tool with its schema and function
|
|
111
|
+
for schema in WEBFETCH_TOOL_SCHEMAS:
|
|
112
|
+
tool_name = schema["name"]
|
|
113
|
+
|
|
114
|
+
# Map tool name to method
|
|
115
|
+
function = getattr(webfetch, tool_name)
|
|
116
|
+
|
|
117
|
+
registry.register_builtin(
|
|
118
|
+
name=tool_name,
|
|
119
|
+
description=schema["description"],
|
|
120
|
+
input_schema=schema["input_schema"],
|
|
121
|
+
function=function,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def setup_update_tools(registry: ToolRegistry, workspace_root: str | Path) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Register built-in update file tools.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
registry: Tool registry to register tools in
|
|
131
|
+
workspace_root: Root directory for file operations
|
|
132
|
+
"""
|
|
133
|
+
update = UpdateFileTools(workspace_root)
|
|
134
|
+
|
|
135
|
+
# Register each tool with its schema and function
|
|
136
|
+
for schema in UPDATE_TOOL_SCHEMAS:
|
|
137
|
+
tool_name = schema["name"]
|
|
138
|
+
|
|
139
|
+
# Map tool name to method
|
|
140
|
+
function = getattr(update, tool_name)
|
|
141
|
+
|
|
142
|
+
registry.register_builtin(
|
|
143
|
+
name=tool_name,
|
|
144
|
+
description=schema["description"],
|
|
145
|
+
input_schema=schema["input_schema"],
|
|
146
|
+
function=function,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def setup_observability_tools(registry: ToolRegistry, log_dir: str | Path | None = None) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Register built-in observability tools.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
registry: Tool registry to register tools in
|
|
156
|
+
log_dir: Directory containing log files (optional)
|
|
157
|
+
"""
|
|
158
|
+
observability = ObservabilityTools(log_dir)
|
|
159
|
+
|
|
160
|
+
# Register each tool with its schema and function
|
|
161
|
+
for schema in OBSERVABILITY_TOOL_SCHEMAS:
|
|
162
|
+
tool_name = schema["name"]
|
|
163
|
+
|
|
164
|
+
# Map tool name to method
|
|
165
|
+
function = getattr(observability, tool_name)
|
|
166
|
+
|
|
167
|
+
registry.register_builtin(
|
|
168
|
+
name=tool_name,
|
|
169
|
+
description=schema["description"],
|
|
170
|
+
input_schema=schema["input_schema"],
|
|
171
|
+
function=function,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def setup_browser_tools(registry: ToolRegistry) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Register built-in browser automation tools.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
registry: Tool registry to register tools in
|
|
181
|
+
"""
|
|
182
|
+
browser = get_browser_tools()
|
|
183
|
+
|
|
184
|
+
# Register each tool with its schema and function
|
|
185
|
+
for schema in BROWSER_TOOL_SCHEMAS:
|
|
186
|
+
tool_name = schema["name"]
|
|
187
|
+
|
|
188
|
+
# Map tool name to method
|
|
189
|
+
function = getattr(browser, tool_name)
|
|
190
|
+
|
|
191
|
+
registry.register_builtin(
|
|
192
|
+
name=tool_name,
|
|
193
|
+
description=schema["description"],
|
|
194
|
+
input_schema=schema["input_schema"],
|
|
195
|
+
function=function,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = [
|
|
200
|
+
"MCPClient",
|
|
201
|
+
"MCPTool",
|
|
202
|
+
"ToolRegistry",
|
|
203
|
+
"BuiltinTool",
|
|
204
|
+
"ToolExecutor",
|
|
205
|
+
"ToolCallResult",
|
|
206
|
+
"ExploreTools",
|
|
207
|
+
"TodoTools",
|
|
208
|
+
"BashTools",
|
|
209
|
+
"WebFetchTools",
|
|
210
|
+
"UpdateFileTools",
|
|
211
|
+
"ObservabilityTools",
|
|
212
|
+
"BrowserTools",
|
|
213
|
+
"setup_explore_tools",
|
|
214
|
+
"setup_todo_tools",
|
|
215
|
+
"setup_bash_tools",
|
|
216
|
+
"setup_webfetch_tools",
|
|
217
|
+
"setup_update_tools",
|
|
218
|
+
"setup_observability_tools",
|
|
219
|
+
"setup_browser_tools",
|
|
220
|
+
]
|
ctrlcode/tools/bash.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Built-in bash execution tools for running shell commands."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BashTools:
|
|
9
|
+
"""Built-in tools for executing bash commands."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, workspace_root: str | Path):
|
|
12
|
+
"""
|
|
13
|
+
Initialize bash tools.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
workspace_root: Root directory for command execution
|
|
17
|
+
"""
|
|
18
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
19
|
+
|
|
20
|
+
def run_command(
|
|
21
|
+
self,
|
|
22
|
+
command: str,
|
|
23
|
+
timeout: int = 30,
|
|
24
|
+
cwd: str | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Execute a shell command.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
command: Shell command to execute
|
|
31
|
+
timeout: Timeout in seconds (default 30, max 300)
|
|
32
|
+
cwd: Working directory for command (relative to workspace root)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dict with stdout, stderr, returncode, and metadata
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
# Enforce timeout limits
|
|
39
|
+
if timeout > 300:
|
|
40
|
+
timeout = 300
|
|
41
|
+
if timeout < 1:
|
|
42
|
+
timeout = 1
|
|
43
|
+
|
|
44
|
+
# Determine working directory
|
|
45
|
+
work_dir = self.workspace_root
|
|
46
|
+
if cwd:
|
|
47
|
+
work_dir = (self.workspace_root / cwd).resolve()
|
|
48
|
+
|
|
49
|
+
# Security: ensure cwd is within workspace
|
|
50
|
+
if not str(work_dir).startswith(str(self.workspace_root)):
|
|
51
|
+
return {"error": "Working directory outside workspace"}
|
|
52
|
+
|
|
53
|
+
if not work_dir.exists():
|
|
54
|
+
return {"error": "Working directory does not exist"}
|
|
55
|
+
|
|
56
|
+
if not work_dir.is_dir():
|
|
57
|
+
return {"error": "Working directory is not a directory"}
|
|
58
|
+
|
|
59
|
+
# Execute command
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
command,
|
|
62
|
+
shell=True,
|
|
63
|
+
cwd=work_dir,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"stdout": result.stdout,
|
|
71
|
+
"stderr": result.stderr,
|
|
72
|
+
"returncode": result.returncode,
|
|
73
|
+
"command": command,
|
|
74
|
+
"cwd": str(work_dir.relative_to(self.workspace_root)),
|
|
75
|
+
"success": result.returncode == 0,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
return {
|
|
80
|
+
"error": f"Command timed out after {timeout} seconds",
|
|
81
|
+
"command": command,
|
|
82
|
+
}
|
|
83
|
+
except Exception as e:
|
|
84
|
+
return {"error": str(e), "command": command}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Tool schemas for LLM providers (Anthropic/OpenAI format)
|
|
88
|
+
BASH_TOOL_SCHEMAS = [
|
|
89
|
+
{
|
|
90
|
+
"name": "run_command",
|
|
91
|
+
"description": "Execute a shell command in the workspace. Use for git operations, testing, build tools, etc. Commands run in a bash shell.",
|
|
92
|
+
"input_schema": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"command": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Shell command to execute (e.g., 'git status', 'npm test', 'make build')",
|
|
98
|
+
},
|
|
99
|
+
"timeout": {
|
|
100
|
+
"type": "integer",
|
|
101
|
+
"description": "Timeout in seconds (default 30, max 300)",
|
|
102
|
+
"default": 30,
|
|
103
|
+
},
|
|
104
|
+
"cwd": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "Working directory relative to workspace root (e.g., 'src', 'tests')",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"required": ["command"],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Browser automation tools for UI validation via Chrome DevTools Protocol."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrowserTools:
|
|
12
|
+
"""Browser automation tools for screenshot capture and DOM inspection."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
"""Initialize browser tools."""
|
|
16
|
+
self._playwright = None
|
|
17
|
+
self._browser = None
|
|
18
|
+
|
|
19
|
+
async def _ensure_browser(self):
|
|
20
|
+
"""Ensure playwright browser is launched."""
|
|
21
|
+
if self._browser is not None:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from playwright.async_api import async_playwright
|
|
26
|
+
except ImportError:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"playwright not installed. Run: uv add playwright && uv run playwright install chromium"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if self._playwright is None:
|
|
32
|
+
self._playwright = await async_playwright().start()
|
|
33
|
+
|
|
34
|
+
self._browser = await self._playwright.chromium.launch(headless=True)
|
|
35
|
+
|
|
36
|
+
async def _cleanup(self):
|
|
37
|
+
"""Cleanup browser resources."""
|
|
38
|
+
if self._browser:
|
|
39
|
+
await self._browser.close()
|
|
40
|
+
self._browser = None
|
|
41
|
+
if self._playwright:
|
|
42
|
+
await self._playwright.stop()
|
|
43
|
+
self._playwright = None
|
|
44
|
+
|
|
45
|
+
async def screenshot(
|
|
46
|
+
self,
|
|
47
|
+
url: str,
|
|
48
|
+
output: str | None = None,
|
|
49
|
+
full_page: bool = True,
|
|
50
|
+
viewport_width: int = 1280,
|
|
51
|
+
viewport_height: int = 720,
|
|
52
|
+
wait_for: str | None = None,
|
|
53
|
+
timeout: int = 30000,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Capture screenshot of a web page.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
url: URL to navigate to
|
|
60
|
+
output: Output file path (defaults to temp file)
|
|
61
|
+
full_page: Capture full scrollable page (default: True)
|
|
62
|
+
viewport_width: Viewport width in pixels (default: 1280)
|
|
63
|
+
viewport_height: Viewport height in pixels (default: 720)
|
|
64
|
+
wait_for: CSS selector to wait for before screenshot (optional)
|
|
65
|
+
timeout: Page load timeout in milliseconds (default: 30000)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dict with:
|
|
69
|
+
- success: Whether screenshot succeeded
|
|
70
|
+
- file_path: Path to screenshot file
|
|
71
|
+
- url: URL that was captured
|
|
72
|
+
- width: Image width
|
|
73
|
+
- height: Image height
|
|
74
|
+
- error: Error message if failed
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
await self._ensure_browser()
|
|
78
|
+
|
|
79
|
+
# Create context with viewport
|
|
80
|
+
context = await self._browser.new_context(
|
|
81
|
+
viewport={"width": viewport_width, "height": viewport_height}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Create page
|
|
85
|
+
page = await context.new_page()
|
|
86
|
+
|
|
87
|
+
# Navigate to URL
|
|
88
|
+
await page.goto(url, timeout=timeout, wait_until="networkidle")
|
|
89
|
+
|
|
90
|
+
# Wait for specific element if requested
|
|
91
|
+
if wait_for:
|
|
92
|
+
await page.wait_for_selector(wait_for, timeout=timeout)
|
|
93
|
+
|
|
94
|
+
# Determine output path
|
|
95
|
+
if output is None:
|
|
96
|
+
screenshots_dir = Path.cwd() / ".ctrlcode" / "screenshots"
|
|
97
|
+
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
temp_file = tempfile.NamedTemporaryFile(
|
|
99
|
+
suffix=".png", delete=False, dir=screenshots_dir
|
|
100
|
+
)
|
|
101
|
+
output = temp_file.name
|
|
102
|
+
temp_file.close()
|
|
103
|
+
else:
|
|
104
|
+
output = str(Path(output).resolve())
|
|
105
|
+
# Ensure output directory exists
|
|
106
|
+
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
# Take screenshot
|
|
109
|
+
await page.screenshot(path=output, full_page=full_page)
|
|
110
|
+
|
|
111
|
+
# Get dimensions
|
|
112
|
+
if full_page:
|
|
113
|
+
dimensions = await page.evaluate(
|
|
114
|
+
"() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
dimensions = {"width": viewport_width, "height": viewport_height}
|
|
118
|
+
|
|
119
|
+
await context.close()
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"success": True,
|
|
123
|
+
"file_path": output,
|
|
124
|
+
"url": url,
|
|
125
|
+
"width": dimensions["width"],
|
|
126
|
+
"height": dimensions["height"],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Screenshot failed: {e}", exc_info=True)
|
|
131
|
+
return {"success": False, "error": str(e), "url": url}
|
|
132
|
+
|
|
133
|
+
async def dom_snapshot(
|
|
134
|
+
self,
|
|
135
|
+
url: str,
|
|
136
|
+
selector: str | None = None,
|
|
137
|
+
include_styles: bool = False,
|
|
138
|
+
timeout: int = 30000,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""
|
|
141
|
+
Capture DOM structure snapshot for HTML inspection.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
url: URL to navigate to
|
|
145
|
+
selector: CSS selector to extract (defaults to full page)
|
|
146
|
+
include_styles: Include computed styles (default: False)
|
|
147
|
+
timeout: Page load timeout in milliseconds (default: 30000)
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Dict with:
|
|
151
|
+
- success: Whether snapshot succeeded
|
|
152
|
+
- url: URL that was captured
|
|
153
|
+
- html: HTML content
|
|
154
|
+
- text_content: Extracted text content
|
|
155
|
+
- element_count: Number of elements
|
|
156
|
+
- links: List of links found
|
|
157
|
+
- forms: List of forms found
|
|
158
|
+
- error: Error message if failed
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
await self._ensure_browser()
|
|
162
|
+
|
|
163
|
+
# Create context
|
|
164
|
+
context = await self._browser.new_context()
|
|
165
|
+
page = await context.new_page()
|
|
166
|
+
|
|
167
|
+
# Navigate to URL
|
|
168
|
+
await page.goto(url, timeout=timeout, wait_until="networkidle")
|
|
169
|
+
|
|
170
|
+
# Get element handle
|
|
171
|
+
if selector:
|
|
172
|
+
element = await page.query_selector(selector)
|
|
173
|
+
if not element:
|
|
174
|
+
return {
|
|
175
|
+
"success": False,
|
|
176
|
+
"error": f"Selector '{selector}' not found",
|
|
177
|
+
"url": url,
|
|
178
|
+
}
|
|
179
|
+
html = await element.inner_html()
|
|
180
|
+
text_content = await element.text_content()
|
|
181
|
+
else:
|
|
182
|
+
html = await page.content()
|
|
183
|
+
text_content = await page.evaluate("() => document.body.textContent")
|
|
184
|
+
|
|
185
|
+
# Extract metadata
|
|
186
|
+
element_count = await page.evaluate("() => document.querySelectorAll('*').length")
|
|
187
|
+
|
|
188
|
+
# Extract links
|
|
189
|
+
links = await page.evaluate(
|
|
190
|
+
"""() => {
|
|
191
|
+
return Array.from(document.querySelectorAll('a[href]')).map(a => ({
|
|
192
|
+
text: a.textContent.trim(),
|
|
193
|
+
href: a.href,
|
|
194
|
+
target: a.target
|
|
195
|
+
}));
|
|
196
|
+
}"""
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Extract forms
|
|
200
|
+
forms = await page.evaluate(
|
|
201
|
+
"""() => {
|
|
202
|
+
return Array.from(document.querySelectorAll('form')).map(form => ({
|
|
203
|
+
action: form.action,
|
|
204
|
+
method: form.method,
|
|
205
|
+
fields: Array.from(form.querySelectorAll('input, textarea, select')).map(field => ({
|
|
206
|
+
name: field.name,
|
|
207
|
+
type: field.type,
|
|
208
|
+
id: field.id
|
|
209
|
+
}))
|
|
210
|
+
}));
|
|
211
|
+
}"""
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
await context.close()
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"success": True,
|
|
218
|
+
"url": url,
|
|
219
|
+
"html": html,
|
|
220
|
+
"text_content": text_content.strip() if text_content else "",
|
|
221
|
+
"element_count": element_count,
|
|
222
|
+
"links": links,
|
|
223
|
+
"forms": forms,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error(f"DOM snapshot failed: {e}", exc_info=True)
|
|
228
|
+
return {"success": False, "error": str(e), "url": url}
|
|
229
|
+
|
|
230
|
+
async def close(self):
|
|
231
|
+
"""Close browser and cleanup resources."""
|
|
232
|
+
await self._cleanup()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Singleton instance
|
|
236
|
+
_browser_tools = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_browser_tools() -> BrowserTools:
|
|
240
|
+
"""Get or create singleton BrowserTools instance."""
|
|
241
|
+
global _browser_tools
|
|
242
|
+
if _browser_tools is None:
|
|
243
|
+
_browser_tools = BrowserTools()
|
|
244
|
+
return _browser_tools
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def cleanup_browser_tools():
|
|
248
|
+
"""Cleanup singleton browser tools."""
|
|
249
|
+
global _browser_tools
|
|
250
|
+
if _browser_tools is not None:
|
|
251
|
+
await _browser_tools.close()
|
|
252
|
+
_browser_tools = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
BROWSER_TOOL_SCHEMAS = [
|
|
256
|
+
{
|
|
257
|
+
"name": "screenshot",
|
|
258
|
+
"description": """Capture screenshot of a web page for UI validation.
|
|
259
|
+
|
|
260
|
+
Useful for verifying:
|
|
261
|
+
- Page renders correctly after changes
|
|
262
|
+
- UI elements are visible and positioned correctly
|
|
263
|
+
- Visual regressions
|
|
264
|
+
- Responsive design at different viewports
|
|
265
|
+
|
|
266
|
+
Returns file path to PNG screenshot that can be analyzed.
|
|
267
|
+
|
|
268
|
+
Examples:
|
|
269
|
+
- screenshot(url="http://localhost:8000") - Capture full page
|
|
270
|
+
- screenshot(url="http://localhost:8000/login", wait_for=".login-form") - Wait for element
|
|
271
|
+
- screenshot(url="http://localhost:8000", full_page=False) - Viewport only""",
|
|
272
|
+
"input_schema": {
|
|
273
|
+
"type": "object",
|
|
274
|
+
"properties": {
|
|
275
|
+
"url": {
|
|
276
|
+
"type": "string",
|
|
277
|
+
"description": "URL to navigate to and capture"
|
|
278
|
+
},
|
|
279
|
+
"output": {
|
|
280
|
+
"type": "string",
|
|
281
|
+
"description": "Output file path (optional, defaults to temp file)"
|
|
282
|
+
},
|
|
283
|
+
"full_page": {
|
|
284
|
+
"type": "boolean",
|
|
285
|
+
"description": "Capture full scrollable page (default: true)",
|
|
286
|
+
"default": True
|
|
287
|
+
},
|
|
288
|
+
"viewport_width": {
|
|
289
|
+
"type": "integer",
|
|
290
|
+
"description": "Viewport width in pixels (default: 1280)",
|
|
291
|
+
"default": 1280
|
|
292
|
+
},
|
|
293
|
+
"viewport_height": {
|
|
294
|
+
"type": "integer",
|
|
295
|
+
"description": "Viewport height in pixels (default: 720)",
|
|
296
|
+
"default": 720
|
|
297
|
+
},
|
|
298
|
+
"wait_for": {
|
|
299
|
+
"type": "string",
|
|
300
|
+
"description": "CSS selector to wait for before screenshot (optional)"
|
|
301
|
+
},
|
|
302
|
+
"timeout": {
|
|
303
|
+
"type": "integer",
|
|
304
|
+
"description": "Page load timeout in milliseconds (default: 30000)",
|
|
305
|
+
"default": 30000
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"required": ["url"]
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
"name": "dom_snapshot",
|
|
313
|
+
"description": """Capture DOM structure snapshot for HTML inspection.
|
|
314
|
+
|
|
315
|
+
Useful for:
|
|
316
|
+
- Verifying page structure and content
|
|
317
|
+
- Extracting text, links, forms
|
|
318
|
+
- Debugging rendering issues
|
|
319
|
+
- Validating SEO elements
|
|
320
|
+
|
|
321
|
+
Returns HTML content, text, element count, links, and forms.
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
- dom_snapshot(url="http://localhost:8000") - Full page snapshot
|
|
325
|
+
- dom_snapshot(url="http://localhost:8000", selector=".main-content") - Specific element
|
|
326
|
+
- dom_snapshot(url="http://localhost:8000/api/docs", include_styles=True) - With styles""",
|
|
327
|
+
"input_schema": {
|
|
328
|
+
"type": "object",
|
|
329
|
+
"properties": {
|
|
330
|
+
"url": {
|
|
331
|
+
"type": "string",
|
|
332
|
+
"description": "URL to navigate to and capture"
|
|
333
|
+
},
|
|
334
|
+
"selector": {
|
|
335
|
+
"type": "string",
|
|
336
|
+
"description": "CSS selector to extract (optional, defaults to full page)"
|
|
337
|
+
},
|
|
338
|
+
"include_styles": {
|
|
339
|
+
"type": "boolean",
|
|
340
|
+
"description": "Include computed styles (default: false)",
|
|
341
|
+
"default": False
|
|
342
|
+
},
|
|
343
|
+
"timeout": {
|
|
344
|
+
"type": "integer",
|
|
345
|
+
"description": "Page load timeout in milliseconds (default: 30000)",
|
|
346
|
+
"default": 30000
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
"required": ["url"]
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
]
|