code-puppy 0.0.336__py3-none-any.whl → 0.0.348__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 (40) hide show
  1. code_puppy/agents/base_agent.py +41 -224
  2. code_puppy/agents/event_stream_handler.py +257 -0
  3. code_puppy/claude_cache_client.py +208 -2
  4. code_puppy/cli_runner.py +53 -35
  5. code_puppy/command_line/add_model_menu.py +8 -9
  6. code_puppy/command_line/autosave_menu.py +18 -24
  7. code_puppy/command_line/clipboard.py +527 -0
  8. code_puppy/command_line/core_commands.py +34 -0
  9. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  10. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  11. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  12. code_puppy/command_line/mcp/handler.py +0 -2
  13. code_puppy/command_line/mcp/help_command.py +1 -5
  14. code_puppy/command_line/mcp/start_command.py +36 -18
  15. code_puppy/command_line/onboarding_slides.py +0 -1
  16. code_puppy/command_line/prompt_toolkit_completion.py +124 -0
  17. code_puppy/command_line/utils.py +54 -0
  18. code_puppy/http_utils.py +93 -130
  19. code_puppy/mcp_/async_lifecycle.py +35 -4
  20. code_puppy/mcp_/managed_server.py +49 -24
  21. code_puppy/mcp_/manager.py +81 -52
  22. code_puppy/messaging/message_queue.py +11 -23
  23. code_puppy/messaging/messages.py +3 -0
  24. code_puppy/messaging/rich_renderer.py +13 -3
  25. code_puppy/model_factory.py +16 -0
  26. code_puppy/models.json +2 -2
  27. code_puppy/plugins/antigravity_oauth/antigravity_model.py +17 -2
  28. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  29. code_puppy/terminal_utils.py +128 -1
  30. code_puppy/tools/agent_tools.py +66 -13
  31. code_puppy/tools/command_runner.py +1 -0
  32. code_puppy/tools/common.py +3 -9
  33. {code_puppy-0.0.336.data → code_puppy-0.0.348.data}/data/code_puppy/models.json +2 -2
  34. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/METADATA +19 -71
  35. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/RECORD +39 -38
  36. code_puppy/command_line/mcp/add_command.py +0 -170
  37. {code_puppy-0.0.336.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
  38. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
  39. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
  40. {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
@@ -43,7 +43,7 @@ CUSTOM_SERVER_EXAMPLES = {
43
43
  "type": "http",
44
44
  "url": "http://localhost:8080/mcp",
45
45
  "headers": {
46
- "Authorization": "Bearer YOUR_API_KEY",
46
+ "Authorization": "Bearer $MY_API_KEY",
47
47
  "Content-Type": "application/json"
48
48
  },
49
49
  "timeout": 30
@@ -52,7 +52,7 @@ CUSTOM_SERVER_EXAMPLES = {
52
52
  "type": "sse",
53
53
  "url": "http://localhost:8080/sse",
54
54
  "headers": {
55
- "Authorization": "Bearer YOUR_API_KEY"
55
+ "Authorization": "Bearer $MY_API_KEY"
56
56
  }
57
57
  }""",
58
58
  }
@@ -367,24 +367,59 @@ class CustomServerForm:
367
367
  config_dict = json.loads(self.json_config)
368
368
 
369
369
  try:
370
- server_config = ServerConfig(
371
- id=server_name,
372
- name=server_name,
373
- type=server_type,
374
- enabled=True,
375
- config=config_dict,
376
- )
377
-
378
- # Register with manager
379
- server_id = self.manager.register_server(server_config)
380
-
381
- if not server_id:
382
- self.validation_error = "Failed to register server"
383
- self.status_message = (
384
- "Save failed: Could not register server (name may already exist)"
370
+ # In edit mode, find the existing server and update it
371
+ if self.edit_mode and self.original_name:
372
+ existing_config = self.manager.get_server_by_name(self.original_name)
373
+ if existing_config:
374
+ # Use the existing server's ID for the update
375
+ server_config = ServerConfig(
376
+ id=existing_config.id,
377
+ name=server_name,
378
+ type=server_type,
379
+ enabled=True,
380
+ config=config_dict,
381
+ )
382
+
383
+ # Update the server in the manager
384
+ success = self.manager.update_server(
385
+ existing_config.id, server_config
386
+ )
387
+
388
+ if not success:
389
+ self.validation_error = "Failed to update server"
390
+ self.status_message = "Save failed: Could not update server"
391
+ self.status_is_error = True
392
+ return False
393
+
394
+ server_id = existing_config.id
395
+ else:
396
+ # Original server not found, treat as new registration
397
+ server_config = ServerConfig(
398
+ id=server_name,
399
+ name=server_name,
400
+ type=server_type,
401
+ enabled=True,
402
+ config=config_dict,
403
+ )
404
+ server_id = self.manager.register_server(server_config)
405
+ else:
406
+ # New server - register it
407
+ server_config = ServerConfig(
408
+ id=server_name,
409
+ name=server_name,
410
+ type=server_type,
411
+ enabled=True,
412
+ config=config_dict,
385
413
  )
386
- self.status_is_error = True
387
- return False
414
+
415
+ # Register with manager
416
+ server_id = self.manager.register_server(server_config)
417
+
418
+ if not server_id:
419
+ self.validation_error = "Failed to register server"
420
+ self.status_message = "Save failed: Could not register server (name may already exist)"
421
+ self.status_is_error = True
422
+ return False
388
423
 
389
424
  # Save to mcp_servers.json for persistence
390
425
  if os.path.exists(MCP_SERVERS_FILE):
@@ -7,6 +7,7 @@ custom MCP servers with JSON configuration.
7
7
  import json
8
8
  import os
9
9
 
10
+ from code_puppy.command_line.utils import safe_input
10
11
  from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
11
12
 
12
13
  # Example configurations for each server type
@@ -24,7 +25,7 @@ CUSTOM_SERVER_EXAMPLES = {
24
25
  "type": "http",
25
26
  "url": "http://localhost:8080/mcp",
26
27
  "headers": {
27
- "Authorization": "Bearer YOUR_API_KEY",
28
+ "Authorization": "Bearer $MY_API_KEY",
28
29
  "Content-Type": "application/json"
29
30
  },
30
31
  "timeout": 30
@@ -33,7 +34,7 @@ CUSTOM_SERVER_EXAMPLES = {
33
34
  "type": "sse",
34
35
  "url": "http://localhost:8080/sse",
35
36
  "headers": {
36
- "Authorization": "Bearer YOUR_API_KEY"
37
+ "Authorization": "Bearer $MY_API_KEY"
37
38
  }
38
39
  }""",
39
40
  }
@@ -58,7 +59,7 @@ def prompt_and_install_custom_server(manager) -> bool:
58
59
 
59
60
  # Get server name
60
61
  try:
61
- server_name = input(" Server name: ").strip()
62
+ server_name = safe_input(" Server name: ")
62
63
  if not server_name:
63
64
  emit_warning("Server name is required")
64
65
  return False
@@ -71,9 +72,7 @@ def prompt_and_install_custom_server(manager) -> bool:
71
72
  existing = find_server_id_by_name(manager, server_name)
72
73
  if existing:
73
74
  try:
74
- override = input(
75
- f" Server '{server_name}' exists. Override? [y/N]: "
76
- ).strip()
75
+ override = safe_input(f" Server '{server_name}' exists. Override? [y/N]: ")
77
76
  if not override.lower().startswith("y"):
78
77
  emit_warning("Cancelled")
79
78
  return False
@@ -89,7 +88,7 @@ def prompt_and_install_custom_server(manager) -> bool:
89
88
  emit_info(" 3. 📡 sse - Server-Sent Events\n")
90
89
 
91
90
  try:
92
- type_choice = input(" Enter choice [1-3]: ").strip()
91
+ type_choice = safe_input(" Enter choice [1-3]: ")
93
92
  except (KeyboardInterrupt, EOFError):
94
93
  emit_info("")
95
94
  emit_warning("Cancelled")
@@ -115,8 +114,8 @@ def prompt_and_install_custom_server(manager) -> bool:
115
114
  empty_count = 0
116
115
  try:
117
116
  while True:
118
- line = input()
119
- if line.strip() == "":
117
+ line = safe_input("")
118
+ if line == "":
120
119
  empty_count += 1
121
120
  if empty_count >= 2:
122
121
  break
@@ -12,7 +12,6 @@ from rich.text import Text
12
12
 
13
13
  from code_puppy.messaging import emit_info
14
14
 
15
- from .add_command import AddCommand
16
15
  from .base import MCPCommandBase
17
16
  from .edit_command import EditCommand
18
17
  from .help_command import HelpCommand
@@ -63,7 +62,6 @@ class MCPCommandHandler(MCPCommandBase):
63
62
  "restart": RestartCommand(),
64
63
  "status": StatusCommand(),
65
64
  "test": TestCommand(),
66
- "add": AddCommand(),
67
65
  "edit": EditCommand(),
68
66
  "remove": RemoveCommand(),
69
67
  "logs": LogsCommand(),
@@ -101,10 +101,6 @@ class HelpCommand(MCPCommandBase):
101
101
  Text("/mcp logs", style="cyan")
102
102
  + Text(" <name> [limit] Show recent events (default limit: 10)")
103
103
  )
104
- help_lines.append(
105
- Text("/mcp add", style="cyan")
106
- + Text(" [json] Add new server (JSON or wizard)")
107
- )
108
104
  help_lines.append(
109
105
  Text("/mcp edit", style="cyan")
110
106
  + Text(" <name> Edit existing server config")
@@ -134,7 +130,7 @@ class HelpCommand(MCPCommandBase):
134
130
  /mcp start-all # Start all servers at once
135
131
  /mcp stop-all # Stop all running servers
136
132
  /mcp edit filesystem # Edit an existing server config
137
- /mcp add {"name": "test", "type": "stdio", "command": "echo"}"""
133
+ /mcp remove filesystem # Remove a server"""
138
134
  help_lines.append(Text(examples_text, style="dim"))
139
135
 
140
136
  # Combine all lines
@@ -3,7 +3,6 @@ MCP Start Command - Starts a specific MCP server.
3
3
  """
4
4
 
5
5
  import logging
6
- import time
7
6
  from typing import List, Optional
8
7
 
9
8
  from rich.text import Text
@@ -23,6 +22,7 @@ class StartCommand(MCPCommandBase):
23
22
  Command handler for starting MCP servers.
24
23
 
25
24
  Starts a specific MCP server by name and reloads the agent.
25
+ The server subprocess starts asynchronously in the background.
26
26
  """
27
27
 
28
28
  def execute(self, args: List[str], group_id: Optional[str] = None) -> None:
@@ -56,31 +56,49 @@ class StartCommand(MCPCommandBase):
56
56
  suggest_similar_servers(self.manager, server_name, group_id=group_id)
57
57
  return
58
58
 
59
- # Start the server (enable and start process)
59
+ # Get server info for better messaging (safely handle missing method)
60
+ server_type = "unknown"
61
+ try:
62
+ if hasattr(self.manager, "get_server_by_name"):
63
+ server_config = self.manager.get_server_by_name(server_name)
64
+ server_type = (
65
+ getattr(server_config, "type", "unknown")
66
+ if server_config
67
+ else "unknown"
68
+ )
69
+ except Exception:
70
+ pass # Default to unknown type if we can't determine it
71
+
72
+ # Start the server (schedules async start in background)
60
73
  success = self.manager.start_server_sync(server_id)
61
74
 
62
75
  if success:
63
- # This and subsequent messages will auto-group with the first message
64
- emit_success(
65
- f"Started server: {server_name}",
66
- message_group=group_id,
67
- )
68
-
69
- # Give async tasks a moment to complete
70
- try:
71
- import asyncio
72
-
73
- asyncio.get_running_loop() # Check if in async context
74
- # If we're in async context, wait a bit for server to start
75
- time.sleep(0.5) # Small delay to let async tasks progress
76
- except RuntimeError:
77
- pass # No async loop, server will start when agent uses it
76
+ if server_type == "stdio":
77
+ # Stdio servers start subprocess asynchronously
78
+ emit_success(
79
+ f"🚀 Starting server: {server_name} (subprocess starting in background)",
80
+ message_group=group_id,
81
+ )
82
+ emit_info(
83
+ Text.from_markup(
84
+ "[dim]Tip: Use /mcp status to check if the server is fully initialized[/dim]"
85
+ ),
86
+ message_group=group_id,
87
+ )
88
+ else:
89
+ # SSE/HTTP servers connect on first use
90
+ emit_success(
91
+ f"✅ Enabled server: {server_name}",
92
+ message_group=group_id,
93
+ )
78
94
 
79
95
  # Reload the agent to pick up the newly enabled server
96
+ # NOTE: We don't block or wait - the server will be ready
97
+ # when the next prompt runs (pydantic-ai handles connection)
80
98
  try:
81
99
  agent = get_current_agent()
82
100
  agent.reload_code_generation_agent()
83
- # Update MCP tool cache immediately so token counts reflect the change
101
+ # Clear MCP tool cache - it will be repopulated on next run
84
102
  agent.update_mcp_tool_cache_sync()
85
103
  emit_info(
86
104
  "Agent reloaded with updated servers",
@@ -122,7 +122,6 @@ def slide_mcp() -> str:
122
122
  content += "[white]Supercharge with external tools![/white]\n\n"
123
123
  content += "[green]Commands:[/green]\n"
124
124
  content += " [cyan]/mcp install[/cyan] Browse catalog\n"
125
- content += " [cyan]/mcp add[/cyan] Add custom server\n"
126
125
  content += " [cyan]/mcp list[/cyan] See your servers\n\n"
127
126
  content += "[yellow]🌟 Popular picks:[/yellow]\n"
128
127
  content += " • GitHub integration\n"
@@ -27,6 +27,10 @@ from code_puppy.command_line.attachments import (
27
27
  _detect_path_tokens,
28
28
  _tokenise,
29
29
  )
30
+ from code_puppy.command_line.clipboard import (
31
+ capture_clipboard_image_to_pending,
32
+ has_image_in_clipboard,
33
+ )
30
34
  from code_puppy.command_line.command_registry import get_unique_commands
31
35
  from code_puppy.command_line.file_path_completion import FilePathCompleter
32
36
  from code_puppy.command_line.load_context_completion import LoadContextCompleter
@@ -644,6 +648,126 @@ async def get_input_with_combined_completion(
644
648
  else:
645
649
  event.current_buffer.validate_and_handle()
646
650
 
651
+ # Handle bracketed paste - smart detection for text vs images.
652
+ # Most terminals (Windows included!) send Ctrl+V through bracketed paste.
653
+ # - If there's meaningful text content → paste as text (drag-and-drop file paths, copied text)
654
+ # - If text is empty/whitespace → check for clipboard image (image paste on Windows)
655
+ @bindings.add(Keys.BracketedPaste)
656
+ def handle_bracketed_paste(event):
657
+ """Handle bracketed paste - smart text vs image detection."""
658
+ pasted_data = event.data
659
+
660
+ # If we have meaningful text content, paste it (don't check for images)
661
+ # This handles drag-and-drop file paths and normal text paste
662
+ if pasted_data and pasted_data.strip():
663
+ # Normalize Windows line endings to Unix style
664
+ sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
665
+ event.app.current_buffer.insert_text(sanitized_data)
666
+ return
667
+
668
+ # No meaningful text - check if clipboard has an image (Windows image paste!)
669
+ try:
670
+ if has_image_in_clipboard():
671
+ placeholder = capture_clipboard_image_to_pending()
672
+ if placeholder:
673
+ event.app.current_buffer.insert_text(placeholder + " ")
674
+ event.app.output.bell()
675
+ return
676
+ except Exception:
677
+ pass
678
+
679
+ # Fallback: if there was whitespace-only data, paste it
680
+ if pasted_data:
681
+ sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
682
+ event.app.current_buffer.insert_text(sanitized_data)
683
+
684
+ # Fallback Ctrl+V for terminals without bracketed paste support
685
+ @bindings.add("c-v", eager=True)
686
+ def handle_smart_paste(event):
687
+ """Handle Ctrl+V - auto-detect image vs text in clipboard."""
688
+ try:
689
+ # Check for image first
690
+ if has_image_in_clipboard():
691
+ placeholder = capture_clipboard_image_to_pending()
692
+ if placeholder:
693
+ event.app.current_buffer.insert_text(placeholder + " ")
694
+ # The placeholder itself is visible feedback - no need for extra output
695
+ # Use bell for audible feedback (works in most terminals)
696
+ event.app.output.bell()
697
+ return # Don't also paste text
698
+ except Exception:
699
+ pass # Fall through to text paste on any error
700
+
701
+ # No image (or error) - do normal text paste
702
+ # prompt_toolkit doesn't have built-in paste, so we handle it manually
703
+ try:
704
+ import platform
705
+ import subprocess
706
+
707
+ text = None
708
+ system = platform.system()
709
+
710
+ if system == "Darwin": # macOS
711
+ result = subprocess.run(
712
+ ["pbpaste"], capture_output=True, text=True, timeout=2
713
+ )
714
+ if result.returncode == 0:
715
+ text = result.stdout
716
+ elif system == "Windows":
717
+ # Windows - use powershell
718
+ result = subprocess.run(
719
+ ["powershell", "-command", "Get-Clipboard"],
720
+ capture_output=True,
721
+ text=True,
722
+ timeout=2,
723
+ )
724
+ if result.returncode == 0:
725
+ text = result.stdout
726
+ else: # Linux
727
+ # Try xclip first, then xsel
728
+ for cmd in [
729
+ ["xclip", "-selection", "clipboard", "-o"],
730
+ ["xsel", "--clipboard", "--output"],
731
+ ]:
732
+ try:
733
+ result = subprocess.run(
734
+ cmd, capture_output=True, text=True, timeout=2
735
+ )
736
+ if result.returncode == 0:
737
+ text = result.stdout
738
+ break
739
+ except FileNotFoundError:
740
+ continue
741
+
742
+ if text:
743
+ # Normalize Windows line endings to Unix style
744
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
745
+ # Strip trailing newline that clipboard tools often add
746
+ text = text.rstrip("\n")
747
+ event.app.current_buffer.insert_text(text)
748
+ except Exception:
749
+ pass # Silently fail if text paste doesn't work
750
+
751
+ # F3 - dedicated image paste (shows error if no image)
752
+ @bindings.add("f3")
753
+ def handle_image_paste_f3(event):
754
+ """Handle F3 - paste image from clipboard (image-only, shows error if none)."""
755
+ try:
756
+ if has_image_in_clipboard():
757
+ placeholder = capture_clipboard_image_to_pending()
758
+ if placeholder:
759
+ event.app.current_buffer.insert_text(placeholder + " ")
760
+ # The placeholder itself is visible feedback
761
+ # Use bell for audible feedback (works in most terminals)
762
+ event.app.output.bell()
763
+ else:
764
+ # Insert a transient message that user can delete
765
+ event.app.current_buffer.insert_text("[⚠️ no image in clipboard] ")
766
+ event.app.output.bell()
767
+ except Exception:
768
+ event.app.current_buffer.insert_text("[❌ clipboard error] ")
769
+ event.app.output.bell()
770
+
647
771
  session = PromptSession(
648
772
  completer=completer,
649
773
  history=history,
@@ -37,3 +37,57 @@ def make_directory_table(path: str = None) -> Table:
37
37
  for f in sorted(files):
38
38
  table.add_row("[yellow]file[/yellow]", f"{f}")
39
39
  return table
40
+
41
+
42
+ def _reset_windows_console() -> None:
43
+ """Reset Windows console to normal input mode.
44
+
45
+ After a prompt_toolkit Application exits on Windows, the console can be
46
+ left in a weird state where Enter doesn't work properly. This resets it.
47
+ """
48
+ import sys
49
+
50
+ if sys.platform != "win32":
51
+ return
52
+
53
+ try:
54
+ import ctypes
55
+
56
+ kernel32 = ctypes.windll.kernel32
57
+ # Get handle to stdin
58
+ STD_INPUT_HANDLE = -10
59
+ handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
60
+
61
+ # Enable line input and echo (normal console mode)
62
+ # ENABLE_LINE_INPUT = 0x0002
63
+ # ENABLE_ECHO_INPUT = 0x0004
64
+ # ENABLE_PROCESSED_INPUT = 0x0001
65
+ NORMAL_MODE = 0x0007 # Line input + echo + processed
66
+ kernel32.SetConsoleMode(handle, NORMAL_MODE)
67
+ except Exception:
68
+ pass # Silently ignore errors - this is best-effort
69
+
70
+
71
+ def safe_input(prompt_text: str = "") -> str:
72
+ """Cross-platform safe input that works after prompt_toolkit Applications.
73
+
74
+ On Windows, raw input() can fail after a prompt_toolkit Application exits
75
+ because the terminal can be left in a weird state. This function resets
76
+ the Windows console mode before calling input().
77
+
78
+ Args:
79
+ prompt_text: The prompt to display to the user
80
+
81
+ Returns:
82
+ The user's input string (stripped)
83
+
84
+ Raises:
85
+ KeyboardInterrupt: If user presses Ctrl+C
86
+ EOFError: If user presses Ctrl+D/Ctrl+Z
87
+ """
88
+ # Reset Windows console to normal mode before reading input
89
+ _reset_windows_console()
90
+
91
+ # Use standard input() - now that console is reset, it should work
92
+ result = input(prompt_text)
93
+ return result.strip() if result else ""