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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- 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))
|