codepp 0.0.437__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. 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 +117 -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 +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -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 +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -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 +2156 -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 +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -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 +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -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 +532 -0
  57. code_puppy/command_line/command_handler.py +293 -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 +867 -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 +96 -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/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,224 @@
1
+ """
2
+ MCP Server Log Management.
3
+
4
+ This module provides persistent log file management for MCP servers.
5
+ Logs are stored in STATE_DIR/mcp_logs/<server_name>.log
6
+ """
7
+
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from code_puppy.config import STATE_DIR
13
+
14
+ # Maximum log file size in bytes (5MB)
15
+ MAX_LOG_SIZE = 5 * 1024 * 1024
16
+
17
+ # Number of rotated logs to keep
18
+ MAX_ROTATED_LOGS = 3
19
+
20
+
21
+ def get_mcp_logs_dir() -> Path:
22
+ """
23
+ Get the directory for MCP server logs.
24
+
25
+ Creates the directory if it doesn't exist.
26
+
27
+ Returns:
28
+ Path to the MCP logs directory
29
+ """
30
+ logs_dir = Path(STATE_DIR) / "mcp_logs"
31
+ logs_dir.mkdir(parents=True, exist_ok=True)
32
+ return logs_dir
33
+
34
+
35
+ def get_log_file_path(server_name: str) -> Path:
36
+ """
37
+ Get the log file path for a specific server.
38
+
39
+ Args:
40
+ server_name: Name of the MCP server
41
+
42
+ Returns:
43
+ Path to the server's log file
44
+ """
45
+ # Sanitize server name for filesystem
46
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
47
+ return get_mcp_logs_dir() / f"{safe_name}.log"
48
+
49
+
50
+ def rotate_log_if_needed(server_name: str) -> None:
51
+ """
52
+ Rotate log file if it exceeds MAX_LOG_SIZE.
53
+
54
+ Args:
55
+ server_name: Name of the MCP server
56
+ """
57
+ log_path = get_log_file_path(server_name)
58
+
59
+ if not log_path.exists():
60
+ return
61
+
62
+ # Check if rotation is needed
63
+ if log_path.stat().st_size < MAX_LOG_SIZE:
64
+ return
65
+
66
+ logs_dir = get_mcp_logs_dir()
67
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
68
+
69
+ # Remove oldest rotated log if we're at the limit
70
+ oldest = logs_dir / f"{safe_name}.log.{MAX_ROTATED_LOGS}"
71
+ if oldest.exists():
72
+ oldest.unlink()
73
+
74
+ # Shift existing rotated logs
75
+ for i in range(MAX_ROTATED_LOGS - 1, 0, -1):
76
+ old_path = logs_dir / f"{safe_name}.log.{i}"
77
+ new_path = logs_dir / f"{safe_name}.log.{i + 1}"
78
+ if old_path.exists():
79
+ old_path.rename(new_path)
80
+
81
+ # Rotate current log
82
+ rotated_path = logs_dir / f"{safe_name}.log.1"
83
+ log_path.rename(rotated_path)
84
+
85
+
86
+ def write_log(server_name: str, message: str, level: str = "INFO") -> None:
87
+ """
88
+ Write a log message for a server.
89
+
90
+ Args:
91
+ server_name: Name of the MCP server
92
+ message: Log message to write
93
+ level: Log level (INFO, ERROR, WARN, DEBUG)
94
+ """
95
+ rotate_log_if_needed(server_name)
96
+
97
+ log_path = get_log_file_path(server_name)
98
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
99
+
100
+ with open(log_path, "a", encoding="utf-8") as f:
101
+ f.write(f"[{timestamp}] [{level}] {message}\n")
102
+
103
+
104
+ def read_logs(
105
+ server_name: str, lines: Optional[int] = None, include_rotated: bool = False
106
+ ) -> List[str]:
107
+ """
108
+ Read log lines for a server.
109
+
110
+ Args:
111
+ server_name: Name of the MCP server
112
+ lines: Number of lines to return (from end). None means all lines.
113
+ include_rotated: Whether to include rotated log files
114
+
115
+ Returns:
116
+ List of log lines (most recent last)
117
+ """
118
+ all_lines = []
119
+
120
+ # Read rotated logs first (oldest to newest)
121
+ if include_rotated:
122
+ logs_dir = get_mcp_logs_dir()
123
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
124
+
125
+ for i in range(MAX_ROTATED_LOGS, 0, -1):
126
+ rotated_path = logs_dir / f"{safe_name}.log.{i}"
127
+ if rotated_path.exists():
128
+ with open(rotated_path, "r", encoding="utf-8", errors="replace") as f:
129
+ all_lines.extend(f.read().splitlines())
130
+
131
+ # Read current log
132
+ log_path = get_log_file_path(server_name)
133
+ if log_path.exists():
134
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
135
+ all_lines.extend(f.read().splitlines())
136
+
137
+ # Return requested number of lines
138
+ if lines is not None and lines > 0:
139
+ return all_lines[-lines:]
140
+
141
+ return all_lines
142
+
143
+
144
+ def clear_logs(server_name: str, include_rotated: bool = True) -> None:
145
+ """
146
+ Clear logs for a server.
147
+
148
+ Args:
149
+ server_name: Name of the MCP server
150
+ include_rotated: Whether to also clear rotated log files
151
+ """
152
+ log_path = get_log_file_path(server_name)
153
+
154
+ if log_path.exists():
155
+ log_path.unlink()
156
+
157
+ if include_rotated:
158
+ logs_dir = get_mcp_logs_dir()
159
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
160
+
161
+ for i in range(1, MAX_ROTATED_LOGS + 1):
162
+ rotated_path = logs_dir / f"{safe_name}.log.{i}"
163
+ if rotated_path.exists():
164
+ rotated_path.unlink()
165
+
166
+
167
+ def list_servers_with_logs() -> List[str]:
168
+ """
169
+ List all servers that have log files.
170
+
171
+ Returns:
172
+ List of server names with log files
173
+ """
174
+ logs_dir = get_mcp_logs_dir()
175
+ servers = set()
176
+
177
+ for path in logs_dir.glob("*.log*"):
178
+ # Extract server name from filename
179
+ name = path.stem
180
+ # Remove .log suffix and rotation numbers
181
+ name = name.replace(".log", "").rstrip(".0123456789")
182
+ if name:
183
+ servers.add(name)
184
+
185
+ return sorted(servers)
186
+
187
+
188
+ def get_log_stats(server_name: str) -> dict:
189
+ """
190
+ Get statistics about a server's logs.
191
+
192
+ Args:
193
+ server_name: Name of the MCP server
194
+
195
+ Returns:
196
+ Dictionary with log statistics
197
+ """
198
+ log_path = get_log_file_path(server_name)
199
+
200
+ stats = {
201
+ "exists": log_path.exists(),
202
+ "size_bytes": 0,
203
+ "line_count": 0,
204
+ "rotated_count": 0,
205
+ "total_size_bytes": 0,
206
+ }
207
+
208
+ if log_path.exists():
209
+ stats["size_bytes"] = log_path.stat().st_size
210
+ stats["total_size_bytes"] = stats["size_bytes"]
211
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
212
+ stats["line_count"] = sum(1 for _ in f)
213
+
214
+ # Count rotated logs
215
+ logs_dir = get_mcp_logs_dir()
216
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
217
+
218
+ for i in range(1, MAX_ROTATED_LOGS + 1):
219
+ rotated_path = logs_dir / f"{safe_name}.log.{i}"
220
+ if rotated_path.exists():
221
+ stats["rotated_count"] += 1
222
+ stats["total_size_bytes"] += rotated_path.stat().st_size
223
+
224
+ return stats
@@ -0,0 +1,451 @@
1
+ """
2
+ ServerRegistry implementation for managing MCP server configurations.
3
+
4
+ This module provides a registry that tracks all MCP server configurations
5
+ and provides thread-safe CRUD operations with JSON persistence.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import threading
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+ from code_puppy import config
16
+
17
+ from .managed_server import ServerConfig
18
+
19
+ # Configure logging
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ServerRegistry:
24
+ """
25
+ Registry for managing MCP server configurations.
26
+
27
+ Provides CRUD operations for server configurations with thread-safe access,
28
+ validation, and persistent storage to XDG_DATA_HOME/code_puppy/mcp_registry.json.
29
+
30
+ All operations are thread-safe and use JSON serialization for ServerConfig objects.
31
+ Handles file not existing gracefully and validates configurations according to
32
+ server type requirements.
33
+ """
34
+
35
+ def __init__(self, storage_path: Optional[str] = None):
36
+ """
37
+ Initialize the server registry.
38
+
39
+ Args:
40
+ storage_path: Optional custom path for registry storage.
41
+ Defaults to XDG_DATA_HOME/code_puppy/mcp_registry.json
42
+ """
43
+ if storage_path is None:
44
+ data_dir = Path(config.DATA_DIR)
45
+ data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
46
+ self._storage_path = data_dir / "mcp_registry.json"
47
+ else:
48
+ self._storage_path = Path(storage_path)
49
+
50
+ # Thread safety lock (reentrant)
51
+ self._lock = threading.RLock()
52
+
53
+ # In-memory storage: server_id -> ServerConfig
54
+ self._servers: Dict[str, ServerConfig] = {}
55
+
56
+ # Load existing configurations
57
+ self._load()
58
+
59
+ logger.info(f"Initialized ServerRegistry with storage at {self._storage_path}")
60
+
61
+ def register(self, config: ServerConfig) -> str:
62
+ """
63
+ Add new server configuration.
64
+
65
+ Args:
66
+ config: Server configuration to register
67
+
68
+ Returns:
69
+ Server ID of the registered server
70
+
71
+ Raises:
72
+ ValueError: If validation fails or server already exists
73
+ """
74
+ with self._lock:
75
+ # Validate configuration
76
+ validation_errors = self.validate_config(config)
77
+ if validation_errors:
78
+ raise ValueError(f"Validation failed: {'; '.join(validation_errors)}")
79
+
80
+ # Generate ID if not provided or ensure uniqueness
81
+ if not config.id:
82
+ config.id = str(uuid.uuid4())
83
+ elif config.id in self._servers:
84
+ raise ValueError(f"Server with ID {config.id} already exists")
85
+
86
+ # Check name uniqueness
87
+ existing_config = self.get_by_name(config.name)
88
+ if existing_config and existing_config.id != config.id:
89
+ raise ValueError(f"Server with name '{config.name}' already exists")
90
+
91
+ # Store configuration
92
+ self._servers[config.id] = config
93
+
94
+ # Persist to disk
95
+ self._persist()
96
+
97
+ logger.info(f"Registered server: {config.name} (ID: {config.id})")
98
+ return config.id
99
+
100
+ def unregister(self, server_id: str) -> bool:
101
+ """
102
+ Remove server configuration.
103
+
104
+ Args:
105
+ server_id: ID of server to remove
106
+
107
+ Returns:
108
+ True if server was removed, False if not found
109
+ """
110
+ with self._lock:
111
+ if server_id not in self._servers:
112
+ logger.warning(
113
+ f"Attempted to unregister non-existent server: {server_id}"
114
+ )
115
+ return False
116
+
117
+ server_name = self._servers[server_id].name
118
+ del self._servers[server_id]
119
+
120
+ # Persist to disk
121
+ self._persist()
122
+
123
+ logger.info(f"Unregistered server: {server_name} (ID: {server_id})")
124
+ return True
125
+
126
+ def get(self, server_id: str) -> Optional[ServerConfig]:
127
+ """
128
+ Get server configuration by ID.
129
+
130
+ Args:
131
+ server_id: ID of server to retrieve
132
+
133
+ Returns:
134
+ ServerConfig if found, None otherwise
135
+ """
136
+ with self._lock:
137
+ return self._servers.get(server_id)
138
+
139
+ def get_by_name(self, name: str) -> Optional[ServerConfig]:
140
+ """
141
+ Get server configuration by name.
142
+
143
+ Args:
144
+ name: Name of server to retrieve
145
+
146
+ Returns:
147
+ ServerConfig if found, None otherwise
148
+ """
149
+ with self._lock:
150
+ for config in self._servers.values():
151
+ if config.name == name:
152
+ return config
153
+ return None
154
+
155
+ def list_all(self) -> List[ServerConfig]:
156
+ """
157
+ Get all server configurations.
158
+
159
+ Returns:
160
+ List of all ServerConfig objects
161
+ """
162
+ with self._lock:
163
+ return list(self._servers.values())
164
+
165
+ def update(self, server_id: str, config: ServerConfig) -> bool:
166
+ """
167
+ Update existing server configuration.
168
+
169
+ Args:
170
+ server_id: ID of server to update
171
+ config: New configuration
172
+
173
+ Returns:
174
+ True if update succeeded, False if server not found
175
+
176
+ Raises:
177
+ ValueError: If validation fails
178
+ """
179
+ with self._lock:
180
+ if server_id not in self._servers:
181
+ logger.warning(f"Attempted to update non-existent server: {server_id}")
182
+ return False
183
+
184
+ # Ensure the ID matches
185
+ config.id = server_id
186
+
187
+ # Validate configuration
188
+ validation_errors = self.validate_config(config)
189
+ if validation_errors:
190
+ raise ValueError(f"Validation failed: {'; '.join(validation_errors)}")
191
+
192
+ # Check name uniqueness (excluding current server)
193
+ existing_config = self.get_by_name(config.name)
194
+ if existing_config and existing_config.id != server_id:
195
+ raise ValueError(f"Server with name '{config.name}' already exists")
196
+
197
+ # Update configuration
198
+ old_name = self._servers[server_id].name
199
+ self._servers[server_id] = config
200
+
201
+ # Persist to disk
202
+ self._persist()
203
+
204
+ logger.info(
205
+ f"Updated server: {old_name} -> {config.name} (ID: {server_id})"
206
+ )
207
+ return True
208
+
209
+ def exists(self, server_id: str) -> bool:
210
+ """
211
+ Check if server exists.
212
+
213
+ Args:
214
+ server_id: ID of server to check
215
+
216
+ Returns:
217
+ True if server exists, False otherwise
218
+ """
219
+ with self._lock:
220
+ return server_id in self._servers
221
+
222
+ def validate_config(self, config: ServerConfig) -> List[str]:
223
+ """
224
+ Validate server configuration.
225
+
226
+ Args:
227
+ config: Configuration to validate
228
+
229
+ Returns:
230
+ List of validation error messages (empty if valid)
231
+ """
232
+ errors = []
233
+
234
+ # Basic validation
235
+ if not config.name or not config.name.strip():
236
+ errors.append("Server name is required")
237
+ elif not config.name.replace("-", "").replace("_", "").isalnum():
238
+ errors.append(
239
+ "Server name must be alphanumeric (hyphens and underscores allowed)"
240
+ )
241
+
242
+ if not config.type:
243
+ errors.append("Server type is required")
244
+ elif config.type.lower() not in ["sse", "stdio", "http"]:
245
+ errors.append("Server type must be one of: sse, stdio, http")
246
+
247
+ if not isinstance(config.config, dict):
248
+ errors.append("Server config must be a dictionary")
249
+ return errors # Can't validate further without valid config dict
250
+
251
+ # Type-specific validation
252
+ server_type = config.type.lower()
253
+ server_config = config.config
254
+
255
+ if server_type in ["sse", "http"]:
256
+ if "url" not in server_config:
257
+ errors.append(f"{server_type.upper()} server requires 'url' in config")
258
+ elif (
259
+ not isinstance(server_config["url"], str)
260
+ or not server_config["url"].strip()
261
+ ):
262
+ errors.append(
263
+ f"{server_type.upper()} server URL must be a non-empty string"
264
+ )
265
+ elif not (
266
+ server_config["url"].startswith("http://")
267
+ or server_config["url"].startswith("https://")
268
+ ):
269
+ errors.append(
270
+ f"{server_type.upper()} server URL must start with http:// or https://"
271
+ )
272
+
273
+ # Optional parameter validation
274
+ if "timeout" in server_config:
275
+ try:
276
+ timeout = float(server_config["timeout"])
277
+ if timeout <= 0:
278
+ errors.append("Timeout must be positive")
279
+ except (ValueError, TypeError):
280
+ errors.append("Timeout must be a number")
281
+
282
+ if "read_timeout" in server_config:
283
+ try:
284
+ read_timeout = float(server_config["read_timeout"])
285
+ if read_timeout <= 0:
286
+ errors.append("Read timeout must be positive")
287
+ except (ValueError, TypeError):
288
+ errors.append("Read timeout must be a number")
289
+
290
+ if "headers" in server_config:
291
+ if not isinstance(server_config["headers"], dict):
292
+ errors.append("Headers must be a dictionary")
293
+
294
+ elif server_type == "stdio":
295
+ if "command" not in server_config:
296
+ errors.append("Stdio server requires 'command' in config")
297
+ elif (
298
+ not isinstance(server_config["command"], str)
299
+ or not server_config["command"].strip()
300
+ ):
301
+ errors.append("Stdio server command must be a non-empty string")
302
+
303
+ # Optional parameter validation
304
+ if "args" in server_config:
305
+ args = server_config["args"]
306
+ if not isinstance(args, (list, str)):
307
+ errors.append("Args must be a list or string")
308
+ elif isinstance(args, list):
309
+ if not all(isinstance(arg, str) for arg in args):
310
+ errors.append("All args must be strings")
311
+
312
+ if "env" in server_config:
313
+ if not isinstance(server_config["env"], dict):
314
+ errors.append("Environment variables must be a dictionary")
315
+ elif not all(
316
+ isinstance(k, str) and isinstance(v, str)
317
+ for k, v in server_config["env"].items()
318
+ ):
319
+ errors.append("All environment variables must be strings")
320
+
321
+ if "cwd" in server_config:
322
+ if not isinstance(server_config["cwd"], str):
323
+ errors.append("Working directory must be a string")
324
+
325
+ return errors
326
+
327
+ def _persist(self) -> None:
328
+ """
329
+ Save registry to disk.
330
+
331
+ This method assumes it's called within a lock context.
332
+
333
+ Raises:
334
+ Exception: If unable to write to storage file
335
+ """
336
+ try:
337
+ # Convert ServerConfig objects to dictionaries for JSON serialization
338
+ data = {}
339
+ for server_id, config in self._servers.items():
340
+ data[server_id] = {
341
+ "id": config.id,
342
+ "name": config.name,
343
+ "type": config.type,
344
+ "enabled": config.enabled,
345
+ "config": config.config,
346
+ }
347
+
348
+ # Ensure directory exists
349
+ self._storage_path.parent.mkdir(parents=True, exist_ok=True)
350
+
351
+ # Write to temporary file first, then rename (atomic operation)
352
+ temp_path = self._storage_path.with_suffix(".tmp")
353
+ with open(temp_path, "w", encoding="utf-8") as f:
354
+ json.dump(data, f, indent=2, ensure_ascii=False)
355
+
356
+ # Atomic rename
357
+ temp_path.replace(self._storage_path)
358
+
359
+ logger.debug(
360
+ f"Persisted {len(self._servers)} server configurations to {self._storage_path}"
361
+ )
362
+
363
+ except Exception as e:
364
+ logger.error(f"Failed to persist server registry: {e}")
365
+ raise
366
+
367
+ def _load(self) -> None:
368
+ """
369
+ Load registry from disk.
370
+
371
+ Handles file not existing gracefully by starting with empty registry.
372
+ Invalid entries are logged and skipped.
373
+ """
374
+ try:
375
+ if not self._storage_path.exists():
376
+ logger.info(
377
+ f"Registry file {self._storage_path} does not exist, starting with empty registry"
378
+ )
379
+ return
380
+
381
+ # Check if file is empty
382
+ if self._storage_path.stat().st_size == 0:
383
+ logger.info(
384
+ f"Registry file {self._storage_path} is empty, starting with empty registry"
385
+ )
386
+ return
387
+
388
+ with open(self._storage_path, "r", encoding="utf-8") as f:
389
+ data = json.load(f)
390
+
391
+ if not isinstance(data, dict):
392
+ logger.warning(
393
+ f"Invalid registry format in {self._storage_path}, starting with empty registry"
394
+ )
395
+ return
396
+
397
+ # Load server configurations
398
+ loaded_count = 0
399
+ for server_id, config_data in data.items():
400
+ try:
401
+ # Validate the structure
402
+ if not isinstance(config_data, dict):
403
+ logger.warning(
404
+ f"Skipping invalid config for server {server_id}: not a dictionary"
405
+ )
406
+ continue
407
+
408
+ required_fields = ["id", "name", "type", "config"]
409
+ if not all(field in config_data for field in required_fields):
410
+ logger.warning(
411
+ f"Skipping incomplete config for server {server_id}: missing required fields"
412
+ )
413
+ continue
414
+
415
+ # Create ServerConfig object
416
+ config = ServerConfig(
417
+ id=config_data["id"],
418
+ name=config_data["name"],
419
+ type=config_data["type"],
420
+ enabled=config_data.get("enabled", True),
421
+ config=config_data["config"],
422
+ )
423
+
424
+ # Basic validation
425
+ validation_errors = self.validate_config(config)
426
+ if validation_errors:
427
+ logger.warning(
428
+ f"Skipping invalid config for server {server_id}: {'; '.join(validation_errors)}"
429
+ )
430
+ continue
431
+
432
+ # Store configuration
433
+ self._servers[server_id] = config
434
+ loaded_count += 1
435
+
436
+ except Exception as e:
437
+ logger.warning(
438
+ f"Skipping invalid config for server {server_id}: {e}"
439
+ )
440
+ continue
441
+
442
+ logger.info(
443
+ f"Loaded {loaded_count} server configurations from {self._storage_path}"
444
+ )
445
+
446
+ except json.JSONDecodeError as e:
447
+ logger.error(f"Invalid JSON in registry file {self._storage_path}: {e}")
448
+ logger.info("Starting with empty registry")
449
+ except Exception as e:
450
+ logger.error(f"Failed to load server registry: {e}")
451
+ logger.info("Starting with empty registry")