klaude-code 1.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""ApplyPatch tool providing direct patch application capability."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import difflib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from klaude_code.core.tool.file import apply_patch as apply_patch_module
|
|
11
|
+
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
12
|
+
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
13
|
+
from klaude_code.core.tool.tool_registry import register
|
|
14
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApplyPatchHandler:
|
|
18
|
+
@classmethod
|
|
19
|
+
async def handle_apply_patch(cls, patch_text: str) -> model.ToolResultItem:
|
|
20
|
+
try:
|
|
21
|
+
output, diff_text = await asyncio.to_thread(cls._apply_patch_in_thread, patch_text)
|
|
22
|
+
except apply_patch_module.DiffError as error:
|
|
23
|
+
return model.ToolResultItem(status="error", output=str(error))
|
|
24
|
+
except Exception as error: # pragma: no cover # unexpected errors bubbled to tool result
|
|
25
|
+
return model.ToolResultItem(status="error", output=f"Execution error: {error}")
|
|
26
|
+
return model.ToolResultItem(
|
|
27
|
+
status="success",
|
|
28
|
+
output=output,
|
|
29
|
+
ui_extra=model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _apply_patch_in_thread(patch_text: str) -> tuple[str, str]:
|
|
34
|
+
ap = apply_patch_module
|
|
35
|
+
normalized_start = patch_text.lstrip()
|
|
36
|
+
if not normalized_start.startswith("*** Begin Patch"):
|
|
37
|
+
raise ap.DiffError("apply_patch content must start with *** Begin Patch")
|
|
38
|
+
|
|
39
|
+
workspace_root = os.path.realpath(os.getcwd())
|
|
40
|
+
file_tracker = get_current_file_tracker()
|
|
41
|
+
|
|
42
|
+
def resolve_path(path: str) -> str:
|
|
43
|
+
candidate = os.path.realpath(path if os.path.isabs(path) else os.path.join(workspace_root, path))
|
|
44
|
+
if not os.path.isabs(path):
|
|
45
|
+
try:
|
|
46
|
+
common = os.path.commonpath([workspace_root, candidate])
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise ap.DiffError(f"Path escapes workspace: {path}") from None
|
|
49
|
+
if common != workspace_root:
|
|
50
|
+
raise ap.DiffError(f"Path escapes workspace: {path}")
|
|
51
|
+
return candidate
|
|
52
|
+
|
|
53
|
+
orig: dict[str, str] = {}
|
|
54
|
+
for path in ap.identify_files_needed(patch_text):
|
|
55
|
+
resolved = resolve_path(path)
|
|
56
|
+
if not os.path.exists(resolved):
|
|
57
|
+
raise ap.DiffError(f"Missing File: {path}")
|
|
58
|
+
if os.path.isdir(resolved):
|
|
59
|
+
raise ap.DiffError(f"Cannot apply patch to directory: {path}")
|
|
60
|
+
try:
|
|
61
|
+
with open(resolved, "r", encoding="utf-8") as handle:
|
|
62
|
+
orig[path] = handle.read()
|
|
63
|
+
except OSError as error:
|
|
64
|
+
raise ap.DiffError(f"Failed to read {path}: {error}") from error
|
|
65
|
+
|
|
66
|
+
patch, _ = ap.text_to_patch(patch_text, orig)
|
|
67
|
+
commit = ap.patch_to_commit(patch, orig)
|
|
68
|
+
diff_text = ApplyPatchHandler._commit_to_diff(commit)
|
|
69
|
+
|
|
70
|
+
def write_fn(path: str, content: str) -> None:
|
|
71
|
+
resolved = resolve_path(path)
|
|
72
|
+
if os.path.isdir(resolved):
|
|
73
|
+
raise ap.DiffError(f"Cannot overwrite directory: {path}")
|
|
74
|
+
parent = os.path.dirname(resolved)
|
|
75
|
+
if parent:
|
|
76
|
+
os.makedirs(parent, exist_ok=True)
|
|
77
|
+
with open(resolved, "w", encoding="utf-8") as handle:
|
|
78
|
+
handle.write(content)
|
|
79
|
+
|
|
80
|
+
if file_tracker is not None:
|
|
81
|
+
try:
|
|
82
|
+
file_tracker[resolved] = Path(resolved).stat().st_mtime
|
|
83
|
+
except Exception: # pragma: no cover - file tracker best-effort
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def remove_fn(path: str) -> None:
|
|
87
|
+
resolved = resolve_path(path)
|
|
88
|
+
if not os.path.exists(resolved):
|
|
89
|
+
raise ap.DiffError(f"Missing File: {path}")
|
|
90
|
+
if os.path.isdir(resolved):
|
|
91
|
+
raise ap.DiffError(f"Cannot delete directory: {path}")
|
|
92
|
+
os.remove(resolved)
|
|
93
|
+
|
|
94
|
+
if file_tracker is not None:
|
|
95
|
+
try:
|
|
96
|
+
file_tracker.pop(resolved, None)
|
|
97
|
+
except Exception: # pragma: no cover - file tracker best-effort
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
ap.apply_commit(commit, write_fn, remove_fn)
|
|
101
|
+
return "Done!", diff_text
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _commit_to_diff(commit: apply_patch_module.Commit) -> str:
|
|
105
|
+
diff_chunks: list[str] = []
|
|
106
|
+
for path, change in commit.changes.items():
|
|
107
|
+
chunk = ApplyPatchHandler._render_change_diff(path, change)
|
|
108
|
+
if chunk:
|
|
109
|
+
if diff_chunks:
|
|
110
|
+
diff_chunks.append("")
|
|
111
|
+
diff_chunks.extend(chunk)
|
|
112
|
+
return "\n".join(diff_chunks)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _render_change_diff(path: str, change: apply_patch_module.FileChange) -> list[str]:
|
|
116
|
+
lines: list[str] = []
|
|
117
|
+
if change.type == apply_patch_module.ActionType.ADD:
|
|
118
|
+
lines.append(f"diff --git a/{path} b/{path}")
|
|
119
|
+
lines.append("new file mode 100644")
|
|
120
|
+
new_lines = ApplyPatchHandler._split_lines(change.new_content)
|
|
121
|
+
lines.extend(ApplyPatchHandler._unified_diff([], new_lines, fromfile="/dev/null", tofile=f"b/{path}"))
|
|
122
|
+
return lines
|
|
123
|
+
if change.type == apply_patch_module.ActionType.DELETE:
|
|
124
|
+
lines.append(f"diff --git a/{path} b/{path}")
|
|
125
|
+
lines.append("deleted file mode 100644")
|
|
126
|
+
old_lines = ApplyPatchHandler._split_lines(change.old_content)
|
|
127
|
+
lines.extend(ApplyPatchHandler._unified_diff(old_lines, [], fromfile=f"a/{path}", tofile="/dev/null"))
|
|
128
|
+
return lines
|
|
129
|
+
if change.type == apply_patch_module.ActionType.UPDATE:
|
|
130
|
+
new_path = change.move_path or path
|
|
131
|
+
lines.append(f"diff --git a/{path} b/{new_path}")
|
|
132
|
+
if change.move_path and change.move_path != path:
|
|
133
|
+
lines.append(f"rename from {path}")
|
|
134
|
+
lines.append(f"rename to {new_path}")
|
|
135
|
+
old_lines = ApplyPatchHandler._split_lines(change.old_content)
|
|
136
|
+
new_lines = ApplyPatchHandler._split_lines(change.new_content)
|
|
137
|
+
lines.extend(
|
|
138
|
+
ApplyPatchHandler._unified_diff(old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{new_path}")
|
|
139
|
+
)
|
|
140
|
+
return lines
|
|
141
|
+
return lines
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _unified_diff(
|
|
145
|
+
old_lines: list[str],
|
|
146
|
+
new_lines: list[str],
|
|
147
|
+
*,
|
|
148
|
+
fromfile: str,
|
|
149
|
+
tofile: str,
|
|
150
|
+
) -> list[str]:
|
|
151
|
+
diff_lines = list(
|
|
152
|
+
difflib.unified_diff(
|
|
153
|
+
old_lines,
|
|
154
|
+
new_lines,
|
|
155
|
+
fromfile=fromfile,
|
|
156
|
+
tofile=tofile,
|
|
157
|
+
lineterm="",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
if not diff_lines:
|
|
161
|
+
diff_lines = [f"--- {fromfile}", f"+++ {tofile}"]
|
|
162
|
+
return diff_lines
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _split_lines(text: str | None) -> list[str]:
|
|
166
|
+
if not text:
|
|
167
|
+
return []
|
|
168
|
+
return text.splitlines()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@register(tools.APPLY_PATCH)
|
|
172
|
+
class ApplyPatchTool(ToolABC):
|
|
173
|
+
class ApplyPatchArguments(BaseModel):
|
|
174
|
+
patch: str
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
178
|
+
return llm_param.ToolSchema(
|
|
179
|
+
name=tools.APPLY_PATCH,
|
|
180
|
+
type="function",
|
|
181
|
+
description=load_desc(Path(__file__).parent / "apply_patch_tool.md"),
|
|
182
|
+
parameters={
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {
|
|
185
|
+
"patch": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"description": """Patch content""",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
"required": ["patch"],
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
196
|
+
try:
|
|
197
|
+
args = cls.ApplyPatchArguments.model_validate_json(arguments)
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
return model.ToolResultItem(status="error", output=f"Invalid arguments: {exc}")
|
|
200
|
+
return await cls.call_with_args(args)
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
async def call_with_args(cls, args: ApplyPatchArguments) -> model.ToolResultItem:
|
|
204
|
+
return await ApplyPatchHandler.handle_apply_patch(args.patch)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Performs exact string replacements in files.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
|
5
|
+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
|
6
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
7
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
8
|
+
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
|
|
9
|
+
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import difflib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
11
|
+
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
12
|
+
from klaude_code.core.tool.tool_registry import register
|
|
13
|
+
from klaude_code.protocol import llm_param, model, tools
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_directory(path: str) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
return Path(path).is_dir()
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _file_exists(path: str) -> bool:
|
|
24
|
+
try:
|
|
25
|
+
return Path(path).exists()
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_text(path: str) -> str:
|
|
31
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
32
|
+
return f.read()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_text(path: str, content: str) -> None:
|
|
36
|
+
parent = Path(path).parent
|
|
37
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(content)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@register(tools.EDIT)
|
|
43
|
+
class EditTool(ToolABC):
|
|
44
|
+
class EditArguments(BaseModel):
|
|
45
|
+
file_path: str
|
|
46
|
+
old_string: str
|
|
47
|
+
new_string: str
|
|
48
|
+
replace_all: bool = Field(default=False)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def schema(cls) -> llm_param.ToolSchema:
|
|
52
|
+
return llm_param.ToolSchema(
|
|
53
|
+
name=tools.EDIT,
|
|
54
|
+
type="function",
|
|
55
|
+
description=load_desc(Path(__file__).parent / "edit_tool.md"),
|
|
56
|
+
parameters={
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"file_path": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "The absolute path to the file to modify",
|
|
62
|
+
},
|
|
63
|
+
"old_string": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "The text to replace",
|
|
66
|
+
},
|
|
67
|
+
"new_string": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "The text to replace it with (must be different from old_string)",
|
|
70
|
+
},
|
|
71
|
+
"replace_all": {
|
|
72
|
+
"type": "boolean",
|
|
73
|
+
"default": False,
|
|
74
|
+
"description": "Replace all occurences of old_string (default false)",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
78
|
+
"additionalProperties": False,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Validation utility for MultiEdit integration
|
|
83
|
+
@classmethod
|
|
84
|
+
def valid(
|
|
85
|
+
cls, *, content: str, old_string: str, new_string: str, replace_all: bool
|
|
86
|
+
) -> str | None: # returns error message or None
|
|
87
|
+
if old_string == new_string:
|
|
88
|
+
return (
|
|
89
|
+
"<tool_use_error>No changes to make: old_string and new_string are exactly the same.</tool_use_error>"
|
|
90
|
+
)
|
|
91
|
+
count = content.count(old_string)
|
|
92
|
+
if count == 0:
|
|
93
|
+
return f"<tool_use_error>String to replace not found in file.\nString: {old_string}</tool_use_error>"
|
|
94
|
+
if not replace_all and count > 1:
|
|
95
|
+
return (
|
|
96
|
+
f"<tool_use_error>Found {count} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\n"
|
|
97
|
+
f"String: {old_string}</tool_use_error>"
|
|
98
|
+
)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# Execute utility for MultiEdit integration
|
|
102
|
+
@classmethod
|
|
103
|
+
def execute(cls, *, content: str, old_string: str, new_string: str, replace_all: bool) -> str:
|
|
104
|
+
if old_string == "":
|
|
105
|
+
# Creating new file content
|
|
106
|
+
return new_string
|
|
107
|
+
if replace_all:
|
|
108
|
+
return content.replace(old_string, new_string)
|
|
109
|
+
# Replace one occurrence only (we already ensured uniqueness)
|
|
110
|
+
return content.replace(old_string, new_string, 1)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
114
|
+
try:
|
|
115
|
+
args = EditTool.EditArguments.model_validate_json(arguments)
|
|
116
|
+
except Exception as e: # pragma: no cover - defensive
|
|
117
|
+
return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
|
|
118
|
+
|
|
119
|
+
file_path = os.path.abspath(args.file_path)
|
|
120
|
+
|
|
121
|
+
# Common file errors
|
|
122
|
+
if _is_directory(file_path):
|
|
123
|
+
return model.ToolResultItem(
|
|
124
|
+
status="error",
|
|
125
|
+
output="<tool_use_error>Illegal operation on a directory. edit</tool_use_error>",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if args.old_string == "":
|
|
129
|
+
return model.ToolResultItem(
|
|
130
|
+
status="error",
|
|
131
|
+
output=(
|
|
132
|
+
"<tool_use_error>old_string must not be empty for Edit. "
|
|
133
|
+
"To create or overwrite a file, use the Write tool instead.</tool_use_error>"
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# FileTracker checks (only for editing existing files)
|
|
138
|
+
file_tracker = get_current_file_tracker()
|
|
139
|
+
if not _file_exists(file_path):
|
|
140
|
+
# We require reading before editing
|
|
141
|
+
return model.ToolResultItem(
|
|
142
|
+
status="error",
|
|
143
|
+
output=("File has not been read yet. Read it first before writing to it."),
|
|
144
|
+
)
|
|
145
|
+
if file_tracker is not None:
|
|
146
|
+
tracked = file_tracker.get(file_path)
|
|
147
|
+
if tracked is None:
|
|
148
|
+
return model.ToolResultItem(
|
|
149
|
+
status="error",
|
|
150
|
+
output=("File has not been read yet. Read it first before writing to it."),
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
154
|
+
except Exception:
|
|
155
|
+
current_mtime = tracked
|
|
156
|
+
if current_mtime != tracked:
|
|
157
|
+
return model.ToolResultItem(
|
|
158
|
+
status="error",
|
|
159
|
+
output=(
|
|
160
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Edit existing file: validate and apply
|
|
165
|
+
try:
|
|
166
|
+
before = await asyncio.to_thread(_read_text, file_path)
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
return model.ToolResultItem(
|
|
169
|
+
status="error",
|
|
170
|
+
output="File has not been read yet. Read it first before writing to it.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
err = cls.valid(
|
|
174
|
+
content=before,
|
|
175
|
+
old_string=args.old_string,
|
|
176
|
+
new_string=args.new_string,
|
|
177
|
+
replace_all=args.replace_all,
|
|
178
|
+
)
|
|
179
|
+
if err is not None:
|
|
180
|
+
return model.ToolResultItem(status="error", output=err)
|
|
181
|
+
|
|
182
|
+
after = cls.execute(
|
|
183
|
+
content=before,
|
|
184
|
+
old_string=args.old_string,
|
|
185
|
+
new_string=args.new_string,
|
|
186
|
+
replace_all=args.replace_all,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# If nothing changed due to replacement semantics (should not happen after valid), guard anyway
|
|
190
|
+
if before == after:
|
|
191
|
+
return model.ToolResultItem(
|
|
192
|
+
status="error",
|
|
193
|
+
output=(
|
|
194
|
+
"<tool_use_error>No changes to make: old_string and new_string are exactly the same.</tool_use_error>"
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Write back
|
|
199
|
+
try:
|
|
200
|
+
await asyncio.to_thread(_write_text, file_path, after)
|
|
201
|
+
except Exception as e: # pragma: no cover
|
|
202
|
+
return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
|
|
203
|
+
|
|
204
|
+
# Prepare UI extra: unified diff with 3 context lines
|
|
205
|
+
diff_lines = list(
|
|
206
|
+
difflib.unified_diff(
|
|
207
|
+
before.splitlines(),
|
|
208
|
+
after.splitlines(),
|
|
209
|
+
fromfile=file_path,
|
|
210
|
+
tofile=file_path,
|
|
211
|
+
n=3,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
diff_text = "\n".join(diff_lines)
|
|
215
|
+
ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
|
|
216
|
+
|
|
217
|
+
# Update tracker with new mtime
|
|
218
|
+
if file_tracker is not None:
|
|
219
|
+
try:
|
|
220
|
+
file_tracker[file_path] = Path(file_path).stat().st_mtime
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# Build output message
|
|
225
|
+
if args.replace_all:
|
|
226
|
+
msg = f"The file {file_path} has been updated. All occurrences of '{args.old_string}' were successfully replaced with '{args.new_string}'."
|
|
227
|
+
return model.ToolResultItem(status="success", output=msg, ui_extra=ui_extra)
|
|
228
|
+
|
|
229
|
+
# For single replacement, show a snippet consisting of context + added lines only
|
|
230
|
+
# Parse the diff to collect target line numbers in the 'after' file
|
|
231
|
+
include_after_line_nos: list[int] = []
|
|
232
|
+
after_line_no = 0
|
|
233
|
+
for line in diff_lines:
|
|
234
|
+
if line.startswith("@@"):
|
|
235
|
+
# Parse header: @@ -l,s +l,s @@
|
|
236
|
+
# Extract the +l,s part
|
|
237
|
+
try:
|
|
238
|
+
header = line
|
|
239
|
+
plus = header.split("+", 1)[1]
|
|
240
|
+
plus_range = plus.split(" ")[0]
|
|
241
|
+
if "," in plus_range:
|
|
242
|
+
start = int(plus_range.split(",")[0])
|
|
243
|
+
else:
|
|
244
|
+
start = int(plus_range)
|
|
245
|
+
after_line_no = start - 1
|
|
246
|
+
except Exception:
|
|
247
|
+
after_line_no = 0
|
|
248
|
+
continue
|
|
249
|
+
if line.startswith(" "):
|
|
250
|
+
after_line_no += 1
|
|
251
|
+
include_after_line_nos.append(after_line_no)
|
|
252
|
+
elif line.startswith("+") and not line.startswith("+++ "):
|
|
253
|
+
after_line_no += 1
|
|
254
|
+
include_after_line_nos.append(after_line_no)
|
|
255
|
+
elif line.startswith("-") and not line.startswith("--- "):
|
|
256
|
+
# Removed line does not advance after_line_no
|
|
257
|
+
continue
|
|
258
|
+
else:
|
|
259
|
+
# file header lines etc.
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
# Build numbered snippet from the new content
|
|
263
|
+
snippet_lines: list[str] = []
|
|
264
|
+
after_lines = after.splitlines()
|
|
265
|
+
for no in include_after_line_nos:
|
|
266
|
+
if 1 <= no <= len(after_lines):
|
|
267
|
+
snippet_lines.append(f"{no:>6}→{after_lines[no - 1]}")
|
|
268
|
+
|
|
269
|
+
snippet = "\n".join(snippet_lines)
|
|
270
|
+
output = (
|
|
271
|
+
f"The file {file_path} has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n"
|
|
272
|
+
f"{snippet}"
|
|
273
|
+
)
|
|
274
|
+
return model.ToolResultItem(status="success", output=output, ui_extra=ui_extra)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
|
|
2
|
+
|
|
3
|
+
Before using this tool:
|
|
4
|
+
|
|
5
|
+
1. Use the Read tool to understand the file's contents and context
|
|
6
|
+
2. Verify the directory path is correct
|
|
7
|
+
|
|
8
|
+
To make multiple file edits, provide the following:
|
|
9
|
+
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
|
10
|
+
2. edits: An array of edit operations to perform, where each edit contains:
|
|
11
|
+
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
|
12
|
+
- new_string: The edited text to replace the old_string
|
|
13
|
+
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
|
|
14
|
+
|
|
15
|
+
IMPORTANT:
|
|
16
|
+
- All edits are applied in sequence, in the order they are provided
|
|
17
|
+
- Each edit operates on the result of the previous edit
|
|
18
|
+
- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
|
|
19
|
+
- This tool is ideal when you need to make several changes to different parts of the same file
|
|
20
|
+
- For Jupyter notebooks (.ipynb files), use the NotebookEdit instead
|
|
21
|
+
|
|
22
|
+
CRITICAL REQUIREMENTS:
|
|
23
|
+
1. All edits follow the same requirements as the single Edit tool
|
|
24
|
+
2. The edits are atomic - either all succeed or none are applied
|
|
25
|
+
3. Plan your edits carefully to avoid conflicts between sequential operations
|
|
26
|
+
|
|
27
|
+
WARNING:
|
|
28
|
+
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
|
|
29
|
+
- The tool will fail if edits.old_string and edits.new_string are the same
|
|
30
|
+
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
|
|
31
|
+
|
|
32
|
+
When making edits:
|
|
33
|
+
- Ensure all edits result in idiomatic, correct code
|
|
34
|
+
- Do not leave the code in a broken state
|
|
35
|
+
- Always use absolute file paths (starting with /)
|
|
36
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
37
|
+
- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
|
38
|
+
|
|
39
|
+
If you want to create a new file, use:
|
|
40
|
+
- A new file path, including dir name if needed
|
|
41
|
+
- First edit: empty old_string and the new file's contents as new_string
|
|
42
|
+
- Subsequent edits: normal edit operations on the created content
|