luckyd-code 1.2.2__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.
- luckyd_code/__init__.py +54 -0
- luckyd_code/__main__.py +5 -0
- luckyd_code/_agent_loop.py +551 -0
- luckyd_code/_data_dir.py +73 -0
- luckyd_code/agent.py +38 -0
- luckyd_code/analytics/__init__.py +18 -0
- luckyd_code/analytics/reporter.py +195 -0
- luckyd_code/analytics/scanner.py +443 -0
- luckyd_code/analytics/smells.py +316 -0
- luckyd_code/analytics/trends.py +303 -0
- luckyd_code/api.py +473 -0
- luckyd_code/audit_daemon.py +845 -0
- luckyd_code/autonomous_fixer.py +473 -0
- luckyd_code/background.py +159 -0
- luckyd_code/backup.py +237 -0
- luckyd_code/brain/__init__.py +84 -0
- luckyd_code/brain/assembler.py +100 -0
- luckyd_code/brain/chunker.py +345 -0
- luckyd_code/brain/constants.py +73 -0
- luckyd_code/brain/embedder.py +163 -0
- luckyd_code/brain/graph.py +311 -0
- luckyd_code/brain/indexer.py +316 -0
- luckyd_code/brain/parser.py +140 -0
- luckyd_code/brain/retriever.py +234 -0
- luckyd_code/cli.py +894 -0
- luckyd_code/cli_commands/__init__.py +1 -0
- luckyd_code/cli_commands/audit.py +120 -0
- luckyd_code/cli_commands/background.py +83 -0
- luckyd_code/cli_commands/brain.py +87 -0
- luckyd_code/cli_commands/config.py +75 -0
- luckyd_code/cli_commands/dispatcher.py +695 -0
- luckyd_code/cli_commands/sessions.py +41 -0
- luckyd_code/cli_entry.py +147 -0
- luckyd_code/cli_utils.py +112 -0
- luckyd_code/config.py +205 -0
- luckyd_code/context.py +214 -0
- luckyd_code/cost_tracker.py +209 -0
- luckyd_code/error_reporter.py +508 -0
- luckyd_code/exceptions.py +39 -0
- luckyd_code/export.py +126 -0
- luckyd_code/feedback_analyzer.py +290 -0
- luckyd_code/file_watcher.py +258 -0
- luckyd_code/git/__init__.py +11 -0
- luckyd_code/git/auto_commit.py +157 -0
- luckyd_code/git/tools.py +85 -0
- luckyd_code/hooks.py +236 -0
- luckyd_code/indexer.py +280 -0
- luckyd_code/init.py +39 -0
- luckyd_code/keybindings.py +77 -0
- luckyd_code/log.py +55 -0
- luckyd_code/mcp/__init__.py +6 -0
- luckyd_code/mcp/client.py +184 -0
- luckyd_code/memory/__init__.py +19 -0
- luckyd_code/memory/manager.py +339 -0
- luckyd_code/metrics/__init__.py +5 -0
- luckyd_code/model_registry.py +131 -0
- luckyd_code/orchestrator.py +204 -0
- luckyd_code/permissions/__init__.py +1 -0
- luckyd_code/permissions/manager.py +103 -0
- luckyd_code/planner.py +361 -0
- luckyd_code/plugins.py +91 -0
- luckyd_code/py.typed +0 -0
- luckyd_code/retry.py +57 -0
- luckyd_code/router.py +417 -0
- luckyd_code/sandbox.py +156 -0
- luckyd_code/self_critique.py +2 -0
- luckyd_code/self_improve.py +274 -0
- luckyd_code/sessions.py +114 -0
- luckyd_code/settings.py +72 -0
- luckyd_code/skills/__init__.py +8 -0
- luckyd_code/skills/review.py +22 -0
- luckyd_code/skills/security.py +17 -0
- luckyd_code/tasks/__init__.py +1 -0
- luckyd_code/tasks/manager.py +102 -0
- luckyd_code/templates/icon-192.png +0 -0
- luckyd_code/templates/icon-512.png +0 -0
- luckyd_code/templates/index.html +1965 -0
- luckyd_code/templates/manifest.json +14 -0
- luckyd_code/templates/src/app.js +694 -0
- luckyd_code/templates/src/body.html +767 -0
- luckyd_code/templates/src/cdn.txt +2 -0
- luckyd_code/templates/src/style.css +474 -0
- luckyd_code/templates/sw.js +31 -0
- luckyd_code/templates/test.html +6 -0
- luckyd_code/themes.py +48 -0
- luckyd_code/tools/__init__.py +97 -0
- luckyd_code/tools/agent_tools.py +65 -0
- luckyd_code/tools/bash.py +360 -0
- luckyd_code/tools/brain_tools.py +137 -0
- luckyd_code/tools/browser.py +369 -0
- luckyd_code/tools/datetime_tool.py +34 -0
- luckyd_code/tools/dockerfile_gen.py +212 -0
- luckyd_code/tools/file_ops.py +381 -0
- luckyd_code/tools/game_gen.py +360 -0
- luckyd_code/tools/git_tools.py +130 -0
- luckyd_code/tools/git_worktree.py +63 -0
- luckyd_code/tools/path_validate.py +64 -0
- luckyd_code/tools/project_gen.py +187 -0
- luckyd_code/tools/readme_gen.py +227 -0
- luckyd_code/tools/registry.py +157 -0
- luckyd_code/tools/shell_detect.py +109 -0
- luckyd_code/tools/web.py +89 -0
- luckyd_code/tools/youtube.py +187 -0
- luckyd_code/tools_bridge.py +144 -0
- luckyd_code/undo.py +126 -0
- luckyd_code/update.py +60 -0
- luckyd_code/verify.py +360 -0
- luckyd_code/web_app.py +176 -0
- luckyd_code/web_routes/__init__.py +23 -0
- luckyd_code/web_routes/background.py +73 -0
- luckyd_code/web_routes/brain.py +109 -0
- luckyd_code/web_routes/cost.py +12 -0
- luckyd_code/web_routes/files.py +133 -0
- luckyd_code/web_routes/memories.py +94 -0
- luckyd_code/web_routes/misc.py +67 -0
- luckyd_code/web_routes/project.py +48 -0
- luckyd_code/web_routes/review.py +20 -0
- luckyd_code/web_routes/sessions.py +44 -0
- luckyd_code/web_routes/settings.py +43 -0
- luckyd_code/web_routes/static.py +70 -0
- luckyd_code/web_routes/update.py +19 -0
- luckyd_code/web_routes/ws.py +237 -0
- luckyd_code-1.2.2.dist-info/METADATA +297 -0
- luckyd_code-1.2.2.dist-info/RECORD +127 -0
- luckyd_code-1.2.2.dist-info/WHEEL +4 -0
- luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
- luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Playwright browser automation tools.
|
|
2
|
+
|
|
3
|
+
Provides a persistent browser session that the AI agent can use to
|
|
4
|
+
navigate, click, type, screenshot, and extract content from web pages.
|
|
5
|
+
|
|
6
|
+
Supports headless (default) and headed modes. Set via settings:
|
|
7
|
+
/config set browser_headless false
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from .registry import Tool
|
|
14
|
+
from ..settings import load_settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BrowserManager:
|
|
18
|
+
"""Singleton browser session manager."""
|
|
19
|
+
|
|
20
|
+
_instance = None
|
|
21
|
+
_playwright = None
|
|
22
|
+
_browser = None
|
|
23
|
+
_context = None
|
|
24
|
+
_page = None
|
|
25
|
+
_headless = None
|
|
26
|
+
|
|
27
|
+
def _ensure(self, headless: bool | None = None):
|
|
28
|
+
if self._page is None:
|
|
29
|
+
try:
|
|
30
|
+
from playwright.sync_api import sync_playwright
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
"Playwright is not installed. "
|
|
34
|
+
"Install it with: pip install deepseek-code[browser]"
|
|
35
|
+
) from exc
|
|
36
|
+
if headless is None:
|
|
37
|
+
settings = load_settings()
|
|
38
|
+
headless = settings.get("browser_headless", True)
|
|
39
|
+
BrowserManager._headless = headless
|
|
40
|
+
self._playwright = sync_playwright().start()
|
|
41
|
+
self._browser = self._playwright.chromium.launch(
|
|
42
|
+
headless=headless,
|
|
43
|
+
args=[
|
|
44
|
+
"--no-sandbox",
|
|
45
|
+
"--disable-blink-features=AutomationControlled",
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
self._context = self._browser.new_context(
|
|
49
|
+
viewport={"width": 1280, "height": 720},
|
|
50
|
+
user_agent=(
|
|
51
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
52
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
53
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
self._page = self._context.new_page()
|
|
57
|
+
return self._page
|
|
58
|
+
|
|
59
|
+
def page(self):
|
|
60
|
+
return self._ensure()
|
|
61
|
+
|
|
62
|
+
def close(self):
|
|
63
|
+
if self._context:
|
|
64
|
+
self._context.close()
|
|
65
|
+
self._context = None
|
|
66
|
+
if self._browser:
|
|
67
|
+
self._browser.close()
|
|
68
|
+
self._browser = None
|
|
69
|
+
if self._playwright:
|
|
70
|
+
self._playwright.stop()
|
|
71
|
+
self._playwright = None
|
|
72
|
+
self._page = None
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def get(cls):
|
|
76
|
+
if cls._instance is None:
|
|
77
|
+
cls._instance = cls()
|
|
78
|
+
return cls._instance
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_manager = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_manager():
|
|
85
|
+
global _manager
|
|
86
|
+
if _manager is None:
|
|
87
|
+
_manager = BrowserManager()
|
|
88
|
+
return _manager
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def make_safe_snapshot(page, max_length=8000) -> str:
|
|
92
|
+
"""Extract an accessibility-style snapshot from the page."""
|
|
93
|
+
try:
|
|
94
|
+
title = page.title()
|
|
95
|
+
url = page.url
|
|
96
|
+
|
|
97
|
+
parts: list[str] = [f"URL: {url}", f"Title: {title}", ""]
|
|
98
|
+
|
|
99
|
+
# Get all interactive elements via JavaScript
|
|
100
|
+
snapshot_js = """
|
|
101
|
+
() => {
|
|
102
|
+
const results = [];
|
|
103
|
+
const selectors = [
|
|
104
|
+
'a[href]', 'button', 'input:not([type="hidden"])',
|
|
105
|
+
'select', 'textarea', '[role="button"]',
|
|
106
|
+
'[role="link"]', '[role="option"]', '[role="tab"]',
|
|
107
|
+
'[role="menuitem"]', '[role="checkbox"]',
|
|
108
|
+
'[role="radio"]', '[role="switch"]',
|
|
109
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
110
|
+
'p', 'li', 'label', 'img[alt]',
|
|
111
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
112
|
+
];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
selectors.forEach(sel => {
|
|
115
|
+
document.querySelectorAll(sel).forEach(el => {
|
|
116
|
+
const rect = el.getBoundingClientRect();
|
|
117
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
118
|
+
const tag = el.tagName.toLowerCase();
|
|
119
|
+
const text = (el.textContent || '').trim().slice(0, 120);
|
|
120
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
121
|
+
const placeholder = el.getAttribute('placeholder') || '';
|
|
122
|
+
const value = el.getAttribute('value') || '';
|
|
123
|
+
const href = el.getAttribute('href') || '';
|
|
124
|
+
const name = el.getAttribute('name') || '';
|
|
125
|
+
const alt = el.getAttribute('alt') || '';
|
|
126
|
+
const type = el.getAttribute('type') || '';
|
|
127
|
+
const role = el.getAttribute('role') || '';
|
|
128
|
+
|
|
129
|
+
const key = tag + '|' + text + '|' + (href || '');
|
|
130
|
+
if (seen.has(key)) return;
|
|
131
|
+
seen.add(key);
|
|
132
|
+
|
|
133
|
+
const info = [`<${tag}`];
|
|
134
|
+
if (role) info.push(` role="${role}"`);
|
|
135
|
+
if (aria) info.push(` aria="${aria}"`);
|
|
136
|
+
if (name) info.push(` name="${name}"`);
|
|
137
|
+
if (type) info.push(` type="${type}"`);
|
|
138
|
+
if (placeholder) info.push(` placeholder="${placeholder}"`);
|
|
139
|
+
if (href) info.push(` href="${href}"`);
|
|
140
|
+
if (alt) info.push(` alt="${alt}"`);
|
|
141
|
+
if (value) info.push(` value="${value}"`);
|
|
142
|
+
info.push(`>[${text.slice(0, 80)}]`);
|
|
143
|
+
info.push(` [box=${Math.round(rect.x)},${Math.round(rect.y)},${Math.round(rect.width)},${Math.round(rect.height)}]`);
|
|
144
|
+
results.push(info.join(''));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
return results.join('\\n');
|
|
148
|
+
}
|
|
149
|
+
"""
|
|
150
|
+
elements = page.evaluate(snapshot_js)
|
|
151
|
+
|
|
152
|
+
if elements:
|
|
153
|
+
parts.append("--- Page Elements ---")
|
|
154
|
+
parts.append(elements)
|
|
155
|
+
else:
|
|
156
|
+
# Fallback: get body text
|
|
157
|
+
body_text = page.inner_text("body") or ""
|
|
158
|
+
parts.append(body_text[:5000])
|
|
159
|
+
|
|
160
|
+
result = "\n".join(parts)
|
|
161
|
+
if len(result) > max_length:
|
|
162
|
+
result = result[:max_length] + "\n... (truncated)"
|
|
163
|
+
return result
|
|
164
|
+
except Exception as e:
|
|
165
|
+
return f"Error taking snapshot: {e}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class BrowserNavigateTool(Tool):
|
|
169
|
+
name = "BrowserNavigate"
|
|
170
|
+
description = "Navigate the browser to a URL. Returns page title and summary of elements."
|
|
171
|
+
parameters = {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"url": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"description": "The URL to navigate to",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
"required": ["url"],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def run(self, url: str) -> str: # type: ignore[override]
|
|
183
|
+
manager = _get_manager()
|
|
184
|
+
page = manager.page()
|
|
185
|
+
try:
|
|
186
|
+
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
187
|
+
page.wait_for_timeout(1000)
|
|
188
|
+
snapshot = make_safe_snapshot(page)
|
|
189
|
+
return f"Navigated to {url}\n\n{snapshot}"
|
|
190
|
+
except Exception as e:
|
|
191
|
+
return f"Error navigating to {url}: {e}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class BrowserClickTool(Tool):
|
|
195
|
+
name = "BrowserClick"
|
|
196
|
+
description = "Click an element on the page by CSS selector."
|
|
197
|
+
parameters = {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"properties": {
|
|
200
|
+
"selector": {
|
|
201
|
+
"type": "string",
|
|
202
|
+
"description": "CSS selector of the element to click",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
"required": ["selector"],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def run(self, selector: str) -> str: # type: ignore[override]
|
|
209
|
+
manager = _get_manager()
|
|
210
|
+
page = manager.page()
|
|
211
|
+
try:
|
|
212
|
+
page.wait_for_selector(selector, timeout=5000)
|
|
213
|
+
page.click(selector)
|
|
214
|
+
page.wait_for_timeout(500)
|
|
215
|
+
snapshot = make_safe_snapshot(page)
|
|
216
|
+
return f"Clicked {selector}\n\n{snapshot}"
|
|
217
|
+
except Exception as e:
|
|
218
|
+
return f"Error clicking {selector}: {e}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BrowserTypeTool(Tool):
|
|
222
|
+
name = "BrowserType"
|
|
223
|
+
description = "Type text into an input field."
|
|
224
|
+
parameters = {
|
|
225
|
+
"type": "object",
|
|
226
|
+
"properties": {
|
|
227
|
+
"selector": {
|
|
228
|
+
"type": "string",
|
|
229
|
+
"description": "CSS selector of the input element",
|
|
230
|
+
},
|
|
231
|
+
"text": {
|
|
232
|
+
"type": "string",
|
|
233
|
+
"description": "Text to type",
|
|
234
|
+
},
|
|
235
|
+
"submit": {
|
|
236
|
+
"type": "boolean",
|
|
237
|
+
"description": "Press Enter after typing (default: false)",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
"required": ["selector", "text"],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def run(self, selector: str, text: str, submit: bool = False) -> str: # type: ignore[override]
|
|
244
|
+
manager = _get_manager()
|
|
245
|
+
page = manager.page()
|
|
246
|
+
try:
|
|
247
|
+
page.wait_for_selector(selector, timeout=5000)
|
|
248
|
+
page.fill(selector, "")
|
|
249
|
+
page.type(selector, text, delay=20)
|
|
250
|
+
if submit:
|
|
251
|
+
page.press(selector, "Enter")
|
|
252
|
+
page.wait_for_timeout(1000)
|
|
253
|
+
snapshot = make_safe_snapshot(page)
|
|
254
|
+
return f"Typed into {selector}\n\n{snapshot}"
|
|
255
|
+
except Exception as e:
|
|
256
|
+
return f"Error typing into {selector}: {e}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class BrowserSnapshotTool(Tool):
|
|
260
|
+
name = "BrowserSnapshot"
|
|
261
|
+
description = "Get a text snapshot of the current page (interactive elements, headings, links, etc.)."
|
|
262
|
+
parameters = {
|
|
263
|
+
"type": "object",
|
|
264
|
+
"properties": {},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
def run(self) -> str: # type: ignore[override]
|
|
268
|
+
manager = _get_manager()
|
|
269
|
+
page = manager.page()
|
|
270
|
+
try:
|
|
271
|
+
return make_safe_snapshot(page)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return f"Error taking snapshot: {e}"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class BrowserScreenshotTool(Tool):
|
|
277
|
+
name = "BrowserScreenshot"
|
|
278
|
+
description = "Take a screenshot of the current page and save it to a file."
|
|
279
|
+
parameters = {
|
|
280
|
+
"type": "object",
|
|
281
|
+
"properties": {
|
|
282
|
+
"path": {
|
|
283
|
+
"type": "string",
|
|
284
|
+
"description": "File path to save the screenshot (default: auto-generated in working dir)",
|
|
285
|
+
},
|
|
286
|
+
"full_page": {
|
|
287
|
+
"type": "boolean",
|
|
288
|
+
"description": "Capture full scrollable page (default: false)",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def run(self, path: str = "", full_page: bool = False) -> str: # type: ignore[override]
|
|
294
|
+
manager = _get_manager()
|
|
295
|
+
page = manager.page()
|
|
296
|
+
try:
|
|
297
|
+
if not path:
|
|
298
|
+
path = os.path.join(os.getcwd(), f"screenshot_{uuid.uuid4().hex[:8]}.png")
|
|
299
|
+
page.screenshot(path=path, full_page=full_page)
|
|
300
|
+
return f"Screenshot saved to {path}"
|
|
301
|
+
except Exception as e:
|
|
302
|
+
return f"Error taking screenshot: {e}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class BrowserEvaluateTool(Tool):
|
|
306
|
+
name = "BrowserEvaluate"
|
|
307
|
+
description = "Run JavaScript on the current page and return the result."
|
|
308
|
+
parameters = {
|
|
309
|
+
"type": "object",
|
|
310
|
+
"properties": {
|
|
311
|
+
"expression": {
|
|
312
|
+
"type": "string",
|
|
313
|
+
"description": "JavaScript expression to evaluate",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
"required": ["expression"],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
def run(self, expression: str) -> str: # type: ignore[override]
|
|
320
|
+
manager = _get_manager()
|
|
321
|
+
page = manager.page()
|
|
322
|
+
try:
|
|
323
|
+
result = page.evaluate(expression)
|
|
324
|
+
return str(result)[:5000]
|
|
325
|
+
except Exception as e:
|
|
326
|
+
return f"Error evaluating JS: {e}"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class BrowserCloseTool(Tool):
|
|
330
|
+
name = "BrowserClose"
|
|
331
|
+
description = "Close the browser session."
|
|
332
|
+
parameters = {
|
|
333
|
+
"type": "object",
|
|
334
|
+
"properties": {},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
def run(self) -> str: # type: ignore[override]
|
|
338
|
+
manager = _get_manager()
|
|
339
|
+
manager.close()
|
|
340
|
+
return "Browser session closed."
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class OpenInBrowserTool(Tool):
|
|
344
|
+
name = "OpenInBrowser"
|
|
345
|
+
description = "Open a URL in the user's default system browser (Chrome, Edge, etc.). Use this when the user wants to see a web page, watch a video, or interact with a site in their real browser."
|
|
346
|
+
parameters = {
|
|
347
|
+
"type": "object",
|
|
348
|
+
"properties": {
|
|
349
|
+
"url": {
|
|
350
|
+
"type": "string",
|
|
351
|
+
"description": "The URL to open",
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
"required": ["url"],
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
def run(self, url: str) -> str: # type: ignore[override]
|
|
358
|
+
import subprocess
|
|
359
|
+
import platform
|
|
360
|
+
try:
|
|
361
|
+
if platform.system() == "Windows":
|
|
362
|
+
subprocess.Popen(["start", url], shell=True)
|
|
363
|
+
elif platform.system() == "Darwin":
|
|
364
|
+
subprocess.Popen(["open", url])
|
|
365
|
+
else:
|
|
366
|
+
subprocess.Popen(["xdg-open", url])
|
|
367
|
+
return f"Opened {url} in your default browser."
|
|
368
|
+
except Exception as e:
|
|
369
|
+
return f"Error opening browser: {e}"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""DateTime tool — returns the current local date/time without touching the shell.
|
|
2
|
+
|
|
3
|
+
Using 'date' or 'time' in a Bash tool on Windows cmd.exe launches an
|
|
4
|
+
interactive prompt that hangs forever. This tool avoids the shell entirely.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from .registry import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DateTimeTool(Tool):
|
|
12
|
+
name = "DateTime"
|
|
13
|
+
description = (
|
|
14
|
+
"Get the current local date and time. "
|
|
15
|
+
"Always use this instead of running 'date' or 'time' in a shell."
|
|
16
|
+
)
|
|
17
|
+
permission_risk = "safe"
|
|
18
|
+
parameters = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"format": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": (
|
|
24
|
+
"Optional strftime format string "
|
|
25
|
+
"(e.g. '%Y-%m-%d %H:%M:%S'). "
|
|
26
|
+
"Defaults to a human-readable format."
|
|
27
|
+
),
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": [],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def run(self, format: str = "%A, %B %d %Y %I:%M:%S %p") -> str: # type: ignore[override]
|
|
34
|
+
return datetime.now().strftime(format)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Dockerfile & docker-compose generator tool.
|
|
2
|
+
|
|
3
|
+
Scans the project to detect the stack, then generates a production-ready
|
|
4
|
+
Dockerfile (and optionally a docker-compose.yml) using the DeepSeek model.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .registry import Tool
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Prompts
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
_SYSTEM_PROMPT = """\
|
|
20
|
+
You are a senior DevOps engineer specialising in Docker.
|
|
21
|
+
|
|
22
|
+
Given a list of project files and their contents, generate a production-ready
|
|
23
|
+
Dockerfile and, if the project has external dependencies (database, cache,
|
|
24
|
+
message broker, etc.), a docker-compose.yml too.
|
|
25
|
+
|
|
26
|
+
OUTPUT FORMAT — respond with ONLY a JSON object:
|
|
27
|
+
{
|
|
28
|
+
"dockerfile": "<full Dockerfile content>",
|
|
29
|
+
"compose": "<full docker-compose.yml content or empty string if not needed>",
|
|
30
|
+
"notes": "<optional short notes: port, env vars, commands to know>"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
RULES:
|
|
34
|
+
1. Output ONLY the JSON. No markdown fences, no prose.
|
|
35
|
+
2. Use multi-stage builds for compiled languages.
|
|
36
|
+
3. Run as a non-root user in the final stage.
|
|
37
|
+
4. Use .dockerignore best practices (hint in notes if needed).
|
|
38
|
+
5. Use ARG/ENV for configurable values; never hard-code secrets.
|
|
39
|
+
6. Pin base image versions (e.g. python:3.12-slim, node:20-alpine).
|
|
40
|
+
7. Install only production dependencies in the final image.
|
|
41
|
+
8. If compose is generated: use named volumes, health checks, depends_on.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_MAX_FILE_CHARS = 2000
|
|
45
|
+
_PRIORITY_FILES = {
|
|
46
|
+
"requirements.txt", "pyproject.toml", "setup.py", "Pipfile",
|
|
47
|
+
"package.json", "yarn.lock", "pnpm-lock.yaml",
|
|
48
|
+
"go.mod", "go.sum", "Cargo.toml",
|
|
49
|
+
"Gemfile", "build.gradle", "pom.xml",
|
|
50
|
+
"main.py", "app.py", "server.py", "index.js", "index.ts",
|
|
51
|
+
"main.go", "main.rs", ".env.example",
|
|
52
|
+
}
|
|
53
|
+
_SKIP_DIRS = {
|
|
54
|
+
".git", ".venv", "venv", "node_modules", "__pycache__",
|
|
55
|
+
"dist", "build", "target",
|
|
56
|
+
}
|
|
57
|
+
_SKIP_EXTS = {".pyc", ".pyo", ".so", ".dll", ".exe", ".png", ".jpg", ".lock"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collect_context(root: Path) -> str:
|
|
61
|
+
parts: list[str] = []
|
|
62
|
+
seen: set[str] = set()
|
|
63
|
+
|
|
64
|
+
for name in _PRIORITY_FILES:
|
|
65
|
+
p = root / name
|
|
66
|
+
if p.exists() and p.is_file():
|
|
67
|
+
rel = str(p.relative_to(root))
|
|
68
|
+
try:
|
|
69
|
+
text = p.read_text(encoding="utf-8", errors="replace")[:_MAX_FILE_CHARS]
|
|
70
|
+
parts.append(f"=== {rel} ===\n{text}")
|
|
71
|
+
seen.add(rel)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
count = 0
|
|
76
|
+
for p in sorted(root.rglob("*")):
|
|
77
|
+
if count >= 10:
|
|
78
|
+
break
|
|
79
|
+
if any(part in _SKIP_DIRS for part in p.parts):
|
|
80
|
+
continue
|
|
81
|
+
if p.suffix in _SKIP_EXTS or not p.is_file():
|
|
82
|
+
continue
|
|
83
|
+
rel = str(p.relative_to(root))
|
|
84
|
+
if rel in seen:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
text = p.read_text(encoding="utf-8", errors="replace")[:_MAX_FILE_CHARS]
|
|
88
|
+
parts.append(f"=== {rel} ===\n{text}")
|
|
89
|
+
seen.add(rel)
|
|
90
|
+
count += 1
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
return "\n\n".join(parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Tool
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
class DockerfileGenTool(Tool):
|
|
102
|
+
"""Generate a production-ready Dockerfile (and docker-compose.yml if needed).
|
|
103
|
+
|
|
104
|
+
Use this tool when the user asks to:
|
|
105
|
+
- Dockerise or containerise their project
|
|
106
|
+
- Write a Dockerfile for any language / framework
|
|
107
|
+
- Set up docker-compose with services (database, cache, etc.)
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
name = "DockerfileGen"
|
|
111
|
+
description = (
|
|
112
|
+
"Scan the current project and generate a production-ready Dockerfile "
|
|
113
|
+
"and docker-compose.yml. Works with any language or framework. "
|
|
114
|
+
"Auto-detects the stack from dependency files and source code."
|
|
115
|
+
)
|
|
116
|
+
parameters = {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"project_dir": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": "Root directory of the project. Defaults to cwd.",
|
|
122
|
+
"default": ".",
|
|
123
|
+
},
|
|
124
|
+
"overwrite": {
|
|
125
|
+
"type": "boolean",
|
|
126
|
+
"description": "Overwrite existing Dockerfile / docker-compose.yml.",
|
|
127
|
+
"default": False,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"required": [],
|
|
131
|
+
}
|
|
132
|
+
permission_risk = "medium"
|
|
133
|
+
|
|
134
|
+
def _call_model(self, context: str) -> dict[str, Any]:
|
|
135
|
+
user_msg = f"Project files:\n\n{context}\n\nGenerate the Dockerfile and compose file."
|
|
136
|
+
raw = self._call_model_direct(user_msg)
|
|
137
|
+
raw = raw.strip()
|
|
138
|
+
if raw.startswith("```"):
|
|
139
|
+
raw = "\n".join(ln for ln in raw.splitlines() if not ln.startswith("```")).strip()
|
|
140
|
+
return json.loads(raw)
|
|
141
|
+
|
|
142
|
+
def _call_model_direct(self, user_msg: str) -> str:
|
|
143
|
+
from ..config import get_api_key, get_base_url # noqa: PLC0415
|
|
144
|
+
payload = {
|
|
145
|
+
"model": "deepseek-chat",
|
|
146
|
+
"max_tokens": 4096,
|
|
147
|
+
"temperature": 0.2,
|
|
148
|
+
"messages": [
|
|
149
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
150
|
+
{"role": "user", "content": user_msg},
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
req = urllib.request.Request(
|
|
154
|
+
f"{get_base_url()}/chat/completions",
|
|
155
|
+
data=json.dumps(payload).encode(),
|
|
156
|
+
headers={
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
"Authorization": f"Bearer {get_api_key()}",
|
|
159
|
+
},
|
|
160
|
+
method="POST",
|
|
161
|
+
)
|
|
162
|
+
with urllib.request.urlopen(req, timeout=90) as resp:
|
|
163
|
+
data = json.loads(resp.read())
|
|
164
|
+
return data["choices"][0]["message"]["content"]
|
|
165
|
+
|
|
166
|
+
def run(self, project_dir: str = ".", overwrite: bool = False) -> str: # type: ignore[override]
|
|
167
|
+
root = Path(project_dir).expanduser().resolve()
|
|
168
|
+
if not root.is_dir():
|
|
169
|
+
return f"Error: '{root}' is not a directory."
|
|
170
|
+
|
|
171
|
+
df_path = root / "Dockerfile"
|
|
172
|
+
compose_path = root / "docker-compose.yml"
|
|
173
|
+
|
|
174
|
+
if df_path.exists() and not overwrite:
|
|
175
|
+
return "Dockerfile already exists. Pass overwrite=true to replace it."
|
|
176
|
+
|
|
177
|
+
context = _collect_context(root)
|
|
178
|
+
if not context:
|
|
179
|
+
return "Error: no readable source files found."
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
result = self._call_model(context)
|
|
183
|
+
except json.JSONDecodeError as e:
|
|
184
|
+
return f"Error: model returned invalid JSON — {e}"
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return f"Error: model call failed — {e}"
|
|
187
|
+
|
|
188
|
+
dockerfile = result.get("dockerfile", "").strip()
|
|
189
|
+
compose = result.get("compose", "").strip()
|
|
190
|
+
notes = result.get("notes", "")
|
|
191
|
+
|
|
192
|
+
written: list[str] = []
|
|
193
|
+
|
|
194
|
+
if dockerfile:
|
|
195
|
+
try:
|
|
196
|
+
df_path.write_text(dockerfile, encoding="utf-8")
|
|
197
|
+
written.append(str(df_path))
|
|
198
|
+
except OSError as e:
|
|
199
|
+
return f"Error writing Dockerfile: {e}"
|
|
200
|
+
|
|
201
|
+
if compose:
|
|
202
|
+
try:
|
|
203
|
+
compose_path.write_text(compose, encoding="utf-8")
|
|
204
|
+
written.append(str(compose_path))
|
|
205
|
+
except OSError as e:
|
|
206
|
+
return f"Error writing docker-compose.yml: {e}"
|
|
207
|
+
|
|
208
|
+
lines = [f"Generated {len(written)} file(s):"]
|
|
209
|
+
lines.extend(f" {w}" for w in written)
|
|
210
|
+
if notes:
|
|
211
|
+
lines += ["", f"Notes: {notes}"]
|
|
212
|
+
return "\n".join(lines)
|