cade-cli 0.7.0__tar.gz → 0.8.0__tar.gz

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 (78) hide show
  1. {cade_cli-0.7.0 → cade_cli-0.8.0}/PKG-INFO +8 -1
  2. {cade_cli-0.7.0 → cade_cli-0.8.0}/pyproject.toml +9 -1
  3. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/__init__.py +1 -1
  4. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/__init__.py +4 -2
  5. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/context.py +3 -3
  6. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/filesystem.py +157 -91
  7. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/git.py +36 -37
  8. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/search.py +38 -30
  9. cade_cli-0.8.0/src/cade_mcp_local/tools/shell.py +157 -0
  10. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/tool_results.py +10 -10
  11. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/tool_schemas.py +1 -3
  12. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/utils.py +32 -9
  13. cade_cli-0.8.0/src/cadecoder/__init__.py +1 -0
  14. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ai/prompts.py +86 -3
  15. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/app.py +19 -5
  16. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/chat.py +53 -1
  17. cade_cli-0.8.0/src/cadecoder/cli/commands/persona.py +478 -0
  18. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/config.py +54 -3
  19. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/constants.py +11 -0
  20. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/errors.py +6 -0
  21. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/context_window.py +5 -2
  22. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/orchestrator.py +66 -42
  23. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/tool_result_store.py +101 -62
  24. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/tool_schema_cache.py +5 -4
  25. cade_cli-0.8.0/src/cadecoder/storage/personas.py +403 -0
  26. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/composite.py +73 -2
  27. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/mcp.py +64 -17
  28. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager.py +1 -1
  29. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/session.py +42 -12
  30. cade_cli-0.8.0/src/cadecoder/voice/__init__.py +30 -0
  31. cade_cli-0.8.0/src/cadecoder/voice/audio.py +583 -0
  32. cade_cli-0.8.0/src/cadecoder/voice/cleanup.py +172 -0
  33. cade_cli-0.8.0/src/cadecoder/voice/session.py +642 -0
  34. cade_cli-0.8.0/src/cadecoder/voice/stt.py +140 -0
  35. cade_cli-0.8.0/src/cadecoder/voice/tts.py +186 -0
  36. cade_cli-0.7.0/src/cade_mcp_local/tools/shell.py +0 -76
  37. cade_cli-0.7.0/src/cadecoder/__init__.py +0 -1
  38. {cade_cli-0.7.0 → cade_cli-0.8.0}/.gitignore +0 -0
  39. {cade_cli-0.7.0 → cade_cli-0.8.0}/LICENSE +0 -0
  40. {cade_cli-0.7.0 → cade_cli-0.8.0}/README.md +0 -0
  41. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/__main__.py +0 -0
  42. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/config.py +0 -0
  43. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/errors.py +0 -0
  44. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/server.py +0 -0
  45. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ai/__init__.py +0 -0
  46. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/__init__.py +0 -0
  47. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/auth.py +0 -0
  48. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/__init__.py +0 -0
  49. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/auth.py +0 -0
  50. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/context.py +0 -0
  51. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/mcp.py +0 -0
  52. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/model.py +0 -0
  53. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/thread.py +0 -0
  54. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/tools.py +0 -0
  55. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/__init__.py +0 -0
  56. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/git.py +0 -0
  57. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/logging.py +0 -0
  58. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/names.py +0 -0
  59. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/types.py +0 -0
  60. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/__init__.py +0 -0
  61. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/parallel.py +0 -0
  62. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/__init__.py +0 -0
  63. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/anthropic.py +0 -0
  64. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/base.py +0 -0
  65. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/ollama.py +0 -0
  66. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/openai.py +0 -0
  67. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/storage/__init__.py +0 -0
  68. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/storage/threads.py +0 -0
  69. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/templates/login_failed.html +0 -0
  70. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/templates/login_success.html +0 -0
  71. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/templates/styles.css +0 -0
  72. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/__init__.py +0 -0
  73. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/__init__.py +0 -0
  74. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/base.py +0 -0
  75. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/config.py +0 -0
  76. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/__init__.py +0 -0
  77. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/display.py +0 -0
  78. {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/input.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cade-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Cade - The CLI Agent from Arcade.dev
5
5
  Project-URL: Homepage, https://arcade.dev
6
6
  Project-URL: Documentation, https://docs.arcade.dev
@@ -43,6 +43,7 @@ Classifier: Topic :: Software Development
43
43
  Classifier: Topic :: Software Development :: Code Generators
44
44
  Classifier: Typing :: Typed
45
45
  Requires-Python: >=3.11
46
+ Requires-Dist: agent-library<1.0.0,>=0.11.0
46
47
  Requires-Dist: anthropic<1.0.0,>=0.34.0
47
48
  Requires-Dist: arcade-core<5.0.0,>=4.1.0
48
49
  Requires-Dist: arcade-mcp-server>=1.0.0
@@ -72,6 +73,12 @@ Requires-Dist: accelerate>=0.27.0; extra == 'training'
72
73
  Requires-Dist: safetensors>=0.4.2; extra == 'training'
73
74
  Requires-Dist: torch>=2.1.0; extra == 'training'
74
75
  Requires-Dist: transformers>=4.38.0; extra == 'training'
76
+ Provides-Extra: voice
77
+ Requires-Dist: mlx-audio<0.4.0,>=0.2.0; extra == 'voice'
78
+ Requires-Dist: mlx-whisper>=0.1.0; extra == 'voice'
79
+ Requires-Dist: numpy>=1.24.0; extra == 'voice'
80
+ Requires-Dist: sounddevice>=0.4.0; extra == 'voice'
81
+ Requires-Dist: webrtcvad>=2.0.10; extra == 'voice'
75
82
  Description-Content-Type: text/markdown
76
83
 
77
84
  # Cade
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cade-cli"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Cade - The CLI Agent from Arcade.dev"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -40,6 +40,7 @@ dependencies = [
40
40
  "tiktoken>=0.11.0,<1.0.0",
41
41
  "httpx>=0.27.0,<1.0.0",
42
42
  "filelock>=3.0.0,<4.0.0",
43
+ "agent-library>=0.11.0,<1.0.0",
43
44
  ]
44
45
 
45
46
  [project.urls]
@@ -62,6 +63,13 @@ dev = [
62
63
  "pytest-cov>=4.0.0,<5.0.0",
63
64
  "mypy>=1.10.0,<2.0.0",
64
65
  ]
66
+ voice = [
67
+ "mlx-audio>=0.2.0,<0.4.0",
68
+ "mlx-whisper>=0.1.0",
69
+ "sounddevice>=0.4.0",
70
+ "numpy>=1.24.0",
71
+ "webrtcvad>=2.0.10",
72
+ ]
65
73
  training = [
66
74
  "torch>=2.1.0",
67
75
  "transformers>=4.38.0",
@@ -69,4 +69,4 @@ __all__ = [
69
69
  "FileOperationError",
70
70
  ]
71
71
 
72
- __version__ = "0.7.0"
72
+ __version__ = "0.8.0"
@@ -6,10 +6,9 @@ commands on the same machine as the running cade process.
6
6
  """
7
7
 
8
8
  from cade_mcp_local.tools.context import search_recent_context_tool
9
- from cade_mcp_local.tools.tool_results import retrieve_tool_result_tool
10
- from cade_mcp_local.tools.tool_schemas import tool_schema_tool
11
9
  from cade_mcp_local.tools.filesystem import (
12
10
  edit_tool,
11
+ insert_text_tool,
13
12
  list_files_tool,
14
13
  read_file_tool,
15
14
  write_file_tool,
@@ -21,6 +20,8 @@ from cade_mcp_local.tools.git import (
21
20
  )
22
21
  from cade_mcp_local.tools.search import search_tool
23
22
  from cade_mcp_local.tools.shell import bash_tool
23
+ from cade_mcp_local.tools.tool_results import retrieve_tool_result_tool
24
+ from cade_mcp_local.tools.tool_schemas import tool_schema_tool
24
25
 
25
26
  __all__ = [
26
27
  # Filesystem
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "read_file_tool",
29
30
  "write_file_tool",
30
31
  "edit_tool",
32
+ "insert_text_tool",
31
33
  # Git
32
34
  "git_tool",
33
35
  "get_current_branch_name",
@@ -175,9 +175,9 @@ def search_recent_context_tool(
175
175
  ] = 5,
176
176
  case_sensitive: Annotated[bool, "Whether search should be case sensitive."] = False,
177
177
  role_filter: Annotated[
178
- str | None,
179
- "Filter matches by message role ('user', 'assistant', 'tool'). None searches all.",
180
- ] = None,
178
+ str,
179
+ "Filter matches by message role: 'user', 'assistant', or 'tool'.",
180
+ ] = "",
181
181
  max_results: Annotated[int, "Maximum number of matching messages to return."] = 20,
182
182
  ) -> Annotated[dict[str, Any], "Search results or backup listing."]:
183
183
  """Search or list compacted context backup files.
@@ -1,9 +1,9 @@
1
1
  """Filesystem tools for reading, writing, and editing files."""
2
2
 
3
+ import collections
3
4
  import difflib
4
5
  import logging
5
6
  import pathlib
6
- from enum import Enum
7
7
  from typing import Annotated, Any, Literal
8
8
 
9
9
  from arcade_tdk import ToolContext, tool
@@ -35,9 +35,7 @@ def _read_text_file(file_path: pathlib.Path) -> str:
35
35
  continue
36
36
 
37
37
  # Fallback: read as binary and decode with replacement
38
- log.warning(
39
- f"Could not decode {file_path} with standard encodings, using replacement"
40
- )
38
+ log.warning(f"Could not decode {file_path} with standard encodings, using replacement")
41
39
  return file_path.read_bytes().decode("utf-8", errors="replace")
42
40
 
43
41
 
@@ -45,12 +43,19 @@ def _write_text_file(file_path: pathlib.Path, content: str) -> None:
45
43
  """Write text content to a file, ensuring it's within project root."""
46
44
  project_root = get_project_root()
47
45
  resolved = file_path.resolve()
46
+ resolved_root = project_root.resolve()
48
47
 
49
48
  # Security check: must be within project root
49
+ # Use string prefix check as fallback for symlink/worktree edge cases
50
50
  try:
51
- resolved.relative_to(project_root)
51
+ resolved.relative_to(resolved_root)
52
52
  except ValueError:
53
- raise PathSecurityError(str(file_path), "outside project root")
53
+ # Fallback: compare resolved string paths
54
+ if not str(resolved).startswith(str(resolved_root) + "/"):
55
+ raise PathSecurityError(
56
+ str(file_path),
57
+ f"outside project root (resolved={resolved}, root={resolved_root})",
58
+ )
54
59
 
55
60
  # Create parent directories if needed
56
61
  resolved.parent.mkdir(parents=True, exist_ok=True)
@@ -75,6 +80,11 @@ def _generate_diff(
75
80
  return "\n".join(diff)
76
81
 
77
82
 
83
+ # ===========================================================================
84
+ # ListFiles
85
+ # ===========================================================================
86
+
87
+
78
88
  @tool(
79
89
  name="ListFiles",
80
90
  desc="List files in a directory (recursively up to a depth limit). Local filesystem only.",
@@ -95,7 +105,7 @@ def list_files_tool(
95
105
  if not 0 <= depth <= MAX_LIST_DEPTH:
96
106
  raise InvalidInputError("depth", f"must be between 0 and {MAX_LIST_DEPTH}")
97
107
 
98
- base_path = resolve_path(directory)
108
+ base_path = resolve_path(directory, confine_to_root=True)
99
109
  if not base_path.is_dir():
100
110
  raise PathNotFoundError(directory, f"Not a directory: {directory}")
101
111
 
@@ -111,12 +121,12 @@ def list_files_tool(
111
121
  break
112
122
  else:
113
123
  # BFS traversal with depth tracking
114
- queue: list[tuple[pathlib.Path, int]] = [
124
+ queue: collections.deque[tuple[pathlib.Path, int]] = collections.deque(
115
125
  (child, 0) for child in base_path.iterdir() if not should_ignore(child)
116
- ]
126
+ )
117
127
 
118
128
  while queue and len(listed_paths) < MAX_LIST_RESULTS:
119
- current, current_depth = queue.pop(0)
129
+ current, current_depth = queue.popleft()
120
130
 
121
131
  if current_depth > depth:
122
132
  continue
@@ -137,16 +147,33 @@ def list_files_tool(
137
147
  return {"files": listed_paths, "message": message}
138
148
 
139
149
 
150
+ # ===========================================================================
151
+ # ReadFile
152
+ # ===========================================================================
153
+
154
+
140
155
  @tool(
141
156
  name="ReadFile",
142
- desc="Read the entire content of a text file. Local filesystem only.",
157
+ desc=(
158
+ "Read a text file, optionally a specific line range. "
159
+ "Use start_line/end_line to read portions of large files. "
160
+ "Lines are 1-indexed. Local filesystem only."
161
+ ),
143
162
  )
144
163
  def read_file_tool(
145
164
  context: ToolContext,
146
165
  file_path_arg: Annotated[str, "Path to the file to read (relative or absolute)."],
147
- ) -> Annotated[dict[str, Any], "File content and message."]:
148
- """Read content of a specified file."""
149
- file_path = resolve_path(file_path_arg)
166
+ start_line: Annotated[
167
+ int,
168
+ "First line to return, 1-indexed. Omit to start from the beginning.",
169
+ ] = 0,
170
+ end_line: Annotated[
171
+ int,
172
+ "Last line to return, 1-indexed. Omit to read to end of file.",
173
+ ] = 0,
174
+ ) -> Annotated[dict[str, Any], "File content, line numbers, and message."]:
175
+ """Read content of a specified file, optionally a specific line range."""
176
+ file_path = resolve_path(file_path_arg, confine_to_root=True)
150
177
 
151
178
  if not file_path.is_file():
152
179
  raise PathNotFoundError(file_path_arg)
@@ -154,27 +181,71 @@ def read_file_tool(
154
181
  try:
155
182
  size = file_path.stat().st_size
156
183
 
184
+ # Line-range mode: read specific lines (0 = not specified)
185
+ if start_line > 0 or end_line > 0:
186
+ content = _read_text_file(file_path)
187
+ all_lines = content.splitlines(keepends=True)
188
+ total_lines = len(all_lines)
189
+
190
+ s = max(1, start_line) if start_line > 0 else 1
191
+ e = min(total_lines, end_line) if end_line > 0 else total_lines
192
+
193
+ if s > total_lines:
194
+ return {
195
+ "content": "",
196
+ "total_lines": total_lines,
197
+ "message": f"start_line {s} exceeds file length ({total_lines} lines).",
198
+ }
199
+
200
+ selected = all_lines[s - 1 : e]
201
+ # Add line numbers for context
202
+ numbered = "".join(f"{i}: {line}" for i, line in enumerate(selected, start=s))
203
+ return {
204
+ "content": numbered,
205
+ "start_line": s,
206
+ "end_line": e,
207
+ "total_lines": total_lines,
208
+ "message": f"Lines {s}-{e} of {total_lines}.",
209
+ }
210
+
157
211
  if size > MAX_PREVIEW_BYTES:
158
212
  log.warning(f"File {file_path_arg} exceeds limit, truncating")
159
213
  with file_path.open("rb") as f:
160
- start = f.read(MAX_PREVIEW_BYTES // 2).decode(
161
- "utf-8", errors="replace"
162
- )
214
+ start = f.read(MAX_PREVIEW_BYTES // 2).decode("utf-8", errors="replace")
163
215
  f.seek(max(0, size - MAX_PREVIEW_BYTES // 2))
164
216
  end = f.read().decode("utf-8", errors="replace")
165
217
  content = f"{start}\n... (truncated, {size} bytes total) ...\n{end}"
166
- return {"content": content, "message": "File truncated due to size."}
218
+ return {
219
+ "content": content,
220
+ "total_lines": None,
221
+ "message": (
222
+ "File truncated due to size. Use start_line/end_line to read specific sections."
223
+ ),
224
+ }
167
225
 
168
226
  content = _read_text_file(file_path)
169
- return {"content": content, "message": "File read successfully."}
227
+ total_lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
228
+ return {
229
+ "content": content,
230
+ "total_lines": total_lines,
231
+ "message": "File read successfully.",
232
+ }
170
233
 
171
234
  except OSError as e:
172
235
  raise FileOperationError("read", file_path_arg, str(e))
173
236
 
174
237
 
238
+ # ===========================================================================
239
+ # WriteFile
240
+ # ===========================================================================
241
+
242
+
175
243
  @tool(
176
244
  name="WriteFile",
177
- desc="Write content to a file, creating/overwriting or appending. Local filesystem only.",
245
+ desc=(
246
+ "Create a new file or overwrite an existing file with the given content. "
247
+ "For partial edits use Edit; for appending use mode='append'."
248
+ ),
178
249
  )
179
250
  def write_file_tool(
180
251
  context: ToolContext,
@@ -182,7 +253,7 @@ def write_file_tool(
182
253
  content: Annotated[str, "The complete content to write into the file."],
183
254
  mode: Annotated[
184
255
  Literal["overwrite", "append"],
185
- "How to write: 'overwrite' to replace, 'append' to add to end.",
256
+ "How to write: 'overwrite' replaces the file, 'append' adds to end.",
186
257
  ] = "overwrite",
187
258
  ) -> Annotated[dict[str, Any], "Result of the write operation."]:
188
259
  """Write content to a file."""
@@ -202,95 +273,63 @@ def write_file_tool(
202
273
  raise FileOperationError("write", file_path_arg, str(e))
203
274
 
204
275
 
205
- class EditMode(str, Enum):
206
- """Edit operation mode."""
207
-
208
- REPLACE = "replace"
209
- INSERT = "insert"
210
-
211
-
212
- class InsertPosition(str, Enum):
213
- """Where to insert relative to the target line."""
214
-
215
- BEFORE = "before"
216
- AFTER = "after"
276
+ # ===========================================================================
277
+ # Edit (find-and-replace)
278
+ # ===========================================================================
217
279
 
218
280
 
219
281
  @tool(
220
282
  name="Edit",
221
283
  desc=(
222
- "Edit a file by replacing text or inserting at a line number. "
223
- "mode='replace': find old_string and replace with new_string. "
224
- "mode='insert': insert content at line_number (1-indexed, 0=end)."
284
+ "Find and replace text in a file. "
285
+ "Provide old_string (the exact text to find) and new_string (the replacement). "
286
+ "The old_string must match the file content exactly, including whitespace and indentation. "
287
+ "Use ReadFile first to see the exact content. "
288
+ "Returns a diff showing what changed."
225
289
  ),
226
290
  )
227
291
  def edit_tool(
228
292
  context: ToolContext,
229
293
  file_path_arg: Annotated[str, "Path to the file to edit."],
230
- mode: Annotated[EditMode, "Edit mode: replace or insert."] = EditMode.REPLACE,
231
- # replace mode params
232
294
  old_string: Annotated[
233
295
  str,
234
- "Exact text to find and replace (mode=replace). Must match including whitespace.",
235
- ] = "",
236
- new_string: Annotated[str, "Replacement text (mode=replace)."] = "",
237
- expected_count: Annotated[
238
- int | None,
239
- "Expected replacement count; fails if mismatch (mode=replace).",
240
- ] = None,
241
- # insert mode params
242
- line_number: Annotated[
243
- int, "Line to insert at, 1-indexed. 0 = end of file (mode=insert)."
244
- ] = 0,
245
- content: Annotated[str, "Text to insert (mode=insert)."] = "",
246
- position: Annotated[
247
- InsertPosition, "Insert before or after the line (mode=insert)."
248
- ] = InsertPosition.BEFORE,
296
+ "The exact text to find in the file. Must match verbatim including whitespace.",
297
+ ],
298
+ new_string: Annotated[
299
+ str,
300
+ "The replacement text. Use an empty string to delete the matched text.",
301
+ ],
249
302
  ) -> Annotated[dict[str, Any], "Edit result with diff."]:
250
- """Edit a file by replacing text or inserting at a line number."""
303
+ """Find old_string in the file and replace it with new_string."""
251
304
  resolved = resolve_path(file_path_arg)
252
305
 
253
306
  if not resolved.is_file():
254
307
  raise PathNotFoundError(file_path_arg)
255
308
 
256
- if mode == EditMode.REPLACE:
257
- return _edit_replace(resolved, file_path_arg, old_string, new_string, expected_count)
258
- elif mode == EditMode.INSERT:
259
- return _edit_insert(resolved, file_path_arg, line_number, content, position)
260
- else:
261
- raise InvalidInputError("mode", f"Unknown mode: {mode}")
262
-
263
-
264
- def _edit_replace(
265
- resolved: pathlib.Path,
266
- file_path_arg: str,
267
- old_string: str,
268
- new_string: str,
269
- expected_count: int | None,
270
- ) -> dict[str, Any]:
271
- """Find-and-replace edit mode."""
272
309
  try:
273
310
  original = _read_text_file(resolved)
274
311
  except Exception as e:
275
312
  raise FileOperationError("read", file_path_arg, str(e))
276
313
 
314
+ if not old_string:
315
+ raise InvalidInputError("old_string", "cannot be empty — provide the text to replace")
316
+
277
317
  count = original.count(old_string)
278
318
 
279
319
  if count == 0:
280
- preview = old_string[:100] + ("..." if len(old_string) > 100 else "")
320
+ # Provide helpful debugging info
321
+ preview = old_string[:120] + ("..." if len(old_string) > 120 else "")
322
+ lines = original.splitlines()
281
323
  return {
282
324
  "success": False,
283
325
  "error": "old_string not found in file",
284
326
  "old_string_preview": preview,
285
- "file_size": len(original),
286
- "suggestion": "Check whitespace, indentation, and exact character match",
287
- }
288
-
289
- if expected_count is not None and count != expected_count:
290
- return {
291
- "success": False,
292
- "error": f"Expected {expected_count} occurrences but found {count}",
293
- "actual_count": count,
327
+ "file_lines": len(lines),
328
+ "file_chars": len(original),
329
+ "suggestion": (
330
+ "The text must match exactly including whitespace and indentation. "
331
+ "Use ReadFile to see the current content, then copy the exact text."
332
+ ),
294
333
  }
295
334
 
296
335
  new_content = original.replace(old_string, new_string)
@@ -303,25 +342,52 @@ def _edit_replace(
303
342
 
304
343
  return {
305
344
  "success": True,
306
- "message": f"Successfully edited {file_path_arg}",
345
+ "message": f"Replaced {count} occurrence(s) in {file_path_arg}",
307
346
  "replacements": count,
308
347
  "diff": diff or "(no visible diff)",
309
348
  }
310
349
 
311
350
 
312
- def _edit_insert(
313
- resolved: pathlib.Path,
314
- file_path_arg: str,
315
- line_number: int,
316
- content: str,
317
- position: InsertPosition,
318
- ) -> dict[str, Any]:
319
- """Line-insert edit mode."""
351
+ # ===========================================================================
352
+ # InsertText (line-based insertion)
353
+ # ===========================================================================
354
+
355
+
356
+ @tool(
357
+ name="InsertText",
358
+ desc=(
359
+ "Insert text at a specific line number in a file. "
360
+ "Use ReadFile first to find the right line number. "
361
+ "Lines are 1-indexed. Set line_number=0 to append at end of file."
362
+ ),
363
+ )
364
+ def insert_text_tool(
365
+ context: ToolContext,
366
+ file_path_arg: Annotated[str, "Path to the file to edit."],
367
+ line_number: Annotated[
368
+ int,
369
+ "Line number to insert at (1-indexed). 0 appends at end of file.",
370
+ ],
371
+ content: Annotated[str, "The text to insert."],
372
+ position: Annotated[
373
+ Literal["before", "after"],
374
+ "Insert before or after the specified line.",
375
+ ] = "before",
376
+ ) -> Annotated[dict[str, Any], "Insert result with diff."]:
377
+ """Insert text at a specific line in a file."""
378
+ resolved = resolve_path(file_path_arg)
379
+
380
+ if not resolved.is_file():
381
+ raise PathNotFoundError(file_path_arg)
382
+
320
383
  try:
321
384
  original = _read_text_file(resolved)
322
385
  except Exception as e:
323
386
  raise FileOperationError("read", file_path_arg, str(e))
324
387
 
388
+ if not content:
389
+ raise InvalidInputError("content", "cannot be empty — provide the text to insert")
390
+
325
391
  lines = original.splitlines(keepends=True)
326
392
 
327
393
  # Ensure content ends with newline
@@ -339,7 +405,7 @@ def _edit_insert(
339
405
  )
340
406
  else:
341
407
  idx = line_number - 1
342
- if position == InsertPosition.BEFORE:
408
+ if position == "before":
343
409
  lines.insert(idx, insert_content)
344
410
  else:
345
411
  lines.insert(idx + 1, insert_content)
@@ -354,6 +420,6 @@ def _edit_insert(
354
420
 
355
421
  return {
356
422
  "success": True,
357
- "message": f"Successfully inserted content at line {line_number}",
423
+ "message": f"Inserted content at line {line_number} in {file_path_arg}",
358
424
  "diff": diff,
359
425
  }
@@ -108,40 +108,28 @@ def get_status() -> tuple[str, str | None]:
108
108
  )
109
109
  def git_tool(
110
110
  context: ToolContext,
111
- action: Annotated[
112
- GitAction, "Git operation: status, diff, log, or branch."
113
- ] = GitAction.STATUS,
111
+ action: Annotated[GitAction, "Git operation: status, diff, log, or branch."] = GitAction.STATUS,
114
112
  # status params
115
113
  short: Annotated[bool, "Use short format (status)."] = True,
116
114
  # diff params
117
- path: Annotated[str | None, "File/directory path (diff, log)."] = None,
115
+ path: Annotated[str, "File or directory path to scope the operation (diff, log)."] = "",
118
116
  staged: Annotated[bool, "Show staged changes, --cached (diff)."] = False,
119
- commit: Annotated[
120
- str | None, "Compare with commit, e.g. 'HEAD~1', 'main' (diff)."
121
- ] = None,
117
+ commit: Annotated[str, "Compare with a commit ref, e.g. 'HEAD~1', 'main' (diff)."] = "",
122
118
  stat: Annotated[bool, "Show diffstat instead of full diff (diff)."] = False,
123
- name_only: Annotated[
124
- bool, "Show only changed file names (diff)."
125
- ] = False,
119
+ name_only: Annotated[bool, "Show only changed file names (diff)."] = False,
126
120
  # log params
127
121
  count: Annotated[int, "Number of commits to show (log)."] = 10,
128
122
  oneline: Annotated[bool, "One line per commit (log)."] = True,
129
- author: Annotated[str | None, "Filter by author (log)."] = None,
130
- since: Annotated[
131
- str | None, "Commits since date, e.g. '2 weeks ago' (log)."
132
- ] = None,
133
- until: Annotated[str | None, "Commits until date (log)."] = None,
134
- grep: Annotated[str | None, "Search commit messages (log)."] = None,
123
+ author: Annotated[str, "Filter by author name or email (log)."] = "",
124
+ since: Annotated[str, "Commits since date, e.g. '2 weeks ago' (log)."] = "",
125
+ until: Annotated[str, "Commits until date (log)."] = "",
126
+ grep: Annotated[str, "Search commit messages for this text (log)."] = "",
135
127
  # branch params
136
128
  branch_action: Annotated[
137
129
  BranchAction, "Branch sub-operation (when action=branch)."
138
130
  ] = BranchAction.LIST,
139
- name: Annotated[
140
- str | None, "Branch name (for create/delete/switch)."
141
- ] = None,
142
- all_branches: Annotated[
143
- bool, "Include remote branches (branch list)."
144
- ] = False,
131
+ name: Annotated[str, "Branch name (required for create/delete/switch)."] = "",
132
+ all_branches: Annotated[bool, "Include remote branches (branch list)."] = False,
145
133
  force: Annotated[bool, "Force action (branch delete)."] = False,
146
134
  ) -> Annotated[dict[str, Any], "Git operation result."]:
147
135
  """Perform git operations on the local repository."""
@@ -149,18 +137,28 @@ def git_tool(
149
137
  return _git_status(short=short)
150
138
  elif action == GitAction.DIFF:
151
139
  return _git_diff(
152
- path=path, staged=staged, commit=commit,
153
- stat=stat, name_only=name_only,
140
+ path=path,
141
+ staged=staged,
142
+ commit=commit,
143
+ stat=stat,
144
+ name_only=name_only,
154
145
  )
155
146
  elif action == GitAction.LOG:
156
147
  return _git_log(
157
- count=count, oneline=oneline, path=path,
158
- author=author, since=since, until=until, grep=grep,
148
+ count=count,
149
+ oneline=oneline,
150
+ path=path,
151
+ author=author,
152
+ since=since,
153
+ until=until,
154
+ grep=grep,
159
155
  )
160
156
  elif action == GitAction.BRANCH:
161
157
  return _git_branch(
162
- branch_action=branch_action, name=name,
163
- all_branches=all_branches, force=force,
158
+ branch_action=branch_action,
159
+ name=name,
160
+ all_branches=all_branches,
161
+ force=force,
164
162
  )
165
163
  else:
166
164
  raise InvalidInputError("action", f"Unknown action: {action}")
@@ -199,9 +197,9 @@ def _git_status(*, short: bool) -> dict[str, Any]:
199
197
 
200
198
  def _git_diff(
201
199
  *,
202
- path: str | None,
200
+ path: str,
203
201
  staged: bool,
204
- commit: str | None,
202
+ commit: str,
205
203
  stat: bool,
206
204
  name_only: bool,
207
205
  ) -> dict[str, Any]:
@@ -237,12 +235,13 @@ def _git_log(
237
235
  *,
238
236
  count: int,
239
237
  oneline: bool,
240
- path: str | None,
241
- author: str | None,
242
- since: str | None,
243
- until: str | None,
244
- grep: str | None,
238
+ path: str,
239
+ author: str,
240
+ since: str,
241
+ until: str,
242
+ grep: str,
245
243
  ) -> dict[str, Any]:
244
+ count = max(1, count)
246
245
  args = ["log", f"-{count}"]
247
246
 
248
247
  if oneline:
@@ -280,7 +279,7 @@ def _git_log(
280
279
  }
281
280
  )
282
281
  else:
283
- parts = line.split("|")
282
+ parts = line.split("|", 4)
284
283
  if len(parts) >= 5:
285
284
  commits.append(
286
285
  {
@@ -298,7 +297,7 @@ def _git_log(
298
297
  def _git_branch(
299
298
  *,
300
299
  branch_action: BranchAction,
301
- name: str | None,
300
+ name: str,
302
301
  all_branches: bool,
303
302
  force: bool,
304
303
  ) -> dict[str, Any]: