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.
Files changed (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. 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)