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.
- {cade_cli-0.7.0 → cade_cli-0.9.0}/PKG-INFO +9 -1
- {cade_cli-0.7.0 → cade_cli-0.9.0}/pyproject.toml +14 -1
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/__init__.py +1 -1
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/server.py +4 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/__init__.py +7 -2
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/context.py +3 -3
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/filesystem.py +165 -91
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/git.py +36 -37
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/search.py +38 -30
- cade_cli-0.9.0/src/cade_mcp_local/tools/shell.py +157 -0
- cade_cli-0.9.0/src/cade_mcp_local/tools/snippets.py +109 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_results.py +23 -10
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_schemas.py +1 -3
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/utils.py +32 -9
- cade_cli-0.9.0/src/cadecoder/__init__.py +1 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ai/prompts.py +102 -3
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/app.py +41 -5
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/chat.py +53 -1
- cade_cli-0.9.0/src/cadecoder/cli/commands/persona.py +478 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/config.py +54 -3
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/constants.py +11 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/errors.py +6 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/context_window.py +5 -2
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/orchestrator.py +79 -42
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_result_store.py +190 -64
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_schema_cache.py +5 -4
- cade_cli-0.9.0/src/cadecoder/storage/personas.py +403 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/composite.py +81 -2
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/mcp.py +83 -21
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager.py +1 -1
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ui/display.py +36 -20
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ui/session.py +133 -12
- cade_cli-0.9.0/src/cadecoder/voice/__init__.py +30 -0
- cade_cli-0.9.0/src/cadecoder/voice/audio.py +583 -0
- cade_cli-0.9.0/src/cadecoder/voice/cleanup.py +172 -0
- cade_cli-0.9.0/src/cadecoder/voice/session.py +666 -0
- cade_cli-0.9.0/src/cadecoder/voice/stt.py +140 -0
- cade_cli-0.9.0/src/cadecoder/voice/tts.py +219 -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.9.0}/.gitignore +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/LICENSE +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/README.md +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/__main__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/config.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cade_mcp_local/errors.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ai/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/auth.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/auth.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/context.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/mcp.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/model.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/thread.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/tools.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/git.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/logging.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/names.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/core/types.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/execution/parallel.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/anthropic.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/base.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/ollama.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/providers/openai.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/storage/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/storage/threads.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_failed.html +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_success.html +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/templates/styles.css +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/__init__.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/base.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/config.py +0 -0
- {cade_cli-0.7.0 → cade_cli-0.9.0}/src/cadecoder/ui/__init__.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
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,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=
|
|
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
|
+
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 {
|
|
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
|
-
|
|
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=
|
|
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'
|
|
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
|
-
|
|
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"
|
|
280
|
+
# ===========================================================================
|
|
281
|
+
# Edit (find-and-replace)
|
|
282
|
+
# ===========================================================================
|
|
217
283
|
|
|
218
284
|
|
|
219
285
|
@tool(
|
|
220
286
|
name="Edit",
|
|
221
287
|
desc=(
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"
|
|
286
|
-
"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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"
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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 ==
|
|
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"
|
|
431
|
+
"message": f"Inserted content at line {line_number} in {file_path_arg}",
|
|
358
432
|
"diff": diff,
|
|
359
433
|
}
|