superqode 0.1.5__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.
Files changed (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,653 @@
1
+ """
2
+ Edit Tools - File Editing Operations.
3
+
4
+ Provides multiple editing strategies:
5
+ - EditFileTool: Simple string replacement (exact match)
6
+ - InsertTextTool: Insert at line number
7
+ - PatchTool: Apply unified diffs (like git patches)
8
+ - MultiEditTool: Batch multiple edits atomically
9
+
10
+ When a QE session is active, edits are tracked through the WorkspaceManager
11
+ to ensure the immutable repo guarantee.
12
+ """
13
+
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+ import re
17
+
18
+ from .base import Tool, ToolResult, ToolContext
19
+ from .validation import validate_path_in_working_directory
20
+ from .file_tracking import check_file_unchanged
21
+ from ..agent.edit_strategies import replace_with_strategies
22
+
23
+
24
+ def _get_workspace():
25
+ """Get the active workspace manager if available."""
26
+ try:
27
+ from superqode.workspace.manager import get_workspace
28
+
29
+ workspace = get_workspace()
30
+ if workspace and workspace.is_active:
31
+ return workspace
32
+ except ImportError:
33
+ pass
34
+ return None
35
+
36
+
37
+ class EditFileTool(Tool):
38
+ """Edit a file by replacing text.
39
+
40
+ Performs string replacements with fallback strategies when exact match fails
41
+ (e.g., line-trimmed, indentation-flexible). Read the file before editing.
42
+ """
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ return "edit_file"
47
+
48
+ @property
49
+ def description(self) -> str:
50
+ return """Performs string replacements in files.
51
+
52
+ Usage:
53
+ - Use read_file at least once before editing. The tool will error if the file was modified externally since last read.
54
+ - When editing text from read_file output, preserve exact indentation. If the output uses a line-number prefix (e.g. spaces + line number + tab), everything after the tab is the actual file content to match. Never include the line-number prefix in old_text or new_text.
55
+ - Prefer editing existing files. Only create new files when explicitly required.
56
+ - The edit will FAIL if old_text is not found (error: 'old_string not found in content').
57
+ - The edit will FAIL if old_text matches multiple times. Provide more surrounding lines to make it unique, or use replace_all=true to change every instance.
58
+ - Use replace_all for renaming variables or replacing across the whole file."""
59
+
60
+ @property
61
+ def parameters(self) -> Dict[str, Any]:
62
+ return {
63
+ "type": "object",
64
+ "properties": {
65
+ "path": {"type": "string", "description": "Path to the file to edit"},
66
+ "old_text": {
67
+ "type": "string",
68
+ "description": "The text to find and replace. Must match exactly (including whitespace) or a fallback strategy may match. Include 3-5 lines of context for unique matching.",
69
+ },
70
+ "new_text": {
71
+ "type": "string",
72
+ "description": "The text to replace it with (must be different from old_text)",
73
+ },
74
+ "replace_all": {
75
+ "type": "boolean",
76
+ "description": "Replace all occurrences (default: false). Use for renaming or changing every instance.",
77
+ },
78
+ },
79
+ "required": ["path", "old_text", "new_text"],
80
+ }
81
+
82
+ async def execute(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
83
+ path = args.get("path", "")
84
+ old_text = args.get("old_text", "")
85
+ new_text = args.get("new_text", "")
86
+ replace_all = args.get("replace_all", False)
87
+
88
+ try:
89
+ # Validate and resolve path - ensures it stays within working directory
90
+ file_path = validate_path_in_working_directory(path, ctx.working_directory)
91
+ except ValueError as e:
92
+ return ToolResult(success=False, output="", error=str(e))
93
+
94
+ try:
95
+ if not file_path.exists():
96
+ return ToolResult(success=False, output="", error=f"File not found: {path}")
97
+
98
+ content = file_path.read_text()
99
+
100
+ # Check file unchanged since last read (avoid external-edit conflicts)
101
+ mtime = file_path.stat().st_mtime
102
+ ok, err = check_file_unchanged(
103
+ getattr(ctx, "session_id", "") or "", str(file_path.resolve()), mtime
104
+ )
105
+ if not ok and err:
106
+ return ToolResult(success=False, output="", error=err)
107
+
108
+ # Use advanced edit strategies (exact match first, then fallbacks)
109
+ try:
110
+ new_content, replaced_count = replace_with_strategies(
111
+ content, old_text, new_text, replace_all
112
+ )
113
+ except ValueError as e:
114
+ return ToolResult(success=False, output="", error=str(e))
115
+
116
+ # Check if QE session is active - route through workspace
117
+ workspace = _get_workspace()
118
+ if workspace:
119
+ try:
120
+ rel_path = file_path.relative_to(workspace.project_root)
121
+ workspace.write_file(str(rel_path), new_content)
122
+ return ToolResult(
123
+ success=True,
124
+ output=f"Replaced {replaced_count} occurrence(s) in {path} (tracked for QE revert)",
125
+ metadata={
126
+ "path": str(file_path),
127
+ "replacements": replaced_count,
128
+ "qe_tracked": True,
129
+ },
130
+ )
131
+ except ValueError:
132
+ # Path is outside project root, write directly
133
+ pass
134
+
135
+ # Write back (no QE session or outside project)
136
+ file_path.write_text(new_content)
137
+
138
+ return ToolResult(
139
+ success=True,
140
+ output=f"Replaced {replaced_count} occurrence(s) in {path}",
141
+ metadata={"path": str(file_path), "replacements": replaced_count},
142
+ )
143
+
144
+ except Exception as e:
145
+ return ToolResult(success=False, output="", error=str(e))
146
+
147
+
148
+ class InsertTextTool(Tool):
149
+ """Insert text at a specific line number."""
150
+
151
+ @property
152
+ def name(self) -> str:
153
+ return "insert_text"
154
+
155
+ @property
156
+ def description(self) -> str:
157
+ return "Insert text at a specific line number in a file."
158
+
159
+ @property
160
+ def parameters(self) -> Dict[str, Any]:
161
+ return {
162
+ "type": "object",
163
+ "properties": {
164
+ "path": {"type": "string", "description": "Path to the file"},
165
+ "line": {"type": "integer", "description": "Line number to insert at (1-indexed)"},
166
+ "text": {"type": "string", "description": "Text to insert"},
167
+ },
168
+ "required": ["path", "line", "text"],
169
+ }
170
+
171
+ async def execute(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
172
+ path = args.get("path", "")
173
+ line_num = args.get("line", 1)
174
+ text = args.get("text", "")
175
+
176
+ try:
177
+ # Validate and resolve path - ensures it stays within working directory
178
+ file_path = validate_path_in_working_directory(path, ctx.working_directory)
179
+ except ValueError as e:
180
+ return ToolResult(success=False, output="", error=str(e))
181
+
182
+ try:
183
+ if not file_path.exists():
184
+ return ToolResult(success=False, output="", error=f"File not found: {path}")
185
+
186
+ lines = file_path.read_text().split("\n")
187
+
188
+ # Check file unchanged since last read
189
+ mtime = file_path.stat().st_mtime
190
+ ok, err = check_file_unchanged(
191
+ getattr(ctx, "session_id", "") or "", str(file_path.resolve()), mtime
192
+ )
193
+ if not ok and err:
194
+ return ToolResult(success=False, output="", error=err)
195
+
196
+ # Validate line number
197
+ if line_num < 1 or line_num > len(lines) + 1:
198
+ return ToolResult(
199
+ success=False,
200
+ output="",
201
+ error=f"Invalid line number {line_num}. File has {len(lines)} lines.",
202
+ )
203
+
204
+ # Insert at position (convert to 0-indexed)
205
+ lines.insert(line_num - 1, text)
206
+ new_content = "\n".join(lines)
207
+
208
+ # Check if QE session is active - route through workspace
209
+ workspace = _get_workspace()
210
+ if workspace:
211
+ try:
212
+ rel_path = file_path.relative_to(workspace.project_root)
213
+ workspace.write_file(str(rel_path), new_content)
214
+ return ToolResult(
215
+ success=True,
216
+ output=f"Inserted text at line {line_num} in {path} (tracked for QE revert)",
217
+ metadata={"path": str(file_path), "line": line_num, "qe_tracked": True},
218
+ )
219
+ except ValueError:
220
+ pass
221
+
222
+ # Write back (no QE session or outside project)
223
+ file_path.write_text(new_content)
224
+
225
+ return ToolResult(
226
+ success=True,
227
+ output=f"Inserted text at line {line_num} in {path}",
228
+ metadata={"path": str(file_path), "line": line_num},
229
+ )
230
+
231
+ except Exception as e:
232
+ return ToolResult(success=False, output="", error=str(e))
233
+
234
+
235
+ class PatchTool(Tool):
236
+ """
237
+ Apply unified diff patches to files.
238
+
239
+ Supports standard unified diff format (like git diff output).
240
+ Can apply patches to single or multiple files.
241
+
242
+ Features:
243
+ - Parse unified diff format
244
+ - Context line matching with configurable fuzz factor
245
+ - Support for multiple files in one patch
246
+ - Detailed success/failure reporting per hunk
247
+ """
248
+
249
+ @property
250
+ def name(self) -> str:
251
+ return "patch"
252
+
253
+ @property
254
+ def description(self) -> str:
255
+ return "Apply a unified diff patch to files. Accepts standard diff format (like git diff output)."
256
+
257
+ @property
258
+ def parameters(self) -> Dict[str, Any]:
259
+ return {
260
+ "type": "object",
261
+ "properties": {
262
+ "patch": {
263
+ "type": "string",
264
+ "description": "The unified diff patch content to apply",
265
+ },
266
+ "path": {
267
+ "type": "string",
268
+ "description": "Optional: specific file to patch (overrides file paths in patch)",
269
+ },
270
+ "fuzz": {
271
+ "type": "integer",
272
+ "description": "Fuzz factor for context matching (0-3, default: 0 for exact match)",
273
+ },
274
+ "reverse": {
275
+ "type": "boolean",
276
+ "description": "Apply patch in reverse (default: false)",
277
+ },
278
+ },
279
+ "required": ["patch"],
280
+ }
281
+
282
+ async def execute(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
283
+ patch_content = args.get("patch", "")
284
+ target_path = args.get("path")
285
+ fuzz = args.get("fuzz", 0)
286
+ reverse = args.get("reverse", False)
287
+
288
+ if not patch_content.strip():
289
+ return ToolResult(success=False, output="", error="Empty patch content")
290
+
291
+ try:
292
+ # Parse the patch into file hunks
293
+ file_patches = self._parse_unified_diff(patch_content)
294
+
295
+ if not file_patches:
296
+ return ToolResult(
297
+ success=False,
298
+ output="",
299
+ error="Could not parse patch. Expected unified diff format.",
300
+ )
301
+
302
+ results = []
303
+ total_hunks = 0
304
+ applied_hunks = 0
305
+
306
+ workspace = _get_workspace()
307
+
308
+ for file_path_str, hunks in file_patches.items():
309
+ # Override path if specified
310
+ if target_path:
311
+ file_path_str = target_path
312
+
313
+ # Validate and resolve path - ensures it stays within working directory
314
+ try:
315
+ file_path = validate_path_in_working_directory(
316
+ file_path_str, ctx.working_directory
317
+ )
318
+ except ValueError as e:
319
+ results.append(f"✗ {file_path_str}: {str(e)}")
320
+ continue
321
+
322
+ # Read current content
323
+ if file_path.exists():
324
+ content = file_path.read_text()
325
+ lines = content.split("\n")
326
+ # Check file unchanged since last read
327
+ mtime = file_path.stat().st_mtime
328
+ ok, err = check_file_unchanged(
329
+ getattr(ctx, "session_id", "") or "", str(file_path.resolve()), mtime
330
+ )
331
+ if not ok and err:
332
+ results.append(f"✗ {file_path_str}: {err}")
333
+ total_hunks += len(hunks)
334
+ continue
335
+ else:
336
+ # New file (no prior read to check)
337
+ lines = []
338
+
339
+ # Apply hunks
340
+ hunk_results = []
341
+ for hunk in hunks:
342
+ total_hunks += 1
343
+ success, new_lines, msg = self._apply_hunk(
344
+ lines, hunk, fuzz=fuzz, reverse=reverse
345
+ )
346
+ if success:
347
+ lines = new_lines
348
+ applied_hunks += 1
349
+ hunk_results.append(
350
+ f" ✓ Hunk @@ {hunk['old_start']},{hunk['old_count']} @@"
351
+ )
352
+ else:
353
+ hunk_results.append(
354
+ f" ✗ Hunk @@ {hunk['old_start']},{hunk['old_count']} @@: {msg}"
355
+ )
356
+
357
+ # Write result
358
+ new_content = "\n".join(lines)
359
+
360
+ if workspace:
361
+ try:
362
+ rel_path = file_path.relative_to(workspace.project_root)
363
+ workspace.write_file(str(rel_path), new_content)
364
+ except ValueError:
365
+ file_path.parent.mkdir(parents=True, exist_ok=True)
366
+ file_path.write_text(new_content)
367
+ else:
368
+ file_path.parent.mkdir(parents=True, exist_ok=True)
369
+ file_path.write_text(new_content)
370
+
371
+ results.append(f"{file_path_str}:")
372
+ results.extend(hunk_results)
373
+
374
+ success = applied_hunks == total_hunks
375
+ output = "\n".join(results)
376
+ output += f"\n\nApplied {applied_hunks}/{total_hunks} hunks"
377
+
378
+ if workspace:
379
+ output += " (tracked for QE revert)"
380
+
381
+ return ToolResult(
382
+ success=success,
383
+ output=output,
384
+ error=None if success else f"Failed to apply {total_hunks - applied_hunks} hunks",
385
+ metadata={
386
+ "total_hunks": total_hunks,
387
+ "applied_hunks": applied_hunks,
388
+ "files": list(file_patches.keys()),
389
+ },
390
+ )
391
+
392
+ except Exception as e:
393
+ return ToolResult(success=False, output="", error=f"Patch error: {str(e)}")
394
+
395
+ def _parse_unified_diff(self, patch: str) -> Dict[str, List[Dict]]:
396
+ """Parse unified diff into file -> hunks mapping."""
397
+ files: Dict[str, List[Dict]] = {}
398
+ current_file = None
399
+ current_hunk = None
400
+
401
+ lines = patch.split("\n")
402
+ i = 0
403
+
404
+ while i < len(lines):
405
+ line = lines[i]
406
+
407
+ # File header: --- a/path or --- path
408
+ if line.startswith("--- "):
409
+ # Next line should be +++
410
+ if i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
411
+ # Extract path (remove a/ or b/ prefix if present)
412
+ old_path = line[4:].split("\t")[0].strip()
413
+ new_path = lines[i + 1][4:].split("\t")[0].strip()
414
+
415
+ # Remove a/ b/ prefixes
416
+ if old_path.startswith("a/"):
417
+ old_path = old_path[2:]
418
+ if new_path.startswith("b/"):
419
+ new_path = new_path[2:]
420
+
421
+ # Use new path (or old if it's /dev/null for new files)
422
+ current_file = new_path if new_path != "/dev/null" else old_path
423
+ if current_file not in files:
424
+ files[current_file] = []
425
+
426
+ i += 2
427
+ continue
428
+
429
+ # Hunk header: @@ -start,count +start,count @@
430
+ hunk_match = re.match(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line)
431
+ if hunk_match and current_file:
432
+ if current_hunk:
433
+ files[current_file].append(current_hunk)
434
+
435
+ current_hunk = {
436
+ "old_start": int(hunk_match.group(1)),
437
+ "old_count": int(hunk_match.group(2) or 1),
438
+ "new_start": int(hunk_match.group(3)),
439
+ "new_count": int(hunk_match.group(4) or 1),
440
+ "lines": [],
441
+ }
442
+ i += 1
443
+ continue
444
+
445
+ # Hunk content
446
+ if current_hunk is not None:
447
+ if (
448
+ line.startswith("+")
449
+ or line.startswith("-")
450
+ or line.startswith(" ")
451
+ or line == ""
452
+ ):
453
+ current_hunk["lines"].append(line)
454
+
455
+ i += 1
456
+
457
+ # Add last hunk
458
+ if current_hunk and current_file:
459
+ files[current_file].append(current_hunk)
460
+
461
+ return files
462
+
463
+ def _apply_hunk(
464
+ self, lines: List[str], hunk: Dict, fuzz: int = 0, reverse: bool = False
465
+ ) -> Tuple[bool, List[str], str]:
466
+ """Apply a single hunk to lines."""
467
+ old_lines = []
468
+ new_lines = []
469
+
470
+ for line in hunk["lines"]:
471
+ if line.startswith("-"):
472
+ old_lines.append(line[1:])
473
+ elif line.startswith("+"):
474
+ new_lines.append(line[1:])
475
+ elif line.startswith(" "):
476
+ old_lines.append(line[1:])
477
+ new_lines.append(line[1:])
478
+ elif line == "":
479
+ # Empty context line
480
+ old_lines.append("")
481
+ new_lines.append("")
482
+
483
+ if reverse:
484
+ old_lines, new_lines = new_lines, old_lines
485
+
486
+ # Find the location to apply (1-indexed in diff, 0-indexed in list)
487
+ start_line = hunk["old_start"] - 1
488
+
489
+ # Try exact match first, then with fuzz
490
+ for fuzz_offset in range(fuzz + 1):
491
+ for offset in [0, -fuzz_offset, fuzz_offset]:
492
+ pos = start_line + offset
493
+ if pos < 0:
494
+ continue
495
+
496
+ # Check if old_lines match at this position
497
+ if self._lines_match(lines, pos, old_lines, fuzz_offset):
498
+ # Apply the change
499
+ result = lines[:pos] + new_lines + lines[pos + len(old_lines) :]
500
+ return True, result, "Applied"
501
+
502
+ return False, lines, "Context mismatch"
503
+
504
+ def _lines_match(
505
+ self, content: List[str], start: int, expected: List[str], fuzz: int = 0
506
+ ) -> bool:
507
+ """Check if lines match at position (with optional fuzz)."""
508
+ if start + len(expected) > len(content):
509
+ return False
510
+
511
+ for i, exp_line in enumerate(expected):
512
+ actual_line = content[start + i]
513
+
514
+ if fuzz == 0:
515
+ if actual_line != exp_line:
516
+ return False
517
+ else:
518
+ # With fuzz, allow whitespace differences
519
+ if actual_line.strip() != exp_line.strip():
520
+ return False
521
+
522
+ return True
523
+
524
+
525
+ class MultiEditTool(Tool):
526
+ """
527
+ Apply multiple edits to a file atomically.
528
+
529
+ More efficient than multiple edit_file calls when making
530
+ several changes to the same file. All edits are validated
531
+ before any are applied.
532
+ """
533
+
534
+ @property
535
+ def name(self) -> str:
536
+ return "multi_edit"
537
+
538
+ @property
539
+ def description(self) -> str:
540
+ return "Apply multiple text replacements to a file atomically. All edits must succeed or none are applied."
541
+
542
+ @property
543
+ def parameters(self) -> Dict[str, Any]:
544
+ return {
545
+ "type": "object",
546
+ "properties": {
547
+ "path": {"type": "string", "description": "Path to the file to edit"},
548
+ "edits": {
549
+ "type": "array",
550
+ "description": "Array of edit operations to apply",
551
+ "items": {
552
+ "type": "object",
553
+ "properties": {
554
+ "old_text": {"type": "string", "description": "Text to find"},
555
+ "new_text": {"type": "string", "description": "Text to replace with"},
556
+ },
557
+ "required": ["old_text", "new_text"],
558
+ },
559
+ },
560
+ },
561
+ "required": ["path", "edits"],
562
+ }
563
+
564
+ async def execute(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
565
+ path = args.get("path", "")
566
+ edits = args.get("edits", [])
567
+
568
+ if not edits:
569
+ return ToolResult(success=False, output="", error="No edits provided")
570
+
571
+ try:
572
+ # Validate and resolve path - ensures it stays within working directory
573
+ file_path = validate_path_in_working_directory(path, ctx.working_directory)
574
+ except ValueError as e:
575
+ return ToolResult(success=False, output="", error=str(e))
576
+
577
+ try:
578
+ if not file_path.exists():
579
+ return ToolResult(success=False, output="", error=f"File not found: {path}")
580
+
581
+ content = file_path.read_text()
582
+
583
+ # Check file unchanged since last read
584
+ mtime = file_path.stat().st_mtime
585
+ ok, err = check_file_unchanged(
586
+ getattr(ctx, "session_id", "") or "", str(file_path.resolve()), mtime
587
+ )
588
+ if not ok and err:
589
+ return ToolResult(success=False, output="", error=err)
590
+
591
+ # Validate all edits first
592
+ validation_errors = []
593
+ for i, edit in enumerate(edits):
594
+ old_text = edit.get("old_text", "")
595
+ if old_text not in content:
596
+ validation_errors.append(f"Edit {i + 1}: Text not found: {old_text[:50]}...")
597
+ elif content.count(old_text) > 1:
598
+ validation_errors.append(
599
+ f"Edit {i + 1}: Multiple occurrences found for: {old_text[:50]}..."
600
+ )
601
+
602
+ if validation_errors:
603
+ return ToolResult(
604
+ success=False,
605
+ output="",
606
+ error="Validation failed:\n" + "\n".join(validation_errors),
607
+ )
608
+
609
+ # Apply all edits (we need to be careful about order to avoid overlaps)
610
+ # Sort edits by position in file (descending) to avoid offset issues
611
+ positioned_edits = []
612
+ for edit in edits:
613
+ old_text = edit.get("old_text", "")
614
+ pos = content.find(old_text)
615
+ positioned_edits.append((pos, edit))
616
+
617
+ # Sort by position descending (apply from end to start)
618
+ positioned_edits.sort(key=lambda x: x[0], reverse=True)
619
+
620
+ # Apply edits
621
+ for pos, edit in positioned_edits:
622
+ old_text = edit.get("old_text", "")
623
+ new_text = edit.get("new_text", "")
624
+ content = content[:pos] + new_text + content[pos + len(old_text) :]
625
+
626
+ # Write result
627
+ workspace = _get_workspace()
628
+ if workspace:
629
+ try:
630
+ rel_path = file_path.relative_to(workspace.project_root)
631
+ workspace.write_file(str(rel_path), content)
632
+ return ToolResult(
633
+ success=True,
634
+ output=f"Applied {len(edits)} edits to {path} (tracked for QE revert)",
635
+ metadata={
636
+ "path": str(file_path),
637
+ "edit_count": len(edits),
638
+ "qe_tracked": True,
639
+ },
640
+ )
641
+ except ValueError:
642
+ pass
643
+
644
+ file_path.write_text(content)
645
+
646
+ return ToolResult(
647
+ success=True,
648
+ output=f"Applied {len(edits)} edits to {path}",
649
+ metadata={"path": str(file_path), "edit_count": len(edits)},
650
+ )
651
+
652
+ except Exception as e:
653
+ return ToolResult(success=False, output="", error=str(e))