llmcode-cli 1.0.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.
Files changed (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,531 @@
1
+ """Git-aware tools for interacting with a local git repository."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import os
6
+ import subprocess
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Sensitive file patterns — block these in GitCommitTool
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _SENSITIVE_PATTERNS: list[str] = [
17
+ ".env",
18
+ ".env.*",
19
+ "*.key",
20
+ "*.pem",
21
+ "*.p12",
22
+ "credentials.*",
23
+ "secret*",
24
+ "*_secret*",
25
+ "*.credential",
26
+ "token.json",
27
+ ]
28
+
29
+
30
+ def _is_sensitive(filename: str) -> bool:
31
+ """Return True if filename matches any sensitive pattern."""
32
+ basename = os.path.basename(filename)
33
+ return any(fnmatch.fnmatch(basename, pattern) for pattern in _SENSITIVE_PATTERNS)
34
+
35
+
36
+ def _auto_stash(cwd: "str | None" = None) -> bool:
37
+ """Stash uncommitted changes if any exist.
38
+
39
+ Returns True if a stash was created, False if the working tree was clean.
40
+ """
41
+ if cwd is None:
42
+ cwd = os.getcwd()
43
+ status = subprocess.run(
44
+ ["git", "status", "--porcelain"],
45
+ capture_output=True,
46
+ text=True,
47
+ cwd=cwd,
48
+ )
49
+ if status.returncode != 0 or not status.stdout.strip():
50
+ return False
51
+ stash = subprocess.run(
52
+ ["git", "stash", "push", "-m", "llm-code auto-stash"],
53
+ capture_output=True,
54
+ text=True,
55
+ cwd=cwd,
56
+ )
57
+ return stash.returncode == 0
58
+
59
+
60
+ def _auto_unstash(cwd: "str | None" = None) -> None:
61
+ """Restore the most recent stash (used after auto-stash)."""
62
+ if cwd is None:
63
+ cwd = os.getcwd()
64
+ subprocess.run(
65
+ ["git", "stash", "pop"],
66
+ capture_output=True,
67
+ text=True,
68
+ cwd=cwd,
69
+ )
70
+
71
+
72
+ def _run_git(args: list[str], cwd: str | None = None) -> ToolResult:
73
+ """Run a git command and return a ToolResult."""
74
+ if cwd is None:
75
+ cwd = os.getcwd()
76
+ result = subprocess.run(
77
+ ["git"] + args,
78
+ capture_output=True,
79
+ text=True,
80
+ cwd=cwd,
81
+ )
82
+ output = result.stdout
83
+ if result.returncode != 0:
84
+ # Combine stderr into output for diagnostics
85
+ output = (result.stderr or result.stdout).strip()
86
+ return ToolResult(output=output, is_error=True)
87
+ return ToolResult(output=output.rstrip("\n"), is_error=False)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Input models
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class GitDiffInput(BaseModel):
96
+ path: str = ""
97
+ staged: bool = False
98
+ commit: str = ""
99
+
100
+
101
+ class GitLogInput(BaseModel):
102
+ limit: int = 10
103
+ oneline: bool = True
104
+ path: str = ""
105
+
106
+
107
+ class GitCommitInput(BaseModel):
108
+ message: str
109
+ files: list[str] = []
110
+
111
+
112
+ class GitPushInput(BaseModel):
113
+ remote: str = "origin"
114
+ branch: str = ""
115
+
116
+
117
+ class GitStashInput(BaseModel):
118
+ action: str # push / pop / list
119
+ message: str = ""
120
+
121
+
122
+ class GitBranchInput(BaseModel):
123
+ action: str # list / create / switch / delete
124
+ name: str = ""
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # 1. GitStatusTool
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ class GitStatusTool(Tool):
133
+ @property
134
+ def name(self) -> str:
135
+ return "git_status"
136
+
137
+ @property
138
+ def description(self) -> str:
139
+ return "Show the working-tree status in short format."
140
+
141
+ @property
142
+ def input_schema(self) -> dict:
143
+ return {"type": "object", "properties": {}, "required": []}
144
+
145
+ @property
146
+ def required_permission(self) -> PermissionLevel:
147
+ return PermissionLevel.READ_ONLY
148
+
149
+ def is_read_only(self, args: dict) -> bool:
150
+ return True
151
+
152
+ def is_concurrency_safe(self, args: dict) -> bool:
153
+ return True
154
+
155
+ def execute(self, args: dict) -> ToolResult:
156
+ return _run_git(["status", "--short"])
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # 2. GitDiffTool
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ class GitDiffTool(Tool):
165
+ @property
166
+ def name(self) -> str:
167
+ return "git_diff"
168
+
169
+ @property
170
+ def description(self) -> str:
171
+ return "Show changes between commits, working tree, or index."
172
+
173
+ @property
174
+ def input_schema(self) -> dict:
175
+ return {
176
+ "type": "object",
177
+ "properties": {
178
+ "path": {"type": "string", "default": ""},
179
+ "staged": {"type": "boolean", "default": False},
180
+ "commit": {"type": "string", "default": ""},
181
+ },
182
+ "required": [],
183
+ }
184
+
185
+ @property
186
+ def required_permission(self) -> PermissionLevel:
187
+ return PermissionLevel.READ_ONLY
188
+
189
+ @property
190
+ def input_model(self) -> type[GitDiffInput]:
191
+ return GitDiffInput
192
+
193
+ def is_read_only(self, args: dict) -> bool:
194
+ return True
195
+
196
+ def is_concurrency_safe(self, args: dict) -> bool:
197
+ return True
198
+
199
+ def execute(self, args: dict) -> ToolResult:
200
+ cmd: list[str] = ["diff"]
201
+ staged: bool = args.get("staged", False)
202
+ commit: str = args.get("commit", "")
203
+ path: str = args.get("path", "")
204
+
205
+ if staged:
206
+ cmd.append("--staged")
207
+ if commit:
208
+ cmd.append(commit)
209
+ if path:
210
+ cmd += ["--", path]
211
+
212
+ return _run_git(cmd)
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # 3. GitLogTool
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ class GitLogTool(Tool):
221
+ @property
222
+ def name(self) -> str:
223
+ return "git_log"
224
+
225
+ @property
226
+ def description(self) -> str:
227
+ return "Show the commit log."
228
+
229
+ @property
230
+ def input_schema(self) -> dict:
231
+ return {
232
+ "type": "object",
233
+ "properties": {
234
+ "limit": {"type": "integer", "default": 10},
235
+ "oneline": {"type": "boolean", "default": True},
236
+ "path": {"type": "string", "default": ""},
237
+ },
238
+ "required": [],
239
+ }
240
+
241
+ @property
242
+ def required_permission(self) -> PermissionLevel:
243
+ return PermissionLevel.READ_ONLY
244
+
245
+ @property
246
+ def input_model(self) -> type[GitLogInput]:
247
+ return GitLogInput
248
+
249
+ def is_read_only(self, args: dict) -> bool:
250
+ return True
251
+
252
+ def is_concurrency_safe(self, args: dict) -> bool:
253
+ return True
254
+
255
+ def execute(self, args: dict) -> ToolResult:
256
+ limit: int = int(args.get("limit", 10))
257
+ oneline: bool = args.get("oneline", True)
258
+ path: str = args.get("path", "")
259
+
260
+ cmd: list[str] = ["log", f"-n{limit}"]
261
+ if oneline:
262
+ cmd.append("--oneline")
263
+ if path:
264
+ cmd += ["--", path]
265
+
266
+ return _run_git(cmd)
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # 4. GitCommitTool
271
+ # ---------------------------------------------------------------------------
272
+
273
+
274
+ class GitCommitTool(Tool):
275
+ @property
276
+ def name(self) -> str:
277
+ return "git_commit"
278
+
279
+ @property
280
+ def description(self) -> str:
281
+ return "Stage files and create a git commit."
282
+
283
+ @property
284
+ def input_schema(self) -> dict:
285
+ return {
286
+ "type": "object",
287
+ "properties": {
288
+ "message": {"type": "string"},
289
+ "files": {
290
+ "type": "array",
291
+ "items": {"type": "string"},
292
+ "default": [],
293
+ },
294
+ },
295
+ "required": ["message"],
296
+ }
297
+
298
+ @property
299
+ def required_permission(self) -> PermissionLevel:
300
+ return PermissionLevel.WORKSPACE_WRITE
301
+
302
+ @property
303
+ def input_model(self) -> type[GitCommitInput]:
304
+ return GitCommitInput
305
+
306
+ def is_read_only(self, args: dict) -> bool:
307
+ return False
308
+
309
+ def is_destructive(self, args: dict) -> bool:
310
+ return False
311
+
312
+ def execute(self, args: dict) -> ToolResult:
313
+ message: str = args["message"]
314
+ files: list[str] = args.get("files", [])
315
+
316
+ # Safety: reject sensitive files
317
+ sensitive = [f for f in files if _is_sensitive(f)]
318
+ if sensitive:
319
+ return ToolResult(
320
+ output=f"Blocked: sensitive file(s) detected: {', '.join(sensitive)}",
321
+ is_error=True,
322
+ )
323
+
324
+ cwd = os.getcwd()
325
+
326
+ # Stage files
327
+ if files:
328
+ add_result = subprocess.run(
329
+ ["git", "add"] + files,
330
+ capture_output=True,
331
+ text=True,
332
+ cwd=cwd,
333
+ )
334
+ else:
335
+ add_result = subprocess.run(
336
+ ["git", "add", "-A"],
337
+ capture_output=True,
338
+ text=True,
339
+ cwd=cwd,
340
+ )
341
+
342
+ if add_result.returncode != 0:
343
+ return ToolResult(
344
+ output=(add_result.stderr or add_result.stdout).strip(),
345
+ is_error=True,
346
+ )
347
+
348
+ commit_result = subprocess.run(
349
+ ["git", "commit", "-m", message],
350
+ capture_output=True,
351
+ text=True,
352
+ cwd=cwd,
353
+ )
354
+ if commit_result.returncode != 0:
355
+ return ToolResult(
356
+ output=(commit_result.stderr or commit_result.stdout).strip(),
357
+ is_error=True,
358
+ )
359
+
360
+ return ToolResult(output=commit_result.stdout.rstrip("\n"), is_error=False)
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # 5. GitPushTool
365
+ # ---------------------------------------------------------------------------
366
+
367
+
368
+ class GitPushTool(Tool):
369
+ @property
370
+ def name(self) -> str:
371
+ return "git_push"
372
+
373
+ @property
374
+ def description(self) -> str:
375
+ return "Push commits to a remote repository."
376
+
377
+ @property
378
+ def input_schema(self) -> dict:
379
+ return {
380
+ "type": "object",
381
+ "properties": {
382
+ "remote": {"type": "string", "default": "origin"},
383
+ "branch": {"type": "string", "default": ""},
384
+ },
385
+ "required": [],
386
+ }
387
+
388
+ @property
389
+ def required_permission(self) -> PermissionLevel:
390
+ return PermissionLevel.FULL_ACCESS
391
+
392
+ @property
393
+ def input_model(self) -> type[GitPushInput]:
394
+ return GitPushInput
395
+
396
+ def is_read_only(self, args: dict) -> bool:
397
+ return False
398
+
399
+ def is_destructive(self, args: dict) -> bool:
400
+ return True
401
+
402
+ def execute(self, args: dict) -> ToolResult:
403
+ remote: str = args.get("remote", "origin")
404
+ branch: str = args.get("branch", "")
405
+
406
+ cmd: list[str] = ["push", remote]
407
+ if branch:
408
+ cmd.append(branch)
409
+
410
+ return _run_git(cmd)
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # 6. GitStashTool
415
+ # ---------------------------------------------------------------------------
416
+
417
+
418
+ class GitStashTool(Tool):
419
+ @property
420
+ def name(self) -> str:
421
+ return "git_stash"
422
+
423
+ @property
424
+ def description(self) -> str:
425
+ return "Stash or restore changes in the working directory."
426
+
427
+ @property
428
+ def input_schema(self) -> dict:
429
+ return {
430
+ "type": "object",
431
+ "properties": {
432
+ "action": {"type": "string", "enum": ["push", "pop", "list"]},
433
+ "message": {"type": "string", "default": ""},
434
+ },
435
+ "required": ["action"],
436
+ }
437
+
438
+ @property
439
+ def required_permission(self) -> PermissionLevel:
440
+ return PermissionLevel.WORKSPACE_WRITE
441
+
442
+ @property
443
+ def input_model(self) -> type[GitStashInput]:
444
+ return GitStashInput
445
+
446
+ def execute(self, args: dict) -> ToolResult:
447
+ action: str = args["action"]
448
+ message: str = args.get("message", "")
449
+
450
+ if action == "push":
451
+ cmd = ["stash", "push"]
452
+ if message:
453
+ cmd += ["-m", message]
454
+ elif action == "pop":
455
+ cmd = ["stash", "pop"]
456
+ elif action == "list":
457
+ cmd = ["stash", "list"]
458
+ else:
459
+ return ToolResult(
460
+ output=f"Unknown stash action: {action!r}. Use push, pop, or list.",
461
+ is_error=True,
462
+ )
463
+
464
+ return _run_git(cmd)
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # 7. GitBranchTool
469
+ # ---------------------------------------------------------------------------
470
+
471
+
472
+ class GitBranchTool(Tool):
473
+ @property
474
+ def name(self) -> str:
475
+ return "git_branch"
476
+
477
+ @property
478
+ def description(self) -> str:
479
+ return "List, create, switch, or delete git branches."
480
+
481
+ @property
482
+ def input_schema(self) -> dict:
483
+ return {
484
+ "type": "object",
485
+ "properties": {
486
+ "action": {"type": "string", "enum": ["list", "create", "switch", "delete"]},
487
+ "name": {"type": "string", "default": ""},
488
+ },
489
+ "required": ["action"],
490
+ }
491
+
492
+ @property
493
+ def required_permission(self) -> PermissionLevel:
494
+ return PermissionLevel.WORKSPACE_WRITE
495
+
496
+ @property
497
+ def input_model(self) -> type[GitBranchInput]:
498
+ return GitBranchInput
499
+
500
+ def is_destructive(self, args: dict) -> bool:
501
+ return args.get("action") == "delete"
502
+
503
+ def execute(self, args: dict) -> ToolResult:
504
+ action: str = args["action"]
505
+ name: str = args.get("name", "")
506
+
507
+ if action == "list":
508
+ cmd = ["branch", "-a"]
509
+ elif action == "create":
510
+ if not name:
511
+ return ToolResult(output="Branch name required for create.", is_error=True)
512
+ cmd = ["checkout", "-b", name]
513
+ elif action == "switch":
514
+ if not name:
515
+ return ToolResult(output="Branch name required for switch.", is_error=True)
516
+ stashed = _auto_stash()
517
+ result = _run_git(["checkout", name])
518
+ if stashed:
519
+ _auto_unstash()
520
+ return result
521
+ elif action == "delete":
522
+ if not name:
523
+ return ToolResult(output="Branch name required for delete.", is_error=True)
524
+ cmd = ["branch", "-d", name]
525
+ else:
526
+ return ToolResult(
527
+ output=f"Unknown branch action: {action!r}. Use list, create, switch, or delete.",
528
+ is_error=True,
529
+ )
530
+
531
+ return _run_git(cmd)
@@ -0,0 +1,112 @@
1
+ """GlobSearchTool — find files matching a glob pattern, sorted by mtime."""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ from typing import Callable
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from llm_code.tools.base import PermissionLevel, Tool, ToolProgress, ToolResult
10
+
11
+ _MAX_RESULTS = 100
12
+ _PROGRESS_INTERVAL = 50 # emit a progress event every N files scanned
13
+
14
+
15
+ class GlobSearchInput(BaseModel):
16
+ pattern: str
17
+ path: str = "."
18
+
19
+
20
+ class GlobSearchTool(Tool):
21
+ @property
22
+ def name(self) -> str:
23
+ return "glob_search"
24
+
25
+ @property
26
+ def description(self) -> str:
27
+ return (
28
+ "Search for files matching a glob pattern. "
29
+ "Returns up to 100 results sorted by modification time (newest first)."
30
+ )
31
+
32
+ @property
33
+ def input_schema(self) -> dict:
34
+ return {
35
+ "type": "object",
36
+ "properties": {
37
+ "pattern": {"type": "string", "description": "Glob pattern (e.g. **/*.py)"},
38
+ "path": {
39
+ "type": "string",
40
+ "description": "Directory to search in (default: current dir)",
41
+ },
42
+ },
43
+ "required": ["pattern"],
44
+ }
45
+
46
+ @property
47
+ def required_permission(self) -> PermissionLevel:
48
+ return PermissionLevel.READ_ONLY
49
+
50
+ @property
51
+ def input_model(self) -> type[GlobSearchInput]:
52
+ return GlobSearchInput
53
+
54
+ def is_read_only(self, args: dict) -> bool:
55
+ return True
56
+
57
+ def is_concurrency_safe(self, args: dict) -> bool:
58
+ return True
59
+
60
+ def execute(self, args: dict) -> ToolResult:
61
+ pattern: str = args["pattern"]
62
+ search_path = pathlib.Path(args.get("path", "."))
63
+
64
+ try:
65
+ matches = list(search_path.glob(pattern))
66
+ except Exception as exc:
67
+ return ToolResult(output=f"Glob error: {exc}", is_error=True)
68
+
69
+ # Sort by mtime descending (newest first)
70
+ matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
71
+ matches = matches[:_MAX_RESULTS]
72
+
73
+ if not matches:
74
+ return ToolResult(output=f"No files matched: {pattern}")
75
+
76
+ return ToolResult(output="\n".join(str(m) for m in matches))
77
+
78
+ def execute_with_progress(
79
+ self,
80
+ args: dict,
81
+ on_progress: Callable[[ToolProgress], None],
82
+ ) -> ToolResult:
83
+ pattern: str = args["pattern"]
84
+ search_path = pathlib.Path(args.get("path", "."))
85
+
86
+ try:
87
+ all_matches = list(search_path.glob(pattern))
88
+ except Exception as exc:
89
+ return ToolResult(output=f"Glob error: {exc}", is_error=True)
90
+
91
+ total = len(all_matches)
92
+
93
+ # Emit progress every PROGRESS_INTERVAL files
94
+ for i, _ in enumerate(all_matches, start=1):
95
+ if i % _PROGRESS_INTERVAL == 0:
96
+ percent = round(i / total * 100.0, 1) if total else 100.0
97
+ on_progress(
98
+ ToolProgress(
99
+ tool_name=self.name,
100
+ message=f"Scanned {i}/{total} files",
101
+ percent=percent,
102
+ )
103
+ )
104
+
105
+ # Sort by mtime descending (newest first)
106
+ all_matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
107
+ matches = all_matches[:_MAX_RESULTS]
108
+
109
+ if not matches:
110
+ return ToolResult(output=f"No files matched: {pattern}")
111
+
112
+ return ToolResult(output="\n".join(str(m) for m in matches))