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,360 @@
1
+ """Video game generator tool.
2
+
3
+ Generates standalone, playable Pygame games from a natural-language description
4
+ using the DeepSeek model. Optionally compiles them into self-contained .exe files
5
+ via PyInstaller. All games use SDL2's software "windib" driver so they run
6
+ without a GPU.
7
+
8
+ Any game concept is supported — just describe what you want.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from .registry import Tool
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Difficulty presets injected into every generation prompt
23
+ # ---------------------------------------------------------------------------
24
+
25
+ DIFFICULTIES = ("easy", "normal", "hard")
26
+
27
+ DIFFICULTY_HINTS = {
28
+ "easy": "Use slow speeds, generous hitboxes, and forgiving rules. Favor the player.",
29
+ "normal": "Use balanced speeds and standard game rules.",
30
+ "hard": "Use fast speeds, tight hitboxes, and punishing rules. Challenge the player.",
31
+ }
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # System prompt for game generation
35
+ # ---------------------------------------------------------------------------
36
+
37
+ GAME_GEN_SYSTEM_PROMPT = """\
38
+ You are an expert Python game developer specialising in Pygame.
39
+
40
+ Your job is to write a COMPLETE, RUNNABLE, single-file Pygame game based on the
41
+ user's description. Follow every rule below without exception.
42
+
43
+ === MANDATORY RULES ===
44
+
45
+ 1. OUTPUT ONLY VALID PYTHON CODE — no markdown fences, no prose, no comments
46
+ outside the code. The very first character must be a double-quote (start of
47
+ the module docstring) or the letter 'i' (start of an import).
48
+
49
+ 2. IMPORTS — always start with:
50
+ import os, sys, random, math
51
+ os.environ.setdefault("SDL_VIDEODRIVER", "windib")
52
+ import pygame
53
+ This order ensures the SDL env var is set before pygame.init().
54
+
55
+ 3. STRUCTURE — use a single main() function and end with:
56
+ if __name__ == "__main__":
57
+ main()
58
+
59
+ 4. WINDOW — 800x600, 60 FPS, pygame.display.set_caption("<game name>").
60
+
61
+ 5. THEME COLOR — the caller substitutes the literal string #THEME_COLOR# with a
62
+ hex color such as #00FF00. Use it as the primary foreground color for all
63
+ game elements. Always reference it as:
64
+ THEME_COLOR = "#THEME_COLOR#"
65
+ Then convert once with:
66
+ def _hex(h):
67
+ h = h.lstrip("#")
68
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
69
+ FG = _hex(THEME_COLOR)
70
+
71
+ 6. DIFFICULTY — the caller substitutes the literal float #DIFF_MULT# (0.7 easy /
72
+ 1.0 normal / 1.4 hard). Store it as:
73
+ DIFF_MULT = #DIFF_MULT#
74
+ Use it to scale speeds, spawn rates, or AI strength.
75
+
76
+ 7. QUIT — always handle pygame.QUIT and the ESC key to exit cleanly.
77
+
78
+ 8. RESTART — after game-over show a "Press R to restart / ESC to quit" screen
79
+ and honour both keys.
80
+
81
+ 9. HUD — always draw the current score (or lives, timer, etc.) on screen.
82
+
83
+ 10. NO EXTERNAL ASSETS — no image files, no sound files, no fonts beyond
84
+ pygame.font.SysFont. Draw everything with pygame primitives.
85
+
86
+ 11. BEEP — synthesise simple square-wave sound effects using pygame.mixer and
87
+ bytearray; never load .wav / .mp3 files. Use this helper:
88
+ pygame.mixer.init(frequency=22050, size=-16, channels=1, buffer=256)
89
+ def beep(freq=440, ms=60, vol=0.12):
90
+ try:
91
+ n = int(22050 * ms / 1000)
92
+ buf = bytearray(n)
93
+ for i in range(n):
94
+ buf[i] = (127 if (i * freq * 2 // 22050) % 2 == 0 else 129) & 0xFF
95
+ s = pygame.mixer.Sound(buffer=bytes(buf))
96
+ s.set_volume(vol)
97
+ s.play()
98
+ except Exception:
99
+ pass
100
+
101
+ 12. COMPLETENESS — every mechanic you describe must be fully implemented. Do not
102
+ leave stubs, TODOs, or placeholder comments. The file must run as-is with
103
+ only `pip install pygame`.
104
+
105
+ 13. BACKGROUND — use a near-black background (5, 5, 15) for all games.
106
+
107
+ === STYLE GUIDELINES ===
108
+ - Keep code clean and readable with concise inline comments.
109
+ - Prefer pure-Python math over numpy.
110
+ - Keep the total file under 400 lines where possible; prioritise playability over
111
+ feature count.
112
+ - Make the game genuinely fun: good game-feel, responsive controls, clear
113
+ feedback on events.
114
+ """
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Compile helpers
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def _resolve_pyinstaller() -> Optional[str]:
122
+ if shutil.which("pyinstaller"):
123
+ return "found"
124
+ try:
125
+ subprocess.run(
126
+ [sys.executable, "-m", "PyInstaller", "--version"],
127
+ capture_output=True, timeout=10,
128
+ )
129
+ return "module"
130
+ except Exception:
131
+ return None
132
+
133
+
134
+ def compile_exe(source_path: Path, output_dir: Path, game_name: str) -> tuple[bool, str]:
135
+ """Compile a Python script to a standalone .exe via PyInstaller."""
136
+ if _resolve_pyinstaller() is None:
137
+ return False, "PyInstaller is not installed. Run: pip install pyinstaller"
138
+
139
+ cmd = [
140
+ sys.executable, "-m", "PyInstaller",
141
+ "--onefile", "--windowed", "--noconsole",
142
+ "--name", game_name,
143
+ "--distpath", str(output_dir),
144
+ "--workpath", str(output_dir / "_build"),
145
+ "--specpath", str(output_dir),
146
+ str(source_path),
147
+ ]
148
+ try:
149
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, cwd=output_dir)
150
+ if result.returncode != 0:
151
+ return False, f"PyInstaller failed (exit {result.returncode}):\n{result.stderr[-500:]}"
152
+ exe_path = output_dir / f"{game_name}.exe"
153
+ if exe_path.exists():
154
+ for artifact in [output_dir / "_build", output_dir / f"{game_name}.spec"]:
155
+ if artifact.is_dir():
156
+ shutil.rmtree(artifact, ignore_errors=True)
157
+ elif artifact.is_file():
158
+ artifact.unlink(missing_ok=True)
159
+ return True, str(exe_path)
160
+ return False, f"Compilation ran but .exe not found at {exe_path}"
161
+ except subprocess.TimeoutExpired:
162
+ return False, "PyInstaller timed out (5 minutes)"
163
+ except Exception as e:
164
+ return False, f"PyInstaller error: {e}"
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Tool
169
+ # ---------------------------------------------------------------------------
170
+
171
+ class GameGenTool(Tool):
172
+ """Generate a playable Pygame game from a natural-language description.
173
+
174
+ Use this tool when the user asks to:
175
+ - Create any kind of video game ("make me a top-down shooter")
176
+ - Generate a playable game they can run locally or distribute as an .exe
177
+ - Adjust an existing game's difficulty or visual theme
178
+ """
179
+
180
+ name = "GameGen"
181
+ description = (
182
+ "Generate a complete, runnable Pygame game from a plain-English description. "
183
+ "Any game concept is supported — just describe the game you want. "
184
+ "Outputs a .py script or a standalone .exe (requires PyInstaller). "
185
+ "No GPU needed; uses SDL2 software rendering."
186
+ )
187
+ parameters = {
188
+ "type": "object",
189
+ "properties": {
190
+ "description": {
191
+ "type": "string",
192
+ "description": (
193
+ "Plain-English description of the game to generate. "
194
+ "Be as specific or as vague as you like — the model will fill in the details. "
195
+ "Examples: 'a Breakout clone', 'a top-down zombie shooter', "
196
+ "'a simple platformer where you collect coins'."
197
+ ),
198
+ },
199
+ "difficulty": {
200
+ "type": "string",
201
+ "description": "Game difficulty: easy, normal, or hard.",
202
+ "enum": list(DIFFICULTIES),
203
+ "default": "normal",
204
+ },
205
+ "theme_color": {
206
+ "type": "string",
207
+ "description": "Hex color for the primary game elements, e.g. '#00FF00'.",
208
+ "default": "#00FF00",
209
+ },
210
+ "output_format": {
211
+ "type": "string",
212
+ "description": "'py' for a Python script, 'exe' for a standalone executable.",
213
+ "enum": ["py", "exe"],
214
+ "default": "exe",
215
+ },
216
+ "output_dir": {
217
+ "type": "string",
218
+ "description": "Directory to write the output file to.",
219
+ "default": ".",
220
+ },
221
+ },
222
+ "required": ["description"],
223
+ }
224
+ permission_risk = "safe"
225
+
226
+ def _generate_source(self, description: str, difficulty: str, _theme_color: str = "") -> str:
227
+ """Ask the DeepSeek model to write the full Pygame source."""
228
+ try:
229
+ return self._generate_source_api(description, difficulty)
230
+ except Exception:
231
+ return self._generate_source_fallback(description, difficulty)
232
+
233
+ def _generate_source_api(self, description: str, difficulty: str) -> str:
234
+ """Use the project's stream_chat API to generate the game."""
235
+ from ..api import stream_chat # noqa: PLC0415
236
+ from ..config import Config # noqa: PLC0415
237
+
238
+ cfg = Config()
239
+ diff_hint = DIFFICULTY_HINTS.get(difficulty, DIFFICULTY_HINTS["normal"])
240
+ user_prompt = (
241
+ f"Game description: {description}\n\n"
242
+ f"Difficulty hint: {diff_hint}\n\n"
243
+ f"Use #THEME_COLOR# as the literal placeholder for the theme color "
244
+ f"and #DIFF_MULT# as the literal placeholder for the difficulty multiplier."
245
+ )
246
+
247
+ messages = [
248
+ {"role": "system", "content": GAME_GEN_SYSTEM_PROMPT},
249
+ {"role": "user", "content": user_prompt},
250
+ ]
251
+
252
+ parts: list[str] = []
253
+ for event in stream_chat(
254
+ messages=messages,
255
+ tools=[],
256
+ model=cfg.model,
257
+ api_key=cfg.api_key,
258
+ base_url=cfg.base_url,
259
+ max_tokens=4096,
260
+ temperature=0.3,
261
+ ):
262
+ kind, data = event
263
+ if kind == "text":
264
+ parts.append(data)
265
+ elif kind == "error":
266
+ raise RuntimeError(f"API error: {data}")
267
+ return "".join(parts).strip()
268
+
269
+ def _generate_source_fallback(self, description: str, difficulty: str) -> str:
270
+ """Direct API call using Config and urllib."""
271
+ import json
272
+ import urllib.request # noqa: PLC0415
273
+ from ..config import Config # noqa: PLC0415
274
+
275
+ cfg = Config()
276
+ diff_hint = DIFFICULTY_HINTS.get(difficulty, DIFFICULTY_HINTS["normal"])
277
+ payload = {
278
+ "model": "deepseek-chat",
279
+ "max_tokens": 4096,
280
+ "temperature": 0.3,
281
+ "messages": [
282
+ {"role": "system", "content": GAME_GEN_SYSTEM_PROMPT},
283
+ {
284
+ "role": "user",
285
+ "content": (
286
+ f"Game description: {description}\n\n"
287
+ f"Difficulty hint: {diff_hint}\n\n"
288
+ "Use #THEME_COLOR# and #DIFF_MULT# as literal placeholders."
289
+ ),
290
+ },
291
+ ],
292
+ }
293
+ req = urllib.request.Request(
294
+ f"{cfg.base_url}/chat/completions",
295
+ data=json.dumps(payload).encode(),
296
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {cfg.api_key}"},
297
+ method="POST",
298
+ )
299
+ with urllib.request.urlopen(req, timeout=120) as resp:
300
+ data: dict[str, Any] = json.loads(resp.read())
301
+ return str(data["choices"][0]["message"]["content"].strip())
302
+
303
+ def run(
304
+ self,
305
+ description: str,
306
+ difficulty: str = "normal",
307
+ theme_color: str = "#00FF00",
308
+ output_format: str = "exe",
309
+ output_dir: str = ".",
310
+ ) -> str:
311
+
312
+ if difficulty not in DIFFICULTIES:
313
+ return f"Error: unknown difficulty '{difficulty}'. Use: {', '.join(DIFFICULTIES)}"
314
+ if output_format not in ("py", "exe"):
315
+ return "Error: output_format must be 'py' or 'exe'."
316
+
317
+ out_dir = Path(output_dir).expanduser().resolve()
318
+ try:
319
+ out_dir.mkdir(parents=True, exist_ok=True)
320
+ except OSError as e:
321
+ return f"Error: cannot create output directory: {e}"
322
+
323
+ try:
324
+ raw_source = self._generate_source(description, difficulty, theme_color)
325
+ except Exception as e:
326
+ return f"Error: model call failed — {e}"
327
+
328
+ source = raw_source
329
+ if source.startswith("```"):
330
+ lines = source.splitlines()
331
+ source = "\n".join(
332
+ ln for ln in lines if not ln.startswith("```")
333
+ ).strip()
334
+
335
+ diff_mult = {"easy": 0.7, "normal": 1.0, "hard": 1.4}[difficulty]
336
+ source = source.replace("#THEME_COLOR#", theme_color)
337
+ source = source.replace("#DIFF_MULT#", str(diff_mult))
338
+
339
+ slug = "_".join(description.split()[:4])
340
+ slug = "".join(c if c.isalnum() or c == "_" else "" for c in slug) or "Game"
341
+ game_name = slug[:40]
342
+
343
+ py_path = out_dir / f"{game_name}.py"
344
+ try:
345
+ py_path.write_text(source, encoding="utf-8")
346
+ except OSError as e:
347
+ return f"Error: failed to write source file: {e}"
348
+
349
+ if output_format == "py":
350
+ return (
351
+ f"Game generated successfully.\n"
352
+ f"File : {py_path}\n"
353
+ f"Run : python \"{py_path}\"\n"
354
+ f"Needs: pip install pygame"
355
+ )
356
+
357
+ success, result = compile_exe(py_path, out_dir, game_name)
358
+ if success:
359
+ return f"Standalone .exe created.\nFile: {result}"
360
+ return f"Compilation failed: {result}\nSource saved at: {py_path}"
@@ -0,0 +1,130 @@
1
+ from .registry import Tool
2
+ from ..git import git_status, git_diff, git_log, git_commit, git_add, git_branch, git_create_pr, git_push
3
+
4
+
5
+ class GitStatusTool(Tool):
6
+ name = "GitStatus"
7
+ description = "Show git working tree status."
8
+ permission_risk = "safe"
9
+ parameters = {
10
+ "type": "object",
11
+ "properties": {},
12
+ }
13
+
14
+ def run(self, **kwargs) -> str: # type: ignore[override]
15
+ return git_status()
16
+
17
+
18
+ class GitDiffTool(Tool):
19
+ name = "GitDiff"
20
+ description = "Show git diff of changes."
21
+ permission_risk = "safe"
22
+ parameters = {
23
+ "type": "object",
24
+ "properties": {
25
+ "staged": {
26
+ "type": "boolean",
27
+ "description": "Show staged changes only",
28
+ },
29
+ },
30
+ }
31
+
32
+ def run(self, staged: bool = False) -> str: # type: ignore[override]
33
+ return git_diff(staged)
34
+
35
+
36
+ class GitLogTool(Tool):
37
+ name = "GitLog"
38
+ description = "Show recent commit history."
39
+ permission_risk = "safe"
40
+ parameters = {
41
+ "type": "object",
42
+ "properties": {
43
+ "count": {"type": "integer", "description": "Number of commits to show"},
44
+ },
45
+ }
46
+
47
+ def run(self, count: int = 10) -> str: # type: ignore[override]
48
+ return git_log(count)
49
+
50
+
51
+ class GitCommitTool(Tool):
52
+ name = "GitCommit"
53
+ description = "Create a git commit with a message."
54
+ permission_risk = "high"
55
+ parameters = {
56
+ "type": "object",
57
+ "properties": {
58
+ "message": {"type": "string", "description": "Commit message"},
59
+ },
60
+ "required": ["message"],
61
+ }
62
+
63
+ def run(self, message: str) -> str: # type: ignore[override]
64
+ return git_commit(message)
65
+
66
+
67
+ class GitAddTool(Tool):
68
+ name = "GitAdd"
69
+ description = "Stage files for commit."
70
+ permission_risk = "medium"
71
+ parameters = {
72
+ "type": "object",
73
+ "properties": {
74
+ "files": {
75
+ "type": "array",
76
+ "items": {"type": "string"},
77
+ "description": "Files to stage (default: all)",
78
+ },
79
+ },
80
+ }
81
+
82
+ def run(self, files: list[str] | None = None) -> str: # type: ignore[override]
83
+ return git_add(files)
84
+
85
+
86
+ class GitBranchTool(Tool):
87
+ name = "GitBranch"
88
+ description = "List and manage git branches."
89
+ permission_risk = "safe"
90
+ parameters = {
91
+ "type": "object",
92
+ "properties": {},
93
+ }
94
+
95
+ def run(self, **kwargs) -> str: # type: ignore[override]
96
+ return git_branch()
97
+
98
+
99
+ class GitPRTool(Tool):
100
+ name = "GitPR"
101
+ description = "Push current branch and create a DRAFT pull request on GitHub."
102
+ permission_risk = "high"
103
+ parameters = {
104
+ "type": "object",
105
+ "properties": {
106
+ "title": {"type": "string", "description": "PR title"},
107
+ "body": {"type": "string", "description": "PR body/description"},
108
+ },
109
+ "required": ["title"],
110
+ }
111
+
112
+ def run(self, title: str, body: str = "") -> str: # type: ignore[override]
113
+ push_result = git_push()
114
+ pr_result = git_create_pr(title, body, draft=True)
115
+ return f"Push: {push_result}\nDraft PR: {pr_result}"
116
+
117
+
118
+ class GitPushTool(Tool):
119
+ name = "GitPush"
120
+ description = "Push commits to remote."
121
+ permission_risk = "high"
122
+ parameters = {
123
+ "type": "object",
124
+ "properties": {
125
+ "branch": {"type": "string", "description": "Branch to push"},
126
+ },
127
+ }
128
+
129
+ def run(self, branch: str | None = None) -> str: # type: ignore[override]
130
+ return git_push(branch)
@@ -0,0 +1,63 @@
1
+ """Git worktree tools."""
2
+
3
+ import subprocess
4
+
5
+ from .registry import Tool
6
+
7
+
8
+ class GitWorktreeTool(Tool):
9
+ name = "GitWorktree"
10
+ description = "Manage git worktrees (create, list, remove)."
11
+ permission_risk = "high"
12
+ parameters = {
13
+ "type": "object",
14
+ "properties": {
15
+ "action": {
16
+ "type": "string",
17
+ "enum": ["list", "create", "remove"],
18
+ "description": "Action to perform",
19
+ },
20
+ "path": {
21
+ "type": "string",
22
+ "description": "Path for the new worktree (required for create)",
23
+ },
24
+ "branch": {
25
+ "type": "string",
26
+ "description": "Branch for the new worktree (optional for create)",
27
+ },
28
+ },
29
+ "required": ["action"],
30
+ }
31
+
32
+ def run(self, action: str, path: str | None = None, branch: str | None = None) -> str: # type: ignore[override]
33
+ try:
34
+ if action == "list":
35
+ r = subprocess.run(
36
+ ["git", "worktree", "list"],
37
+ capture_output=True, text=True, timeout=30,
38
+ )
39
+ return r.stdout.strip() or r.stderr.strip()
40
+
41
+ elif action == "create":
42
+ if not path:
43
+ return "Error: path is required for create"
44
+ cmd = ["git", "worktree", "add", path]
45
+ if branch:
46
+ cmd.extend(["-b", branch])
47
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
48
+ return r.stdout.strip() or r.stderr.strip()
49
+
50
+ elif action == "remove":
51
+ if not path:
52
+ return "Error: path is required for remove"
53
+ r = subprocess.run(
54
+ ["git", "worktree", "remove", path],
55
+ capture_output=True, text=True, timeout=30,
56
+ )
57
+ return r.stdout.strip() or r.stderr.strip()
58
+
59
+ return f"Unknown action: {action}"
60
+ except subprocess.TimeoutExpired:
61
+ return "Error: command timed out"
62
+ except Exception as e:
63
+ return f"Error: {e}"
@@ -0,0 +1,64 @@
1
+ """Path traversal protection and file path utilities."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def safe_resolve(path: str, working_dir: str | None = None) -> Path:
8
+ """Resolve a path safely, preventing directory traversal outside allowed directories.
9
+
10
+ Raises ValueError if the resolved path is outside the allowed directory.
11
+ """
12
+ resolved = Path(path).resolve()
13
+
14
+ if working_dir:
15
+ allowed = Path(working_dir).resolve()
16
+ else:
17
+ allowed = Path(os.getcwd()).resolve()
18
+
19
+ # Check if resolved path is within the allowed directory
20
+ try:
21
+ resolved.relative_to(allowed)
22
+ except ValueError:
23
+ raise ValueError(
24
+ f"Path traversal blocked: '{path}' resolves outside the working directory '{allowed}'"
25
+ )
26
+
27
+ return resolved
28
+
29
+
30
+ def validate_file_path(file_path: str, must_exist: bool = False, working_dir: str | None = None) -> Path:
31
+ """Validate a file path and return the resolved Path.
32
+
33
+ Args:
34
+ file_path: The path to validate
35
+ must_exist: If True, raises FileNotFoundError when path doesn't exist
36
+ working_dir: Optional working directory to restrict to
37
+
38
+ Returns:
39
+ Resolved Path object
40
+
41
+ Raises:
42
+ ValueError: If path traversal is detected
43
+ FileNotFoundError: If must_exist is True and path doesn't exist
44
+ """
45
+ resolved = safe_resolve(file_path, working_dir)
46
+
47
+ if must_exist and not resolved.exists():
48
+ raise FileNotFoundError(f"Path does not exist: {file_path}")
49
+
50
+ return resolved
51
+
52
+
53
+ def sanitize_filename(name: str) -> str:
54
+ """Sanitize a filename by removing dangerous characters."""
55
+ import re
56
+ # Remove path separators and null bytes
57
+ name = name.replace("/", "_").replace("\\", "_").replace("\0", "")
58
+ # Remove any remaining dangerous characters
59
+ name = re.sub(r'[<>:"|?*]', "_", name)
60
+ # Limit length
61
+ if len(name) > 200:
62
+ base, ext = os.path.splitext(name)
63
+ name = base[:195] + ext
64
+ return name.strip()