klaude-code 1.2.6__py3-none-any.whl → 1.8.0__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/auth/__init__.py +24 -0
- klaude_code/auth/codex/__init__.py +20 -0
- klaude_code/auth/codex/exceptions.py +17 -0
- klaude_code/auth/codex/jwt_utils.py +45 -0
- klaude_code/auth/codex/oauth.py +229 -0
- klaude_code/auth/codex/token_manager.py +84 -0
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +91 -0
- klaude_code/cli/cost_cmd.py +338 -0
- klaude_code/cli/debug.py +78 -0
- klaude_code/cli/list_model.py +307 -0
- klaude_code/cli/main.py +233 -134
- klaude_code/cli/runtime.py +309 -117
- klaude_code/{version.py → cli/self_update.py} +114 -5
- klaude_code/cli/session_cmd.py +37 -21
- klaude_code/command/__init__.py +88 -27
- klaude_code/command/clear_cmd.py +8 -7
- klaude_code/command/command_abc.py +31 -31
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/export_cmd.py +19 -53
- klaude_code/command/export_online_cmd.py +154 -0
- klaude_code/command/fork_session_cmd.py +267 -0
- klaude_code/command/help_cmd.py +7 -8
- klaude_code/command/model_cmd.py +60 -10
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/prompt_command.py +19 -11
- klaude_code/command/refresh_cmd.py +8 -10
- klaude_code/command/registry.py +139 -40
- klaude_code/command/release_notes_cmd.py +84 -0
- klaude_code/command/resume_cmd.py +111 -0
- klaude_code/command/status_cmd.py +104 -60
- klaude_code/command/terminal_setup_cmd.py +7 -9
- klaude_code/command/thinking_cmd.py +98 -0
- klaude_code/config/__init__.py +14 -6
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +303 -0
- klaude_code/config/builtin_config.py +38 -0
- klaude_code/config/config.py +378 -109
- klaude_code/config/select_model.py +117 -53
- klaude_code/config/thinking.py +269 -0
- klaude_code/{const/__init__.py → const.py} +50 -19
- klaude_code/core/agent.py +20 -28
- klaude_code/core/executor.py +327 -112
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/llm_clients.py +1 -15
- klaude_code/core/manager/llm_clients_builder.py +10 -11
- klaude_code/core/manager/sub_agent_manager.py +37 -6
- klaude_code/core/prompt.py +63 -44
- klaude_code/core/prompts/prompt-claude-code.md +2 -13
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/prompt-codex.md +9 -42
- klaude_code/core/prompts/prompt-minimal.md +12 -0
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +283 -95
- klaude_code/core/task.py +113 -75
- klaude_code/core/tool/__init__.py +24 -31
- klaude_code/core/tool/file/_utils.py +36 -0
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +57 -77
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +50 -63
- klaude_code/core/tool/file/move_tool.md +41 -0
- klaude_code/core/tool/file/move_tool.py +435 -0
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +86 -86
- klaude_code/core/tool/file/write_tool.py +59 -69
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/shell/bash_tool.py +265 -22
- klaude_code/core/tool/shell/command_safety.py +3 -6
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
- klaude_code/core/tool/sub_agent_tool.py +13 -2
- klaude_code/core/tool/todo/todo_write_tool.md +0 -157
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +27 -12
- klaude_code/core/tool/tool_registry.py +7 -7
- klaude_code/core/tool/tool_runner.py +44 -36
- klaude_code/core/tool/truncation.py +29 -14
- klaude_code/core/tool/web/mermaid_tool.md +43 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -5
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +112 -22
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +130 -0
- klaude_code/core/turn.py +168 -66
- klaude_code/llm/__init__.py +2 -10
- klaude_code/llm/anthropic/client.py +190 -178
- klaude_code/llm/anthropic/input.py +39 -15
- klaude_code/llm/bedrock/__init__.py +3 -0
- klaude_code/llm/bedrock/client.py +60 -0
- klaude_code/llm/client.py +7 -21
- klaude_code/llm/codex/__init__.py +5 -0
- klaude_code/llm/codex/client.py +149 -0
- klaude_code/llm/google/__init__.py +3 -0
- klaude_code/llm/google/client.py +309 -0
- klaude_code/llm/google/input.py +215 -0
- klaude_code/llm/input_common.py +3 -9
- klaude_code/llm/openai_compatible/client.py +72 -164
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream.py +273 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/llm/openrouter/client.py +89 -160
- klaude_code/llm/openrouter/input.py +18 -30
- klaude_code/llm/openrouter/reasoning.py +118 -0
- klaude_code/llm/registry.py +39 -7
- klaude_code/llm/responses/client.py +184 -171
- klaude_code/llm/responses/input.py +20 -1
- klaude_code/llm/usage.py +17 -12
- klaude_code/protocol/commands.py +17 -1
- klaude_code/protocol/events.py +31 -4
- klaude_code/protocol/llm_param.py +13 -10
- klaude_code/protocol/model.py +232 -29
- klaude_code/protocol/op.py +90 -1
- klaude_code/protocol/op_handler.py +35 -1
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +4 -2
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +293 -86
- klaude_code/session/selector.py +89 -67
- klaude_code/session/session.py +320 -309
- klaude_code/session/store.py +220 -0
- klaude_code/session/templates/export_session.html +595 -83
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/trace/__init__.py +20 -2
- klaude_code/trace/log.py +150 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +7 -7
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +3 -48
- klaude_code/ui/modes/repl/clipboard.py +5 -5
- klaude_code/ui/modes/repl/completers.py +487 -123
- klaude_code/ui/modes/repl/display.py +5 -4
- klaude_code/ui/modes/repl/event_handler.py +370 -117
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
- klaude_code/ui/modes/repl/key_bindings.py +146 -23
- klaude_code/ui/modes/repl/renderer.py +189 -99
- klaude_code/ui/renderers/assistant.py +9 -2
- klaude_code/ui/renderers/bash_syntax.py +178 -0
- klaude_code/ui/renderers/common.py +78 -0
- klaude_code/ui/renderers/developer.py +104 -48
- klaude_code/ui/renderers/diffs.py +87 -6
- klaude_code/ui/renderers/errors.py +11 -6
- klaude_code/ui/renderers/mermaid_viewer.py +57 -0
- klaude_code/ui/renderers/metadata.py +112 -76
- klaude_code/ui/renderers/sub_agent.py +92 -7
- klaude_code/ui/renderers/thinking.py +40 -18
- klaude_code/ui/renderers/tools.py +405 -227
- klaude_code/ui/renderers/user_input.py +73 -13
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/code_panel.py +131 -0
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +305 -170
- klaude_code/ui/rich/searchable_text.py +10 -13
- klaude_code/ui/rich/status.py +190 -49
- klaude_code/ui/rich/theme.py +135 -39
- klaude_code/ui/terminal/__init__.py +55 -0
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +13 -22
- klaude_code/ui/terminal/notifier.py +44 -4
- klaude_code/ui/terminal/selector.py +658 -0
- klaude_code/ui/utils/common.py +0 -18
- klaude_code-1.8.0.dist-info/METADATA +377 -0
- klaude_code-1.8.0.dist-info/RECORD +219 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
- klaude_code/command/diff_cmd.py +0 -138
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/config/list_model.py +0 -162
- klaude_code/core/manager/agent_manager.py +0 -127
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -199
- klaude_code/core/tool/memory/memory_tool.md +0 -16
- klaude_code/core/tool/memory/memory_tool.py +0 -462
- klaude_code/llm/openrouter/reasoning_handler.py +0 -209
- klaude_code/protocol/sub_agent.py +0 -348
- klaude_code/ui/utils/debouncer.py +0 -42
- klaude_code-1.2.6.dist-info/METADATA +0 -178
- klaude_code-1.2.6.dist-info/RECORD +0 -167
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""ApplyPatch tool providing direct patch application capability."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
4
|
+
import contextlib
|
|
5
5
|
import os
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
10
|
from klaude_code.core.tool.file import apply_patch as apply_patch_module
|
|
11
|
+
from klaude_code.core.tool.file._utils import hash_text_sha256
|
|
12
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_file_diff
|
|
11
13
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
12
14
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
13
15
|
from klaude_code.core.tool.tool_registry import register
|
|
@@ -18,7 +20,7 @@ class ApplyPatchHandler:
|
|
|
18
20
|
@classmethod
|
|
19
21
|
async def handle_apply_patch(cls, patch_text: str) -> model.ToolResultItem:
|
|
20
22
|
try:
|
|
21
|
-
output,
|
|
23
|
+
output, ui_extra = await asyncio.to_thread(cls._apply_patch_in_thread, patch_text)
|
|
22
24
|
except apply_patch_module.DiffError as error:
|
|
23
25
|
return model.ToolResultItem(status="error", output=str(error))
|
|
24
26
|
except Exception as error: # pragma: no cover # unexpected errors bubbled to tool result
|
|
@@ -26,11 +28,11 @@ class ApplyPatchHandler:
|
|
|
26
28
|
return model.ToolResultItem(
|
|
27
29
|
status="success",
|
|
28
30
|
output=output,
|
|
29
|
-
ui_extra=
|
|
31
|
+
ui_extra=ui_extra,
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
@staticmethod
|
|
33
|
-
def _apply_patch_in_thread(patch_text: str) -> tuple[str,
|
|
35
|
+
def _apply_patch_in_thread(patch_text: str) -> tuple[str, model.ToolResultUIExtra]:
|
|
34
36
|
ap = apply_patch_module
|
|
35
37
|
normalized_start = patch_text.lstrip()
|
|
36
38
|
if not normalized_start.startswith("*** Begin Patch"):
|
|
@@ -58,14 +60,24 @@ class ApplyPatchHandler:
|
|
|
58
60
|
if os.path.isdir(resolved):
|
|
59
61
|
raise ap.DiffError(f"Cannot apply patch to directory: {path}")
|
|
60
62
|
try:
|
|
61
|
-
with open(resolved,
|
|
63
|
+
with open(resolved, encoding="utf-8") as handle:
|
|
62
64
|
orig[path] = handle.read()
|
|
63
65
|
except OSError as error:
|
|
64
66
|
raise ap.DiffError(f"Failed to read {path}: {error}") from error
|
|
65
67
|
|
|
66
68
|
patch, _ = ap.text_to_patch(patch_text, orig)
|
|
67
69
|
commit = ap.patch_to_commit(patch, orig)
|
|
68
|
-
|
|
70
|
+
diff_ui = ApplyPatchHandler._commit_to_structured_diff(commit)
|
|
71
|
+
|
|
72
|
+
md_items: list[model.MarkdownDocUIExtra] = []
|
|
73
|
+
for change_path, change in commit.changes.items():
|
|
74
|
+
if change.type == apply_patch_module.ActionType.ADD and change_path.endswith(".md"):
|
|
75
|
+
md_items.append(
|
|
76
|
+
model.MarkdownDocUIExtra(
|
|
77
|
+
file_path=resolve_path(change_path),
|
|
78
|
+
content=change.new_content or "",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
69
81
|
|
|
70
82
|
def write_fn(path: str, content: str) -> None:
|
|
71
83
|
resolved = resolve_path(path)
|
|
@@ -78,10 +90,14 @@ class ApplyPatchHandler:
|
|
|
78
90
|
handle.write(content)
|
|
79
91
|
|
|
80
92
|
if file_tracker is not None:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
|
|
94
|
+
existing = file_tracker.get(resolved)
|
|
95
|
+
is_mem = existing.is_memory if existing else False
|
|
96
|
+
file_tracker[resolved] = model.FileStatus(
|
|
97
|
+
mtime=Path(resolved).stat().st_mtime,
|
|
98
|
+
content_sha256=hash_text_sha256(content),
|
|
99
|
+
is_memory=is_mem,
|
|
100
|
+
)
|
|
85
101
|
|
|
86
102
|
def remove_fn(path: str) -> None:
|
|
87
103
|
resolved = resolve_path(path)
|
|
@@ -92,80 +108,44 @@ class ApplyPatchHandler:
|
|
|
92
108
|
os.remove(resolved)
|
|
93
109
|
|
|
94
110
|
if file_tracker is not None:
|
|
95
|
-
|
|
111
|
+
with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
|
|
96
112
|
file_tracker.pop(resolved, None)
|
|
97
|
-
except Exception: # pragma: no cover - file tracker best-effort
|
|
98
|
-
pass
|
|
99
113
|
|
|
100
114
|
ap.apply_commit(commit, write_fn, remove_fn)
|
|
101
|
-
return "Done!", diff_text
|
|
102
115
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
diff_chunks.extend(chunk)
|
|
112
|
-
return "\n".join(diff_chunks)
|
|
116
|
+
# apply_patch can include multiple operations. If we added markdown files,
|
|
117
|
+
# return a MultiUIExtra so UI can render markdown previews (without showing a diff for those markdown adds).
|
|
118
|
+
if md_items:
|
|
119
|
+
items: list[model.MultiUIExtraItem] = []
|
|
120
|
+
items.extend(md_items)
|
|
121
|
+
if diff_ui.files:
|
|
122
|
+
items.append(diff_ui)
|
|
123
|
+
return "Done!", model.MultiUIExtra(items=items)
|
|
113
124
|
|
|
114
|
-
|
|
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
|
|
125
|
+
return "Done!", diff_ui
|
|
163
126
|
|
|
164
127
|
@staticmethod
|
|
165
|
-
def
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
128
|
+
def _commit_to_structured_diff(commit: apply_patch_module.Commit) -> model.DiffUIExtra:
|
|
129
|
+
files: list[model.DiffFileDiff] = []
|
|
130
|
+
for path in sorted(commit.changes):
|
|
131
|
+
change = commit.changes[path]
|
|
132
|
+
if change.type == apply_patch_module.ActionType.ADD:
|
|
133
|
+
# For markdown files created via Add File, we render content via MarkdownDocUIExtra instead of a diff.
|
|
134
|
+
if path.endswith(".md"):
|
|
135
|
+
continue
|
|
136
|
+
files.append(build_structured_file_diff("", change.new_content or "", file_path=path))
|
|
137
|
+
elif change.type == apply_patch_module.ActionType.DELETE:
|
|
138
|
+
files.append(build_structured_file_diff(change.old_content or "", "", file_path=path))
|
|
139
|
+
elif change.type == apply_patch_module.ActionType.UPDATE:
|
|
140
|
+
display_path = path
|
|
141
|
+
if change.move_path and change.move_path != path:
|
|
142
|
+
display_path = f"{path} → {change.move_path}"
|
|
143
|
+
files.append(
|
|
144
|
+
build_structured_file_diff(
|
|
145
|
+
change.old_content or "", change.new_content or "", file_path=display_path
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return model.DiffUIExtra(files=files)
|
|
169
149
|
|
|
170
150
|
|
|
171
151
|
@register(tools.APPLY_PATCH)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from diff_match_patch import diff_match_patch # type: ignore[import-untyped]
|
|
7
|
+
|
|
8
|
+
from klaude_code.protocol import model
|
|
9
|
+
|
|
10
|
+
_MAX_LINE_LENGTH_FOR_CHAR_DIFF = 2000
|
|
11
|
+
_DEFAULT_CONTEXT_LINES = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_structured_diff(before: str, after: str, *, file_path: str) -> model.DiffUIExtra:
|
|
15
|
+
"""Build a structured diff with char-level spans for a single file."""
|
|
16
|
+
file_diff = _build_file_diff(before, after, file_path=file_path)
|
|
17
|
+
return model.DiffUIExtra(files=[file_diff])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_structured_file_diff(before: str, after: str, *, file_path: str) -> model.DiffFileDiff:
|
|
21
|
+
"""Build a structured diff for a single file."""
|
|
22
|
+
return _build_file_diff(before, after, file_path=file_path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_file_diff(before: str, after: str, *, file_path: str) -> model.DiffFileDiff:
|
|
26
|
+
before_lines = _split_lines(before)
|
|
27
|
+
after_lines = _split_lines(after)
|
|
28
|
+
|
|
29
|
+
matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
|
|
30
|
+
lines: list[model.DiffLine] = []
|
|
31
|
+
stats_add = 0
|
|
32
|
+
stats_remove = 0
|
|
33
|
+
|
|
34
|
+
grouped_opcodes = matcher.get_grouped_opcodes(n=_DEFAULT_CONTEXT_LINES)
|
|
35
|
+
for group_idx, group in enumerate(grouped_opcodes):
|
|
36
|
+
if group_idx > 0:
|
|
37
|
+
lines.append(_gap_line())
|
|
38
|
+
|
|
39
|
+
# Anchor line numbers to the actual start of the displayed hunk in the "after" file.
|
|
40
|
+
new_line_no = group[0][3] + 1
|
|
41
|
+
|
|
42
|
+
for tag, i1, i2, j1, j2 in group:
|
|
43
|
+
if tag == "equal":
|
|
44
|
+
for line in after_lines[j1:j2]:
|
|
45
|
+
lines.append(_ctx_line(line, new_line_no))
|
|
46
|
+
new_line_no += 1
|
|
47
|
+
elif tag == "delete":
|
|
48
|
+
for line in before_lines[i1:i2]:
|
|
49
|
+
lines.append(_remove_line([model.DiffSpan(op="equal", text=line)]))
|
|
50
|
+
stats_remove += 1
|
|
51
|
+
elif tag == "insert":
|
|
52
|
+
for line in after_lines[j1:j2]:
|
|
53
|
+
lines.append(_add_line([model.DiffSpan(op="equal", text=line)], new_line_no))
|
|
54
|
+
stats_add += 1
|
|
55
|
+
new_line_no += 1
|
|
56
|
+
elif tag == "replace":
|
|
57
|
+
old_block = before_lines[i1:i2]
|
|
58
|
+
new_block = after_lines[j1:j2]
|
|
59
|
+
max_len = max(len(old_block), len(new_block))
|
|
60
|
+
for idx in range(max_len):
|
|
61
|
+
old_line = old_block[idx] if idx < len(old_block) else None
|
|
62
|
+
new_line = new_block[idx] if idx < len(new_block) else None
|
|
63
|
+
if old_line is not None and new_line is not None:
|
|
64
|
+
remove_spans, add_spans = _diff_line_spans(old_line, new_line)
|
|
65
|
+
lines.append(_remove_line(remove_spans))
|
|
66
|
+
lines.append(_add_line(add_spans, new_line_no))
|
|
67
|
+
stats_remove += 1
|
|
68
|
+
stats_add += 1
|
|
69
|
+
new_line_no += 1
|
|
70
|
+
elif old_line is not None:
|
|
71
|
+
lines.append(_remove_line([model.DiffSpan(op="equal", text=old_line)]))
|
|
72
|
+
stats_remove += 1
|
|
73
|
+
elif new_line is not None:
|
|
74
|
+
lines.append(_add_line([model.DiffSpan(op="equal", text=new_line)], new_line_no))
|
|
75
|
+
stats_add += 1
|
|
76
|
+
new_line_no += 1
|
|
77
|
+
|
|
78
|
+
return model.DiffFileDiff(
|
|
79
|
+
file_path=file_path,
|
|
80
|
+
lines=lines,
|
|
81
|
+
stats_add=stats_add,
|
|
82
|
+
stats_remove=stats_remove,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _split_lines(text: str) -> list[str]:
|
|
87
|
+
if not text:
|
|
88
|
+
return []
|
|
89
|
+
return text.splitlines()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ctx_line(text: str, new_line_no: int) -> model.DiffLine:
|
|
93
|
+
return model.DiffLine(
|
|
94
|
+
kind="ctx",
|
|
95
|
+
new_line_no=new_line_no,
|
|
96
|
+
spans=[model.DiffSpan(op="equal", text=text)],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _gap_line() -> model.DiffLine:
|
|
101
|
+
return model.DiffLine(
|
|
102
|
+
kind="gap",
|
|
103
|
+
new_line_no=None,
|
|
104
|
+
spans=[model.DiffSpan(op="equal", text="")],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _add_line(spans: list[model.DiffSpan], new_line_no: int) -> model.DiffLine:
|
|
109
|
+
return model.DiffLine(kind="add", new_line_no=new_line_no, spans=_ensure_spans(spans))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _remove_line(spans: list[model.DiffSpan]) -> model.DiffLine:
|
|
113
|
+
return model.DiffLine(kind="remove", new_line_no=None, spans=_ensure_spans(spans))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_spans(spans: list[model.DiffSpan]) -> list[model.DiffSpan]:
|
|
117
|
+
if spans:
|
|
118
|
+
return spans
|
|
119
|
+
return [model.DiffSpan(op="equal", text="")]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _diff_line_spans(old_line: str, new_line: str) -> tuple[list[model.DiffSpan], list[model.DiffSpan]]:
|
|
123
|
+
if not _should_char_diff(old_line, new_line):
|
|
124
|
+
return (
|
|
125
|
+
[model.DiffSpan(op="equal", text=old_line)],
|
|
126
|
+
[model.DiffSpan(op="equal", text=new_line)],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
differ = diff_match_patch()
|
|
130
|
+
diffs = cast(list[tuple[int, str]], differ.diff_main(old_line, new_line)) # type: ignore[no-untyped-call]
|
|
131
|
+
differ.diff_cleanupSemantic(diffs) # type: ignore[no-untyped-call]
|
|
132
|
+
|
|
133
|
+
remove_spans: list[model.DiffSpan] = []
|
|
134
|
+
add_spans: list[model.DiffSpan] = []
|
|
135
|
+
|
|
136
|
+
for op, text in diffs:
|
|
137
|
+
if not text:
|
|
138
|
+
continue
|
|
139
|
+
if op == diff_match_patch.DIFF_EQUAL: # type: ignore[no-untyped-call]
|
|
140
|
+
remove_spans.append(model.DiffSpan(op="equal", text=text))
|
|
141
|
+
add_spans.append(model.DiffSpan(op="equal", text=text))
|
|
142
|
+
elif op == diff_match_patch.DIFF_DELETE: # type: ignore[no-untyped-call]
|
|
143
|
+
remove_spans.append(model.DiffSpan(op="delete", text=text))
|
|
144
|
+
elif op == diff_match_patch.DIFF_INSERT: # type: ignore[no-untyped-call]
|
|
145
|
+
add_spans.append(model.DiffSpan(op="insert", text=text))
|
|
146
|
+
|
|
147
|
+
return _ensure_spans(remove_spans), _ensure_spans(add_spans)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _should_char_diff(old_line: str, new_line: str) -> bool:
|
|
151
|
+
return len(old_line) <= _MAX_LINE_LENGTH_FOR_CHAR_DIFF and len(new_line) <= _MAX_LINE_LENGTH_FOR_CHAR_DIFF
|
|
@@ -1,44 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import difflib
|
|
5
6
|
import os
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from pydantic import BaseModel, Field
|
|
9
10
|
|
|
11
|
+
from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
|
|
12
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_diff
|
|
10
13
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
11
14
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
12
15
|
from klaude_code.core.tool.tool_registry import register
|
|
13
16
|
from klaude_code.protocol import llm_param, model, tools
|
|
14
17
|
|
|
15
18
|
|
|
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
19
|
@register(tools.EDIT)
|
|
43
20
|
class EditTool(ToolABC):
|
|
44
21
|
class EditArguments(BaseModel):
|
|
@@ -79,7 +56,6 @@ class EditTool(ToolABC):
|
|
|
79
56
|
},
|
|
80
57
|
)
|
|
81
58
|
|
|
82
|
-
# Validation utility for MultiEdit integration
|
|
83
59
|
@classmethod
|
|
84
60
|
def valid(
|
|
85
61
|
cls, *, content: str, old_string: str, new_string: str, replace_all: bool
|
|
@@ -98,7 +74,6 @@ class EditTool(ToolABC):
|
|
|
98
74
|
)
|
|
99
75
|
return None
|
|
100
76
|
|
|
101
|
-
# Execute utility for MultiEdit integration
|
|
102
77
|
@classmethod
|
|
103
78
|
def execute(cls, *, content: str, old_string: str, new_string: str, replace_all: bool) -> str:
|
|
104
79
|
if old_string == "":
|
|
@@ -113,13 +88,13 @@ class EditTool(ToolABC):
|
|
|
113
88
|
async def call(cls, arguments: str) -> model.ToolResultItem:
|
|
114
89
|
try:
|
|
115
90
|
args = EditTool.EditArguments.model_validate_json(arguments)
|
|
116
|
-
except
|
|
91
|
+
except ValueError as e: # pragma: no cover - defensive
|
|
117
92
|
return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
|
|
118
93
|
|
|
119
94
|
file_path = os.path.abspath(args.file_path)
|
|
120
95
|
|
|
121
96
|
# Common file errors
|
|
122
|
-
if
|
|
97
|
+
if is_directory(file_path):
|
|
123
98
|
return model.ToolResultItem(
|
|
124
99
|
status="error",
|
|
125
100
|
output="<tool_use_error>Illegal operation on a directory. edit</tool_use_error>",
|
|
@@ -136,40 +111,55 @@ class EditTool(ToolABC):
|
|
|
136
111
|
|
|
137
112
|
# FileTracker checks (only for editing existing files)
|
|
138
113
|
file_tracker = get_current_file_tracker()
|
|
139
|
-
|
|
114
|
+
tracked_status: model.FileStatus | None = None
|
|
115
|
+
if not file_exists(file_path):
|
|
140
116
|
# We require reading before editing
|
|
141
117
|
return model.ToolResultItem(
|
|
142
118
|
status="error",
|
|
143
119
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
144
120
|
)
|
|
145
121
|
if file_tracker is not None:
|
|
146
|
-
|
|
147
|
-
if
|
|
122
|
+
tracked_status = file_tracker.get(file_path)
|
|
123
|
+
if tracked_status is None:
|
|
148
124
|
return model.ToolResultItem(
|
|
149
125
|
status="error",
|
|
150
126
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
151
127
|
)
|
|
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
128
|
|
|
164
129
|
# Edit existing file: validate and apply
|
|
165
130
|
try:
|
|
166
|
-
before = await asyncio.to_thread(
|
|
131
|
+
before = await asyncio.to_thread(read_text, file_path)
|
|
167
132
|
except FileNotFoundError:
|
|
168
133
|
return model.ToolResultItem(
|
|
169
134
|
status="error",
|
|
170
135
|
output="File has not been read yet. Read it first before writing to it.",
|
|
171
136
|
)
|
|
172
137
|
|
|
138
|
+
# Re-check external modifications using content hash when available.
|
|
139
|
+
if tracked_status is not None:
|
|
140
|
+
if tracked_status.content_sha256 is not None:
|
|
141
|
+
current_sha256 = hash_text_sha256(before)
|
|
142
|
+
if current_sha256 != tracked_status.content_sha256:
|
|
143
|
+
return model.ToolResultItem(
|
|
144
|
+
status="error",
|
|
145
|
+
output=(
|
|
146
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Backward-compat: old sessions only stored mtime.
|
|
151
|
+
try:
|
|
152
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
153
|
+
except OSError:
|
|
154
|
+
current_mtime = tracked_status.mtime
|
|
155
|
+
if current_mtime != tracked_status.mtime:
|
|
156
|
+
return model.ToolResultItem(
|
|
157
|
+
status="error",
|
|
158
|
+
output=(
|
|
159
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
173
163
|
err = cls.valid(
|
|
174
164
|
content=before,
|
|
175
165
|
old_string=args.old_string,
|
|
@@ -197,8 +187,8 @@ class EditTool(ToolABC):
|
|
|
197
187
|
|
|
198
188
|
# Write back
|
|
199
189
|
try:
|
|
200
|
-
await asyncio.to_thread(
|
|
201
|
-
except
|
|
190
|
+
await asyncio.to_thread(write_text, file_path, after)
|
|
191
|
+
except (OSError, UnicodeError) as e: # pragma: no cover
|
|
202
192
|
return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
|
|
203
193
|
|
|
204
194
|
# Prepare UI extra: unified diff with 3 context lines
|
|
@@ -211,15 +201,18 @@ class EditTool(ToolABC):
|
|
|
211
201
|
n=3,
|
|
212
202
|
)
|
|
213
203
|
)
|
|
214
|
-
|
|
215
|
-
ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
|
|
204
|
+
ui_extra = build_structured_diff(before, after, file_path=file_path)
|
|
216
205
|
|
|
217
|
-
# Update tracker with new mtime
|
|
206
|
+
# Update tracker with new mtime and content hash
|
|
218
207
|
if file_tracker is not None:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
208
|
+
with contextlib.suppress(Exception):
|
|
209
|
+
existing = file_tracker.get(file_path)
|
|
210
|
+
is_mem = existing.is_memory if existing else False
|
|
211
|
+
file_tracker[file_path] = model.FileStatus(
|
|
212
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
213
|
+
content_sha256=hash_text_sha256(after),
|
|
214
|
+
is_memory=is_mem,
|
|
215
|
+
)
|
|
223
216
|
|
|
224
217
|
# Build output message
|
|
225
218
|
if args.replace_all:
|
|
@@ -238,18 +231,12 @@ class EditTool(ToolABC):
|
|
|
238
231
|
header = line
|
|
239
232
|
plus = header.split("+", 1)[1]
|
|
240
233
|
plus_range = plus.split(" ")[0]
|
|
241
|
-
if "," in plus_range
|
|
242
|
-
start = int(plus_range.split(",")[0])
|
|
243
|
-
else:
|
|
244
|
-
start = int(plus_range)
|
|
234
|
+
start = int(plus_range.split(",")[0]) if "," in plus_range else int(plus_range)
|
|
245
235
|
after_line_no = start - 1
|
|
246
|
-
except
|
|
236
|
+
except (ValueError, IndexError):
|
|
247
237
|
after_line_no = 0
|
|
248
238
|
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("+++ "):
|
|
239
|
+
if line.startswith(" ") or (line.startswith("+") and not line.startswith("+++ ")):
|
|
253
240
|
after_line_no += 1
|
|
254
241
|
include_after_line_nos.append(after_line_no)
|
|
255
242
|
elif line.startswith("-") and not line.startswith("--- "):
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Moves a range of lines from one file to another.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
- Cuts lines from `start_line` to `end_line` (inclusive, 1-indexed) from the source file
|
|
5
|
+
- Pastes them into the target file at `insert_line` (inserted before that line)
|
|
6
|
+
- Both files must have been read first using the Read tool
|
|
7
|
+
- To create a new target file, set `insert_line` to 1 and ensure target file does not exist
|
|
8
|
+
- For same-file moves, line numbers refer to the original file state before any changes
|
|
9
|
+
- Use this tool when refactoring code into separate modules to avoid passing large code blocks twice
|
|
10
|
+
- To move files or directories, use the Bash tool with `mv` command instead
|
|
11
|
+
|
|
12
|
+
Return format:
|
|
13
|
+
The tool returns context snippets showing the state after the operation:
|
|
14
|
+
|
|
15
|
+
1. Source file context (after cut): Shows 3 lines before and after the cut location
|
|
16
|
+
2. Target file context (after insert): Shows 3 lines before the inserted content, the inserted content itself, and 3 lines after
|
|
17
|
+
|
|
18
|
+
Example output:
|
|
19
|
+
```
|
|
20
|
+
Cut 8 lines from /path/source.py (lines 9-16) and pasted into /path/target.py (updated) at line 10.
|
|
21
|
+
|
|
22
|
+
Source file context (after cut):
|
|
23
|
+
6 return value
|
|
24
|
+
7
|
|
25
|
+
8
|
|
26
|
+
-------- cut here --------
|
|
27
|
+
9 class NextClass:
|
|
28
|
+
10 pass
|
|
29
|
+
11
|
|
30
|
+
|
|
31
|
+
Target file context (after insert):
|
|
32
|
+
7 return {}
|
|
33
|
+
8
|
|
34
|
+
9
|
|
35
|
+
-------- inserted --------
|
|
36
|
+
10 class MovedClass:
|
|
37
|
+
...
|
|
38
|
+
17 return result
|
|
39
|
+
-------- end --------
|
|
40
|
+
18 # Next section
|
|
41
|
+
```
|