crabcode 0.1.6__py3-none-win_amd64.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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,123 @@
1
+ """Diff utilities for file edit/write tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+
7
+
8
+ def compute_diff(
9
+ old: str,
10
+ new: str,
11
+ filepath: str = "",
12
+ context_lines: int = 3,
13
+ ) -> dict:
14
+ """Compute a structured diff between old and new file content.
15
+
16
+ Returns a dict with:
17
+ line_range - (start, end) 1-indexed line range of first change
18
+ diff_text - unified diff string (compact, for model)
19
+ display - formatted diff string (for terminal display)
20
+ stats - {"added": N, "removed": N}
21
+ """
22
+ # ``unified_diff`` supplies separators through ``lineterm``. Passing
23
+ # keepends=True and then joining the result inserted blank lines between
24
+ # every diff row in terminal and desktop renderers.
25
+ old_lines = old.splitlines(keepends=False)
26
+ new_lines = new.splitlines(keepends=False)
27
+
28
+ diff_lines = list(difflib.unified_diff(
29
+ old_lines,
30
+ new_lines,
31
+ fromfile=filepath,
32
+ tofile=filepath,
33
+ lineterm="",
34
+ n=context_lines,
35
+ ))
36
+
37
+ if not diff_lines:
38
+ return {
39
+ "line_range": None,
40
+ "diff_text": "",
41
+ "display": "",
42
+ "stats": {"added": 0, "removed": 0},
43
+ }
44
+
45
+ added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
46
+ removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
47
+
48
+ first_change_line = _find_first_change_line(old_lines, new_lines)
49
+ last_change_line = _find_last_change_line(old_lines, new_lines)
50
+
51
+ diff_text = "\n".join(diff_lines)
52
+
53
+ max_diff_chars = 10_000
54
+ if len(diff_text) > max_diff_chars:
55
+ diff_text = diff_text[:max_diff_chars] + "\n... (diff truncated)"
56
+
57
+ return {
58
+ "line_range": (first_change_line, last_change_line),
59
+ "diff_text": diff_text,
60
+ "display": diff_text,
61
+ "stats": {"added": added, "removed": removed},
62
+ }
63
+
64
+
65
+ def _find_first_change_line(old: list[str], new: list[str]) -> int:
66
+ """Find the 1-indexed line number of the first difference."""
67
+ for i, (a, b) in enumerate(zip(old, new)):
68
+ if a != b:
69
+ return i + 1
70
+ return min(len(old), len(new)) + 1
71
+
72
+
73
+ def _find_last_change_line(old: list[str], new: list[str]) -> int:
74
+ """Find the 1-indexed line number (in new file) of the last difference."""
75
+ old_rev = list(reversed(old))
76
+ new_rev = list(reversed(new))
77
+ tail_common = 0
78
+ for a, b in zip(old_rev, new_rev):
79
+ if a != b:
80
+ break
81
+ tail_common += 1
82
+ return max(1, len(new) - tail_common)
83
+
84
+
85
+ def format_edit_summary(
86
+ filepath: str,
87
+ diff_info: dict,
88
+ replacements: int = 1,
89
+ ) -> tuple[str, str]:
90
+ """Format diff info into (result_for_model, result_for_display).
91
+
92
+ Returns a pair of strings optimized for model consumption and
93
+ terminal display respectively.
94
+ """
95
+ lr = diff_info["line_range"]
96
+ stats = diff_info["stats"]
97
+
98
+ if lr:
99
+ line_desc = f"lines {lr[0]}-{lr[1]}" if lr[0] != lr[1] else f"line {lr[0]}"
100
+ else:
101
+ line_desc = ""
102
+
103
+ stat_parts = []
104
+ if stats["added"]:
105
+ stat_parts.append(f"+{stats['added']}")
106
+ if stats["removed"]:
107
+ stat_parts.append(f"-{stats['removed']}")
108
+ stat_str = ", ".join(stat_parts)
109
+
110
+ model_msg = f"Updated {filepath}"
111
+ if line_desc:
112
+ model_msg += f" ({line_desc})"
113
+ if stat_str:
114
+ model_msg += f" [{stat_str}]"
115
+ if replacements > 1:
116
+ model_msg += f" ({replacements} replacements)"
117
+
118
+ display_parts = [model_msg]
119
+ if diff_info["display"]:
120
+ display_parts.append(diff_info["display"])
121
+ display_msg = "\n".join(display_parts)
122
+
123
+ return model_msg, display_msg
@@ -0,0 +1,240 @@
1
+ """FileEditTool — string replacement editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from crabcode_core.logging_utils import get_logger
10
+ from crabcode_core.text_io import normalize_newlines, read_utf8_text, write_utf8_text
11
+ from crabcode_core.tools.diff_utils import compute_diff, format_edit_summary
12
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
13
+ from crabcode_core.io_worker import run_file_tool
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ async def _get_lsp_diagnostics(
19
+ file_path: str | Path,
20
+ context: ToolContext,
21
+ ) -> str:
22
+ """Collect LSP diagnostics for the edited file, return formatted string."""
23
+ lsp = context.lsp_manager
24
+ if lsp is None:
25
+ return ""
26
+ from crabcode_core.lsp.diagnostics import collect_and_format_diagnostics
27
+ return await collect_and_format_diagnostics(lsp, str(file_path))
28
+
29
+
30
+ class FileEditTool(Tool):
31
+ name = "Edit"
32
+ description = "Edit a file by replacing a specific string."
33
+ is_read_only = False
34
+ is_concurrency_safe = False
35
+ input_schema = {
36
+ "type": "object",
37
+ "properties": {
38
+ "file_path": {
39
+ "type": "string",
40
+ "description": "The path of the file to edit.",
41
+ },
42
+ "old_string": {
43
+ "type": "string",
44
+ "description": "The exact string to find and replace.",
45
+ },
46
+ "new_string": {
47
+ "type": "string",
48
+ "description": "The replacement string.",
49
+ },
50
+ "replace_all": {
51
+ "type": "boolean",
52
+ "description": "Replace all occurrences (default: false).",
53
+ },
54
+ },
55
+ "required": ["file_path", "old_string", "new_string"],
56
+ }
57
+
58
+ async def get_prompt(self, **kwargs: Any) -> str:
59
+ return (
60
+ "Edit a file by performing exact string replacements. "
61
+ "The old_string must uniquely identify the text to replace — "
62
+ "if it matches multiple locations, the edit will FAIL. Include "
63
+ "surrounding context lines to make the match unique. Preserves "
64
+ "exact indentation (tabs/spaces) as it appears in the file. "
65
+ "Use replace_all=true to replace all occurrences (useful for "
66
+ "renaming a variable across the file).\n\n"
67
+ "IMPORTANT: You MUST read the file before editing it. Never "
68
+ "guess the file content.\n\n"
69
+ "GOOD example — unique match with context:\n"
70
+ ' old_string: "def calculate(a, b):\\n return a + b"\n'
71
+ ' new_string: "def calculate(a, b):\\n return a * b"\n\n'
72
+ "BAD example — too short, likely matches multiple locations:\n"
73
+ ' old_string: "return"\n'
74
+ ' new_string: "return None"'
75
+ )
76
+
77
+ async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
78
+ missing = [
79
+ key
80
+ for key in ("file_path", "old_string", "new_string")
81
+ if key not in tool_input
82
+ ]
83
+ if missing:
84
+ return f"missing required field(s): {', '.join(missing)}"
85
+
86
+ old_string = tool_input.get("old_string")
87
+ new_string = tool_input.get("new_string")
88
+ if not isinstance(old_string, str) or not isinstance(new_string, str):
89
+ return "old_string and new_string must be strings"
90
+ if old_string == "":
91
+ return "old_string must not be empty"
92
+ if old_string == new_string:
93
+ return "old_string and new_string must be different"
94
+ return None
95
+
96
+ async def call(
97
+ self, tool_input: dict[str, Any], context: ToolContext,
98
+ ) -> ToolResult:
99
+ result = await run_file_tool(self.name, tool_input, context)
100
+ if not result.is_error and result.data:
101
+ result.result_for_model += await _get_lsp_diagnostics(result.data["file_path"], context)
102
+ return result
103
+
104
+ async def _call_local(
105
+ self,
106
+ tool_input: dict[str, Any],
107
+ context: ToolContext,
108
+ ) -> ToolResult:
109
+ file_path = tool_input.get("file_path", "")
110
+ old_string = tool_input.get("old_string", "")
111
+ new_string = tool_input.get("new_string", "")
112
+ replace_all = tool_input.get("replace_all", False)
113
+
114
+ if not file_path:
115
+ return ToolResult(
116
+ result_for_model="Error: file_path is required",
117
+ is_error=True,
118
+ )
119
+
120
+ path = Path(file_path)
121
+ if not path.is_absolute():
122
+ path = Path(context.cwd) / path
123
+
124
+ if not path.exists():
125
+ return ToolResult(
126
+ result_for_model=f"Error: file not found: {path}",
127
+ is_error=True,
128
+ )
129
+
130
+ try:
131
+ source = read_utf8_text(path)
132
+ content = source.text
133
+ except Exception as e:
134
+ return ToolResult(
135
+ result_for_model=f"Error reading file: {e}",
136
+ is_error=True,
137
+ )
138
+
139
+ normalized_old = normalize_newlines(old_string, "\n")
140
+ pattern = re.compile(
141
+ r"(?:\r\n|\r|\n)".join(
142
+ re.escape(part) for part in normalized_old.split("\n")
143
+ )
144
+ )
145
+ matches = list(pattern.finditer(content))
146
+ count = len(matches)
147
+
148
+ if count == 0:
149
+ # Give the model a head start: show the first 120 chars of the
150
+ # old_string it provided so it can spot whitespace/encoding drift,
151
+ # and remind it to re-read before retrying.
152
+ snippet = repr(old_string[:120]) + ("..." if len(old_string) > 120 else "")
153
+ return ToolResult(
154
+ result_for_model=(
155
+ f"Error: old_string not found in {path}.\n"
156
+ f"The string you provided (first 120 chars): {snippet}\n"
157
+ "Common causes: wrong indentation (tabs vs spaces), "
158
+ "stale content (file was already modified), or "
159
+ "the string was never in this file.\n"
160
+ "Fix: use the Read tool to re-read the file and verify "
161
+ "the exact content before retrying the Edit."
162
+ ),
163
+ is_error=True,
164
+ )
165
+
166
+ if count > 1 and not replace_all:
167
+ return ToolResult(
168
+ result_for_model=(
169
+ f"Error: old_string found {count} times in {path}. "
170
+ "Use replace_all=true to replace all occurrences, "
171
+ "or provide more context to make the match unique."
172
+ ),
173
+ is_error=True,
174
+ )
175
+
176
+ def render_replacement(match: re.Match[str]) -> str:
177
+ matched = match.group(0)
178
+ replacement_newline = source.newline
179
+ if "\r\n" in matched:
180
+ replacement_newline = "\r\n"
181
+ elif "\n" in matched:
182
+ replacement_newline = "\n"
183
+ elif "\r" in matched:
184
+ replacement_newline = "\r"
185
+ return (
186
+ normalize_newlines(new_string, replacement_newline)
187
+ if replacement_newline is not None
188
+ else new_string
189
+ )
190
+
191
+ if replace_all:
192
+ new_content = pattern.sub(render_replacement, content)
193
+ else:
194
+ match = matches[0]
195
+ new_content = (
196
+ content[:match.start()]
197
+ + render_replacement(match)
198
+ + content[match.end():]
199
+ )
200
+
201
+ # Track snapshot before writing
202
+ if context.session_id:
203
+ try:
204
+ from crabcode_core.snapshot.tracker import track_snapshot_for_file
205
+ track_snapshot_for_file(
206
+ cwd=context.cwd,
207
+ session_id=context.session_id,
208
+ file_path=str(path),
209
+ old_content=content,
210
+ action="modify",
211
+ )
212
+ except Exception:
213
+ logger.debug("Failed to track snapshot for edit", exc_info=True)
214
+
215
+ try:
216
+ write_utf8_text(path, new_content, has_bom=source.has_bom)
217
+ except Exception as e:
218
+ return ToolResult(
219
+ result_for_model=f"Error writing file: {e}",
220
+ is_error=True,
221
+ )
222
+
223
+ replaced = count if replace_all else 1
224
+
225
+ diff_info = compute_diff(content, new_content, str(path))
226
+ model_msg, display_msg = format_edit_summary(
227
+ str(path), diff_info, replacements=replaced
228
+ )
229
+ lsp_msg = await _get_lsp_diagnostics(path, context)
230
+
231
+ return ToolResult(
232
+ data={
233
+ "file_path": str(path),
234
+ "replacements": replaced,
235
+ "line_range": diff_info["line_range"],
236
+ "stats": diff_info["stats"],
237
+ },
238
+ result_for_model=model_msg + lsp_msg,
239
+ result_for_display=display_msg,
240
+ )
@@ -0,0 +1,187 @@
1
+ """FileReadTool — read file contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from crabcode_core.logging_utils import get_logger
10
+ from crabcode_core.text_io import read_utf8_text
11
+ from crabcode_core.tools._input_helpers import first_non_empty_str
12
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
13
+ from crabcode_core.io_worker import run_file_tool
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class FileReadTool(Tool):
19
+ name = "Read"
20
+ description = "Read a text file or view a local image, including a saved PSD composite preview."
21
+ is_read_only = True
22
+ is_concurrency_safe = True
23
+ input_schema = {
24
+ "type": "object",
25
+ "properties": {
26
+ "file_path": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "description": "The absolute or relative path of the file to read.",
30
+ },
31
+ "offset": {
32
+ "type": "integer",
33
+ "description": "Text only: line number to start reading from (1-based). Negative values count from end. Ignored for images.",
34
+ },
35
+ "limit": {
36
+ "type": "integer",
37
+ "description": "Text only: number of lines to read. Ignored for images.",
38
+ },
39
+ },
40
+ "required": ["file_path"],
41
+ }
42
+
43
+ async def get_prompt(self, **kwargs: Any) -> str:
44
+ return (
45
+ "Read text or visually inspect an image from the local filesystem. "
46
+ "For images, the actual image content is returned to you for visual analysis, "
47
+ "not just a filename or a UI preview. Supports PNG, JPEG, WebP, GIF, BMP, TIFF "
48
+ "and saved PSD composite previews. Non-native formats are converted to PNG; "
49
+ "large images may be resized. Animated/multipage images return the first frame/page. "
50
+ "PSD previews show the saved merged appearance, not editable layers. "
51
+ "offset and limit apply only to text and are ignored for images.\n\n"
52
+ "You can optionally specify a line offset and limit (especially "
53
+ "useful for large files), but it's recommended to read the whole "
54
+ "file by not providing these parameters.\n\n"
55
+ "Lines in the output are numbered starting at 1, in the format "
56
+ "LINE_NUMBER|LINE_CONTENT. The LINE_NUMBER prefix is metadata — "
57
+ "do NOT treat it as part of the actual code.\n\n"
58
+ "IMPORTANT: You MUST read a file at least once before editing it. "
59
+ "You can call Read on multiple files in a single response to read "
60
+ "them in parallel.\n\n"
61
+ "If the file exists but is empty, you will receive 'File is empty.'.\n"
62
+ "If the file does not exist, you will receive an error."
63
+ )
64
+
65
+ async def call(
66
+ self, tool_input: dict[str, Any], context: ToolContext,
67
+ ) -> ToolResult:
68
+ result = await run_file_tool(self.name, tool_input, context)
69
+ if (not result.is_error and result.data and not result.images
70
+ and context.lsp_manager is not None):
71
+ asyncio.create_task(_lsp_touch(result.data["file_path"], context.lsp_manager))
72
+ return result
73
+
74
+ async def _call_local(
75
+ self,
76
+ tool_input: dict[str, Any],
77
+ context: ToolContext,
78
+ ) -> ToolResult:
79
+ file_path = first_non_empty_str(
80
+ tool_input,
81
+ ("file_path", "filePath", "path", "target_file", "filepath", "file"),
82
+ )
83
+ offset = tool_input.get("offset")
84
+ limit = tool_input.get("limit")
85
+
86
+ if not file_path:
87
+ return ToolResult(
88
+ result_for_model=(
89
+ "Error: file_path is required. Pass the file path as \"file_path\" "
90
+ "(aliases: filePath, path, target_file, filepath, file)."
91
+ ),
92
+ is_error=True,
93
+ )
94
+
95
+ path = Path(file_path).expanduser()
96
+ if not path.is_absolute():
97
+ path = Path(context.cwd) / path
98
+
99
+ if not path.exists():
100
+ return ToolResult(
101
+ result_for_model=f"Error: file not found: {path}",
102
+ is_error=True,
103
+ )
104
+
105
+ if not path.is_file():
106
+ return ToolResult(
107
+ result_for_model=f"Error: not a file: {path}",
108
+ is_error=True,
109
+ )
110
+
111
+ try:
112
+ from crabcode_core.image_io import is_image_file, load_image_preview
113
+
114
+ if is_image_file(path):
115
+ preview = load_image_preview(path)
116
+ context.emit_image(preview.data, preview.media_type)
117
+ message = f"Image read: {path} ({preview.summary()})"
118
+ if offset is not None or limit is not None:
119
+ message += "\nText offset/limit ignored for this image."
120
+ return ToolResult(
121
+ data={"file_path": str(path), "media_type": preview.media_type,
122
+ "width": preview.width, "height": preview.height,
123
+ "source_format": preview.source_format},
124
+ result_for_model=message,
125
+ result_for_display=message,
126
+ images=[context.emitted_images[-1]],
127
+ )
128
+ content = read_utf8_text(path).text
129
+ except Exception as e:
130
+ return ToolResult(
131
+ result_for_model=f"Error reading file: {e}",
132
+ is_error=True,
133
+ )
134
+
135
+ if not content:
136
+ return ToolResult(
137
+ result_for_model="File is empty.",
138
+ data={"file_path": str(path), "content": ""},
139
+ )
140
+
141
+ lines = content.splitlines(keepends=True)
142
+ total_lines = len(lines)
143
+
144
+ if offset is not None:
145
+ if offset < 0:
146
+ start = max(0, total_lines + offset)
147
+ else:
148
+ start = max(0, offset - 1)
149
+ else:
150
+ start = 0
151
+
152
+ if limit is not None:
153
+ end = min(total_lines, start + limit)
154
+ else:
155
+ end = total_lines
156
+
157
+ selected = lines[start:end]
158
+
159
+ numbered = []
160
+ for i, line in enumerate(selected, start=start + 1):
161
+ numbered.append(f"{i:6d}|{line.rstrip()}")
162
+
163
+ result_text = "\n".join(numbered)
164
+
165
+ # Background LSP touch — notifies language servers about this file
166
+ # so diagnostics are ready when the user writes later. Non-blocking.
167
+ lsp = context.lsp_manager
168
+ if lsp is not None:
169
+ try:
170
+ asyncio.get_event_loop().create_task(
171
+ _lsp_touch(str(path), lsp),
172
+ )
173
+ except Exception:
174
+ pass
175
+
176
+ return ToolResult(
177
+ data={"file_path": str(path), "content": content, "total_lines": total_lines},
178
+ result_for_model=result_text,
179
+ )
180
+
181
+
182
+ async def _lsp_touch(file_path: str, lsp: Any) -> None:
183
+ """Fire-and-forget LSP touch_file call."""
184
+ try:
185
+ await lsp.touch_file(file_path)
186
+ except Exception:
187
+ logger.debug("LSP touch_file failed for %s", file_path, exc_info=True)