cade-cli 0.7.0__tar.gz → 0.9.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 (79) hide show
  1. {cade_cli-0.7.0 → cade_cli-0.9.0}/PKG-INFO +9 -1
  2. {cade_cli-0.7.0 → cade_cli-0.9.0}/pyproject.toml +14 -1
  3. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/__init__.py +1 -1
  4. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/server.py +4 -0
  5. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/__init__.py +7 -2
  6. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/context.py +3 -3
  7. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/filesystem.py +165 -91
  8. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/git.py +36 -37
  9. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/search.py +38 -30
  10. cade_cli-0.9.0/src/cade_mcp_local/tools/shell.py +157 -0
  11. cade_cli-0.9.0/src/cade_mcp_local/tools/snippets.py +109 -0
  12. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_results.py +23 -10
  13. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_schemas.py +1 -3
  14. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/utils.py +32 -9
  15. cade_cli-0.9.0/src/cadecoder/__init__.py +1 -0
  16. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ai/prompts.py +102 -3
  17. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/app.py +41 -5
  18. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/chat.py +53 -1
  19. cade_cli-0.9.0/src/cadecoder/cli/commands/persona.py +478 -0
  20. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/config.py +54 -3
  21. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/constants.py +11 -0
  22. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/errors.py +6 -0
  23. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/context_window.py +5 -2
  24. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/orchestrator.py +79 -42
  25. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_result_store.py +190 -64
  26. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_schema_cache.py +5 -4
  27. cade_cli-0.9.0/src/cadecoder/storage/personas.py +403 -0
  28. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/composite.py +81 -2
  29. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/mcp.py +83 -21
  30. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager.py +1 -1
  31. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ui/display.py +36 -20
  32. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ui/session.py +133 -12
  33. cade_cli-0.9.0/src/cadecoder/voice/__init__.py +30 -0
  34. cade_cli-0.9.0/src/cadecoder/voice/audio.py +583 -0
  35. cade_cli-0.9.0/src/cadecoder/voice/cleanup.py +172 -0
  36. cade_cli-0.9.0/src/cadecoder/voice/session.py +666 -0
  37. cade_cli-0.9.0/src/cadecoder/voice/stt.py +140 -0
  38. cade_cli-0.9.0/src/cadecoder/voice/tts.py +219 -0
  39. cade_cli-0.7.0/src/cade_mcp_local/tools/shell.py +0 -76
  40. cade_cli-0.7.0/src/cadecoder/__init__.py +0 -1
  41. {cade_cli-0.7.0 → cade_cli-0.9.0}/.gitignore +0 -0
  42. {cade_cli-0.7.0 → cade_cli-0.9.0}/LICENSE +0 -0
  43. {cade_cli-0.7.0 → cade_cli-0.9.0}/README.md +0 -0
  44. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/__main__.py +0 -0
  45. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/config.py +0 -0
  46. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/errors.py +0 -0
  47. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ai/__init__.py +0 -0
  48. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/__init__.py +0 -0
  49. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/auth.py +0 -0
  50. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/__init__.py +0 -0
  51. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/auth.py +0 -0
  52. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/context.py +0 -0
  53. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/mcp.py +0 -0
  54. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/model.py +0 -0
  55. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/thread.py +0 -0
  56. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/tools.py +0 -0
  57. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/__init__.py +0 -0
  58. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/git.py +0 -0
  59. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/logging.py +0 -0
  60. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/names.py +0 -0
  61. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/types.py +0 -0
  62. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/__init__.py +0 -0
  63. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/parallel.py +0 -0
  64. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/__init__.py +0 -0
  65. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/anthropic.py +0 -0
  66. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/base.py +0 -0
  67. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/ollama.py +0 -0
  68. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/openai.py +0 -0
  69. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/storage/__init__.py +0 -0
  70. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/storage/threads.py +0 -0
  71. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_failed.html +0 -0
  72. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_success.html +0 -0
  73. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/templates/styles.css +0 -0
  74. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/__init__.py +0 -0
  75. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/__init__.py +0 -0
  76. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/base.py +0 -0
  77. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/config.py +0 -0
  78. {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ui/__init__.py +0 -0
  79. {cade_cli-0.7.0 → cade_cli-0.9.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.9.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
@@ -56,6 +57,7 @@ Requires-Dist: pydantic[email]<3.0.0,>=2.0.0
56
57
  Requires-Dist: pyperclip<2.0.0,>=1.8.0
57
58
  Requires-Dist: pyyaml<7.0.0,>=6.0
58
59
  Requires-Dist: rich<14.0.0,>=13.0.0
60
+ Requires-Dist: sounddevice>=0.5.5
59
61
  Requires-Dist: tiktoken<1.0.0,>=0.11.0
60
62
  Requires-Dist: toml<1.0.0,>=0.10.0
61
63
  Requires-Dist: typer>0.10.0
@@ -72,6 +74,12 @@ Requires-Dist: accelerate>=0.27.0; extra == 'training'
72
74
  Requires-Dist: safetensors>=0.4.2; extra == 'training'
73
75
  Requires-Dist: torch>=2.1.0; extra == 'training'
74
76
  Requires-Dist: transformers>=4.38.0; extra == 'training'
77
+ Provides-Extra: voice
78
+ Requires-Dist: mlx-audio<0.4.0,>=0.2.0; extra == 'voice'
79
+ Requires-Dist: mlx-whisper>=0.1.0; extra == 'voice'
80
+ Requires-Dist: numpy>=1.24.0; extra == 'voice'
81
+ Requires-Dist: sounddevice>=0.4.0; extra == 'voice'
82
+ Requires-Dist: webrtcvad>=2.0.10; extra == 'voice'
75
83
  Description-Content-Type: text/markdown
76
84
 
77
85
  # Cade
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cade-cli"
3
- version = "0.7.0"
3
+ version = "0.9.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,8 @@ 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",
44
+ "sounddevice>=0.5.5",
43
45
  ]
44
46
 
45
47
  [project.urls]
@@ -62,6 +64,13 @@ dev = [
62
64
  "pytest-cov>=4.0.0,<5.0.0",
63
65
  "mypy>=1.10.0,<2.0.0",
64
66
  ]
67
+ voice = [
68
+ "mlx-audio>=0.2.0,<0.4.0",
69
+ "mlx-whisper>=0.1.0",
70
+ "sounddevice>=0.4.0",
71
+ "numpy>=1.24.0",
72
+ "webrtcvad>=2.0.10",
73
+ ]
65
74
  training = [
66
75
  "torch>=2.1.0",
67
76
  "transformers>=4.38.0",
@@ -95,6 +104,10 @@ target-version = "py311"
95
104
  select = ["E", "F", "W", "I", "UP"]
96
105
  ignore = ["E501"]
97
106
 
107
+ [tool.ruff.lint.per-file-ignores]
108
+ # Imports must come after os.environ and warnings setup at module top
109
+ "src/cadecoder/cli/app.py" = ["E402"]
110
+
98
111
  [tool.ruff.format]
99
112
  quote-style = "double"
100
113
 
@@ -69,4 +69,4 @@ __all__ = [
69
69
  "FileOperationError",
70
70
  ]
71
71
 
72
- __version__ = "0.7.0"
72
+ __version__ = "0.9.0"
@@ -18,6 +18,7 @@ from cade_mcp_local.tools import (
18
18
  edit_tool,
19
19
  git_tool,
20
20
  list_files_tool,
21
+ pin_context_tool,
21
22
  read_file_tool,
22
23
  retrieve_tool_result_tool,
23
24
  search_recent_context_tool,
@@ -67,6 +68,9 @@ def create_app() -> MCPApp:
67
68
  # Context tools
68
69
  app.add_tool(search_recent_context_tool)
69
70
 
71
+ # Pinned context
72
+ app.add_tool(pin_context_tool)
73
+
70
74
  # Tool result retrieval (consolidated: full, search, summarize, lines, json, list)
71
75
  app.add_tool(retrieve_tool_result_tool)
72
76
 
@@ -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,9 @@ 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.snippets import pin_context_tool
24
+ from cade_mcp_local.tools.tool_results import retrieve_tool_result_tool
25
+ from cade_mcp_local.tools.tool_schemas import tool_schema_tool
24
26
 
25
27
  __all__ = [
26
28
  # Filesystem
@@ -28,6 +30,7 @@ __all__ = [
28
30
  "read_file_tool",
29
31
  "write_file_tool",
30
32
  "edit_tool",
33
+ "insert_text_tool",
31
34
  # Git
32
35
  "git_tool",
33
36
  "get_current_branch_name",
@@ -38,6 +41,8 @@ __all__ = [
38
41
  "bash_tool",
39
42
  # Context
40
43
  "search_recent_context_tool",
44
+ # Pinned context
45
+ "pin_context_tool",
41
46
  # Tool Results
42
47
  "retrieve_tool_result_tool",
43
48
  # Tool Schemas
@@ -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,35 @@ 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
+ if not file_path_arg:
177
+ raise InvalidInputError("file_path_arg", "file path is required")
178
+ file_path = resolve_path(file_path_arg, confine_to_root=True)
150
179
 
151
180
  if not file_path.is_file():
152
181
  raise PathNotFoundError(file_path_arg)
@@ -154,27 +183,71 @@ def read_file_tool(
154
183
  try:
155
184
  size = file_path.stat().st_size
156
185
 
186
+ # Line-range mode: read specific lines (0 = not specified)
187
+ if start_line > 0 or end_line > 0:
188
+ content = _read_text_file(file_path)
189
+ all_lines = content.splitlines(keepends=True)
190
+ total_lines = len(all_lines)
191
+
192
+ s = max(1, start_line) if start_line > 0 else 1
193
+ e = min(total_lines, end_line) if end_line > 0 else total_lines
194
+
195
+ if s > total_lines:
196
+ return {
197
+ "content": "",
198
+ "total_lines": total_lines,
199
+ "message": f"start_line {s} exceeds file length ({total_lines} lines).",
200
+ }
201
+
202
+ selected = all_lines[s - 1 : e]
203
+ # Add line numbers for context
204
+ numbered = "".join(f"{i}: {line}" for i, line in enumerate(selected, start=s))
205
+ return {
206
+ "content": numbered,
207
+ "start_line": s,
208
+ "end_line": e,
209
+ "total_lines": total_lines,
210
+ "message": f"Lines {s}-{e} of {total_lines}.",
211
+ }
212
+
157
213
  if size > MAX_PREVIEW_BYTES:
158
214
  log.warning(f"File {file_path_arg} exceeds limit, truncating")
159
215
  with file_path.open("rb") as f:
160
- start = f.read(MAX_PREVIEW_BYTES // 2).decode(
161
- "utf-8", errors="replace"
162
- )
216
+ start = f.read(MAX_PREVIEW_BYTES // 2).decode("utf-8", errors="replace")
163
217
  f.seek(max(0, size - MAX_PREVIEW_BYTES // 2))
164
218
  end = f.read().decode("utf-8", errors="replace")
165
219
  content = f"{start}\n... (truncated, {size} bytes total) ...\n{end}"
166
- return {"content": content, "message": "File truncated due to size."}
220
+ return {
221
+ "content": content,
222
+ "total_lines": None,
223
+ "message": (
224
+ "File truncated due to size. Use start_line/end_line to read specific sections."
225
+ ),
226
+ }
167
227
 
168
228
  content = _read_text_file(file_path)
169
- return {"content": content, "message": "File read successfully."}
229
+ total_lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
230
+ return {
231
+ "content": content,
232
+ "total_lines": total_lines,
233
+ "message": "File read successfully.",
234
+ }
170
235
 
171
236
  except OSError as e:
172
237
  raise FileOperationError("read", file_path_arg, str(e))
173
238
 
174
239
 
240
+ # ===========================================================================
241
+ # WriteFile
242
+ # ===========================================================================
243
+
244
+
175
245
  @tool(
176
246
  name="WriteFile",
177
- desc="Write content to a file, creating/overwriting or appending. Local filesystem only.",
247
+ desc=(
248
+ "Create a new file or overwrite an existing file with the given content. "
249
+ "For partial edits use Edit; for appending use mode='append'."
250
+ ),
178
251
  )
179
252
  def write_file_tool(
180
253
  context: ToolContext,
@@ -182,10 +255,12 @@ def write_file_tool(
182
255
  content: Annotated[str, "The complete content to write into the file."],
183
256
  mode: Annotated[
184
257
  Literal["overwrite", "append"],
185
- "How to write: 'overwrite' to replace, 'append' to add to end.",
258
+ "How to write: 'overwrite' replaces the file, 'append' adds to end.",
186
259
  ] = "overwrite",
187
260
  ) -> Annotated[dict[str, Any], "Result of the write operation."]:
188
261
  """Write content to a file."""
262
+ if not file_path_arg:
263
+ raise InvalidInputError("file_path_arg", "file path is required")
189
264
  resolved = resolve_path(file_path_arg)
190
265
 
191
266
  try:
@@ -202,95 +277,65 @@ def write_file_tool(
202
277
  raise FileOperationError("write", file_path_arg, str(e))
203
278
 
204
279
 
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"
280
+ # ===========================================================================
281
+ # Edit (find-and-replace)
282
+ # ===========================================================================
217
283
 
218
284
 
219
285
  @tool(
220
286
  name="Edit",
221
287
  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)."
288
+ "Find and replace text in a file. "
289
+ "Provide old_string (the exact text to find) and new_string (the replacement). "
290
+ "The old_string must match the file content exactly, including whitespace and indentation. "
291
+ "Use ReadFile first to see the exact content. "
292
+ "Returns a diff showing what changed."
225
293
  ),
226
294
  )
227
295
  def edit_tool(
228
296
  context: ToolContext,
229
297
  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
298
  old_string: Annotated[
233
299
  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,
300
+ "The exact text to find in the file. Must match verbatim including whitespace.",
301
+ ],
302
+ new_string: Annotated[
303
+ str,
304
+ "The replacement text. Use an empty string to delete the matched text.",
305
+ ],
249
306
  ) -> Annotated[dict[str, Any], "Edit result with diff."]:
250
- """Edit a file by replacing text or inserting at a line number."""
307
+ """Find old_string in the file and replace it with new_string."""
308
+ if not file_path_arg:
309
+ raise InvalidInputError("file_path_arg", "file path is required")
251
310
  resolved = resolve_path(file_path_arg)
252
311
 
253
312
  if not resolved.is_file():
254
313
  raise PathNotFoundError(file_path_arg)
255
314
 
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
315
  try:
273
316
  original = _read_text_file(resolved)
274
317
  except Exception as e:
275
318
  raise FileOperationError("read", file_path_arg, str(e))
276
319
 
320
+ if not old_string:
321
+ raise InvalidInputError("old_string", "cannot be empty — provide the text to replace")
322
+
277
323
  count = original.count(old_string)
278
324
 
279
325
  if count == 0:
280
- preview = old_string[:100] + ("..." if len(old_string) > 100 else "")
326
+ # Provide helpful debugging info
327
+ preview = old_string[:120] + ("..." if len(old_string) > 120 else "")
328
+ lines = original.splitlines()
281
329
  return {
282
330
  "success": False,
283
331
  "error": "old_string not found in file",
284
332
  "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,
333
+ "file_lines": len(lines),
334
+ "file_chars": len(original),
335
+ "suggestion": (
336
+ "The text must match exactly including whitespace and indentation. "
337
+ "Use ReadFile to see the current content, then copy the exact text."
338
+ ),
294
339
  }
295
340
 
296
341
  new_content = original.replace(old_string, new_string)
@@ -303,25 +348,54 @@ def _edit_replace(
303
348
 
304
349
  return {
305
350
  "success": True,
306
- "message": f"Successfully edited {file_path_arg}",
351
+ "message": f"Replaced {count} occurrence(s) in {file_path_arg}",
307
352
  "replacements": count,
308
353
  "diff": diff or "(no visible diff)",
309
354
  }
310
355
 
311
356
 
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."""
357
+ # ===========================================================================
358
+ # InsertText (line-based insertion)
359
+ # ===========================================================================
360
+
361
+
362
+ @tool(
363
+ name="InsertText",
364
+ desc=(
365
+ "Insert text at a specific line number in a file. "
366
+ "Use ReadFile first to find the right line number. "
367
+ "Lines are 1-indexed. Set line_number=0 to append at end of file."
368
+ ),
369
+ )
370
+ def insert_text_tool(
371
+ context: ToolContext,
372
+ file_path_arg: Annotated[str, "Path to the file to edit."],
373
+ line_number: Annotated[
374
+ int,
375
+ "Line number to insert at (1-indexed). 0 appends at end of file.",
376
+ ],
377
+ content: Annotated[str, "The text to insert."],
378
+ position: Annotated[
379
+ Literal["before", "after"],
380
+ "Insert before or after the specified line.",
381
+ ] = "before",
382
+ ) -> Annotated[dict[str, Any], "Insert result with diff."]:
383
+ """Insert text at a specific line in a file."""
384
+ if not file_path_arg:
385
+ raise InvalidInputError("file_path_arg", "file path is required")
386
+ resolved = resolve_path(file_path_arg)
387
+
388
+ if not resolved.is_file():
389
+ raise PathNotFoundError(file_path_arg)
390
+
320
391
  try:
321
392
  original = _read_text_file(resolved)
322
393
  except Exception as e:
323
394
  raise FileOperationError("read", file_path_arg, str(e))
324
395
 
396
+ if not content:
397
+ raise InvalidInputError("content", "cannot be empty — provide the text to insert")
398
+
325
399
  lines = original.splitlines(keepends=True)
326
400
 
327
401
  # Ensure content ends with newline
@@ -339,7 +413,7 @@ def _edit_insert(
339
413
  )
340
414
  else:
341
415
  idx = line_number - 1
342
- if position == InsertPosition.BEFORE:
416
+ if position == "before":
343
417
  lines.insert(idx, insert_content)
344
418
  else:
345
419
  lines.insert(idx + 1, insert_content)
@@ -354,6 +428,6 @@ def _edit_insert(
354
428
 
355
429
  return {
356
430
  "success": True,
357
- "message": f"Successfully inserted content at line {line_number}",
431
+ "message": f"Inserted content at line {line_number} in {file_path_arg}",
358
432
  "diff": diff,
359
433
  }