code-puppy 0.0.134__py3-none-any.whl → 0.0.136__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 (60) hide show
  1. code_puppy/agent.py +15 -17
  2. code_puppy/agents/agent_manager.py +320 -9
  3. code_puppy/agents/base_agent.py +58 -2
  4. code_puppy/agents/runtime_manager.py +68 -42
  5. code_puppy/command_line/command_handler.py +82 -33
  6. code_puppy/command_line/mcp/__init__.py +10 -0
  7. code_puppy/command_line/mcp/add_command.py +183 -0
  8. code_puppy/command_line/mcp/base.py +35 -0
  9. code_puppy/command_line/mcp/handler.py +133 -0
  10. code_puppy/command_line/mcp/help_command.py +146 -0
  11. code_puppy/command_line/mcp/install_command.py +176 -0
  12. code_puppy/command_line/mcp/list_command.py +94 -0
  13. code_puppy/command_line/mcp/logs_command.py +126 -0
  14. code_puppy/command_line/mcp/remove_command.py +82 -0
  15. code_puppy/command_line/mcp/restart_command.py +92 -0
  16. code_puppy/command_line/mcp/search_command.py +117 -0
  17. code_puppy/command_line/mcp/start_all_command.py +126 -0
  18. code_puppy/command_line/mcp/start_command.py +98 -0
  19. code_puppy/command_line/mcp/status_command.py +185 -0
  20. code_puppy/command_line/mcp/stop_all_command.py +109 -0
  21. code_puppy/command_line/mcp/stop_command.py +79 -0
  22. code_puppy/command_line/mcp/test_command.py +107 -0
  23. code_puppy/command_line/mcp/utils.py +129 -0
  24. code_puppy/command_line/mcp/wizard_utils.py +259 -0
  25. code_puppy/command_line/model_picker_completion.py +21 -4
  26. code_puppy/command_line/prompt_toolkit_completion.py +9 -0
  27. code_puppy/main.py +23 -17
  28. code_puppy/mcp/__init__.py +42 -16
  29. code_puppy/mcp/async_lifecycle.py +51 -49
  30. code_puppy/mcp/blocking_startup.py +125 -113
  31. code_puppy/mcp/captured_stdio_server.py +63 -70
  32. code_puppy/mcp/circuit_breaker.py +63 -47
  33. code_puppy/mcp/config_wizard.py +169 -136
  34. code_puppy/mcp/dashboard.py +79 -71
  35. code_puppy/mcp/error_isolation.py +147 -100
  36. code_puppy/mcp/examples/retry_example.py +55 -42
  37. code_puppy/mcp/health_monitor.py +152 -141
  38. code_puppy/mcp/managed_server.py +100 -97
  39. code_puppy/mcp/manager.py +168 -156
  40. code_puppy/mcp/registry.py +148 -110
  41. code_puppy/mcp/retry_manager.py +63 -61
  42. code_puppy/mcp/server_registry_catalog.py +271 -225
  43. code_puppy/mcp/status_tracker.py +80 -80
  44. code_puppy/mcp/system_tools.py +47 -52
  45. code_puppy/messaging/message_queue.py +20 -13
  46. code_puppy/messaging/renderers.py +30 -15
  47. code_puppy/state_management.py +103 -0
  48. code_puppy/tui/app.py +64 -7
  49. code_puppy/tui/components/chat_view.py +3 -3
  50. code_puppy/tui/components/human_input_modal.py +12 -8
  51. code_puppy/tui/screens/__init__.py +2 -2
  52. code_puppy/tui/screens/mcp_install_wizard.py +208 -179
  53. code_puppy/tui/tests/test_agent_command.py +3 -3
  54. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/METADATA +1 -1
  55. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/RECORD +59 -41
  56. code_puppy/command_line/mcp_commands.py +0 -1789
  57. {code_puppy-0.0.134.data → code_puppy-0.0.136.data}/data/code_puppy/models.json +0 -0
  58. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/WHEEL +0 -0
  59. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/entry_points.txt +0 -0
  60. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/licenses/LICENSE +0 -0
@@ -12,19 +12,24 @@ import os
12
12
  import tempfile
13
13
  import threading
14
14
  import uuid
15
- from typing import Optional, Callable, List
16
15
  from contextlib import asynccontextmanager
17
- from pydantic_ai.mcp import MCPServerStdio
16
+ from typing import List, Optional
17
+
18
18
  from mcp.client.stdio import StdioServerParameters, stdio_client
19
- from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
20
- from mcp.shared.session import SessionMessage
19
+ from pydantic_ai.mcp import MCPServerStdio
20
+
21
21
  from code_puppy.messaging import emit_info
22
22
 
23
23
 
24
24
  class StderrFileCapture:
25
25
  """Captures stderr to a file and monitors it in a background thread."""
26
-
27
- def __init__(self, server_name: str, emit_to_user: bool = True, message_group: Optional[uuid.UUID] = None):
26
+
27
+ def __init__(
28
+ self,
29
+ server_name: str,
30
+ emit_to_user: bool = True,
31
+ message_group: Optional[uuid.UUID] = None,
32
+ ):
28
33
  self.server_name = server_name
29
34
  self.emit_to_user = emit_to_user
30
35
  self.message_group = message_group or uuid.uuid4()
@@ -33,30 +38,32 @@ class StderrFileCapture:
33
38
  self.monitor_thread = None
34
39
  self.stop_monitoring = threading.Event()
35
40
  self.captured_lines = []
36
-
41
+
37
42
  def start(self):
38
43
  """Start capture by creating temp file and monitor thread."""
39
44
  # Create temp file
40
- self.temp_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.err')
45
+ self.temp_file = tempfile.NamedTemporaryFile(
46
+ mode="w+", delete=False, suffix=".err"
47
+ )
41
48
  self.temp_path = self.temp_file.name
42
-
49
+
43
50
  # Start monitoring thread
44
51
  self.stop_monitoring.clear()
45
52
  self.monitor_thread = threading.Thread(target=self._monitor_file)
46
53
  self.monitor_thread.daemon = True
47
54
  self.monitor_thread.start()
48
-
55
+
49
56
  return self.temp_file
50
-
57
+
51
58
  def _monitor_file(self):
52
59
  """Monitor the temp file for new content."""
53
60
  if not self.temp_path:
54
61
  return
55
-
62
+
56
63
  last_pos = 0
57
64
  while not self.stop_monitoring.is_set():
58
65
  try:
59
- with open(self.temp_path, 'r') as f:
66
+ with open(self.temp_path, "r") as f:
60
67
  f.seek(last_pos)
61
68
  new_content = f.read()
62
69
  if new_content:
@@ -67,47 +74,47 @@ class StderrFileCapture:
67
74
  self.captured_lines.append(line)
68
75
  if self.emit_to_user:
69
76
  emit_info(
70
- f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
77
+ f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
71
78
  style="dim cyan",
72
- message_group=self.message_group
79
+ message_group=self.message_group,
73
80
  )
74
-
81
+
75
82
  except Exception:
76
83
  pass # File might not exist yet or be deleted
77
-
84
+
78
85
  self.stop_monitoring.wait(0.1) # Check every 100ms
79
-
86
+
80
87
  def stop(self):
81
88
  """Stop monitoring and clean up."""
82
89
  self.stop_monitoring.set()
83
90
  if self.monitor_thread:
84
91
  self.monitor_thread.join(timeout=1)
85
-
92
+
86
93
  if self.temp_file:
87
94
  try:
88
95
  self.temp_file.close()
89
- except:
96
+ except Exception:
90
97
  pass
91
-
98
+
92
99
  if self.temp_path and os.path.exists(self.temp_path):
93
100
  try:
94
101
  # Read any remaining content
95
- with open(self.temp_path, 'r') as f:
102
+ with open(self.temp_path, "r") as f:
96
103
  content = f.read()
97
104
  for line in content.splitlines():
98
105
  if line.strip() and line not in self.captured_lines:
99
106
  self.captured_lines.append(line)
100
107
  if self.emit_to_user:
101
108
  emit_info(
102
- f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
109
+ f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
103
110
  style="dim cyan",
104
- message_group=self.message_group
111
+ message_group=self.message_group,
105
112
  )
106
-
113
+
107
114
  os.unlink(self.temp_path)
108
- except:
115
+ except Exception:
109
116
  pass
110
-
117
+
111
118
  def get_captured_lines(self) -> List[str]:
112
119
  """Get all captured lines."""
113
120
  return self.captured_lines.copy()
@@ -117,7 +124,7 @@ class SimpleCapturedMCPServerStdio(MCPServerStdio):
117
124
  """
118
125
  MCPServerStdio that captures stderr to a file and optionally emits to user.
119
126
  """
120
-
127
+
121
128
  def __init__(
122
129
  self,
123
130
  command: str,
@@ -126,34 +133,36 @@ class SimpleCapturedMCPServerStdio(MCPServerStdio):
126
133
  cwd=None,
127
134
  emit_stderr: bool = True,
128
135
  message_group: Optional[uuid.UUID] = None,
129
- **kwargs
136
+ **kwargs,
130
137
  ):
131
138
  super().__init__(command=command, args=args, env=env, cwd=cwd, **kwargs)
132
139
  self.emit_stderr = emit_stderr
133
140
  self.message_group = message_group or uuid.uuid4()
134
141
  self._stderr_capture = None
135
-
142
+
136
143
  @asynccontextmanager
137
144
  async def client_streams(self):
138
145
  """Create streams with stderr capture."""
139
146
  server = StdioServerParameters(
140
- command=self.command,
141
- args=list(self.args),
142
- env=self.env,
143
- cwd=self.cwd
147
+ command=self.command, args=list(self.args), env=self.env, cwd=self.cwd
144
148
  )
145
-
149
+
146
150
  # Create stderr capture
147
- server_name = getattr(self, 'tool_prefix', self.command)
148
- self._stderr_capture = StderrFileCapture(server_name, self.emit_stderr, self.message_group)
151
+ server_name = getattr(self, "tool_prefix", self.command)
152
+ self._stderr_capture = StderrFileCapture(
153
+ server_name, self.emit_stderr, self.message_group
154
+ )
149
155
  stderr_file = self._stderr_capture.start()
150
-
156
+
151
157
  try:
152
- async with stdio_client(server=server, errlog=stderr_file) as (read_stream, write_stream):
158
+ async with stdio_client(server=server, errlog=stderr_file) as (
159
+ read_stream,
160
+ write_stream,
161
+ ):
153
162
  yield read_stream, write_stream
154
163
  finally:
155
164
  self._stderr_capture.stop()
156
-
165
+
157
166
  def get_captured_stderr(self) -> List[str]:
158
167
  """Get captured stderr lines."""
159
168
  if self._stderr_capture:
@@ -164,97 +173,99 @@ class SimpleCapturedMCPServerStdio(MCPServerStdio):
164
173
  class BlockingMCPServerStdio(SimpleCapturedMCPServerStdio):
165
174
  """
166
175
  MCP Server that blocks until fully initialized.
167
-
176
+
168
177
  This server ensures that initialization is complete before
169
178
  allowing any operations, preventing race conditions.
170
179
  """
171
-
180
+
172
181
  def __init__(self, *args, **kwargs):
173
182
  super().__init__(*args, **kwargs)
174
183
  self._initialized = asyncio.Event()
175
184
  self._init_error: Optional[Exception] = None
176
185
  self._initialization_task = None
177
-
186
+
178
187
  async def __aenter__(self):
179
188
  """Enter context and track initialization."""
180
189
  try:
181
190
  # Start initialization
182
191
  result = await super().__aenter__()
183
-
192
+
184
193
  # Mark as initialized
185
194
  self._initialized.set()
186
-
195
+
187
196
  # Emit success message
188
- server_name = getattr(self, 'tool_prefix', self.command)
197
+ server_name = getattr(self, "tool_prefix", self.command)
189
198
  emit_info(
190
- f"✅ MCP Server '{server_name}' initialized successfully",
199
+ f"✅ MCP Server '{server_name}' initialized successfully",
191
200
  style="green",
192
- message_group=self.message_group
201
+ message_group=self.message_group,
193
202
  )
194
-
203
+
195
204
  return result
196
-
205
+
197
206
  except Exception as e:
198
207
  # Store error and mark as initialized (with error)
199
208
  self._init_error = e
200
209
  self._initialized.set()
201
-
210
+
202
211
  # Emit error message
203
- server_name = getattr(self, 'tool_prefix', self.command)
212
+ server_name = getattr(self, "tool_prefix", self.command)
204
213
  emit_info(
205
- f"❌ MCP Server '{server_name}' failed to initialize: {e}",
214
+ f"❌ MCP Server '{server_name}' failed to initialize: {e}",
206
215
  style="red",
207
- message_group=self.message_group
216
+ message_group=self.message_group,
208
217
  )
209
-
218
+
210
219
  raise
211
-
220
+
212
221
  async def wait_until_ready(self, timeout: float = 30.0) -> bool:
213
222
  """
214
223
  Wait until the server is ready.
215
-
224
+
216
225
  Args:
217
226
  timeout: Maximum time to wait in seconds
218
-
227
+
219
228
  Returns:
220
229
  True if server is ready, False if timeout or error
221
-
230
+
222
231
  Raises:
223
232
  TimeoutError: If server doesn't initialize within timeout
224
233
  Exception: If server initialization failed
225
234
  """
226
235
  try:
227
236
  await asyncio.wait_for(self._initialized.wait(), timeout=timeout)
228
-
237
+
229
238
  # Check if there was an initialization error
230
239
  if self._init_error:
231
240
  raise self._init_error
232
-
241
+
233
242
  return True
234
-
243
+
235
244
  except asyncio.TimeoutError:
236
- server_name = getattr(self, 'tool_prefix', self.command)
237
- raise TimeoutError(f"Server '{server_name}' initialization timeout after {timeout}s")
238
-
245
+ server_name = getattr(self, "tool_prefix", self.command)
246
+ raise TimeoutError(
247
+ f"Server '{server_name}' initialization timeout after {timeout}s"
248
+ )
249
+
239
250
  async def ensure_ready(self, timeout: float = 30.0):
240
251
  """
241
252
  Ensure server is ready before proceeding.
242
-
253
+
243
254
  This is a convenience method that raises if not ready.
244
-
255
+
245
256
  Args:
246
257
  timeout: Maximum time to wait in seconds
247
-
258
+
248
259
  Raises:
249
260
  TimeoutError: If server doesn't initialize within timeout
250
261
  Exception: If server initialization failed
251
262
  """
252
263
  await self.wait_until_ready(timeout)
253
-
264
+
254
265
  def is_ready(self) -> bool:
255
266
  """
256
267
  Check if server is ready without blocking.
257
-
268
+
258
269
  Returns:
259
270
  True if server is initialized and ready
260
271
  """
@@ -264,33 +275,34 @@ class BlockingMCPServerStdio(SimpleCapturedMCPServerStdio):
264
275
  class StartupMonitor:
265
276
  """
266
277
  Monitor for tracking multiple server startups.
267
-
278
+
268
279
  This class helps coordinate startup of multiple MCP servers
269
280
  and ensures all are ready before proceeding.
270
281
  """
271
-
282
+
272
283
  def __init__(self, message_group: Optional[uuid.UUID] = None):
273
284
  self.servers = {}
274
285
  self.startup_times = {}
275
286
  self.message_group = message_group or uuid.uuid4()
276
-
287
+
277
288
  def add_server(self, name: str, server: BlockingMCPServerStdio):
278
289
  """Add a server to monitor."""
279
290
  self.servers[name] = server
280
-
291
+
281
292
  async def wait_all_ready(self, timeout: float = 30.0) -> dict:
282
293
  """
283
294
  Wait for all servers to be ready.
284
-
295
+
285
296
  Args:
286
297
  timeout: Maximum time to wait for all servers
287
-
298
+
288
299
  Returns:
289
300
  Dictionary of server names to ready status
290
301
  """
291
302
  import time
303
+
292
304
  results = {}
293
-
305
+
294
306
  # Create tasks for all servers
295
307
  async def wait_server(name: str, server: BlockingMCPServerStdio):
296
308
  start = time.time()
@@ -299,52 +311,52 @@ class StartupMonitor:
299
311
  self.startup_times[name] = time.time() - start
300
312
  results[name] = True
301
313
  emit_info(
302
- f" {name}: Ready in {self.startup_times[name]:.2f}s",
314
+ f" {name}: Ready in {self.startup_times[name]:.2f}s",
303
315
  style="dim green",
304
- message_group=self.message_group
316
+ message_group=self.message_group,
305
317
  )
306
318
  except Exception as e:
307
319
  self.startup_times[name] = time.time() - start
308
320
  results[name] = False
309
321
  emit_info(
310
- f" {name}: Failed after {self.startup_times[name]:.2f}s - {e}",
322
+ f" {name}: Failed after {self.startup_times[name]:.2f}s - {e}",
311
323
  style="dim red",
312
- message_group=self.message_group
324
+ message_group=self.message_group,
313
325
  )
314
-
326
+
315
327
  # Wait for all servers in parallel
316
328
  emit_info(
317
- f"⏳ Waiting for {len(self.servers)} MCP servers to initialize...",
329
+ f"⏳ Waiting for {len(self.servers)} MCP servers to initialize...",
318
330
  style="cyan",
319
- message_group=self.message_group
331
+ message_group=self.message_group,
320
332
  )
321
-
333
+
322
334
  tasks = [
323
335
  asyncio.create_task(wait_server(name, server))
324
336
  for name, server in self.servers.items()
325
337
  ]
326
-
338
+
327
339
  await asyncio.gather(*tasks, return_exceptions=True)
328
-
340
+
329
341
  # Report summary
330
342
  ready_count = sum(1 for r in results.values() if r)
331
343
  total_count = len(results)
332
-
344
+
333
345
  if ready_count == total_count:
334
346
  emit_info(
335
- f"✅ All {total_count} servers ready!",
347
+ f"✅ All {total_count} servers ready!",
336
348
  style="green bold",
337
- message_group=self.message_group
349
+ message_group=self.message_group,
338
350
  )
339
351
  else:
340
352
  emit_info(
341
- f"⚠️ {ready_count}/{total_count} servers ready",
353
+ f"⚠️ {ready_count}/{total_count} servers ready",
342
354
  style="yellow",
343
- message_group=self.message_group
355
+ message_group=self.message_group,
344
356
  )
345
-
357
+
346
358
  return results
347
-
359
+
348
360
  def get_startup_report(self) -> str:
349
361
  """Get a report of startup times."""
350
362
  lines = ["Server Startup Times:"]
@@ -354,51 +366,51 @@ class StartupMonitor:
354
366
  return "\n".join(lines)
355
367
 
356
368
 
357
- async def start_servers_with_blocking(*servers: BlockingMCPServerStdio, timeout: float = 30.0, message_group: Optional[uuid.UUID] = None):
369
+ async def start_servers_with_blocking(
370
+ *servers: BlockingMCPServerStdio,
371
+ timeout: float = 30.0,
372
+ message_group: Optional[uuid.UUID] = None,
373
+ ):
358
374
  """
359
375
  Start multiple servers and wait for all to be ready.
360
-
376
+
361
377
  Args:
362
378
  *servers: Variable number of BlockingMCPServerStdio instances
363
379
  timeout: Maximum time to wait for all servers
364
380
  message_group: Optional UUID for grouping log messages
365
-
381
+
366
382
  Returns:
367
383
  List of ready servers
368
-
384
+
369
385
  Example:
370
386
  server1 = BlockingMCPServerStdio(...)
371
387
  server2 = BlockingMCPServerStdio(...)
372
388
  ready = await start_servers_with_blocking(server1, server2)
373
389
  """
374
390
  monitor = StartupMonitor(message_group=message_group)
375
-
391
+
376
392
  for i, server in enumerate(servers):
377
- name = getattr(server, 'tool_prefix', f"server-{i}")
393
+ name = getattr(server, "tool_prefix", f"server-{i}")
378
394
  monitor.add_server(name, server)
379
-
395
+
380
396
  # Start all servers
381
397
  async def start_server(server):
382
398
  async with server:
383
399
  await asyncio.sleep(0.1) # Keep context alive briefly
384
400
  return server
385
-
401
+
386
402
  # Start servers in parallel
387
- server_tasks = [
388
- asyncio.create_task(start_server(server))
389
- for server in servers
390
- ]
391
-
403
+ [asyncio.create_task(start_server(server)) for server in servers]
404
+
392
405
  # Wait for all to be ready
393
406
  results = await monitor.wait_all_ready(timeout)
394
-
407
+
395
408
  # Get the report
396
409
  emit_info(monitor.get_startup_report(), message_group=monitor.message_group)
397
-
410
+
398
411
  # Return ready servers
399
412
  ready_servers = [
400
- server for name, server in monitor.servers.items()
401
- if results.get(name, False)
413
+ server for name, server in monitor.servers.items() if results.get(name, False)
402
414
  ]
403
-
404
- return ready_servers
415
+
416
+ return ready_servers