code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# file_operations.py
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
4
6
|
import tempfile
|
|
5
7
|
from typing import List
|
|
6
8
|
|
|
@@ -10,15 +12,14 @@ from pydantic_ai import RunContext
|
|
|
10
12
|
# ---------------------------------------------------------------------------
|
|
11
13
|
# Module-level helper functions (exposed for unit tests _and_ used as tools)
|
|
12
14
|
# ---------------------------------------------------------------------------
|
|
13
|
-
from code_puppy.messaging import (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
from code_puppy.messaging import ( # New structured messaging types
|
|
16
|
+
FileContentMessage,
|
|
17
|
+
FileEntry,
|
|
18
|
+
FileListingMessage,
|
|
19
|
+
GrepMatch,
|
|
20
|
+
GrepResultMessage,
|
|
21
|
+
get_message_bus,
|
|
20
22
|
)
|
|
21
|
-
from code_puppy.tools.common import generate_group_id
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
# Pydantic models for tool return types
|
|
@@ -49,6 +50,7 @@ class MatchInfo(BaseModel):
|
|
|
49
50
|
|
|
50
51
|
class GrepOutput(BaseModel):
|
|
51
52
|
matches: List[MatchInfo]
|
|
53
|
+
error: str | None = None
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
def is_likely_home_directory(directory):
|
|
@@ -107,54 +109,71 @@ def is_project_directory(directory):
|
|
|
107
109
|
return False
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
def would_match_directory(pattern: str, directory: str) -> bool:
|
|
113
|
+
"""Check if a glob pattern would match the given directory path.
|
|
114
|
+
|
|
115
|
+
This is used to avoid adding ignore patterns that would inadvertently
|
|
116
|
+
exclude the directory we're actually trying to search in.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
pattern: A glob pattern like '**/tmp/**' or 'node_modules'
|
|
120
|
+
directory: The directory path to check against
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if the pattern would match the directory, False otherwise
|
|
124
|
+
"""
|
|
125
|
+
import fnmatch
|
|
126
|
+
|
|
127
|
+
# Normalize the directory path
|
|
128
|
+
abs_dir = os.path.abspath(directory)
|
|
129
|
+
dir_name = os.path.basename(abs_dir)
|
|
130
|
+
|
|
131
|
+
# Strip leading/trailing wildcards and slashes for simpler matching
|
|
132
|
+
clean_pattern = pattern.strip("*").strip("/")
|
|
133
|
+
|
|
134
|
+
# Check if the directory name matches the pattern
|
|
135
|
+
if fnmatch.fnmatch(dir_name, clean_pattern):
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
# Check if the full path contains the pattern
|
|
139
|
+
if fnmatch.fnmatch(abs_dir, pattern):
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
# Check if any part of the path matches
|
|
143
|
+
path_parts = abs_dir.split(os.sep)
|
|
144
|
+
for part in path_parts:
|
|
145
|
+
if fnmatch.fnmatch(part, clean_pattern):
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
110
151
|
def _list_files(
|
|
111
152
|
context: RunContext, directory: str = ".", recursive: bool = True
|
|
112
153
|
) -> ListFileOutput:
|
|
113
|
-
import subprocess
|
|
114
|
-
import shutil
|
|
115
154
|
import sys
|
|
116
155
|
|
|
117
156
|
results = []
|
|
118
|
-
directory = os.path.abspath(directory)
|
|
157
|
+
directory = os.path.abspath(os.path.expanduser(directory))
|
|
119
158
|
|
|
120
|
-
#
|
|
159
|
+
# Plain text output for LLM consumption
|
|
121
160
|
output_lines = []
|
|
122
|
-
|
|
123
|
-
directory_listing_header = (
|
|
124
|
-
"\n[bold white on blue] DIRECTORY LISTING [/bold white on blue]"
|
|
125
|
-
)
|
|
126
|
-
output_lines.append(directory_listing_header)
|
|
127
|
-
|
|
128
|
-
directory_info = f"\U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim](recursive={recursive})[/dim]\n"
|
|
129
|
-
output_lines.append(directory_info)
|
|
130
|
-
|
|
131
|
-
divider = "[dim]" + "─" * 100 + "\n" + "[/dim]"
|
|
132
|
-
output_lines.append(divider)
|
|
161
|
+
output_lines.append(f"DIRECTORY LISTING: {directory} (recursive={recursive})")
|
|
133
162
|
|
|
134
163
|
if not os.path.exists(directory):
|
|
135
|
-
error_msg =
|
|
136
|
-
|
|
137
|
-
)
|
|
138
|
-
output_lines.append(error_msg)
|
|
139
|
-
|
|
140
|
-
output_lines.append(divider)
|
|
141
|
-
return ListFileOutput(content="\n".join(output_lines))
|
|
164
|
+
error_msg = f"Error: Directory '{directory}' does not exist"
|
|
165
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
142
166
|
if not os.path.isdir(directory):
|
|
143
|
-
error_msg = f"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
output_lines.append(divider)
|
|
147
|
-
return ListFileOutput(content="\n".join(output_lines))
|
|
167
|
+
error_msg = f"Error: '{directory}' is not a directory"
|
|
168
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
148
169
|
|
|
149
170
|
# Smart home directory detection - auto-limit recursion for performance
|
|
150
171
|
# But allow recursion in tests (when context=None) or when explicitly requested
|
|
151
172
|
if context is not None and is_likely_home_directory(directory) and recursive:
|
|
152
173
|
if not is_project_directory(directory):
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
info_msg = f"[dim]💡 To force recursive listing in home directory, use list_files('{directory}', recursive=True) explicitly[/dim]"
|
|
157
|
-
output_lines.append(info_msg)
|
|
174
|
+
output_lines.append(
|
|
175
|
+
"Warning: Detected home directory - limiting to non-recursive listing for performance"
|
|
176
|
+
)
|
|
158
177
|
recursive = False
|
|
159
178
|
|
|
160
179
|
# Create a temporary ignore file with our ignore patterns
|
|
@@ -178,129 +197,133 @@ def _list_files(
|
|
|
178
197
|
rg_path = venv_rg_exe_path
|
|
179
198
|
break
|
|
180
199
|
|
|
181
|
-
if not rg_path:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return ListFileOutput(content=
|
|
185
|
-
|
|
186
|
-
# Build command for ripgrep --files
|
|
187
|
-
cmd = [rg_path, "--files"]
|
|
200
|
+
if not rg_path and recursive:
|
|
201
|
+
# Only need ripgrep for recursive listings
|
|
202
|
+
error_msg = "Error: ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
203
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
188
204
|
|
|
189
|
-
#
|
|
190
|
-
if
|
|
191
|
-
|
|
205
|
+
# Only use ripgrep for recursive listings
|
|
206
|
+
if recursive:
|
|
207
|
+
# Build command for ripgrep --files
|
|
208
|
+
cmd = [rg_path, "--files"]
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
ignore_file = f.name
|
|
198
|
-
for pattern in IGNORE_PATTERNS:
|
|
199
|
-
f.write(f"{pattern}\n")
|
|
200
|
-
|
|
201
|
-
cmd.extend(["--ignore-file", ignore_file])
|
|
202
|
-
cmd.append(directory)
|
|
203
|
-
|
|
204
|
-
# Run ripgrep to get file listing
|
|
205
|
-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
206
|
-
|
|
207
|
-
# Process the output lines
|
|
208
|
-
files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
209
|
-
|
|
210
|
-
# Create ListedFile objects with metadata
|
|
211
|
-
for full_path in files:
|
|
212
|
-
if not full_path: # Skip empty lines
|
|
213
|
-
continue
|
|
214
|
-
|
|
215
|
-
# Skip if file doesn't exist (though it should)
|
|
216
|
-
if not os.path.exists(full_path):
|
|
217
|
-
continue
|
|
218
|
-
|
|
219
|
-
# Extract relative path from the full path
|
|
220
|
-
if full_path.startswith(directory):
|
|
221
|
-
file_path = full_path[len(directory):].lstrip(os.sep)
|
|
222
|
-
else:
|
|
223
|
-
file_path = full_path
|
|
224
|
-
|
|
225
|
-
# For non-recursive mode, skip files in subdirectories
|
|
226
|
-
# Only check the relative path, not the full path
|
|
227
|
-
if not recursive and os.sep in file_path:
|
|
228
|
-
continue
|
|
229
|
-
|
|
230
|
-
# Check if path is a file or directory
|
|
231
|
-
if os.path.isfile(full_path):
|
|
232
|
-
entry_type = "file"
|
|
233
|
-
size = os.path.getsize(full_path)
|
|
234
|
-
elif os.path.isdir(full_path):
|
|
235
|
-
entry_type = "directory"
|
|
236
|
-
size = 0
|
|
237
|
-
else:
|
|
238
|
-
# Skip if it's neither a file nor directory
|
|
239
|
-
continue
|
|
210
|
+
# Add ignore patterns to the command via a temporary file
|
|
211
|
+
from code_puppy.tools.common import (
|
|
212
|
+
DIR_IGNORE_PATTERNS,
|
|
213
|
+
)
|
|
240
214
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
215
|
+
with tempfile.NamedTemporaryFile(
|
|
216
|
+
mode="w", delete=False, suffix=".ignore"
|
|
217
|
+
) as f:
|
|
218
|
+
ignore_file = f.name
|
|
219
|
+
for pattern in DIR_IGNORE_PATTERNS:
|
|
220
|
+
# Skip patterns that would match the search directory itself
|
|
221
|
+
# For example, if searching in /tmp/test-dir, skip **/tmp/**
|
|
222
|
+
if would_match_directory(pattern, directory):
|
|
223
|
+
continue
|
|
224
|
+
f.write(f"{pattern}\n")
|
|
225
|
+
|
|
226
|
+
cmd.extend(["--ignore-file", ignore_file])
|
|
227
|
+
cmd.append(directory)
|
|
228
|
+
|
|
229
|
+
# Run ripgrep to get file listing
|
|
230
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
231
|
+
|
|
232
|
+
# Process the output lines
|
|
233
|
+
files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
234
|
+
|
|
235
|
+
# Create ListedFile objects with metadata
|
|
236
|
+
for full_path in files:
|
|
237
|
+
if not full_path: # Skip empty lines
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Skip if file doesn't exist (though it should)
|
|
241
|
+
if not os.path.exists(full_path):
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
# Extract relative path from the full path
|
|
245
|
+
if full_path.startswith(directory):
|
|
246
|
+
file_path = full_path[len(directory) :].lstrip(os.sep)
|
|
247
|
+
else:
|
|
248
|
+
file_path = full_path
|
|
249
|
+
|
|
250
|
+
# Check if path is a file or directory
|
|
251
|
+
if os.path.isfile(full_path):
|
|
252
|
+
entry_type = "file"
|
|
253
|
+
size = os.path.getsize(full_path)
|
|
254
|
+
elif os.path.isdir(full_path):
|
|
255
|
+
entry_type = "directory"
|
|
256
|
+
size = 0
|
|
257
|
+
else:
|
|
258
|
+
# Skip if it's neither a file nor directory
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# Get stats for the entry
|
|
263
|
+
stat_info = os.stat(full_path)
|
|
264
|
+
actual_size = stat_info.st_size
|
|
265
|
+
|
|
266
|
+
# For files, we use the actual size; for directories, we keep size=0
|
|
267
|
+
if entry_type == "file":
|
|
268
|
+
size = actual_size
|
|
269
|
+
|
|
270
|
+
# Calculate depth based on the relative path
|
|
271
|
+
depth = file_path.count(os.sep)
|
|
272
|
+
|
|
273
|
+
# Add directory entries if needed for files
|
|
274
|
+
if entry_type == "file":
|
|
275
|
+
dir_path = os.path.dirname(file_path)
|
|
276
|
+
if dir_path:
|
|
277
|
+
# Add directory path components if they don't exist
|
|
278
|
+
path_parts = dir_path.split(os.sep)
|
|
279
|
+
for i in range(len(path_parts)):
|
|
280
|
+
partial_path = os.sep.join(path_parts[: i + 1])
|
|
281
|
+
# Check if we already added this directory
|
|
282
|
+
if not any(
|
|
283
|
+
f.path == partial_path and f.type == "directory"
|
|
284
|
+
for f in results
|
|
285
|
+
):
|
|
286
|
+
results.append(
|
|
287
|
+
ListedFile(
|
|
288
|
+
path=partial_path,
|
|
289
|
+
type="directory",
|
|
290
|
+
size=0,
|
|
291
|
+
full_path=os.path.join(
|
|
292
|
+
directory, partial_path
|
|
293
|
+
),
|
|
294
|
+
depth=partial_path.count(os.sep),
|
|
295
|
+
)
|
|
273
296
|
)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
|
|
298
|
+
# Add the entry (file or directory)
|
|
299
|
+
results.append(
|
|
300
|
+
ListedFile(
|
|
301
|
+
path=file_path,
|
|
302
|
+
type=entry_type,
|
|
303
|
+
size=size,
|
|
304
|
+
full_path=full_path,
|
|
305
|
+
depth=depth,
|
|
306
|
+
)
|
|
284
307
|
)
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
continue
|
|
308
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
309
|
+
# Skip files we can't access
|
|
310
|
+
continue
|
|
289
311
|
|
|
290
|
-
# In non-recursive mode, we also need to explicitly list
|
|
291
|
-
# ripgrep's --files option only returns files
|
|
312
|
+
# In non-recursive mode, we also need to explicitly list immediate entries
|
|
313
|
+
# ripgrep's --files option only returns files; we add directories and files ourselves
|
|
292
314
|
if not recursive:
|
|
293
315
|
try:
|
|
294
316
|
entries = os.listdir(directory)
|
|
295
|
-
for entry in entries:
|
|
317
|
+
for entry in sorted(entries):
|
|
296
318
|
full_entry_path = os.path.join(directory, entry)
|
|
297
|
-
|
|
298
|
-
if not os.path.exists(full_entry_path) or os.path.isfile(full_entry_path):
|
|
319
|
+
if not os.path.exists(full_entry_path):
|
|
299
320
|
continue
|
|
300
|
-
|
|
301
|
-
# For non-recursive mode, only include directories that are directly in the target directory
|
|
321
|
+
|
|
302
322
|
if os.path.isdir(full_entry_path):
|
|
303
|
-
#
|
|
323
|
+
# In non-recursive mode, only skip obviously system/hidden directories
|
|
324
|
+
# Don't use the full should_ignore_dir_path which is too aggressive
|
|
325
|
+
if entry.startswith("."):
|
|
326
|
+
continue
|
|
304
327
|
results.append(
|
|
305
328
|
ListedFile(
|
|
306
329
|
path=entry,
|
|
@@ -310,21 +333,30 @@ def _list_files(
|
|
|
310
333
|
depth=0,
|
|
311
334
|
)
|
|
312
335
|
)
|
|
336
|
+
elif os.path.isfile(full_entry_path):
|
|
337
|
+
# Include top-level files (including binaries)
|
|
338
|
+
try:
|
|
339
|
+
size = os.path.getsize(full_entry_path)
|
|
340
|
+
except OSError:
|
|
341
|
+
size = 0
|
|
342
|
+
results.append(
|
|
343
|
+
ListedFile(
|
|
344
|
+
path=entry,
|
|
345
|
+
type="file",
|
|
346
|
+
size=size,
|
|
347
|
+
full_path=full_entry_path,
|
|
348
|
+
depth=0,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
313
351
|
except (FileNotFoundError, PermissionError, OSError):
|
|
314
|
-
# Skip
|
|
352
|
+
# Skip entries we can't access
|
|
315
353
|
pass
|
|
316
354
|
except subprocess.TimeoutExpired:
|
|
317
|
-
error_msg =
|
|
318
|
-
|
|
319
|
-
)
|
|
320
|
-
output_lines.append(error_msg)
|
|
321
|
-
return ListFileOutput(content="\n".join(output_lines))
|
|
355
|
+
error_msg = "Error: List files command timed out after 30 seconds"
|
|
356
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
322
357
|
except Exception as e:
|
|
323
|
-
error_msg =
|
|
324
|
-
|
|
325
|
-
)
|
|
326
|
-
output_lines.append(error_msg)
|
|
327
|
-
return ListFileOutput(content="\n".join(output_lines))
|
|
358
|
+
error_msg = f"Error: Error during list files operation: {e}"
|
|
359
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
328
360
|
finally:
|
|
329
361
|
# Clean up the temporary ignore file
|
|
330
362
|
if ignore_file and os.path.exists(ignore_file):
|
|
@@ -373,62 +405,49 @@ def _list_files(
|
|
|
373
405
|
dir_count = sum(1 for item in results if item.type == "directory")
|
|
374
406
|
file_count = sum(1 for item in results if item.type == "file")
|
|
375
407
|
total_size = sum(item.size for item in results if item.type == "file")
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
# Build the directory header section
|
|
380
|
-
dir_name = os.path.basename(directory) or directory
|
|
381
|
-
dir_header = f"\U0001f4c1 [bold blue]{dir_name}[/bold blue]"
|
|
382
|
-
output_lines.append(dir_header)
|
|
383
|
-
|
|
384
|
-
# Sort all items by path for consistent display
|
|
385
|
-
all_items = sorted(results, key=lambda x: x.path)
|
|
386
408
|
|
|
387
|
-
# Build
|
|
388
|
-
|
|
389
|
-
for item in
|
|
390
|
-
# Skip root directory entries with no path
|
|
409
|
+
# Build structured FileEntry objects for the UI
|
|
410
|
+
file_entries = []
|
|
411
|
+
for item in sorted(results, key=lambda x: x.path):
|
|
391
412
|
if item.type == "directory" and not item.path:
|
|
392
413
|
continue
|
|
414
|
+
file_entries.append(
|
|
415
|
+
FileEntry(
|
|
416
|
+
path=item.path,
|
|
417
|
+
type="dir" if item.type == "directory" else "file",
|
|
418
|
+
size=item.size,
|
|
419
|
+
depth=item.depth or 0,
|
|
420
|
+
)
|
|
421
|
+
)
|
|
393
422
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
prefix += "\u2514\u2500\u2500 "
|
|
405
|
-
else:
|
|
406
|
-
prefix += " "
|
|
423
|
+
# Emit structured message for the UI
|
|
424
|
+
file_listing_msg = FileListingMessage(
|
|
425
|
+
directory=directory,
|
|
426
|
+
files=file_entries,
|
|
427
|
+
recursive=recursive,
|
|
428
|
+
total_size=total_size,
|
|
429
|
+
dir_count=dir_count,
|
|
430
|
+
file_count=file_count,
|
|
431
|
+
)
|
|
432
|
+
get_message_bus().emit(file_listing_msg)
|
|
407
433
|
|
|
408
|
-
|
|
434
|
+
# Build plain text output for LLM consumption
|
|
435
|
+
for item in sorted(results, key=lambda x: x.path):
|
|
436
|
+
if item.type == "directory" and not item.path:
|
|
437
|
+
continue
|
|
409
438
|
name = os.path.basename(item.path) or item.path
|
|
410
|
-
|
|
411
|
-
# Add directory or file line with appropriate formatting
|
|
439
|
+
indent = " " * (item.depth or 0)
|
|
412
440
|
if item.type == "directory":
|
|
413
|
-
|
|
414
|
-
output_lines.append(dir_line)
|
|
441
|
+
output_lines.append(f"{indent}{name}/")
|
|
415
442
|
else:
|
|
416
|
-
icon = get_file_icon(item.path)
|
|
417
443
|
size_str = format_size(item.size)
|
|
418
|
-
|
|
419
|
-
output_lines.append(file_line)
|
|
444
|
+
output_lines.append(f"{indent}{name} ({size_str})")
|
|
420
445
|
|
|
421
|
-
# Add summary
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
summary_line = f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]"
|
|
426
|
-
output_lines.append(summary_line)
|
|
427
|
-
|
|
428
|
-
final_divider = "[dim]" + "─" * 100 + "\n" + "[/dim]"
|
|
429
|
-
output_lines.append(final_divider)
|
|
446
|
+
# Add summary
|
|
447
|
+
output_lines.append(
|
|
448
|
+
f"\nSummary: {dir_count} directories, {file_count} files ({format_size(total_size)} total)"
|
|
449
|
+
)
|
|
430
450
|
|
|
431
|
-
# Return the content string
|
|
432
451
|
return ListFileOutput(content="\n".join(output_lines))
|
|
433
452
|
|
|
434
453
|
|
|
@@ -438,18 +457,8 @@ def _read_file(
|
|
|
438
457
|
start_line: int | None = None,
|
|
439
458
|
num_lines: int | None = None,
|
|
440
459
|
) -> ReadFileOutput:
|
|
441
|
-
file_path = os.path.abspath(file_path)
|
|
442
|
-
|
|
443
|
-
# Generate group_id for this tool execution
|
|
444
|
-
group_id = generate_group_id("read_file", file_path)
|
|
460
|
+
file_path = os.path.abspath(os.path.expanduser(file_path))
|
|
445
461
|
|
|
446
|
-
# Build console message with optional parameters
|
|
447
|
-
console_msg = f"\n[bold white on blue] READ FILE [/bold white on blue] \U0001f4c2 [bold cyan]{file_path}[/bold cyan]"
|
|
448
|
-
if start_line is not None and num_lines is not None:
|
|
449
|
-
console_msg += f" [dim](lines {start_line}-{start_line + num_lines - 1})[/dim]"
|
|
450
|
-
emit_info(console_msg, message_group=group_id)
|
|
451
|
-
|
|
452
|
-
emit_divider(message_group=group_id)
|
|
453
462
|
if not os.path.exists(file_path):
|
|
454
463
|
error_msg = f"File {file_path} does not exist"
|
|
455
464
|
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
@@ -457,12 +466,15 @@ def _read_file(
|
|
|
457
466
|
error_msg = f"{file_path} is not a file"
|
|
458
467
|
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
459
468
|
try:
|
|
460
|
-
|
|
469
|
+
# Use errors="surrogateescape" to handle files with invalid UTF-8 sequences
|
|
470
|
+
# This is common on Windows when files contain emojis or were created by
|
|
471
|
+
# applications that don't properly encode Unicode
|
|
472
|
+
with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
|
461
473
|
if start_line is not None and num_lines is not None:
|
|
462
474
|
# Read only the specified lines
|
|
463
475
|
lines = f.readlines()
|
|
464
|
-
# Adjust for 1-based line numbering
|
|
465
|
-
start_idx = start_line - 1
|
|
476
|
+
# Adjust for 1-based line numbering and handle negative values
|
|
477
|
+
start_idx = start_line - 1 if start_line > 0 else 0
|
|
466
478
|
end_idx = start_idx + num_lines
|
|
467
479
|
# Ensure indices are within bounds
|
|
468
480
|
start_idx = max(0, start_idx)
|
|
@@ -472,6 +484,21 @@ def _read_file(
|
|
|
472
484
|
# Read the entire file
|
|
473
485
|
content = f.read()
|
|
474
486
|
|
|
487
|
+
# Sanitize the content to remove any surrogate characters that could
|
|
488
|
+
# cause issues when the content is later serialized or displayed
|
|
489
|
+
# This re-encodes with surrogatepass then decodes with replace to
|
|
490
|
+
# convert lone surrogates to replacement characters
|
|
491
|
+
try:
|
|
492
|
+
content = content.encode("utf-8", errors="surrogatepass").decode(
|
|
493
|
+
"utf-8", errors="replace"
|
|
494
|
+
)
|
|
495
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
496
|
+
# If that fails, do a more aggressive cleanup
|
|
497
|
+
content = "".join(
|
|
498
|
+
char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
|
|
499
|
+
for char in content
|
|
500
|
+
)
|
|
501
|
+
|
|
475
502
|
# Simple approximation: ~4 characters per token
|
|
476
503
|
num_tokens = len(content) // 4
|
|
477
504
|
if num_tokens > 10000:
|
|
@@ -480,6 +507,30 @@ def _read_file(
|
|
|
480
507
|
error="The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks.",
|
|
481
508
|
num_tokens=0,
|
|
482
509
|
)
|
|
510
|
+
|
|
511
|
+
# Count total lines for the message
|
|
512
|
+
total_lines = content.count("\n") + (
|
|
513
|
+
1 if content and not content.endswith("\n") else 0
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Emit structured message for the UI
|
|
517
|
+
# Only include start_line/num_lines if they are valid positive integers
|
|
518
|
+
emit_start_line = (
|
|
519
|
+
start_line if start_line is not None and start_line >= 1 else None
|
|
520
|
+
)
|
|
521
|
+
emit_num_lines = (
|
|
522
|
+
num_lines if num_lines is not None and num_lines >= 1 else None
|
|
523
|
+
)
|
|
524
|
+
file_content_msg = FileContentMessage(
|
|
525
|
+
path=file_path,
|
|
526
|
+
content=content,
|
|
527
|
+
start_line=emit_start_line,
|
|
528
|
+
num_lines=emit_num_lines,
|
|
529
|
+
total_lines=total_lines,
|
|
530
|
+
num_tokens=num_tokens,
|
|
531
|
+
)
|
|
532
|
+
get_message_bus().emit(file_content_msg)
|
|
533
|
+
|
|
483
534
|
return ReadFileOutput(content=content, num_tokens=num_tokens)
|
|
484
535
|
except (FileNotFoundError, PermissionError):
|
|
485
536
|
# For backward compatibility with tests, return "FILE NOT FOUND" for these specific errors
|
|
@@ -490,24 +541,46 @@ def _read_file(
|
|
|
490
541
|
return ReadFileOutput(content=message, num_tokens=0, error=message)
|
|
491
542
|
|
|
492
543
|
|
|
544
|
+
def _sanitize_string(text: str) -> str:
|
|
545
|
+
"""Sanitize a string to remove invalid Unicode surrogates.
|
|
546
|
+
|
|
547
|
+
This handles encoding issues common on Windows with copy-paste operations.
|
|
548
|
+
"""
|
|
549
|
+
if not text:
|
|
550
|
+
return text
|
|
551
|
+
try:
|
|
552
|
+
# Try encoding - if it works, string is clean
|
|
553
|
+
text.encode("utf-8")
|
|
554
|
+
return text
|
|
555
|
+
except UnicodeEncodeError:
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
# Encode allowing surrogates, then decode replacing them
|
|
560
|
+
return text.encode("utf-8", errors="surrogatepass").decode(
|
|
561
|
+
"utf-8", errors="replace"
|
|
562
|
+
)
|
|
563
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
564
|
+
# Last resort: filter out surrogate characters
|
|
565
|
+
return "".join(
|
|
566
|
+
char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
|
|
567
|
+
for char in text
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
493
571
|
def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
|
|
494
|
-
import subprocess
|
|
495
572
|
import json
|
|
496
573
|
import os
|
|
497
574
|
import shutil
|
|
575
|
+
import subprocess
|
|
498
576
|
import sys
|
|
499
577
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
# Generate group_id for this tool execution
|
|
504
|
-
group_id = generate_group_id("grep", f"{directory}_{search_string}")
|
|
578
|
+
# Sanitize search string to handle any surrogates from copy-paste
|
|
579
|
+
search_string = _sanitize_string(search_string)
|
|
505
580
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
)
|
|
510
|
-
emit_divider(message_group=group_id)
|
|
581
|
+
directory = os.path.abspath(os.path.expanduser(directory))
|
|
582
|
+
matches: List[MatchInfo] = []
|
|
583
|
+
error_message: str | None = None
|
|
511
584
|
|
|
512
585
|
# Create a temporary ignore file with our ignore patterns
|
|
513
586
|
ignore_file = None
|
|
@@ -539,11 +612,10 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
539
612
|
break
|
|
540
613
|
|
|
541
614
|
if not rg_path:
|
|
542
|
-
|
|
543
|
-
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
544
|
-
message_group=group_id,
|
|
615
|
+
error_message = (
|
|
616
|
+
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
545
617
|
)
|
|
546
|
-
return GrepOutput(matches=[])
|
|
618
|
+
return GrepOutput(matches=[], error=error_message)
|
|
547
619
|
|
|
548
620
|
cmd = [
|
|
549
621
|
rg_path,
|
|
@@ -556,16 +628,24 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
556
628
|
]
|
|
557
629
|
|
|
558
630
|
# Add ignore patterns to the command via a temporary file
|
|
559
|
-
from code_puppy.tools.common import
|
|
631
|
+
from code_puppy.tools.common import DIR_IGNORE_PATTERNS
|
|
560
632
|
|
|
561
633
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".ignore") as f:
|
|
562
634
|
ignore_file = f.name
|
|
563
|
-
for pattern in
|
|
635
|
+
for pattern in DIR_IGNORE_PATTERNS:
|
|
564
636
|
f.write(f"{pattern}\n")
|
|
565
637
|
|
|
566
638
|
cmd.extend(["--ignore-file", ignore_file])
|
|
567
639
|
cmd.extend([search_string, directory])
|
|
568
|
-
|
|
640
|
+
# Use encoding with error handling to handle files with invalid UTF-8
|
|
641
|
+
result = subprocess.run(
|
|
642
|
+
cmd,
|
|
643
|
+
capture_output=True,
|
|
644
|
+
text=True,
|
|
645
|
+
timeout=30,
|
|
646
|
+
encoding="utf-8",
|
|
647
|
+
errors="replace", # Replace invalid chars instead of crashing
|
|
648
|
+
)
|
|
569
649
|
|
|
570
650
|
# Parse the JSON output from ripgrep
|
|
571
651
|
for line in result.stdout.strip().split("\n"):
|
|
@@ -589,49 +669,57 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
589
669
|
if len(line_content.strip()) > 512:
|
|
590
670
|
line_content = line_content.strip()[0:512]
|
|
591
671
|
if file_path and line_number:
|
|
672
|
+
# Sanitize content to handle any remaining encoding issues
|
|
592
673
|
match_info = MatchInfo(
|
|
593
|
-
file_path=file_path,
|
|
674
|
+
file_path=_sanitize_string(file_path),
|
|
594
675
|
line_number=line_number,
|
|
595
|
-
line_content=line_content.strip(),
|
|
676
|
+
line_content=_sanitize_string(line_content.strip()),
|
|
596
677
|
)
|
|
597
678
|
matches.append(match_info)
|
|
598
679
|
# Limit to 50 matches total, same as original implementation
|
|
599
680
|
if len(matches) >= 50:
|
|
600
681
|
break
|
|
601
|
-
emit_system_message(
|
|
602
|
-
f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}",
|
|
603
|
-
message_group=group_id,
|
|
604
|
-
)
|
|
605
682
|
except json.JSONDecodeError:
|
|
606
683
|
# Skip lines that aren't valid JSON
|
|
607
684
|
continue
|
|
608
685
|
|
|
609
|
-
if not matches:
|
|
610
|
-
emit_warning(
|
|
611
|
-
f"No matches found for '{search_string}' in {directory}",
|
|
612
|
-
message_group=group_id,
|
|
613
|
-
)
|
|
614
|
-
else:
|
|
615
|
-
emit_success(
|
|
616
|
-
f"Found {len(matches)} match(es) for '{search_string}' in {directory}",
|
|
617
|
-
message_group=group_id,
|
|
618
|
-
)
|
|
619
|
-
|
|
620
686
|
except subprocess.TimeoutExpired:
|
|
621
|
-
|
|
687
|
+
error_message = "Grep command timed out after 30 seconds"
|
|
622
688
|
except FileNotFoundError:
|
|
623
|
-
|
|
624
|
-
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
625
|
-
message_group=group_id,
|
|
689
|
+
error_message = (
|
|
690
|
+
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
626
691
|
)
|
|
627
692
|
except Exception as e:
|
|
628
|
-
|
|
693
|
+
error_message = f"Error during grep operation: {e}"
|
|
629
694
|
finally:
|
|
630
695
|
# Clean up the temporary ignore file
|
|
631
696
|
if ignore_file and os.path.exists(ignore_file):
|
|
632
697
|
os.unlink(ignore_file)
|
|
633
698
|
|
|
634
|
-
|
|
699
|
+
# Build structured GrepMatch objects for the UI
|
|
700
|
+
grep_matches = [
|
|
701
|
+
GrepMatch(
|
|
702
|
+
file_path=m.file_path or "",
|
|
703
|
+
line_number=m.line_number or 1,
|
|
704
|
+
line_content=m.line_content or "",
|
|
705
|
+
)
|
|
706
|
+
for m in matches
|
|
707
|
+
]
|
|
708
|
+
|
|
709
|
+
# Count unique files searched (approximation based on matches)
|
|
710
|
+
unique_files = len(set(m.file_path for m in matches)) if matches else 0
|
|
711
|
+
|
|
712
|
+
# Emit structured message for the UI (only once, at the end)
|
|
713
|
+
grep_result_msg = GrepResultMessage(
|
|
714
|
+
search_term=search_string,
|
|
715
|
+
directory=directory,
|
|
716
|
+
matches=grep_matches,
|
|
717
|
+
total_matches=len(matches),
|
|
718
|
+
files_searched=unique_files,
|
|
719
|
+
)
|
|
720
|
+
get_message_bus().emit(grep_result_msg)
|
|
721
|
+
|
|
722
|
+
return GrepOutput(matches=matches, error=error_message)
|
|
635
723
|
|
|
636
724
|
|
|
637
725
|
def register_list_files(agent):
|
|
@@ -692,10 +780,8 @@ def register_list_files(agent):
|
|
|
692
780
|
recursive = False
|
|
693
781
|
result = _list_files(context, directory, recursive)
|
|
694
782
|
|
|
695
|
-
#
|
|
696
|
-
|
|
697
|
-
result.content, message_group=generate_group_id("list_files", directory)
|
|
698
|
-
)
|
|
783
|
+
# The structured FileListingMessage is already emitted by _list_files
|
|
784
|
+
# No need to emit again here
|
|
699
785
|
if warning:
|
|
700
786
|
result.error = warning
|
|
701
787
|
if (len(result.content)) > 200000:
|