newcode 0.1.1__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 +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +147 -0
- 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 +630 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +122 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +380 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +167 -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_scheduler.py +121 -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 +2145 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +296 -0
- code_puppy/agents/pack/husky.py +307 -0
- code_puppy/agents/pack/retriever.py +380 -0
- code_puppy/agents/pack/shepherd.py +327 -0
- code_puppy/agents/pack/terrier.py +281 -0
- code_puppy/agents/pack/watchdog.py +357 -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 +453 -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 +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +674 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +664 -0
- code_puppy/cli_runner.py +1038 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +526 -0
- code_puppy/command_line/command_handler.py +283 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +853 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- 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 +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +91 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1787 -0
- code_puppy/error_logging.py +133 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +15 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +195 -0
- code_puppy/hook_engine/executor.py +293 -0
- code_puppy/hook_engine/matcher.py +145 -0
- code_puppy/hook_engine/models.py +222 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +141 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1153 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +96 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +130 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- 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 +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -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 +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -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 +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +131 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -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 +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +601 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -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/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +277 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
- code_puppy/plugins/oauth_puppy_html.py +224 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -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/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +317 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +470 -0
- code_puppy/tools/agent_tools.py +616 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +36 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- 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 +378 -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 +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +739 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +251 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- newcode-0.1.1.data/data/code_puppy/models.json +130 -0
- newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
- newcode-0.1.1.dist-info/METADATA +154 -0
- newcode-0.1.1.dist-info/RECORD +289 -0
- newcode-0.1.1.dist-info/WHEEL +4 -0
- newcode-0.1.1.dist-info/entry_points.txt +3 -0
- newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
# file_operations.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, conint
|
|
10
|
+
from pydantic_ai import RunContext
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Module-level helper functions (exposed for unit tests _and_ used as tools)
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
from code_puppy.messaging import ( # New structured messaging types
|
|
16
|
+
FileContentMessage,
|
|
17
|
+
FileEntry,
|
|
18
|
+
FileListingMessage,
|
|
19
|
+
GrepMatch,
|
|
20
|
+
GrepResultMessage,
|
|
21
|
+
get_message_bus,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Pydantic models for tool return types
|
|
26
|
+
class ListedFile(BaseModel):
|
|
27
|
+
path: str | None
|
|
28
|
+
type: str | None
|
|
29
|
+
size: int = 0
|
|
30
|
+
full_path: str | None
|
|
31
|
+
depth: int | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ListFileOutput(BaseModel):
|
|
35
|
+
content: str
|
|
36
|
+
error: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReadFileOutput(BaseModel):
|
|
40
|
+
content: str | None
|
|
41
|
+
num_tokens: conint(lt=10000)
|
|
42
|
+
error: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MatchInfo(BaseModel):
|
|
46
|
+
file_path: str | None
|
|
47
|
+
line_number: int | None
|
|
48
|
+
line_content: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GrepOutput(BaseModel):
|
|
52
|
+
matches: List[MatchInfo]
|
|
53
|
+
error: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_likely_home_directory(directory):
|
|
57
|
+
"""Detect if directory is likely a user's home directory or common home subdirectory"""
|
|
58
|
+
abs_dir = os.path.abspath(directory)
|
|
59
|
+
home_dir = os.path.expanduser("~")
|
|
60
|
+
|
|
61
|
+
# Exact home directory match
|
|
62
|
+
if abs_dir == home_dir:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
# Check for common home directory subdirectories
|
|
66
|
+
common_home_subdirs = {
|
|
67
|
+
"Documents",
|
|
68
|
+
"Desktop",
|
|
69
|
+
"Downloads",
|
|
70
|
+
"Pictures",
|
|
71
|
+
"Music",
|
|
72
|
+
"Videos",
|
|
73
|
+
"Movies",
|
|
74
|
+
"Public",
|
|
75
|
+
"Library",
|
|
76
|
+
"Applications", # Cover macOS/Linux
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
os.path.basename(abs_dir) in common_home_subdirs
|
|
80
|
+
and os.path.dirname(abs_dir) == home_dir
|
|
81
|
+
):
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_project_directory(directory):
|
|
88
|
+
"""Quick heuristic to detect if this looks like a project directory"""
|
|
89
|
+
project_indicators = {
|
|
90
|
+
"package.json",
|
|
91
|
+
"pyproject.toml",
|
|
92
|
+
"Cargo.toml",
|
|
93
|
+
"pom.xml",
|
|
94
|
+
"build.gradle",
|
|
95
|
+
"CMakeLists.txt",
|
|
96
|
+
".git",
|
|
97
|
+
"requirements.txt",
|
|
98
|
+
"composer.json",
|
|
99
|
+
"Gemfile",
|
|
100
|
+
"go.mod",
|
|
101
|
+
"Makefile",
|
|
102
|
+
"setup.py",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
contents = os.listdir(directory)
|
|
107
|
+
return any(indicator in contents for indicator in project_indicators)
|
|
108
|
+
except (OSError, PermissionError):
|
|
109
|
+
return False
|
|
110
|
+
|
|
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
|
+
|
|
151
|
+
def _list_files(
|
|
152
|
+
context: RunContext, directory: str = ".", recursive: bool = True
|
|
153
|
+
) -> ListFileOutput:
|
|
154
|
+
import sys
|
|
155
|
+
|
|
156
|
+
results = []
|
|
157
|
+
directory = os.path.abspath(os.path.expanduser(directory))
|
|
158
|
+
|
|
159
|
+
# Plain text output for LLM consumption
|
|
160
|
+
output_lines = []
|
|
161
|
+
output_lines.append(f"DIRECTORY LISTING: {directory} (recursive={recursive})")
|
|
162
|
+
|
|
163
|
+
if not os.path.exists(directory):
|
|
164
|
+
error_msg = f"Error: Directory '{directory}' does not exist"
|
|
165
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
166
|
+
if not os.path.isdir(directory):
|
|
167
|
+
error_msg = f"Error: '{directory}' is not a directory"
|
|
168
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
169
|
+
|
|
170
|
+
# Smart home directory detection - auto-limit recursion for performance
|
|
171
|
+
# But allow recursion in tests (when context=None) or when explicitly requested
|
|
172
|
+
if context is not None and is_likely_home_directory(directory) and recursive:
|
|
173
|
+
if not is_project_directory(directory):
|
|
174
|
+
output_lines.append(
|
|
175
|
+
"Warning: Detected home directory - limiting to non-recursive listing for performance"
|
|
176
|
+
)
|
|
177
|
+
recursive = False
|
|
178
|
+
|
|
179
|
+
# Create a temporary ignore file with our ignore patterns
|
|
180
|
+
ignore_file = None
|
|
181
|
+
try:
|
|
182
|
+
# Find ripgrep executable - first check system PATH, then virtual environment
|
|
183
|
+
rg_path = shutil.which("rg")
|
|
184
|
+
if not rg_path:
|
|
185
|
+
# Try to find it in the virtual environment
|
|
186
|
+
# Use sys.executable to determine the Python environment path
|
|
187
|
+
python_dir = os.path.dirname(sys.executable)
|
|
188
|
+
# python_dir is already bin/ (Unix) or Scripts/ (Windows)
|
|
189
|
+
for name in ["rg", "rg.exe"]:
|
|
190
|
+
candidate = os.path.join(python_dir, name)
|
|
191
|
+
if os.path.exists(candidate):
|
|
192
|
+
rg_path = candidate
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
if not rg_path and recursive:
|
|
196
|
+
# Only need ripgrep for recursive listings
|
|
197
|
+
error_msg = "Error: ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
198
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
199
|
+
|
|
200
|
+
# Only use ripgrep for recursive listings
|
|
201
|
+
if recursive:
|
|
202
|
+
# Build command for ripgrep --files
|
|
203
|
+
cmd = [rg_path, "--files"]
|
|
204
|
+
|
|
205
|
+
# Add ignore patterns to the command via a temporary file
|
|
206
|
+
from code_puppy.tools.common import (
|
|
207
|
+
DIR_IGNORE_PATTERNS,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".ignore")
|
|
211
|
+
ignore_file = f.name
|
|
212
|
+
try:
|
|
213
|
+
for pattern in DIR_IGNORE_PATTERNS:
|
|
214
|
+
# Skip patterns that would match the search directory itself
|
|
215
|
+
# For example, if searching in /tmp/test-dir, skip **/tmp/**
|
|
216
|
+
if would_match_directory(pattern, directory):
|
|
217
|
+
continue
|
|
218
|
+
f.write(f"{pattern}\n")
|
|
219
|
+
finally:
|
|
220
|
+
f.close()
|
|
221
|
+
|
|
222
|
+
cmd.extend(["--ignore-file", ignore_file])
|
|
223
|
+
cmd.append(directory)
|
|
224
|
+
|
|
225
|
+
# Run ripgrep to get file listing
|
|
226
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
227
|
+
|
|
228
|
+
# Process the output lines
|
|
229
|
+
files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
230
|
+
|
|
231
|
+
# Create ListedFile objects with metadata
|
|
232
|
+
for full_path in files:
|
|
233
|
+
if not full_path: # Skip empty lines
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Skip if file doesn't exist (though it should)
|
|
237
|
+
if not os.path.exists(full_path):
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Extract relative path from the full path
|
|
241
|
+
if full_path.startswith(directory):
|
|
242
|
+
file_path = full_path[len(directory) :].lstrip(os.sep)
|
|
243
|
+
else:
|
|
244
|
+
file_path = full_path
|
|
245
|
+
|
|
246
|
+
# Check if path is a file or directory
|
|
247
|
+
if os.path.isfile(full_path):
|
|
248
|
+
entry_type = "file"
|
|
249
|
+
size = os.path.getsize(full_path)
|
|
250
|
+
elif os.path.isdir(full_path):
|
|
251
|
+
entry_type = "directory"
|
|
252
|
+
size = 0
|
|
253
|
+
else:
|
|
254
|
+
# Skip if it's neither a file nor directory
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Get stats for the entry
|
|
259
|
+
stat_info = os.stat(full_path)
|
|
260
|
+
actual_size = stat_info.st_size
|
|
261
|
+
|
|
262
|
+
# For files, we use the actual size; for directories, we keep size=0
|
|
263
|
+
if entry_type == "file":
|
|
264
|
+
size = actual_size
|
|
265
|
+
|
|
266
|
+
# Calculate depth based on the relative path
|
|
267
|
+
depth = file_path.count(os.sep)
|
|
268
|
+
|
|
269
|
+
# Add directory entries if needed for files
|
|
270
|
+
if entry_type == "file":
|
|
271
|
+
dir_path = os.path.dirname(file_path)
|
|
272
|
+
if dir_path:
|
|
273
|
+
# Add directory path components if they don't exist
|
|
274
|
+
path_parts = dir_path.split(os.sep)
|
|
275
|
+
for i in range(len(path_parts)):
|
|
276
|
+
partial_path = os.sep.join(path_parts[: i + 1])
|
|
277
|
+
# Check if we already added this directory
|
|
278
|
+
if not any(
|
|
279
|
+
f.path == partial_path and f.type == "directory"
|
|
280
|
+
for f in results
|
|
281
|
+
):
|
|
282
|
+
results.append(
|
|
283
|
+
ListedFile(
|
|
284
|
+
path=partial_path,
|
|
285
|
+
type="directory",
|
|
286
|
+
size=0,
|
|
287
|
+
full_path=os.path.join(
|
|
288
|
+
directory, partial_path
|
|
289
|
+
),
|
|
290
|
+
depth=partial_path.count(os.sep),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Add the entry (file or directory)
|
|
295
|
+
results.append(
|
|
296
|
+
ListedFile(
|
|
297
|
+
path=file_path,
|
|
298
|
+
type=entry_type,
|
|
299
|
+
size=size,
|
|
300
|
+
full_path=full_path,
|
|
301
|
+
depth=depth,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
305
|
+
# Skip files we can't access
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
# In non-recursive mode, we also need to explicitly list immediate entries
|
|
309
|
+
# ripgrep's --files option only returns files; we add directories and files ourselves
|
|
310
|
+
if not recursive:
|
|
311
|
+
try:
|
|
312
|
+
entries = os.listdir(directory)
|
|
313
|
+
for entry in sorted(entries):
|
|
314
|
+
full_entry_path = os.path.join(directory, entry)
|
|
315
|
+
if not os.path.exists(full_entry_path):
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
if os.path.isdir(full_entry_path):
|
|
319
|
+
# In non-recursive mode, only skip obviously system/hidden directories
|
|
320
|
+
# Don't use the full should_ignore_dir_path which is too aggressive
|
|
321
|
+
if entry.startswith("."):
|
|
322
|
+
continue
|
|
323
|
+
results.append(
|
|
324
|
+
ListedFile(
|
|
325
|
+
path=entry,
|
|
326
|
+
type="directory",
|
|
327
|
+
size=0,
|
|
328
|
+
full_path=full_entry_path,
|
|
329
|
+
depth=0,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
elif os.path.isfile(full_entry_path):
|
|
333
|
+
# Include top-level files (including binaries)
|
|
334
|
+
try:
|
|
335
|
+
size = os.path.getsize(full_entry_path)
|
|
336
|
+
except OSError:
|
|
337
|
+
size = 0
|
|
338
|
+
results.append(
|
|
339
|
+
ListedFile(
|
|
340
|
+
path=entry,
|
|
341
|
+
type="file",
|
|
342
|
+
size=size,
|
|
343
|
+
full_path=full_entry_path,
|
|
344
|
+
depth=0,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
348
|
+
# Skip entries we can't access
|
|
349
|
+
pass
|
|
350
|
+
except subprocess.TimeoutExpired:
|
|
351
|
+
error_msg = "Error: List files command timed out after 30 seconds"
|
|
352
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
error_msg = f"Error: Error during list files operation: {e}"
|
|
355
|
+
return ListFileOutput(content=error_msg, error=error_msg)
|
|
356
|
+
finally:
|
|
357
|
+
# Clean up the temporary ignore file
|
|
358
|
+
if ignore_file and os.path.exists(ignore_file):
|
|
359
|
+
os.unlink(ignore_file)
|
|
360
|
+
|
|
361
|
+
def format_size(size_bytes):
|
|
362
|
+
if size_bytes < 1024:
|
|
363
|
+
return f"{size_bytes} B"
|
|
364
|
+
elif size_bytes < 1024 * 1024:
|
|
365
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
366
|
+
elif size_bytes < 1024 * 1024 * 1024:
|
|
367
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
368
|
+
else:
|
|
369
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
370
|
+
|
|
371
|
+
def get_file_icon(file_path):
|
|
372
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
373
|
+
if ext in [".py", ".pyw"]:
|
|
374
|
+
return "\U0001f40d"
|
|
375
|
+
elif ext in [".js", ".jsx", ".ts", ".tsx"]:
|
|
376
|
+
return "\U0001f4dc"
|
|
377
|
+
elif ext in [".html", ".htm", ".xml"]:
|
|
378
|
+
return "\U0001f310"
|
|
379
|
+
elif ext in [".css", ".scss", ".sass"]:
|
|
380
|
+
return "\U0001f3a8"
|
|
381
|
+
elif ext in [".md", ".markdown", ".rst"]:
|
|
382
|
+
return "\U0001f4dd"
|
|
383
|
+
elif ext in [".json", ".yaml", ".yml", ".toml"]:
|
|
384
|
+
return "\u2699\ufe0f"
|
|
385
|
+
elif ext in [".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"]:
|
|
386
|
+
return "\U0001f5bc\ufe0f"
|
|
387
|
+
elif ext in [".mp3", ".wav", ".ogg", ".flac"]:
|
|
388
|
+
return "\U0001f3b5"
|
|
389
|
+
elif ext in [".mp4", ".avi", ".mov", ".webm"]:
|
|
390
|
+
return "\U0001f3ac"
|
|
391
|
+
elif ext in [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]:
|
|
392
|
+
return "\U0001f4c4"
|
|
393
|
+
elif ext in [".zip", ".tar", ".gz", ".rar", ".7z"]:
|
|
394
|
+
return "\U0001f4e6"
|
|
395
|
+
elif ext in [".exe", ".dll", ".so", ".dylib"]:
|
|
396
|
+
return "\u26a1"
|
|
397
|
+
else:
|
|
398
|
+
return "\U0001f4c4"
|
|
399
|
+
|
|
400
|
+
# Count items in results
|
|
401
|
+
dir_count = sum(1 for item in results if item.type == "directory")
|
|
402
|
+
file_count = sum(1 for item in results if item.type == "file")
|
|
403
|
+
total_size = sum(item.size for item in results if item.type == "file")
|
|
404
|
+
|
|
405
|
+
# Build structured FileEntry objects for the UI
|
|
406
|
+
file_entries = []
|
|
407
|
+
|
|
408
|
+
def _sort_key(item):
|
|
409
|
+
"""Sort by path components to keep children grouped under parents.
|
|
410
|
+
|
|
411
|
+
Splitting on os.sep ensures 'src/foo' always sorts right after 'src'
|
|
412
|
+
rather than letting 'src-tauri' (with '-' < '/') slip in between.
|
|
413
|
+
Directories sort before files at the same level.
|
|
414
|
+
"""
|
|
415
|
+
parts = item.path.split(os.sep)
|
|
416
|
+
return (parts, item.type != "directory")
|
|
417
|
+
|
|
418
|
+
for item in sorted(results, key=_sort_key):
|
|
419
|
+
if item.type == "directory" and not item.path:
|
|
420
|
+
continue
|
|
421
|
+
file_entries.append(
|
|
422
|
+
FileEntry(
|
|
423
|
+
path=item.path,
|
|
424
|
+
type="dir" if item.type == "directory" else "file",
|
|
425
|
+
size=item.size,
|
|
426
|
+
depth=item.depth or 0,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Emit structured message for the UI
|
|
431
|
+
file_listing_msg = FileListingMessage(
|
|
432
|
+
directory=directory,
|
|
433
|
+
files=file_entries,
|
|
434
|
+
recursive=recursive,
|
|
435
|
+
total_size=total_size,
|
|
436
|
+
dir_count=dir_count,
|
|
437
|
+
file_count=file_count,
|
|
438
|
+
)
|
|
439
|
+
get_message_bus().emit(file_listing_msg)
|
|
440
|
+
|
|
441
|
+
# Build plain text output for LLM consumption
|
|
442
|
+
for item in sorted(results, key=_sort_key):
|
|
443
|
+
if item.type == "directory" and not item.path:
|
|
444
|
+
continue
|
|
445
|
+
name = os.path.basename(item.path) or item.path
|
|
446
|
+
indent = " " * (item.depth or 0)
|
|
447
|
+
if item.type == "directory":
|
|
448
|
+
output_lines.append(f"{indent}{name}/")
|
|
449
|
+
else:
|
|
450
|
+
size_str = format_size(item.size)
|
|
451
|
+
output_lines.append(f"{indent}{name} ({size_str})")
|
|
452
|
+
|
|
453
|
+
# Add summary
|
|
454
|
+
output_lines.append(
|
|
455
|
+
f"\nSummary: {dir_count} directories, {file_count} files ({format_size(total_size)} total)"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return ListFileOutput(content="\n".join(output_lines))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _read_file(
|
|
462
|
+
context: RunContext,
|
|
463
|
+
file_path: str,
|
|
464
|
+
start_line: int | None = None,
|
|
465
|
+
num_lines: int | None = None,
|
|
466
|
+
) -> ReadFileOutput:
|
|
467
|
+
file_path = os.path.abspath(os.path.expanduser(file_path))
|
|
468
|
+
|
|
469
|
+
if not os.path.exists(file_path):
|
|
470
|
+
error_msg = f"File {file_path} does not exist"
|
|
471
|
+
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
472
|
+
if not os.path.isfile(file_path):
|
|
473
|
+
error_msg = f"{file_path} is not a file"
|
|
474
|
+
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
475
|
+
try:
|
|
476
|
+
# Use errors="surrogateescape" to handle files with invalid UTF-8 sequences
|
|
477
|
+
# This is common on Windows when files contain emojis or were created by
|
|
478
|
+
# applications that don't properly encode Unicode
|
|
479
|
+
with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
|
480
|
+
if start_line is not None and start_line < 1:
|
|
481
|
+
error_msg = "start_line must be >= 1 (1-based indexing)"
|
|
482
|
+
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
483
|
+
if num_lines is not None and num_lines < 1:
|
|
484
|
+
error_msg = "num_lines must be >= 1"
|
|
485
|
+
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
486
|
+
if start_line is not None and num_lines is not None:
|
|
487
|
+
# Read only the specified lines efficiently using itertools.islice
|
|
488
|
+
# to avoid loading the entire file into memory
|
|
489
|
+
import itertools
|
|
490
|
+
|
|
491
|
+
start_idx = start_line - 1
|
|
492
|
+
selected_lines = list(
|
|
493
|
+
itertools.islice(f, start_idx, start_idx + num_lines)
|
|
494
|
+
)
|
|
495
|
+
content = "".join(selected_lines)
|
|
496
|
+
else:
|
|
497
|
+
# Read the entire file
|
|
498
|
+
content = f.read()
|
|
499
|
+
|
|
500
|
+
# Sanitize the content to remove any surrogate characters that could
|
|
501
|
+
# cause issues when the content is later serialized or displayed
|
|
502
|
+
# This re-encodes with surrogatepass then decodes with replace to
|
|
503
|
+
# convert lone surrogates to replacement characters
|
|
504
|
+
try:
|
|
505
|
+
content = content.encode("utf-8", errors="surrogatepass").decode(
|
|
506
|
+
"utf-8", errors="replace"
|
|
507
|
+
)
|
|
508
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
509
|
+
# If that fails, do a more aggressive cleanup
|
|
510
|
+
content = "".join(
|
|
511
|
+
char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
|
|
512
|
+
for char in content
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Simple approximation: ~4 characters per token
|
|
516
|
+
num_tokens = len(content) // 4
|
|
517
|
+
if num_tokens > 10000:
|
|
518
|
+
return ReadFileOutput(
|
|
519
|
+
content=None,
|
|
520
|
+
error="The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks.",
|
|
521
|
+
num_tokens=0,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# Count total lines for the message
|
|
525
|
+
total_lines = content.count("\n") + (
|
|
526
|
+
1 if content and not content.endswith("\n") else 0
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Emit structured message for the UI
|
|
530
|
+
# Only include start_line/num_lines if they are valid positive integers
|
|
531
|
+
emit_start_line = (
|
|
532
|
+
start_line if start_line is not None and start_line >= 1 else None
|
|
533
|
+
)
|
|
534
|
+
emit_num_lines = (
|
|
535
|
+
num_lines if num_lines is not None and num_lines >= 1 else None
|
|
536
|
+
)
|
|
537
|
+
file_content_msg = FileContentMessage(
|
|
538
|
+
path=file_path,
|
|
539
|
+
content=content,
|
|
540
|
+
start_line=emit_start_line,
|
|
541
|
+
num_lines=emit_num_lines,
|
|
542
|
+
total_lines=total_lines,
|
|
543
|
+
num_tokens=num_tokens,
|
|
544
|
+
)
|
|
545
|
+
get_message_bus().emit(file_content_msg)
|
|
546
|
+
|
|
547
|
+
return ReadFileOutput(content=content, num_tokens=num_tokens)
|
|
548
|
+
except FileNotFoundError:
|
|
549
|
+
error_msg = "FILE NOT FOUND"
|
|
550
|
+
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
551
|
+
except PermissionError:
|
|
552
|
+
error_msg = "PERMISSION DENIED"
|
|
553
|
+
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
554
|
+
except Exception as e:
|
|
555
|
+
message = f"An error occurred trying to read the file: {e}"
|
|
556
|
+
return ReadFileOutput(content=message, num_tokens=0, error=message)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _sanitize_string(text: str) -> str:
|
|
560
|
+
"""Sanitize a string to remove invalid Unicode surrogates.
|
|
561
|
+
|
|
562
|
+
This handles encoding issues common on Windows with copy-paste operations.
|
|
563
|
+
"""
|
|
564
|
+
if not text:
|
|
565
|
+
return text
|
|
566
|
+
try:
|
|
567
|
+
# Try encoding - if it works, string is clean
|
|
568
|
+
text.encode("utf-8")
|
|
569
|
+
return text
|
|
570
|
+
except UnicodeEncodeError:
|
|
571
|
+
pass
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
# Encode allowing surrogates, then decode replacing them
|
|
575
|
+
return text.encode("utf-8", errors="surrogatepass").decode(
|
|
576
|
+
"utf-8", errors="replace"
|
|
577
|
+
)
|
|
578
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
579
|
+
# Last resort: filter out surrogate characters
|
|
580
|
+
return "".join(
|
|
581
|
+
char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
|
|
582
|
+
for char in text
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
|
|
587
|
+
import json
|
|
588
|
+
import os
|
|
589
|
+
import shlex
|
|
590
|
+
import shutil
|
|
591
|
+
import subprocess
|
|
592
|
+
import sys
|
|
593
|
+
|
|
594
|
+
# Sanitize search string to handle any surrogates from copy-paste
|
|
595
|
+
search_string = _sanitize_string(search_string)
|
|
596
|
+
|
|
597
|
+
directory = os.path.abspath(os.path.expanduser(directory))
|
|
598
|
+
matches: List[MatchInfo] = []
|
|
599
|
+
error_message: str | None = None
|
|
600
|
+
|
|
601
|
+
# Create a temporary ignore file with our ignore patterns
|
|
602
|
+
ignore_file = None
|
|
603
|
+
try:
|
|
604
|
+
# Use ripgrep to search for the string
|
|
605
|
+
# Use absolute path to ensure it works from any directory
|
|
606
|
+
# --json for structured output
|
|
607
|
+
# --max-count 50 to limit results
|
|
608
|
+
# --max-filesize 5M to avoid huge files (increased from 1M)
|
|
609
|
+
# --type=all to search across all recognized text file types
|
|
610
|
+
# --ignore-file to obey our ignore list
|
|
611
|
+
|
|
612
|
+
# Find ripgrep executable - first check system PATH, then virtual environment
|
|
613
|
+
rg_path = shutil.which("rg")
|
|
614
|
+
if not rg_path:
|
|
615
|
+
# Try to find it in the virtual environment
|
|
616
|
+
# Use sys.executable to determine the Python environment path
|
|
617
|
+
python_dir = os.path.dirname(sys.executable)
|
|
618
|
+
# python_dir is already bin/ (Unix) or Scripts/ (Windows)
|
|
619
|
+
for name in ["rg", "rg.exe"]:
|
|
620
|
+
candidate = os.path.join(python_dir, name)
|
|
621
|
+
if os.path.exists(candidate):
|
|
622
|
+
rg_path = candidate
|
|
623
|
+
break
|
|
624
|
+
|
|
625
|
+
if not rg_path:
|
|
626
|
+
error_message = (
|
|
627
|
+
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
628
|
+
)
|
|
629
|
+
return GrepOutput(matches=[], error=error_message)
|
|
630
|
+
|
|
631
|
+
cmd = [
|
|
632
|
+
rg_path,
|
|
633
|
+
"--json",
|
|
634
|
+
"--max-count",
|
|
635
|
+
"50",
|
|
636
|
+
"--max-filesize",
|
|
637
|
+
"5M",
|
|
638
|
+
"--type=all",
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
# Add ignore patterns to the command via a temporary file
|
|
642
|
+
from code_puppy.tools.common import DIR_IGNORE_PATTERNS
|
|
643
|
+
|
|
644
|
+
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".ignore")
|
|
645
|
+
ignore_file = f.name
|
|
646
|
+
try:
|
|
647
|
+
for pattern in DIR_IGNORE_PATTERNS:
|
|
648
|
+
f.write(f"{pattern}\n")
|
|
649
|
+
finally:
|
|
650
|
+
f.close()
|
|
651
|
+
|
|
652
|
+
cmd.extend(["--ignore-file", ignore_file])
|
|
653
|
+
# Split search_string to support ripgrep flags like --ignore-case
|
|
654
|
+
try:
|
|
655
|
+
parts = shlex.split(search_string)
|
|
656
|
+
except ValueError:
|
|
657
|
+
# Fallback for unmatched quotes (e.g., apostrophes in search terms)
|
|
658
|
+
parts = [search_string]
|
|
659
|
+
cmd.extend(parts)
|
|
660
|
+
cmd.append(directory)
|
|
661
|
+
# Use encoding with error handling to handle files with invalid UTF-8
|
|
662
|
+
result = subprocess.run(
|
|
663
|
+
cmd,
|
|
664
|
+
capture_output=True,
|
|
665
|
+
text=True,
|
|
666
|
+
timeout=30,
|
|
667
|
+
encoding="utf-8",
|
|
668
|
+
errors="replace", # Replace invalid chars instead of crashing
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# Parse the JSON output from ripgrep
|
|
672
|
+
for line in result.stdout.strip().split("\n"):
|
|
673
|
+
if not line:
|
|
674
|
+
continue
|
|
675
|
+
try:
|
|
676
|
+
match_data = json.loads(line)
|
|
677
|
+
# Only process match events, not context or summary
|
|
678
|
+
if match_data.get("type") == "match":
|
|
679
|
+
data = match_data.get("data", {})
|
|
680
|
+
path_data = data.get("path", {})
|
|
681
|
+
file_path = (
|
|
682
|
+
path_data.get("text", "") if path_data.get("text") else ""
|
|
683
|
+
)
|
|
684
|
+
line_number = data.get("line_number", None)
|
|
685
|
+
line_content = (
|
|
686
|
+
data.get("lines", {}).get("text", "")
|
|
687
|
+
if data.get("lines", {}).get("text")
|
|
688
|
+
else ""
|
|
689
|
+
)
|
|
690
|
+
if len(line_content.strip()) > 512:
|
|
691
|
+
line_content = line_content.strip()[0:512]
|
|
692
|
+
if file_path and line_number:
|
|
693
|
+
# Sanitize content to handle any remaining encoding issues
|
|
694
|
+
match_info = MatchInfo(
|
|
695
|
+
file_path=_sanitize_string(file_path),
|
|
696
|
+
line_number=line_number,
|
|
697
|
+
line_content=_sanitize_string(line_content.strip()),
|
|
698
|
+
)
|
|
699
|
+
matches.append(match_info)
|
|
700
|
+
# Limit to 50 matches total, same as original implementation
|
|
701
|
+
if len(matches) >= 50:
|
|
702
|
+
break
|
|
703
|
+
except json.JSONDecodeError:
|
|
704
|
+
# Skip lines that aren't valid JSON
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
except subprocess.TimeoutExpired:
|
|
708
|
+
error_message = "Grep command timed out after 30 seconds"
|
|
709
|
+
except FileNotFoundError:
|
|
710
|
+
error_message = (
|
|
711
|
+
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
712
|
+
)
|
|
713
|
+
except Exception as e:
|
|
714
|
+
error_message = f"Error during grep operation: {e}"
|
|
715
|
+
finally:
|
|
716
|
+
# Clean up the temporary ignore file
|
|
717
|
+
if ignore_file and os.path.exists(ignore_file):
|
|
718
|
+
os.unlink(ignore_file)
|
|
719
|
+
|
|
720
|
+
# Build structured GrepMatch objects for the UI
|
|
721
|
+
grep_matches = [
|
|
722
|
+
GrepMatch(
|
|
723
|
+
file_path=m.file_path or "",
|
|
724
|
+
line_number=m.line_number or 1,
|
|
725
|
+
line_content=m.line_content or "",
|
|
726
|
+
)
|
|
727
|
+
for m in matches
|
|
728
|
+
]
|
|
729
|
+
|
|
730
|
+
# Count unique files searched (approximation based on matches)
|
|
731
|
+
unique_files = len(set(m.file_path for m in matches)) if matches else 0
|
|
732
|
+
|
|
733
|
+
# Emit structured message for the UI (only once, at the end)
|
|
734
|
+
grep_result_msg = GrepResultMessage(
|
|
735
|
+
search_term=search_string,
|
|
736
|
+
directory=directory,
|
|
737
|
+
matches=grep_matches,
|
|
738
|
+
total_matches=len(matches),
|
|
739
|
+
files_searched=unique_files,
|
|
740
|
+
)
|
|
741
|
+
get_message_bus().emit(grep_result_msg)
|
|
742
|
+
|
|
743
|
+
return GrepOutput(matches=matches, error=error_message)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def register_list_files(agent):
|
|
747
|
+
"""Register only the list_files tool."""
|
|
748
|
+
from code_puppy.config import get_allow_recursion
|
|
749
|
+
|
|
750
|
+
@agent.tool
|
|
751
|
+
def list_files(
|
|
752
|
+
context: RunContext, directory: str = ".", recursive: bool = True
|
|
753
|
+
) -> ListFileOutput:
|
|
754
|
+
"""List files and directories with intelligent filtering and safety features.
|
|
755
|
+
|
|
756
|
+
Automatically ignores build artifacts, caches, and common noise.
|
|
757
|
+
"""
|
|
758
|
+
warning = None
|
|
759
|
+
if recursive and not get_allow_recursion():
|
|
760
|
+
warning = "Recursion disabled globally for list_files - returning non-recursive results"
|
|
761
|
+
recursive = False
|
|
762
|
+
result = _list_files(context, directory, recursive)
|
|
763
|
+
|
|
764
|
+
# The structured FileListingMessage is already emitted by _list_files
|
|
765
|
+
# No need to emit again here
|
|
766
|
+
if warning:
|
|
767
|
+
result.error = warning
|
|
768
|
+
if (len(result.content)) > 200000:
|
|
769
|
+
result.content = result.content[0:200000]
|
|
770
|
+
result.error = "Results truncated. This is a massive directory tree, recommend non-recursive calls to list_files"
|
|
771
|
+
return result
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def register_read_file(agent):
|
|
775
|
+
"""Register only the read_file tool."""
|
|
776
|
+
|
|
777
|
+
@agent.tool
|
|
778
|
+
def read_file(
|
|
779
|
+
context: RunContext,
|
|
780
|
+
file_path: str = "",
|
|
781
|
+
start_line: int | None = None,
|
|
782
|
+
num_lines: int | None = None,
|
|
783
|
+
) -> ReadFileOutput:
|
|
784
|
+
"""Read file contents with optional line-range selection and token safety.
|
|
785
|
+
|
|
786
|
+
Use start_line/num_lines for large files to avoid overwhelming context.
|
|
787
|
+
"""
|
|
788
|
+
return _read_file(context, file_path, start_line, num_lines)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def register_grep(agent):
|
|
792
|
+
"""Register only the grep tool."""
|
|
793
|
+
|
|
794
|
+
@agent.tool
|
|
795
|
+
def grep(
|
|
796
|
+
context: RunContext, search_string: str = "", directory: str = "."
|
|
797
|
+
) -> GrepOutput:
|
|
798
|
+
"""Recursively search for text patterns across files using ripgrep (rg).
|
|
799
|
+
|
|
800
|
+
search_string supports ripgrep flag syntax (regex, -i for case-insensitive, etc).
|
|
801
|
+
"""
|
|
802
|
+
return _grep(context, search_string, directory)
|