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.
- {cade_cli-0.7.0 → cade_cli-0.8.0}/PKG-INFO +8 -1
- {cade_cli-0.7.0 → cade_cli-0.8.0}/pyproject.toml +9 -1
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/__init__.py +1 -1
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/__init__.py +4 -2
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/context.py +3 -3
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/filesystem.py +157 -91
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/git.py +36 -37
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/search.py +38 -30
- cade_cli-0.8.0/src/cade_mcp_local/tools/shell.py +157 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/tool_results.py +10 -10
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/tools/tool_schemas.py +1 -3
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/utils.py +32 -9
- cade_cli-0.8.0/src/cadecoder/__init__.py +1 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ai/prompts.py +86 -3
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/app.py +19 -5
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/chat.py +53 -1
- cade_cli-0.8.0/src/cadecoder/cli/commands/persona.py +478 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/config.py +54 -3
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/constants.py +11 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/errors.py +6 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/context_window.py +5 -2
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/orchestrator.py +66 -42
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/tool_result_store.py +101 -62
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/tool_schema_cache.py +5 -4
- cade_cli-0.8.0/src/cadecoder/storage/personas.py +403 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/composite.py +73 -2
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/mcp.py +64 -17
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager.py +1 -1
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/session.py +42 -12
- cade_cli-0.8.0/src/cadecoder/voice/__init__.py +30 -0
- cade_cli-0.8.0/src/cadecoder/voice/audio.py +583 -0
- cade_cli-0.8.0/src/cadecoder/voice/cleanup.py +172 -0
- cade_cli-0.8.0/src/cadecoder/voice/session.py +642 -0
- cade_cli-0.8.0/src/cadecoder/voice/stt.py +140 -0
- cade_cli-0.8.0/src/cadecoder/voice/tts.py +186 -0
- cade_cli-0.7.0/src/cade_mcp_local/tools/shell.py +0 -76
- cade_cli-0.7.0/src/cadecoder/__init__.py +0 -1
- {cade_cli-0.7.0 → cade_cli-0.8.0}/.gitignore +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/LICENSE +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/README.md +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/__main__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/config.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/errors.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cade_mcp_local/server.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ai/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/auth.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/auth.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/context.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/mcp.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/model.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/thread.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/cli/commands/tools.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/git.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/logging.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/names.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/core/types.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/execution/parallel.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/anthropic.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/base.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/ollama.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/providers/openai.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/storage/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/storage/threads.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/templates/login_failed.html +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/templates/login_success.html +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/templates/styles.css +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/base.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/tools/manager/config.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.8.0}/src/cadecoder/ui/display.py +0 -0
- {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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
179
|
-
"Filter matches by message role
|
|
180
|
-
] =
|
|
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(
|
|
51
|
+
resolved.relative_to(resolved_root)
|
|
52
52
|
except ValueError:
|
|
53
|
-
|
|
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:
|
|
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.
|
|
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=
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 {
|
|
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
|
-
|
|
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=
|
|
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'
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
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
|
-
"
|
|
235
|
-
]
|
|
236
|
-
new_string: Annotated[
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"
|
|
286
|
-
"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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"
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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 ==
|
|
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"
|
|
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
|
|
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
|
|
130
|
-
since: Annotated[
|
|
131
|
-
|
|
132
|
-
] =
|
|
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
|
-
|
|
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,
|
|
153
|
-
|
|
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,
|
|
158
|
-
|
|
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,
|
|
163
|
-
|
|
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
|
|
200
|
+
path: str,
|
|
203
201
|
staged: bool,
|
|
204
|
-
commit: str
|
|
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
|
|
241
|
-
author: str
|
|
242
|
-
since: str
|
|
243
|
-
until: str
|
|
244
|
-
grep: str
|
|
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
|
|
300
|
+
name: str,
|
|
302
301
|
all_branches: bool,
|
|
303
302
|
force: bool,
|
|
304
303
|
) -> dict[str, Any]:
|