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.
Files changed (289) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +147 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +630 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +122 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +380 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +167 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2145 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +296 -0
  28. code_puppy/agents/pack/husky.py +307 -0
  29. code_puppy/agents/pack/retriever.py +380 -0
  30. code_puppy/agents/pack/shepherd.py +327 -0
  31. code_puppy/agents/pack/terrier.py +281 -0
  32. code_puppy/agents/pack/watchdog.py +357 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +674 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +664 -0
  49. code_puppy/cli_runner.py +1038 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +526 -0
  57. code_puppy/command_line/command_handler.py +283 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +853 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +91 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/skills_completion.py +160 -0
  97. code_puppy/command_line/uc_menu.py +893 -0
  98. code_puppy/command_line/utils.py +93 -0
  99. code_puppy/command_line/wiggum_state.py +78 -0
  100. code_puppy/config.py +1787 -0
  101. code_puppy/error_logging.py +133 -0
  102. code_puppy/gemini_code_assist.py +385 -0
  103. code_puppy/gemini_model.py +754 -0
  104. code_puppy/hook_engine/README.md +105 -0
  105. code_puppy/hook_engine/__init__.py +15 -0
  106. code_puppy/hook_engine/aliases.py +155 -0
  107. code_puppy/hook_engine/engine.py +195 -0
  108. code_puppy/hook_engine/executor.py +293 -0
  109. code_puppy/hook_engine/matcher.py +145 -0
  110. code_puppy/hook_engine/models.py +222 -0
  111. code_puppy/hook_engine/registry.py +106 -0
  112. code_puppy/hook_engine/validator.py +141 -0
  113. code_puppy/http_utils.py +361 -0
  114. code_puppy/keymap.py +128 -0
  115. code_puppy/main.py +10 -0
  116. code_puppy/mcp_/__init__.py +66 -0
  117. code_puppy/mcp_/async_lifecycle.py +286 -0
  118. code_puppy/mcp_/blocking_startup.py +469 -0
  119. code_puppy/mcp_/captured_stdio_server.py +275 -0
  120. code_puppy/mcp_/circuit_breaker.py +290 -0
  121. code_puppy/mcp_/config_wizard.py +507 -0
  122. code_puppy/mcp_/dashboard.py +308 -0
  123. code_puppy/mcp_/error_isolation.py +407 -0
  124. code_puppy/mcp_/examples/retry_example.py +226 -0
  125. code_puppy/mcp_/health_monitor.py +589 -0
  126. code_puppy/mcp_/managed_server.py +428 -0
  127. code_puppy/mcp_/manager.py +807 -0
  128. code_puppy/mcp_/mcp_logs.py +224 -0
  129. code_puppy/mcp_/registry.py +451 -0
  130. code_puppy/mcp_/retry_manager.py +337 -0
  131. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  132. code_puppy/mcp_/status_tracker.py +355 -0
  133. code_puppy/mcp_/system_tools.py +209 -0
  134. code_puppy/mcp_prompts/__init__.py +1 -0
  135. code_puppy/mcp_prompts/hook_creator.py +103 -0
  136. code_puppy/messaging/__init__.py +255 -0
  137. code_puppy/messaging/bus.py +613 -0
  138. code_puppy/messaging/commands.py +167 -0
  139. code_puppy/messaging/markdown_patches.py +57 -0
  140. code_puppy/messaging/message_queue.py +361 -0
  141. code_puppy/messaging/messages.py +569 -0
  142. code_puppy/messaging/queue_console.py +271 -0
  143. code_puppy/messaging/renderers.py +311 -0
  144. code_puppy/messaging/rich_renderer.py +1153 -0
  145. code_puppy/messaging/spinner/__init__.py +83 -0
  146. code_puppy/messaging/spinner/console_spinner.py +240 -0
  147. code_puppy/messaging/spinner/spinner_base.py +96 -0
  148. code_puppy/messaging/subagent_console.py +460 -0
  149. code_puppy/model_factory.py +848 -0
  150. code_puppy/model_switching.py +63 -0
  151. code_puppy/model_utils.py +168 -0
  152. code_puppy/models.json +130 -0
  153. code_puppy/models_dev_api.json +1 -0
  154. code_puppy/models_dev_parser.py +592 -0
  155. code_puppy/plugins/__init__.py +186 -0
  156. code_puppy/plugins/agent_skills/__init__.py +22 -0
  157. code_puppy/plugins/agent_skills/config.py +175 -0
  158. code_puppy/plugins/agent_skills/discovery.py +136 -0
  159. code_puppy/plugins/agent_skills/downloader.py +392 -0
  160. code_puppy/plugins/agent_skills/installer.py +22 -0
  161. code_puppy/plugins/agent_skills/metadata.py +219 -0
  162. code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
  163. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  164. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  165. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  166. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  167. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  168. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  169. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  170. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  171. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  172. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  173. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  174. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  175. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  176. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  177. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  178. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  179. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  180. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  181. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  182. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  183. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  184. code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
  185. code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
  186. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  187. code_puppy/plugins/claude_code_hooks/config.py +131 -0
  188. code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
  189. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  190. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  191. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  192. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  193. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  194. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  195. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  196. code_puppy/plugins/claude_code_oauth/utils.py +601 -0
  197. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  198. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  199. code_puppy/plugins/example_custom_command/README.md +280 -0
  200. code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
  201. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  202. code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
  203. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  204. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  205. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  206. code_puppy/plugins/hook_creator/__init__.py +1 -0
  207. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  208. code_puppy/plugins/hook_manager/__init__.py +1 -0
  209. code_puppy/plugins/hook_manager/config.py +277 -0
  210. code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
  211. code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
  212. code_puppy/plugins/oauth_puppy_html.py +224 -0
  213. code_puppy/plugins/scheduler/__init__.py +1 -0
  214. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  215. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  216. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  217. code_puppy/plugins/shell_safety/__init__.py +6 -0
  218. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  219. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  220. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  221. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  222. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  223. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  224. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  225. code_puppy/plugins/universal_constructor/models.py +138 -0
  226. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  227. code_puppy/plugins/universal_constructor/registry.py +302 -0
  228. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  229. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  230. code_puppy/pydantic_patches.py +317 -0
  231. code_puppy/reopenable_async_client.py +232 -0
  232. code_puppy/round_robin_model.py +150 -0
  233. code_puppy/scheduler/__init__.py +41 -0
  234. code_puppy/scheduler/__main__.py +9 -0
  235. code_puppy/scheduler/cli.py +118 -0
  236. code_puppy/scheduler/config.py +126 -0
  237. code_puppy/scheduler/daemon.py +280 -0
  238. code_puppy/scheduler/executor.py +155 -0
  239. code_puppy/scheduler/platform.py +19 -0
  240. code_puppy/scheduler/platform_unix.py +22 -0
  241. code_puppy/scheduler/platform_win.py +32 -0
  242. code_puppy/session_storage.py +338 -0
  243. code_puppy/status_display.py +257 -0
  244. code_puppy/summarization_agent.py +176 -0
  245. code_puppy/terminal_utils.py +418 -0
  246. code_puppy/tools/__init__.py +470 -0
  247. code_puppy/tools/agent_tools.py +616 -0
  248. code_puppy/tools/ask_user_question/__init__.py +26 -0
  249. code_puppy/tools/ask_user_question/constants.py +73 -0
  250. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  251. code_puppy/tools/ask_user_question/handler.py +232 -0
  252. code_puppy/tools/ask_user_question/models.py +304 -0
  253. code_puppy/tools/ask_user_question/registration.py +36 -0
  254. code_puppy/tools/ask_user_question/renderers.py +309 -0
  255. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  256. code_puppy/tools/ask_user_question/theme.py +155 -0
  257. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  258. code_puppy/tools/browser/__init__.py +37 -0
  259. code_puppy/tools/browser/browser_control.py +289 -0
  260. code_puppy/tools/browser/browser_interactions.py +545 -0
  261. code_puppy/tools/browser/browser_locators.py +640 -0
  262. code_puppy/tools/browser/browser_manager.py +378 -0
  263. code_puppy/tools/browser/browser_navigation.py +251 -0
  264. code_puppy/tools/browser/browser_screenshot.py +179 -0
  265. code_puppy/tools/browser/browser_scripts.py +462 -0
  266. code_puppy/tools/browser/browser_workflows.py +221 -0
  267. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  268. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  269. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  270. code_puppy/tools/browser/terminal_tools.py +525 -0
  271. code_puppy/tools/command_runner.py +1346 -0
  272. code_puppy/tools/common.py +1409 -0
  273. code_puppy/tools/display.py +84 -0
  274. code_puppy/tools/file_modifications.py +739 -0
  275. code_puppy/tools/file_operations.py +802 -0
  276. code_puppy/tools/scheduler_tools.py +412 -0
  277. code_puppy/tools/skills_tools.py +251 -0
  278. code_puppy/tools/subagent_context.py +158 -0
  279. code_puppy/tools/tools_content.py +51 -0
  280. code_puppy/tools/universal_constructor.py +889 -0
  281. code_puppy/uvx_detection.py +242 -0
  282. code_puppy/version_checker.py +82 -0
  283. newcode-0.1.1.data/data/code_puppy/models.json +130 -0
  284. newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
  285. newcode-0.1.1.dist-info/METADATA +154 -0
  286. newcode-0.1.1.dist-info/RECORD +289 -0
  287. newcode-0.1.1.dist-info/WHEEL +4 -0
  288. newcode-0.1.1.dist-info/entry_points.txt +3 -0
  289. 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)