cade-cli 0.3.3__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 (44) hide show
  1. cade_cli-0.3.3.dist-info/METADATA +151 -0
  2. cade_cli-0.3.3.dist-info/RECORD +44 -0
  3. cade_cli-0.3.3.dist-info/WHEEL +4 -0
  4. cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
  5. cadecoder/__init__.py +1 -0
  6. cadecoder/ai/__init__.py +6 -0
  7. cadecoder/ai/prompts.py +572 -0
  8. cadecoder/cli/__init__.py +0 -0
  9. cadecoder/cli/app.py +147 -0
  10. cadecoder/cli/auth.py +483 -0
  11. cadecoder/cli/commands/__init__.py +5 -0
  12. cadecoder/cli/commands/auth.py +143 -0
  13. cadecoder/cli/commands/chat.py +264 -0
  14. cadecoder/cli/commands/mcp.py +477 -0
  15. cadecoder/cli/commands/tools.py +226 -0
  16. cadecoder/core/__init__.py +12 -0
  17. cadecoder/core/config.py +380 -0
  18. cadecoder/core/constants.py +281 -0
  19. cadecoder/core/errors.py +145 -0
  20. cadecoder/core/logging.py +148 -0
  21. cadecoder/core/types.py +235 -0
  22. cadecoder/core/utils.py +279 -0
  23. cadecoder/execution/__init__.py +46 -0
  24. cadecoder/execution/context_window.py +521 -0
  25. cadecoder/execution/orchestrator.py +562 -0
  26. cadecoder/execution/parallel.py +287 -0
  27. cadecoder/providers/__init__.py +60 -0
  28. cadecoder/providers/base.py +294 -0
  29. cadecoder/providers/openai.py +251 -0
  30. cadecoder/storage/__init__.py +0 -0
  31. cadecoder/storage/threads.py +489 -0
  32. cadecoder/templates/login_failed.html +21 -0
  33. cadecoder/templates/login_success.html +21 -0
  34. cadecoder/templates/styles.css +87 -0
  35. cadecoder/tools/__init__.py +19 -0
  36. cadecoder/tools/builtin.py +644 -0
  37. cadecoder/tools/filesystem.py +315 -0
  38. cadecoder/tools/git.py +221 -0
  39. cadecoder/tools/manager.py +1635 -0
  40. cadecoder/ui/__init__.py +7 -0
  41. cadecoder/ui/display.py +338 -0
  42. cadecoder/ui/input.py +145 -0
  43. cadecoder/ui/session.py +455 -0
  44. cadecoder/ui/state.py +20 -0
@@ -0,0 +1,644 @@
1
+ """Builtin tools for CadeCoder."""
2
+
3
+ import fnmatch
4
+ import json
5
+ import pathlib
6
+ import subprocess
7
+ from collections.abc import Callable
8
+ from typing import Annotated, Any, Literal
9
+
10
+ from arcade_tdk import ToolContext, tool
11
+ from arcade_tdk.errors import ToolExecutionError
12
+
13
+ from cadecoder.core.constants import (
14
+ DEFAULT_IGNORE_PATTERNS,
15
+ MAX_LIST_DEPTH,
16
+ MAX_LIST_RESULTS,
17
+ MAX_PREVIEW_BYTES,
18
+ MODE_APPEND,
19
+ MODE_OVERWRITE,
20
+ )
21
+ from cadecoder.core.logging import log
22
+ from cadecoder.tools.filesystem import (
23
+ generate_diff_from_content,
24
+ read_text_file,
25
+ write_text_file,
26
+ )
27
+ from cadecoder.tools.git import get_current_branch_name, get_status
28
+
29
+
30
+ def get_project_root() -> pathlib.Path:
31
+ """Get the project root directory."""
32
+ return pathlib.Path.cwd()
33
+
34
+
35
+ PROJECT_ROOT = get_project_root()
36
+
37
+
38
+ class PathResolver:
39
+ """Resolve and validate paths safely."""
40
+
41
+ @staticmethod
42
+ def resolve_safe_path(path_str: str, base_dir: pathlib.Path) -> pathlib.Path:
43
+ """Resolve a path safely within a base directory."""
44
+ path = pathlib.Path(path_str)
45
+ if path.is_absolute():
46
+ resolved = path.resolve()
47
+ else:
48
+ resolved = (base_dir / path).resolve()
49
+ return resolved
50
+
51
+
52
+ # --- Helper Functions ---
53
+
54
+
55
+ def _should_ignore(path: pathlib.Path, ignore_patterns: list[str]) -> bool:
56
+ """Check if a path should be ignored based on patterns."""
57
+ path_str = str(path)
58
+ name = path.name
59
+
60
+ for pattern in ignore_patterns:
61
+ if pattern.startswith("*."):
62
+ if name.endswith(pattern[1:]):
63
+ return True
64
+ elif "*" in pattern:
65
+ if fnmatch.fnmatch(name, pattern):
66
+ return True
67
+ else:
68
+ if pattern in path_str.split("/"):
69
+ return True
70
+ if name == pattern:
71
+ return True
72
+ return False
73
+
74
+
75
+ # --- File Management Tools ---
76
+
77
+
78
+ @tool(
79
+ name="Local_ListFiles",
80
+ desc="List files in a directory (recursively up to a depth limit).",
81
+ )
82
+ def list_files_tool(
83
+ context: ToolContext,
84
+ directory: Annotated[
85
+ str | None,
86
+ "Directory path to list (relative to project root). Defaults to current directory.",
87
+ ] = ".",
88
+ recursive: Annotated[bool, "Whether to list files recursively."] = True,
89
+ depth: Annotated[
90
+ int, "Maximum depth for listing files. 0 means no depth limit."
91
+ ] = MAX_LIST_DEPTH,
92
+ ) -> Annotated[dict, "Output containing a list of files and an optional message."]:
93
+ """Lists files and directories within a specified path."""
94
+ if not 0 <= depth <= MAX_LIST_DEPTH:
95
+ raise ToolExecutionError(f"Depth must be between 0 and {MAX_LIST_DEPTH}")
96
+
97
+ base_path = PathResolver.resolve_safe_path(directory or ".", PROJECT_ROOT)
98
+ if not base_path.is_dir():
99
+ raise ToolExecutionError(f"Not a directory: {directory or '.'}")
100
+
101
+ listed_paths: list[str] = []
102
+ if not recursive:
103
+ for item in base_path.iterdir():
104
+ try:
105
+ if _should_ignore(item, DEFAULT_IGNORE_PATTERNS):
106
+ continue
107
+ relative_to_project = item.relative_to(PROJECT_ROOT)
108
+ listed_paths.append(str(relative_to_project))
109
+ if len(listed_paths) >= MAX_LIST_RESULTS:
110
+ break
111
+ except ValueError:
112
+ continue
113
+ except OSError as e:
114
+ log.warning(f"Error processing path {item}: {e}")
115
+ continue
116
+ else:
117
+ queue: list[tuple[pathlib.Path, int]] = [
118
+ (child, 0)
119
+ for child in base_path.iterdir()
120
+ if not _should_ignore(child, DEFAULT_IGNORE_PATTERNS)
121
+ ]
122
+
123
+ while queue:
124
+ if len(listed_paths) >= MAX_LIST_RESULTS:
125
+ break
126
+ current_item, item_depth = queue.pop(0)
127
+
128
+ if item_depth > depth:
129
+ continue
130
+
131
+ try:
132
+ relative_to_project = current_item.relative_to(PROJECT_ROOT)
133
+ listed_paths.append(str(relative_to_project))
134
+
135
+ if current_item.is_dir() and item_depth < depth:
136
+ for child in current_item.iterdir():
137
+ if not _should_ignore(child, DEFAULT_IGNORE_PATTERNS):
138
+ queue.append((child, item_depth + 1))
139
+ except ValueError:
140
+ continue
141
+ except OSError as e:
142
+ log.warning(f"Error processing path {current_item}: {e}")
143
+ continue
144
+
145
+ message = None
146
+ if len(listed_paths) >= MAX_LIST_RESULTS:
147
+ message = f"... (truncated at {MAX_LIST_RESULTS} entries)"
148
+
149
+ listed_paths.sort()
150
+
151
+ return {
152
+ "files": listed_paths,
153
+ "message": message if message else "Files listed successfully.",
154
+ }
155
+
156
+
157
+ @tool(
158
+ name="Local_ReadFile",
159
+ desc="Read the entire content of a text file from the workspace.",
160
+ )
161
+ def read_file_tool(
162
+ context: ToolContext,
163
+ file_path_arg: Annotated[str, "Path to the file to read (relative or absolute)."],
164
+ ) -> Annotated[dict, "Output containing the file content and an optional message."]:
165
+ """Reads content of a specified file."""
166
+ file_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
167
+ if not file_path.is_file():
168
+ raise ToolExecutionError(f"File not found: {file_path_arg} (resolved: {file_path})")
169
+
170
+ try:
171
+ size = file_path.stat().st_size
172
+ if size > MAX_PREVIEW_BYTES:
173
+ log.warning(f"File {file_path_arg} exceeds preview limit. Truncating.")
174
+ with file_path.open("rb") as f:
175
+ start_bytes = f.read(MAX_PREVIEW_BYTES // 2)
176
+ f.seek(max(0, size - MAX_PREVIEW_BYTES // 2))
177
+ end_bytes = f.read(MAX_PREVIEW_BYTES // 2)
178
+ start_str = start_bytes.decode("utf-8", errors="replace")
179
+ end_str = end_bytes.decode("utf-8", errors="replace")
180
+ content = start_str + f"\n... (file truncated, size: {size} bytes) ...\n" + end_str
181
+ return {"content": content, "message": "File was truncated due to size."}
182
+ else:
183
+ content = read_text_file(file_path)
184
+ return {"content": content, "message": "File read successfully."}
185
+ except OSError as e:
186
+ raise ToolExecutionError(f"Error accessing file {file_path_arg}: {e}") from e
187
+
188
+
189
+ @tool(
190
+ name="Local_WriteFile",
191
+ desc="Write content to a file, creating/overwriting or appending.",
192
+ )
193
+ def write_file_tool(
194
+ context: ToolContext,
195
+ file_path_arg: Annotated[str, "Path of the file to write."],
196
+ content: Annotated[str, "The complete content to write into the file."],
197
+ mode: Annotated[
198
+ Literal["overwrite", "append"],
199
+ f"How to write: '{MODE_OVERWRITE}' to replace all content, '{MODE_APPEND}' to add to the end.",
200
+ ] = "overwrite",
201
+ ) -> Annotated[dict, "Result of the write operation."]:
202
+ """Writes content to a file."""
203
+ resolved_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
204
+
205
+ try:
206
+ if mode == MODE_APPEND and resolved_path.exists():
207
+ existing_content = read_text_file(resolved_path)
208
+ content = existing_content + content
209
+
210
+ write_text_file(resolved_path, content)
211
+ return {
212
+ "success": True,
213
+ "message": f"Successfully wrote to {file_path_arg}",
214
+ }
215
+ except Exception as e:
216
+ raise ToolExecutionError(f"Failed to write file {file_path_arg}: {e}") from e
217
+
218
+
219
+ # --- Command Execution Tools ---
220
+
221
+
222
+ @tool(name="Local_ExecuteCommand", desc="Execute a shell command. Use with caution.")
223
+ def execute_command_tool(
224
+ context: ToolContext,
225
+ command: Annotated[str, "The shell command to execute."],
226
+ cwd: Annotated[
227
+ str | None,
228
+ "Working directory for the command (relative to project root).",
229
+ ] = None,
230
+ timeout: Annotated[int, "Timeout for the command in seconds."] = 30,
231
+ ) -> Annotated[dict, "Output of the command execution."]:
232
+ """Executes a shell command."""
233
+ if cwd:
234
+ working_dir = PathResolver.resolve_safe_path(cwd, PROJECT_ROOT)
235
+ if not working_dir.is_dir():
236
+ raise ToolExecutionError(f"Working directory not found: {cwd}")
237
+ else:
238
+ working_dir = PROJECT_ROOT
239
+
240
+ try:
241
+ result = subprocess.run(
242
+ command,
243
+ shell=True,
244
+ cwd=str(working_dir),
245
+ capture_output=True,
246
+ text=True,
247
+ timeout=timeout if timeout > 0 else None,
248
+ )
249
+
250
+ return {
251
+ "stdout": result.stdout if result.stdout else "no output",
252
+ "stderr": result.stderr if result.stderr else "no stderr",
253
+ "exit_code": result.returncode,
254
+ "success": result.returncode == 0,
255
+ }
256
+ except subprocess.TimeoutExpired:
257
+ raise ToolExecutionError(f"Command timed out after {timeout} seconds")
258
+ except Exception as e:
259
+ log.error(f"Command execution failed: {e}")
260
+ raise ToolExecutionError(f"Command execution failed: {e}") from e
261
+
262
+
263
+ # --- Search Tool (Simple ripgrep-based) ---
264
+
265
+
266
+ @tool(
267
+ name="Local_SearchCode",
268
+ desc="Search for files containing specific text or patterns using ripgrep.",
269
+ )
270
+ def search_code_tool(
271
+ context: ToolContext,
272
+ pattern: Annotated[str, "Text or regex pattern to search for."],
273
+ directory: Annotated[str, "Directory to search in (relative to project root)."] = ".",
274
+ file_extensions: Annotated[
275
+ list[str] | None,
276
+ "File extensions to search (e.g., ['py', 'js']). None searches all.",
277
+ ] = None,
278
+ case_sensitive: Annotated[bool, "Whether search should be case sensitive."] = False,
279
+ max_results: Annotated[int, "Maximum number of results to return."] = 100,
280
+ ) -> Annotated[dict[str, Any], "Search results with file paths and matching lines."]:
281
+ """Searches for files containing specific patterns using ripgrep."""
282
+ if not pattern or not pattern.strip():
283
+ raise ToolExecutionError("Search pattern cannot be empty.")
284
+
285
+ safe_dir = PathResolver.resolve_safe_path(directory, PROJECT_ROOT)
286
+ if not safe_dir.exists() or not safe_dir.is_dir():
287
+ return {
288
+ "results": [],
289
+ "summary": {
290
+ "total_matches": 0,
291
+ "error": f"Directory '{directory}' not found or not a directory",
292
+ },
293
+ }
294
+
295
+ # Build ripgrep command
296
+ rg_cmd = ["rg", "--json", "-m", str(max_results)]
297
+ if not case_sensitive:
298
+ rg_cmd.append("-i")
299
+ if file_extensions:
300
+ for ext in file_extensions:
301
+ rg_cmd.extend(["-g", f"*.{ext.lstrip('.')}"])
302
+
303
+ # Add ignore patterns
304
+ for ignore in DEFAULT_IGNORE_PATTERNS:
305
+ rg_cmd.extend(["-g", f"!{ignore}"])
306
+
307
+ rg_cmd.append(pattern)
308
+ rg_cmd.append(str(safe_dir))
309
+
310
+ try:
311
+ result = subprocess.run(
312
+ rg_cmd,
313
+ capture_output=True,
314
+ text=True,
315
+ timeout=30,
316
+ )
317
+
318
+ results = []
319
+ files_with_matches = set()
320
+
321
+ for line in result.stdout.strip().split("\n"):
322
+ if not line:
323
+ continue
324
+ try:
325
+ data = json.loads(line)
326
+ if data.get("type") == "match":
327
+ match_data = data.get("data", {})
328
+ path = match_data.get("path", {}).get("text", "")
329
+ line_num = match_data.get("line_number", 0)
330
+ line_text = match_data.get("lines", {}).get("text", "").strip()
331
+
332
+ # Make path relative
333
+ try:
334
+ rel_path = str(pathlib.Path(path).relative_to(PROJECT_ROOT))
335
+ except ValueError:
336
+ rel_path = path
337
+
338
+ results.append(
339
+ {
340
+ "file": rel_path,
341
+ "line": line_num,
342
+ "content": line_text,
343
+ }
344
+ )
345
+ files_with_matches.add(rel_path)
346
+ except json.JSONDecodeError:
347
+ continue
348
+
349
+ return {
350
+ "results": results,
351
+ "summary": {
352
+ "total_matches": len(results),
353
+ "files_with_matches": len(files_with_matches),
354
+ "pattern_searched": pattern,
355
+ },
356
+ }
357
+ except FileNotFoundError:
358
+ # ripgrep not installed, fall back to grep
359
+ log.warning("ripgrep not found, falling back to grep")
360
+ return _search_with_grep(pattern, safe_dir, case_sensitive, max_results)
361
+ except subprocess.TimeoutExpired:
362
+ raise ToolExecutionError("Search timed out")
363
+ except Exception as e:
364
+ log.error(f"Search failed: {e}")
365
+ raise ToolExecutionError(f"Search failed: {e}") from e
366
+
367
+
368
+ def _search_with_grep(
369
+ pattern: str,
370
+ directory: pathlib.Path,
371
+ case_sensitive: bool,
372
+ max_results: int,
373
+ ) -> dict[str, Any]:
374
+ """Fallback search using grep."""
375
+ grep_cmd = ["grep", "-rn"]
376
+ if not case_sensitive:
377
+ grep_cmd.append("-i")
378
+ grep_cmd.extend([pattern, str(directory)])
379
+
380
+ try:
381
+ result = subprocess.run(
382
+ grep_cmd,
383
+ capture_output=True,
384
+ text=True,
385
+ timeout=30,
386
+ )
387
+
388
+ results = []
389
+ files_with_matches = set()
390
+
391
+ for line in result.stdout.strip().split("\n")[:max_results]:
392
+ if not line:
393
+ continue
394
+ # Parse grep output: file:line:content
395
+ parts = line.split(":", 2)
396
+ if len(parts) >= 3:
397
+ try:
398
+ rel_path = str(pathlib.Path(parts[0]).relative_to(PROJECT_ROOT))
399
+ except ValueError:
400
+ rel_path = parts[0]
401
+
402
+ results.append(
403
+ {
404
+ "file": rel_path,
405
+ "line": int(parts[1]) if parts[1].isdigit() else 0,
406
+ "content": parts[2].strip(),
407
+ }
408
+ )
409
+ files_with_matches.add(rel_path)
410
+
411
+ return {
412
+ "results": results,
413
+ "summary": {
414
+ "total_matches": len(results),
415
+ "files_with_matches": len(files_with_matches),
416
+ "pattern_searched": pattern,
417
+ },
418
+ }
419
+ except Exception as e:
420
+ return {
421
+ "results": [],
422
+ "summary": {"total_matches": 0, "error": str(e)},
423
+ }
424
+
425
+
426
+ # --- Git Tools ---
427
+
428
+
429
+ @tool(name="Local_GitStatus", desc="Check git status of the repository.")
430
+ def git_status_tool(
431
+ context: ToolContext,
432
+ ) -> Annotated[dict, "Git status information."]:
433
+ """Gets the current git status."""
434
+ stdout, stderr = get_status()
435
+
436
+ if stderr:
437
+ raise ToolExecutionError(f"Git status failed: {stderr}")
438
+
439
+ files = []
440
+ for line in stdout.split("\n"):
441
+ if line.strip():
442
+ if len(line) >= 3:
443
+ status = line[:2]
444
+ filename = line[3:].strip()
445
+ files.append({"status": status, "file": filename})
446
+
447
+ branch_stdout, branch_stderr = get_current_branch_name()
448
+ current_branch = branch_stdout if not branch_stderr else "unknown"
449
+
450
+ return {
451
+ "branch": current_branch,
452
+ "files": files if files else "no files",
453
+ "clean": len(files) == 0,
454
+ "raw_output": stdout if stdout else "no output",
455
+ }
456
+
457
+
458
+ # --- Edit File Tool ---
459
+
460
+
461
+ @tool(
462
+ name="Local_EditFile",
463
+ desc="Edit a file by replacing specific text. Use for targeted changes.",
464
+ )
465
+ def edit_file_tool(
466
+ context: ToolContext,
467
+ file_path_arg: Annotated[str, "Path to the file to edit."],
468
+ old_string: Annotated[
469
+ str,
470
+ "The exact text to find and replace. Must match exactly including whitespace.",
471
+ ],
472
+ new_string: Annotated[str, "The text to replace old_string with."],
473
+ expected_count: Annotated[
474
+ int | None,
475
+ "Expected number of replacements. If provided, fails if count doesn't match.",
476
+ ] = None,
477
+ ) -> Annotated[dict, "Result of the edit operation including diff."]:
478
+ """Edit a file by finding and replacing specific text.
479
+
480
+ This tool performs precise text replacement in files. The old_string must
481
+ match exactly (including whitespace and indentation). Use this for targeted
482
+ edits rather than full file rewrites.
483
+
484
+ Args:
485
+ context: Tool context
486
+ file_path_arg: Path to file to edit
487
+ old_string: Exact text to find
488
+ new_string: Text to replace with
489
+ expected_count: Optional expected replacement count for validation
490
+
491
+ Returns:
492
+ Dict with success status, diff, and replacement count
493
+ """
494
+ resolved_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
495
+
496
+ if not resolved_path.is_file():
497
+ raise ToolExecutionError(f"File not found: {file_path_arg}")
498
+
499
+ try:
500
+ original_content = read_text_file(resolved_path)
501
+ except Exception as e:
502
+ raise ToolExecutionError(f"Failed to read file: {e}") from e
503
+
504
+ # Count occurrences
505
+ count = original_content.count(old_string)
506
+
507
+ if count == 0:
508
+ # Provide helpful context for debugging
509
+ preview_len = 100
510
+ old_preview = old_string[:preview_len]
511
+ if len(old_string) > preview_len:
512
+ old_preview += "..."
513
+
514
+ return {
515
+ "success": False,
516
+ "error": "old_string not found in file",
517
+ "old_string_preview": old_preview,
518
+ "file_size": len(original_content),
519
+ "suggestion": "Check whitespace, indentation, and exact character match",
520
+ }
521
+
522
+ if expected_count is not None and count != expected_count:
523
+ return {
524
+ "success": False,
525
+ "error": f"Expected {expected_count} occurrences but found {count}",
526
+ "actual_count": count,
527
+ }
528
+
529
+ # Perform replacement
530
+ new_content = original_content.replace(old_string, new_string)
531
+
532
+ # Generate diff for review
533
+ diff = generate_diff_from_content(
534
+ original_content,
535
+ new_content,
536
+ filename=file_path_arg,
537
+ context_lines=3,
538
+ )
539
+
540
+ # Write the file
541
+ try:
542
+ write_text_file(resolved_path, new_content)
543
+ except Exception as e:
544
+ raise ToolExecutionError(f"Failed to write file: {e}") from e
545
+
546
+ return {
547
+ "success": True,
548
+ "message": f"Successfully edited {file_path_arg}",
549
+ "replacements": count,
550
+ "diff": diff if diff else "(no visible diff - content may be identical)",
551
+ }
552
+
553
+
554
+ @tool(
555
+ name="Local_EditFileInsert",
556
+ desc="Insert text at a specific line number in a file.",
557
+ )
558
+ def edit_file_insert_tool(
559
+ context: ToolContext,
560
+ file_path_arg: Annotated[str, "Path to the file to edit."],
561
+ line_number: Annotated[int, "Line number to insert at (1-indexed). 0 = end of file."],
562
+ content: Annotated[str, "The text to insert."],
563
+ position: Annotated[
564
+ Literal["before", "after"],
565
+ "Insert before or after the specified line.",
566
+ ] = "before",
567
+ ) -> Annotated[dict, "Result of the insert operation including diff."]:
568
+ """Insert text at a specific line in a file.
569
+
570
+ Args:
571
+ context: Tool context
572
+ file_path_arg: Path to file to edit
573
+ line_number: Line number (1-indexed) to insert at. 0 means end of file.
574
+ content: Text to insert
575
+ position: Insert 'before' or 'after' the specified line
576
+
577
+ Returns:
578
+ Dict with success status and diff
579
+ """
580
+ resolved_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
581
+
582
+ if not resolved_path.is_file():
583
+ raise ToolExecutionError(f"File not found: {file_path_arg}")
584
+
585
+ try:
586
+ original_content = read_text_file(resolved_path)
587
+ except Exception as e:
588
+ raise ToolExecutionError(f"Failed to read file: {e}") from e
589
+
590
+ lines = original_content.splitlines(keepends=True)
591
+
592
+ # Handle line number
593
+ if line_number == 0:
594
+ # Append to end
595
+ if lines and not lines[-1].endswith("\n"):
596
+ lines[-1] += "\n"
597
+ lines.append(content if content.endswith("\n") else content + "\n")
598
+ elif line_number < 0 or line_number > len(lines) + 1:
599
+ raise ToolExecutionError(
600
+ f"Line number {line_number} out of range (file has {len(lines)} lines)"
601
+ )
602
+ else:
603
+ # Convert to 0-indexed
604
+ idx = line_number - 1
605
+ insert_content = content if content.endswith("\n") else content + "\n"
606
+
607
+ if position == "before":
608
+ lines.insert(idx, insert_content)
609
+ else: # after
610
+ lines.insert(idx + 1, insert_content)
611
+
612
+ new_content = "".join(lines)
613
+
614
+ # Generate diff
615
+ diff = generate_diff_from_content(
616
+ original_content,
617
+ new_content,
618
+ filename=file_path_arg,
619
+ context_lines=3,
620
+ )
621
+
622
+ # Write the file
623
+ try:
624
+ write_text_file(resolved_path, new_content)
625
+ except Exception as e:
626
+ raise ToolExecutionError(f"Failed to write file: {e}") from e
627
+
628
+ return {
629
+ "success": True,
630
+ "message": f"Successfully inserted content at line {line_number}",
631
+ "diff": diff,
632
+ }
633
+
634
+
635
+ # --- Tool Registration Helper ---
636
+
637
+
638
+ def get_all_tools() -> list[Callable]:
639
+ """Returns a list of all tool functions defined in this module."""
640
+ tool_functions = []
641
+ for name, obj in globals().items():
642
+ if callable(obj) and hasattr(obj, "__tool_name__") and hasattr(obj, "__tool_description__"):
643
+ tool_functions.append(obj)
644
+ return tool_functions