code-puppy 0.0.338__py3-none-any.whl → 0.0.340__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.
@@ -10,10 +10,13 @@ serialization, avoiding httpx/Pydantic internals.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import logging
13
14
  from typing import Any, Callable, MutableMapping
14
15
 
15
16
  import httpx
16
17
 
18
+ logger = logging.getLogger(__name__)
19
+
17
20
  try:
18
21
  from anthropic import AsyncAnthropic
19
22
  except ImportError: # pragma: no cover - optional dep
@@ -58,25 +61,44 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
58
61
  pass
59
62
  response = await super().send(request, *args, **kwargs)
60
63
  try:
61
- if response.status_code == 401 and not request.extensions.get(
64
+ # Check for both 401 and 400 - Anthropic/Cloudflare may return 400 for auth errors
65
+ # Also check if it's a Cloudflare HTML error response
66
+ if response.status_code in (400, 401) and not request.extensions.get(
62
67
  "claude_oauth_refresh_attempted"
63
68
  ):
64
- refreshed_token = self._refresh_claude_oauth_token()
65
- if refreshed_token:
66
- await response.aclose()
67
- body_bytes = self._extract_body_bytes(request)
68
- headers = dict(request.headers)
69
- self._update_auth_headers(headers, refreshed_token)
70
- retry_request = self.build_request(
71
- method=request.method,
72
- url=request.url,
73
- headers=headers,
74
- content=body_bytes,
75
- )
76
- retry_request.extensions["claude_oauth_refresh_attempted"] = True
77
- return await super().send(retry_request, *args, **kwargs)
78
- except Exception:
79
- pass
69
+ # Determine if this is an auth error (including Cloudflare HTML errors)
70
+ is_auth_error = response.status_code == 401
71
+
72
+ if response.status_code == 400:
73
+ # Check if this is a Cloudflare HTML error
74
+ is_auth_error = self._is_cloudflare_html_error(response)
75
+ if is_auth_error:
76
+ logger.info(
77
+ "Detected Cloudflare 400 error (likely auth-related), attempting token refresh"
78
+ )
79
+
80
+ if is_auth_error:
81
+ refreshed_token = self._refresh_claude_oauth_token()
82
+ if refreshed_token:
83
+ logger.info("Token refreshed successfully, retrying request")
84
+ await response.aclose()
85
+ body_bytes = self._extract_body_bytes(request)
86
+ headers = dict(request.headers)
87
+ self._update_auth_headers(headers, refreshed_token)
88
+ retry_request = self.build_request(
89
+ method=request.method,
90
+ url=request.url,
91
+ headers=headers,
92
+ content=body_bytes,
93
+ )
94
+ retry_request.extensions["claude_oauth_refresh_attempted"] = (
95
+ True
96
+ )
97
+ return await super().send(retry_request, *args, **kwargs)
98
+ else:
99
+ logger.warning("Token refresh failed, returning original error")
100
+ except Exception as exc:
101
+ logger.debug("Error during token refresh attempt: %s", exc)
80
102
  return response
81
103
 
82
104
  @staticmethod
@@ -111,15 +133,51 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
111
133
  else:
112
134
  headers["Authorization"] = bearer_value
113
135
 
136
+ @staticmethod
137
+ def _is_cloudflare_html_error(response: httpx.Response) -> bool:
138
+ """Check if this is a Cloudflare HTML error response.
139
+
140
+ Cloudflare often returns HTML error pages with status 400 when
141
+ there are authentication issues.
142
+ """
143
+ # Check content type
144
+ content_type = response.headers.get("content-type", "")
145
+ if "text/html" not in content_type.lower():
146
+ return False
147
+
148
+ # Check if body contains Cloudflare markers
149
+ try:
150
+ # Read response body if not already consumed
151
+ if hasattr(response, "_content") and response._content:
152
+ body = response._content.decode("utf-8", errors="ignore")
153
+ else:
154
+ # Try to read the text (this might be already consumed)
155
+ try:
156
+ body = response.text
157
+ except Exception:
158
+ return False
159
+
160
+ # Look for Cloudflare and 400 Bad Request markers
161
+ body_lower = body.lower()
162
+ return "cloudflare" in body_lower and "400 bad request" in body_lower
163
+ except Exception as exc:
164
+ logger.debug("Error checking for Cloudflare error: %s", exc)
165
+ return False
166
+
114
167
  def _refresh_claude_oauth_token(self) -> str | None:
115
168
  try:
116
169
  from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
117
170
 
171
+ logger.info("Attempting to refresh Claude Code OAuth token...")
118
172
  refreshed_token = refresh_access_token(force=True)
119
173
  if refreshed_token:
120
174
  self._update_auth_headers(self.headers, refreshed_token)
175
+ logger.info("Successfully refreshed Claude Code OAuth token")
176
+ else:
177
+ logger.warning("Token refresh returned None")
121
178
  return refreshed_token
122
- except Exception:
179
+ except Exception as exc:
180
+ logger.error("Exception during token refresh: %s", exc)
123
181
  return None
124
182
 
125
183
  @staticmethod
code_puppy/cli_runner.py CHANGED
@@ -22,6 +22,7 @@ from rich.console import Console
22
22
  from code_puppy import __version__, callbacks, plugins
23
23
  from code_puppy.agents import get_current_agent
24
24
  from code_puppy.command_line.attachments import parse_prompt_attachments
25
+ from code_puppy.command_line.clipboard import get_clipboard_manager
25
26
  from code_puppy.config import (
26
27
  AUTOSAVE_DIR,
27
28
  COMMAND_HISTORY_FILE,
@@ -143,8 +144,8 @@ async def main():
143
144
  except ImportError:
144
145
  emit_system_message("🐶 Code Puppy is Loading...")
145
146
 
146
- # Check for truecolor support and warn if not available
147
- print_truecolor_warning(display_console)
147
+ # Truecolor warning moved to interactive_mode() so it prints LAST
148
+ # after all the help stuff - max visibility for the ugly red box!
148
149
 
149
150
  available_port = find_available_port()
150
151
  if available_port is None:
@@ -354,6 +355,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
354
355
  emit_system_message(
355
356
  "Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Ctrl+J."
356
357
  )
358
+ emit_system_message("Paste images: Ctrl+V (even on Mac!), F3, or /paste command.")
359
+ import platform
360
+
361
+ if platform.system() == "Darwin":
362
+ emit_system_message(
363
+ "💡 macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal."
364
+ )
357
365
  cancel_key = get_cancel_agent_display_name()
358
366
  emit_system_message(
359
367
  f"Press {cancel_key} during processing to cancel the current task or inference. Use Ctrl+X to interrupt running shell commands."
@@ -374,6 +382,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
374
382
 
375
383
  emit_warning(f"MOTD error: {e}")
376
384
 
385
+ # Print truecolor warning LAST so it's the most visible thing on startup
386
+ # Big ugly red box should be impossible to miss! 🔴
387
+ print_truecolor_warning(display_console)
388
+
377
389
  # Initialize the runtime agent manager
378
390
  if initial_command:
379
391
  from code_puppy.agents import get_current_agent
@@ -567,6 +579,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
567
579
 
568
580
  # Check for clear command (supports both `clear` and `/clear`)
569
581
  if task.strip().lower() in ("clear", "/clear"):
582
+ from code_puppy.command_line.clipboard import get_clipboard_manager
570
583
  from code_puppy.messaging import (
571
584
  emit_info,
572
585
  emit_system_message,
@@ -579,6 +592,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
579
592
  emit_warning("Conversation history cleared!")
580
593
  emit_system_message("The agent will not remember previous interactions.")
581
594
  emit_info(f"Auto-save session rotated to: {new_session_id}")
595
+
596
+ # Also clear pending clipboard images
597
+ clipboard_manager = get_clipboard_manager()
598
+ clipboard_count = clipboard_manager.get_pending_count()
599
+ clipboard_manager.clear_pending()
600
+ if clipboard_count > 0:
601
+ emit_info(f"Cleared {clipboard_count} pending clipboard image(s)")
582
602
  continue
583
603
 
584
604
  # Parse attachments first so leading paths aren't misread as commands
@@ -761,6 +781,7 @@ async def run_prompt_with_attachments(
761
781
  tuple: (result, task) where result is the agent response and task is the asyncio task
762
782
  """
763
783
  import asyncio
784
+ import re
764
785
 
765
786
  from code_puppy.messaging import emit_system_message, emit_warning
766
787
 
@@ -769,21 +790,41 @@ async def run_prompt_with_attachments(
769
790
  for warning in processed_prompt.warnings:
770
791
  emit_warning(warning)
771
792
 
793
+ # Get clipboard images and merge with file attachments
794
+ clipboard_manager = get_clipboard_manager()
795
+ clipboard_images = clipboard_manager.get_pending_images()
796
+
797
+ # Clear pending clipboard images after retrieval
798
+ clipboard_manager.clear_pending()
799
+
800
+ # Build summary of all attachments
772
801
  summary_parts = []
773
802
  if processed_prompt.attachments:
774
- summary_parts.append(f"binary files: {len(processed_prompt.attachments)}")
803
+ summary_parts.append(f"files: {len(processed_prompt.attachments)}")
804
+ if clipboard_images:
805
+ summary_parts.append(f"clipboard images: {len(clipboard_images)}")
775
806
  if processed_prompt.link_attachments:
776
807
  summary_parts.append(f"urls: {len(processed_prompt.link_attachments)}")
777
808
  if summary_parts:
778
809
  emit_system_message("Attachments detected -> " + ", ".join(summary_parts))
779
810
 
780
- if not processed_prompt.prompt:
811
+ # Clean up clipboard placeholders from the prompt text
812
+ cleaned_prompt = processed_prompt.prompt
813
+ if clipboard_images and cleaned_prompt:
814
+ cleaned_prompt = re.sub(
815
+ r"\[📋 clipboard image \d+\]\s*", "", cleaned_prompt
816
+ ).strip()
817
+
818
+ if not cleaned_prompt:
781
819
  emit_warning(
782
820
  "Prompt is empty after removing attachments; add instructions and retry."
783
821
  )
784
822
  return None, None
785
823
 
824
+ # Combine file attachments with clipboard images
786
825
  attachments = [attachment.content for attachment in processed_prompt.attachments]
826
+ attachments.extend(clipboard_images) # Add clipboard images
827
+
787
828
  link_attachments = [link.url_part for link in processed_prompt.link_attachments]
788
829
 
789
830
  # IMPORTANT: Set the shared console on the agent so that streaming output
@@ -795,7 +836,7 @@ async def run_prompt_with_attachments(
795
836
  # Create the agent task first so we can track and cancel it
796
837
  agent_task = asyncio.create_task(
797
838
  agent.run_with_mcp(
798
- processed_prompt.prompt,
839
+ cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
799
840
  attachments=attachments,
800
841
  link_attachments=link_attachments,
801
842
  )
@@ -0,0 +1,527 @@
1
+ """Clipboard image reading and management utilities.
2
+
3
+ Provides cross-platform clipboard image capture:
4
+ - Windows/macOS: Uses PIL.ImageGrab (native)
5
+ - Linux: Falls back to xclip or wl-paste via subprocess
6
+
7
+ Also provides a thread-safe ClipboardAttachmentManager for managing
8
+ pending clipboard image attachments in the CLI.
9
+ """
10
+
11
+ import io
12
+ import logging
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from typing import Optional
18
+
19
+ # Try to import PIL - it's optional but needed for clipboard image support
20
+ try:
21
+ from PIL import Image, ImageGrab
22
+
23
+ PIL_AVAILABLE = True
24
+ # SEC-CLIP-002: Protect against decompression bombs
25
+ # Set explicit limit (PIL default) to prevent memory exhaustion from malicious images
26
+ Image.MAX_IMAGE_PIXELS = 178956970
27
+ except ImportError:
28
+ PIL_AVAILABLE = False
29
+ Image = None # type: ignore[misc, assignment]
30
+ ImageGrab = None # type: ignore[misc, assignment]
31
+
32
+ # Import BinaryContent for pydantic-ai integration
33
+ try:
34
+ from pydantic_ai import BinaryContent
35
+
36
+ BINARY_CONTENT_AVAILABLE = True
37
+ except ImportError:
38
+ BINARY_CONTENT_AVAILABLE = False
39
+ BinaryContent = None # type: ignore[misc, assignment]
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Constants
44
+ MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 # 10MB
45
+ MAX_IMAGE_DIMENSION = 4096 # Max width/height for resize
46
+ MAX_PENDING_IMAGES = (
47
+ 10 # SEC-CLIP-001: Limit pending images to prevent memory exhaustion
48
+ )
49
+ CLIPBOARD_RATE_LIMIT_SECONDS: float = 0.5 # SEC-CLIP-004: Max 2 captures per second
50
+
51
+ # Rate limiting state
52
+ _last_clipboard_capture: float = 0.0
53
+
54
+
55
+ def _safe_open_image(image_bytes: bytes) -> Optional["Image.Image"]:
56
+ """Safely open and verify an image from bytes.
57
+
58
+ Verifies image integrity to protect against malicious images.
59
+
60
+ Args:
61
+ image_bytes: Raw image bytes to open.
62
+
63
+ Returns:
64
+ PIL Image if valid, None if verification fails.
65
+ """
66
+ if not PIL_AVAILABLE or Image is None:
67
+ return None
68
+
69
+ try:
70
+ # First pass: verify integrity without fully loading
71
+ verify_image = Image.open(io.BytesIO(image_bytes))
72
+ verify_image.verify() # Checks for corruption/malicious data
73
+
74
+ # Re-open after verify (verify() closes the image)
75
+ image = Image.open(io.BytesIO(image_bytes))
76
+ return image
77
+ except Image.DecompressionBombError as e:
78
+ logger.warning(f"Rejected decompression bomb image: {e}")
79
+ return None
80
+ except Image.UnidentifiedImageError as e:
81
+ logger.warning(f"Rejected unidentified image format: {e}")
82
+ return None
83
+ except OSError as e:
84
+ logger.warning(f"Rejected potentially malicious image: {e}")
85
+ return None
86
+ except Exception as e:
87
+ logger.warning(f"Failed to open/verify image: {type(e).__name__}: {e}")
88
+ return None
89
+
90
+
91
+ def _check_linux_clipboard_tool() -> Optional[str]:
92
+ """Check which Linux clipboard tool is available.
93
+
94
+ Returns:
95
+ 'xclip', 'wl-paste', or None if neither is available.
96
+ """
97
+ # Check for wl-paste first (Wayland)
98
+ try:
99
+ subprocess.run(
100
+ ["wl-paste", "--version"],
101
+ capture_output=True,
102
+ timeout=5,
103
+ )
104
+ return "wl-paste"
105
+ except (FileNotFoundError, subprocess.TimeoutExpired):
106
+ pass
107
+
108
+ # Check for xclip (X11)
109
+ try:
110
+ subprocess.run(
111
+ ["xclip", "-version"],
112
+ capture_output=True,
113
+ timeout=5,
114
+ )
115
+ return "xclip"
116
+ except (FileNotFoundError, subprocess.TimeoutExpired):
117
+ pass
118
+
119
+ return None
120
+
121
+
122
+ def _get_linux_clipboard_image() -> Optional[bytes]:
123
+ """Get clipboard image on Linux using xclip or wl-paste.
124
+
125
+ Returns:
126
+ PNG bytes if image found, None otherwise.
127
+ """
128
+ tool = _check_linux_clipboard_tool()
129
+
130
+ if tool is None:
131
+ logger.warning(
132
+ "No clipboard tool found on Linux. "
133
+ "Install 'xclip' (X11) or 'wl-clipboard' (Wayland) for clipboard image support."
134
+ )
135
+ return None
136
+
137
+ try:
138
+ if tool == "wl-paste":
139
+ # wl-paste for Wayland
140
+ result = subprocess.run(
141
+ ["wl-paste", "--type", "image/png"],
142
+ capture_output=True,
143
+ timeout=10,
144
+ )
145
+ if result.returncode == 0 and result.stdout:
146
+ return result.stdout
147
+ elif tool == "xclip":
148
+ # xclip for X11
149
+ result = subprocess.run(
150
+ ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
151
+ capture_output=True,
152
+ timeout=10,
153
+ )
154
+ if result.returncode == 0 and result.stdout:
155
+ return result.stdout
156
+ except subprocess.TimeoutExpired:
157
+ logger.warning(f"Timeout reading clipboard with {tool}")
158
+ except Exception as e:
159
+ logger.warning(f"Error reading clipboard with {tool}: {e}")
160
+
161
+ return None
162
+
163
+
164
+ def _resize_image_if_needed(image: "Image.Image", max_bytes: int) -> "Image.Image":
165
+ """Resize image if it exceeds max size when saved as PNG.
166
+
167
+ Args:
168
+ image: PIL Image to potentially resize.
169
+ max_bytes: Maximum allowed size in bytes.
170
+
171
+ Returns:
172
+ Original or resized image.
173
+ """
174
+ if Image is None:
175
+ return image
176
+
177
+ # Check current size
178
+ buffer = io.BytesIO()
179
+ image.save(buffer, format="PNG", optimize=True)
180
+ current_size = buffer.tell()
181
+
182
+ if current_size <= max_bytes:
183
+ return image
184
+
185
+ logger.info(
186
+ f"Image size ({current_size / 1024 / 1024:.2f}MB) exceeds limit "
187
+ f"({max_bytes / 1024 / 1024:.2f}MB), resizing..."
188
+ )
189
+
190
+ # Calculate scale factor to reduce size
191
+ # Rough estimate: size scales with area (width * height)
192
+ scale_factor = (max_bytes / current_size) ** 0.5 * 0.9 # 0.9 for safety margin
193
+
194
+ new_width = int(image.width * scale_factor)
195
+ new_height = int(image.height * scale_factor)
196
+
197
+ # Ensure we don't go below minimum dimensions
198
+ new_width = max(new_width, 100)
199
+ new_height = max(new_height, 100)
200
+
201
+ # Also cap at max dimension
202
+ if new_width > MAX_IMAGE_DIMENSION:
203
+ ratio = MAX_IMAGE_DIMENSION / new_width
204
+ new_width = MAX_IMAGE_DIMENSION
205
+ new_height = int(new_height * ratio)
206
+ if new_height > MAX_IMAGE_DIMENSION:
207
+ ratio = MAX_IMAGE_DIMENSION / new_height
208
+ new_height = MAX_IMAGE_DIMENSION
209
+ new_width = int(new_width * ratio)
210
+
211
+ resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
212
+ logger.info(
213
+ f"Resized image from {image.width}x{image.height} to {new_width}x{new_height}"
214
+ )
215
+
216
+ return resized
217
+
218
+
219
+ def has_image_in_clipboard() -> bool:
220
+ """Check if clipboard contains an image.
221
+
222
+ Returns:
223
+ True if clipboard contains an image, False otherwise.
224
+ """
225
+ if sys.platform == "linux":
226
+ # For Linux, we need to actually try to get the image
227
+ # since there's no lightweight "check" method
228
+ tool = _check_linux_clipboard_tool()
229
+ if tool is None:
230
+ return False
231
+
232
+ try:
233
+ if tool == "wl-paste":
234
+ result = subprocess.run(
235
+ ["wl-paste", "--list-types"],
236
+ capture_output=True,
237
+ timeout=5,
238
+ text=True,
239
+ )
240
+ return "image/png" in result.stdout or "image/" in result.stdout
241
+ elif tool == "xclip":
242
+ result = subprocess.run(
243
+ ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
244
+ capture_output=True,
245
+ timeout=5,
246
+ text=True,
247
+ )
248
+ return "image/png" in result.stdout or "image/" in result.stdout
249
+ except (subprocess.TimeoutExpired, Exception):
250
+ return False
251
+
252
+ return False
253
+
254
+ # Windows/macOS - use PIL
255
+ if not PIL_AVAILABLE:
256
+ return False
257
+
258
+ try:
259
+ image = ImageGrab.grabclipboard()
260
+ return image is not None and isinstance(image, Image.Image)
261
+ except Exception as e:
262
+ logger.debug(f"Error checking clipboard: {e}")
263
+ return False
264
+
265
+
266
+ def get_clipboard_image() -> Optional[bytes]:
267
+ """Get clipboard image as PNG bytes.
268
+
269
+ Handles cross-platform clipboard access:
270
+ - Windows/macOS: Uses PIL.ImageGrab
271
+ - Linux: Uses xclip or wl-paste
272
+
273
+ Images larger than 10MB are automatically resized.
274
+
275
+ Returns:
276
+ PNG bytes if clipboard contains an image, None otherwise.
277
+ """
278
+ image_bytes: Optional[bytes] = None
279
+
280
+ # Linux path - use command line tools
281
+ if sys.platform == "linux":
282
+ image_bytes = _get_linux_clipboard_image()
283
+ if image_bytes is None:
284
+ return None
285
+
286
+ # Check size and resize if needed
287
+ if len(image_bytes) > MAX_IMAGE_SIZE_BYTES:
288
+ if not PIL_AVAILABLE:
289
+ logger.warning(
290
+ f"Image size ({len(image_bytes) / 1024 / 1024:.2f}MB) exceeds limit, "
291
+ "but PIL not available for resizing."
292
+ )
293
+ return None
294
+
295
+ try:
296
+ # Use safe image opening with verification
297
+ image = _safe_open_image(image_bytes)
298
+ if image is None:
299
+ logger.warning(
300
+ "Image verification failed for Linux clipboard image"
301
+ )
302
+ return None
303
+ image = _resize_image_if_needed(image, MAX_IMAGE_SIZE_BYTES)
304
+ buffer = io.BytesIO()
305
+ image.save(buffer, format="PNG", optimize=True)
306
+ image_bytes = buffer.getvalue()
307
+ except Exception as e:
308
+ logger.warning(f"Error resizing Linux clipboard image: {e}")
309
+ return None
310
+ else:
311
+ # Verify even small images for safety
312
+ if PIL_AVAILABLE:
313
+ image = _safe_open_image(image_bytes)
314
+ if image is None:
315
+ logger.warning(
316
+ "Image verification failed for Linux clipboard image"
317
+ )
318
+ return None
319
+
320
+ return image_bytes
321
+
322
+ # Windows/macOS path - use PIL
323
+ if not PIL_AVAILABLE:
324
+ logger.warning("PIL/Pillow not available. Install with: pip install Pillow")
325
+ return None
326
+
327
+ try:
328
+ image = ImageGrab.grabclipboard()
329
+
330
+ if image is None:
331
+ return None
332
+
333
+ if not isinstance(image, Image.Image):
334
+ # Could be a list of file paths on some systems
335
+ logger.debug(f"Clipboard contains non-image data: {type(image)}")
336
+ return None
337
+
338
+ # Log original dimensions
339
+ logger.info(f"Captured clipboard image: {image.width}x{image.height}")
340
+
341
+ # Convert to RGB if necessary (handles RGBA, P mode, etc.)
342
+ if image.mode in ("RGBA", "LA") or (
343
+ image.mode == "P" and "transparency" in image.info
344
+ ):
345
+ # Keep alpha channel for PNG
346
+ pass
347
+ elif image.mode != "RGB":
348
+ image = image.convert("RGB")
349
+
350
+ # Resize if needed
351
+ image = _resize_image_if_needed(image, MAX_IMAGE_SIZE_BYTES)
352
+
353
+ # Convert to PNG bytes
354
+ buffer = io.BytesIO()
355
+ image.save(buffer, format="PNG", optimize=True)
356
+ image_bytes = buffer.getvalue()
357
+
358
+ logger.info(f"Clipboard image size: {len(image_bytes) / 1024:.1f}KB")
359
+ return image_bytes
360
+
361
+ except Exception as e:
362
+ logger.warning(f"Error reading clipboard image: {e}")
363
+ return None
364
+
365
+
366
+ def get_clipboard_image_as_binary_content() -> Optional["BinaryContent"]:
367
+ """Get clipboard image as pydantic-ai BinaryContent.
368
+
369
+ This is the preferred method for integrating clipboard images
370
+ with pydantic-ai agents.
371
+
372
+ Returns:
373
+ BinaryContent with PNG image if available, None otherwise.
374
+ """
375
+ if not BINARY_CONTENT_AVAILABLE:
376
+ logger.warning("pydantic-ai BinaryContent not available")
377
+ return None
378
+
379
+ image_bytes = get_clipboard_image()
380
+ if image_bytes is None:
381
+ return None
382
+
383
+ return BinaryContent(data=image_bytes, media_type="image/png")
384
+
385
+
386
+ class ClipboardAttachmentManager:
387
+ """Thread-safe manager for pending clipboard image attachments.
388
+
389
+ This class manages clipboard images that have been captured but not yet
390
+ sent to the AI model. It provides a simple interface for adding images,
391
+ retrieving them as BinaryContent, and clearing the queue.
392
+
393
+ Usage:
394
+ manager = get_clipboard_manager()
395
+ placeholder = manager.add_image(image_bytes)
396
+ # Later, when sending to AI:
397
+ images = manager.get_pending_images()
398
+ manager.clear_pending()
399
+ """
400
+
401
+ def __init__(self) -> None:
402
+ """Initialize the clipboard attachment manager."""
403
+ self._pending_images: list[bytes] = []
404
+ self._lock = threading.Lock()
405
+ self._counter = 0
406
+
407
+ def add_image(self, image_bytes: bytes) -> str:
408
+ """Add image bytes to pending attachments.
409
+
410
+ Args:
411
+ image_bytes: PNG image bytes to add.
412
+
413
+ Returns:
414
+ Placeholder ID string like '[📋 clipboard image 1]'
415
+
416
+ Raises:
417
+ ValueError: If MAX_PENDING_IMAGES limit is reached.
418
+ """
419
+ with self._lock:
420
+ # SEC-CLIP-001: Check limit BEFORE adding to prevent memory exhaustion
421
+ if len(self._pending_images) >= MAX_PENDING_IMAGES:
422
+ raise ValueError(
423
+ f"Maximum of {MAX_PENDING_IMAGES} pending images reached. "
424
+ "Send your message to clear the queue, or use /paste clear."
425
+ )
426
+ self._counter += 1
427
+ self._pending_images.append(image_bytes)
428
+ placeholder = f"[📋 clipboard image {self._counter}]"
429
+ logger.debug(
430
+ f"Added clipboard image {self._counter} "
431
+ f"({len(image_bytes) / 1024:.1f}KB)"
432
+ )
433
+ return placeholder
434
+
435
+ def get_pending_images(self) -> list["BinaryContent"]:
436
+ """Get all pending images as BinaryContent list.
437
+
438
+ Returns:
439
+ List of BinaryContent objects for each pending image.
440
+ Returns empty list if BinaryContent not available.
441
+ """
442
+ if not BINARY_CONTENT_AVAILABLE:
443
+ logger.warning("BinaryContent not available, returning empty list")
444
+ return []
445
+
446
+ with self._lock:
447
+ return [
448
+ BinaryContent(data=img_bytes, media_type="image/png")
449
+ for img_bytes in self._pending_images
450
+ ]
451
+
452
+ def clear_pending(self) -> None:
453
+ """Clear all pending images."""
454
+ with self._lock:
455
+ count = len(self._pending_images)
456
+ self._pending_images.clear()
457
+ if count > 0:
458
+ logger.debug(f"Cleared {count} pending clipboard image(s)")
459
+
460
+ def get_pending_count(self) -> int:
461
+ """Get count of pending images.
462
+
463
+ Returns:
464
+ Number of images currently pending.
465
+ """
466
+ with self._lock:
467
+ return len(self._pending_images)
468
+
469
+ def has_pending(self) -> bool:
470
+ """Check if there are any pending images.
471
+
472
+ Returns:
473
+ True if there are pending images, False otherwise.
474
+ """
475
+ with self._lock:
476
+ return len(self._pending_images) > 0
477
+
478
+
479
+ # Global singleton instance
480
+ _clipboard_manager: Optional[ClipboardAttachmentManager] = None
481
+ _manager_lock = threading.Lock()
482
+
483
+
484
+ def get_clipboard_manager() -> ClipboardAttachmentManager:
485
+ """Get or create the global clipboard manager singleton.
486
+
487
+ Returns:
488
+ The global ClipboardAttachmentManager instance.
489
+ """
490
+ global _clipboard_manager
491
+
492
+ if _clipboard_manager is None:
493
+ with _manager_lock:
494
+ # Double-check locking pattern
495
+ if _clipboard_manager is None:
496
+ _clipboard_manager = ClipboardAttachmentManager()
497
+
498
+ return _clipboard_manager
499
+
500
+
501
+ def capture_clipboard_image_to_pending() -> Optional[str]:
502
+ """Convenience function to capture clipboard image and add to pending.
503
+
504
+ This combines get_clipboard_image() and add_image() into a single call.
505
+ Includes rate limiting to prevent rapid captures (SEC-CLIP-004).
506
+
507
+ Returns:
508
+ Placeholder string if image captured, None if no image or rate limited.
509
+ """
510
+ global _last_clipboard_capture
511
+
512
+ # SEC-CLIP-004: Rate limiting to prevent rapid captures
513
+ now = time.monotonic()
514
+ if now - _last_clipboard_capture < CLIPBOARD_RATE_LIMIT_SECONDS:
515
+ logger.debug("Clipboard capture rate limited")
516
+ return None # Rate limited, silently ignore
517
+
518
+ image_bytes = get_clipboard_image()
519
+ if image_bytes is None:
520
+ return None
521
+
522
+ manager = get_clipboard_manager()
523
+ placeholder = manager.add_image(image_bytes)
524
+
525
+ # Update timestamp on successful capture
526
+ _last_clipboard_capture = now
527
+ return placeholder
@@ -115,6 +115,40 @@ def handle_motd_command(command: str) -> bool:
115
115
  return True
116
116
 
117
117
 
118
+ @register_command(
119
+ name="paste",
120
+ description="Paste image from clipboard (same as F3, or Ctrl+V with image)",
121
+ usage="/paste, /clipboard, /cb",
122
+ aliases=["clipboard", "cb"],
123
+ category="core",
124
+ )
125
+ def handle_paste_command(command: str) -> bool:
126
+ """Paste an image from the clipboard into the pending attachments."""
127
+ from code_puppy.command_line.clipboard import (
128
+ capture_clipboard_image_to_pending,
129
+ get_clipboard_manager,
130
+ has_image_in_clipboard,
131
+ )
132
+ from code_puppy.messaging import emit_info, emit_success, emit_warning
133
+
134
+ if not has_image_in_clipboard():
135
+ emit_warning("No image found in clipboard")
136
+ emit_info("Copy an image (screenshot, from browser, etc.) and try again")
137
+ return True
138
+
139
+ placeholder = capture_clipboard_image_to_pending()
140
+ if placeholder:
141
+ manager = get_clipboard_manager()
142
+ count = manager.get_pending_count()
143
+ emit_success(f"📋 {placeholder}")
144
+ emit_info(f"Total pending clipboard images: {count}")
145
+ emit_info("Type your prompt and press Enter to send with the image(s)")
146
+ else:
147
+ emit_warning("Failed to capture clipboard image")
148
+
149
+ return True
150
+
151
+
118
152
  @register_command(
119
153
  name="tutorial",
120
154
  description="Run the interactive tutorial wizard",
@@ -27,6 +27,11 @@ 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
+ get_clipboard_manager,
33
+ has_image_in_clipboard,
34
+ )
30
35
  from code_puppy.command_line.command_registry import get_unique_commands
31
36
  from code_puppy.command_line.file_path_completion import FilePathCompleter
32
37
  from code_puppy.command_line.load_context_completion import LoadContextCompleter
@@ -644,6 +649,113 @@ async def get_input_with_combined_completion(
644
649
  else:
645
650
  event.current_buffer.validate_and_handle()
646
651
 
652
+ # Handle bracketed paste (triggered by most terminal Cmd+V / Ctrl+V)
653
+ # This is the PRIMARY paste handler - works with Cmd+V on macOS terminals
654
+ @bindings.add(Keys.BracketedPaste)
655
+ def handle_bracketed_paste(event):
656
+ """Handle bracketed paste - works with Cmd+V on macOS terminals."""
657
+ # The pasted data is in event.data
658
+ pasted_data = event.data
659
+
660
+ # Check if clipboard has an image (the pasted text might just be empty or a file path)
661
+ try:
662
+ if has_image_in_clipboard():
663
+ placeholder = capture_clipboard_image_to_pending()
664
+ if placeholder:
665
+ event.app.current_buffer.insert_text(placeholder + " ")
666
+ count = get_clipboard_manager().get_pending_count()
667
+ sys.stdout.write(f"\033[36m📋 +image ({count})\033[0m ")
668
+ sys.stdout.flush()
669
+ return # Don't also paste the text data
670
+ except Exception:
671
+ pass
672
+
673
+ # No image - insert the pasted text as normal
674
+ if pasted_data:
675
+ event.app.current_buffer.insert_text(pasted_data)
676
+
677
+ # Fallback Ctrl+V for terminals without bracketed paste support
678
+ @bindings.add("c-v", eager=True)
679
+ def handle_smart_paste(event):
680
+ """Handle Ctrl+V - auto-detect image vs text in clipboard."""
681
+ try:
682
+ # Check for image first
683
+ if has_image_in_clipboard():
684
+ placeholder = capture_clipboard_image_to_pending()
685
+ if placeholder:
686
+ event.app.current_buffer.insert_text(placeholder + " ")
687
+ count = get_clipboard_manager().get_pending_count()
688
+ sys.stdout.write(f"\033[36m📋 +image ({count})\033[0m ")
689
+ sys.stdout.flush()
690
+ return # Don't also paste text
691
+ except Exception:
692
+ pass # Fall through to text paste on any error
693
+
694
+ # No image (or error) - do normal text paste
695
+ # prompt_toolkit doesn't have built-in paste, so we handle it manually
696
+ try:
697
+ import platform
698
+ import subprocess
699
+
700
+ text = None
701
+ system = platform.system()
702
+
703
+ if system == "Darwin": # macOS
704
+ result = subprocess.run(
705
+ ["pbpaste"], capture_output=True, text=True, timeout=2
706
+ )
707
+ if result.returncode == 0:
708
+ text = result.stdout
709
+ elif system == "Windows":
710
+ # Windows - use powershell
711
+ result = subprocess.run(
712
+ ["powershell", "-command", "Get-Clipboard"],
713
+ capture_output=True,
714
+ text=True,
715
+ timeout=2,
716
+ )
717
+ if result.returncode == 0:
718
+ text = result.stdout.rstrip("\r\n")
719
+ else: # Linux
720
+ # Try xclip first, then xsel
721
+ for cmd in [
722
+ ["xclip", "-selection", "clipboard", "-o"],
723
+ ["xsel", "--clipboard", "--output"],
724
+ ]:
725
+ try:
726
+ result = subprocess.run(
727
+ cmd, capture_output=True, text=True, timeout=2
728
+ )
729
+ if result.returncode == 0:
730
+ text = result.stdout
731
+ break
732
+ except FileNotFoundError:
733
+ continue
734
+
735
+ if text:
736
+ event.app.current_buffer.insert_text(text)
737
+ except Exception:
738
+ pass # Silently fail if text paste doesn't work
739
+
740
+ # F3 - dedicated image paste (shows error if no image)
741
+ @bindings.add("f3")
742
+ def handle_image_paste_f3(event):
743
+ """Handle F3 - paste image from clipboard (image-only, shows error if none)."""
744
+ try:
745
+ if has_image_in_clipboard():
746
+ placeholder = capture_clipboard_image_to_pending()
747
+ if placeholder:
748
+ event.app.current_buffer.insert_text(placeholder + " ")
749
+ count = get_clipboard_manager().get_pending_count()
750
+ sys.stdout.write(f"\033[36m📋 +image ({count})\033[0m ")
751
+ sys.stdout.flush()
752
+ else:
753
+ sys.stdout.write("\033[33m⚠️ no image\033[0m ")
754
+ sys.stdout.flush()
755
+ except Exception:
756
+ sys.stdout.write("\033[31m❌ clipboard error\033[0m ")
757
+ sys.stdout.flush()
758
+
647
759
  session = PromptSession(
648
760
  completer=completer,
649
761
  history=history,
@@ -222,18 +222,14 @@ class ManagedMCPServer:
222
222
  http_kwargs["timeout"] = config["timeout"]
223
223
  if "read_timeout" in config:
224
224
  http_kwargs["read_timeout"] = config["read_timeout"]
225
- if "headers" in config:
226
- # Expand environment variables in headers
227
- headers = config.get("headers")
228
- resolved_headers = {}
229
- if isinstance(headers, dict):
230
- for k, v in headers.items():
231
- if isinstance(v, str):
232
- resolved_headers[k] = os.path.expandvars(v)
233
- else:
234
- resolved_headers[k] = v
235
- http_kwargs["headers"] = resolved_headers
225
+
226
+ # Handle http_client vs headers (mutually exclusive)
227
+ if "http_client" in config:
228
+ # Use provided http_client
229
+ http_kwargs["http_client"] = config["http_client"]
230
+ elif config.get("headers"):
236
231
  # Create HTTP client if headers are provided but no client specified
232
+ http_kwargs["http_client"] = self._get_http_client()
237
233
 
238
234
  self._pydantic_server = MCPServerStreamableHTTP(
239
235
  **http_kwargs, process_tool_call=process_tool_call
@@ -727,15 +727,9 @@ def _format_diff_with_syntax_highlighting(
727
727
  result.append("\n")
728
728
  continue
729
729
 
730
- # Handle diff headers specially
731
- if line.startswith("---"):
732
- result.append(line, style="yellow")
733
- elif line.startswith("+++"):
734
- result.append(line, style="yellow")
735
- elif line.startswith("@@"):
736
- result.append(line, style="cyan")
737
- elif line.startswith(("diff ", "index ")):
738
- result.append(line, style="dim")
730
+ # Skip diff headers - they're redundant noise since we show the filename in the banner
731
+ if line.startswith(("---", "+++", "@@", "diff ", "index ")):
732
+ continue
739
733
  else:
740
734
  # Determine line type and extract code content
741
735
  if line.startswith("-"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.338
3
+ Version: 0.0.340
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -22,6 +22,7 @@ Requires-Dist: httpx[http2]>=0.24.1
22
22
  Requires-Dist: json-repair>=0.46.2
23
23
  Requires-Dist: logfire>=0.7.1
24
24
  Requires-Dist: openai>=1.99.1
25
+ Requires-Dist: pillow>=10.0.0
25
26
  Requires-Dist: playwright>=1.40.0
26
27
  Requires-Dist: prompt-toolkit>=3.0.52
27
28
  Requires-Dist: pydantic-ai==1.25.0
@@ -2,8 +2,8 @@ code_puppy/__init__.py,sha256=xMPewo9RNHb3yfFNIk5WCbv2cvSPtJOCgK2-GqLbNnU,373
2
2
  code_puppy/__main__.py,sha256=pDVssJOWP8A83iFkxMLY9YteHYat0EyWDQqMkKHpWp4,203
3
3
  code_puppy/callbacks.py,sha256=hqTV--dNxG5vwWWm3MrEjmb8MZuHFFdmHePl23NXPHk,8621
4
4
  code_puppy/chatgpt_codex_client.py,sha256=Om0ANB_kpHubhCwNzF9ENf8RvKBqs0IYzBLl_SNw0Vk,9833
5
- code_puppy/claude_cache_client.py,sha256=QaucFONE0InS1GANCZwMFx-7sEptbZfjVzb_CgjvuUo,7949
6
- code_puppy/cli_runner.py,sha256=E7C2pCWof4JgNcCRgMlodeTs2DyWh0NaB7_a_nwafM0,33117
5
+ code_puppy/claude_cache_client.py,sha256=hfDsb2x_L2maXqI0xQNX7gCDeHRI9XEttK0geoiNqQM,10678
6
+ code_puppy/cli_runner.py,sha256=BQu5Sa9y_ueqtgvbmuhWS-Tmd1FAjMbhTrtjFKbVZjM,34919
7
7
  code_puppy/config.py,sha256=RlnrLkyFXm7h2Htf8rQA7vqoAyzLPMrESle417uLmFw,52373
8
8
  code_puppy/error_logging.py,sha256=a80OILCUtJhexI6a9GM-r5LqIdjvSRzggfgPp2jv1X0,3297
9
9
  code_puppy/gemini_code_assist.py,sha256=KGS7sO5OLc83nDF3xxS-QiU6vxW9vcm6hmzilu79Ef8,13867
@@ -47,11 +47,12 @@ code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZ
47
47
  code_puppy/command_line/add_model_menu.py,sha256=caXxSQc6dgx0qQ68RRFrDTsiH-wZjl4nUv2r0javhaM,43262
48
48
  code_puppy/command_line/attachments.py,sha256=4Q5I2Es4j0ltnz5wjw2z0QXMsiMJvEfWRkPf_lJeITM,13093
49
49
  code_puppy/command_line/autosave_menu.py,sha256=vW-2UqX8qT2Rb07ZSbsCG1MMwCPIH4MQ4YM9PEkcPyo,20105
50
+ code_puppy/command_line/clipboard.py,sha256=oe9bfAX5RnT81FiYrDmhvHaePS1tAT-NFG1fSXubSD4,16869
50
51
  code_puppy/command_line/colors_menu.py,sha256=LoFVfJ-Mo-Eq9hnb2Rj5mn7oBCnadAGr-8NNHsHlu18,17273
51
52
  code_puppy/command_line/command_handler.py,sha256=CY9F27eovZJK_kpU1YmbroYLWGTCuouCOQ-TXfDp-nw,10916
52
53
  code_puppy/command_line/command_registry.py,sha256=qFySsw1g8dol3kgi0p6cXrIDlP11_OhOoaQ5nAadWXg,4416
53
54
  code_puppy/command_line/config_commands.py,sha256=qS9Cm758DPz2QGvHLhAV4Tp_Xfgo3PyoCoLDusbnmCw,25742
54
- code_puppy/command_line/core_commands.py,sha256=MhnyqcTIMheT8W1cqmfZTGLWo8iGM-fAAo8E3dNORro,26468
55
+ code_puppy/command_line/core_commands.py,sha256=ujAPD4yDbXwYGJJfR2u4ei24eBV-Ps_-BVBjFMEoJy0,27668
55
56
  code_puppy/command_line/diff_menu.py,sha256=_Gr9SP9fbItk-08dya9WTAR53s_PlyAvEnbt-8VWKPk,24141
56
57
  code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwoXKKn-yvCqit7h2y6Gg,2931
57
58
  code_puppy/command_line/load_context_completion.py,sha256=a3JvLDeLLSYxVgTjAdqWzS4spjv6ccCrK2LKZgVJ1IM,2202
@@ -62,7 +63,7 @@ code_puppy/command_line/motd.py,sha256=XuIk3UTLawwVFM-NfoaJGU5F2hPLASTFXq84UdDMT
62
63
  code_puppy/command_line/onboarding_slides.py,sha256=tHob7rB_n32dfjtPH-RSG0WLMjDHhlmNxfsF7WCgcVc,7191
63
64
  code_puppy/command_line/onboarding_wizard.py,sha256=U5lV_1P3IwDYZUHar0zKgdp121zzkvOwwORvdCZwFcw,10241
64
65
  code_puppy/command_line/pin_command_completion.py,sha256=juSvdqRpk7AdfkPy1DJx5NzfEUU5KYGlChvP0hisM18,11667
65
- code_puppy/command_line/prompt_toolkit_completion.py,sha256=x4Of32g8oH9ckhx-P6BigV7HUUhhjL8xkvK03uq9HRw,27308
66
+ code_puppy/command_line/prompt_toolkit_completion.py,sha256=pyawy4qWGIkEMZVWDRYy_noFTD25k6vR3Z2fSH12jVU,32009
66
67
  code_puppy/command_line/session_commands.py,sha256=Jh8GGfhlfBAEVfucKLbcZjNaXYd0twImiOwq2ZnGdQQ,9902
67
68
  code_puppy/command_line/utils.py,sha256=7eyxDHjPjPB9wGDJQQcXV_zOsGdYsFgI0SGCetVmTqE,1251
68
69
  code_puppy/command_line/mcp/__init__.py,sha256=0-OQuwjq_pLiTVJ1_NrirVwdRerghyKs_MTZkwPC7YY,315
@@ -98,7 +99,7 @@ code_puppy/mcp_/config_wizard.py,sha256=JNNpgnSD6PFSyS3pTdEdD164oXd2VKp4VHLSz3To
98
99
  code_puppy/mcp_/dashboard.py,sha256=VtaFxLtPnbM_HL2TXRDAg6IqcM-EcFkoghGgkfhMrKI,9417
99
100
  code_puppy/mcp_/error_isolation.py,sha256=mpPBiH17zTXPsOEAn9WmkbwQwnt4gmgiaWv87JBJbUo,12426
100
101
  code_puppy/mcp_/health_monitor.py,sha256=n5R6EeYOYbUucUFe74qGWCU3g6Mep5UEQbLF0wbT0dU,19688
101
- code_puppy/mcp_/managed_server.py,sha256=KmrFQAQBS-XHuvkuWUltFJk2jiR0pt55gdQlI0gA2QE,14304
102
+ code_puppy/mcp_/managed_server.py,sha256=APqFKjHtsG8iM4so1dYxvKnb0BTmppHnaY8UJ5DBE9g,14075
102
103
  code_puppy/mcp_/manager.py,sha256=pJ4cALicTxfwG2JIjJraLLf0Mzes-cEVAKIcUwfOoKA,29172
103
104
  code_puppy/mcp_/mcp_logs.py,sha256=o4pSHwELWIjEjqhfaMMEGrBvb159-VIgUp21E707BPo,6264
104
105
  code_puppy/mcp_/registry.py,sha256=U_t12WQ-En-KGyZoiTYdqlhp9NkDTWafu8g5InvF2NM,15774
@@ -160,7 +161,7 @@ code_puppy/prompts/codex_system_prompt.md,sha256=hEFTCziroLqZmqNle5kG34A8kvTteOW
160
161
  code_puppy/tools/__init__.py,sha256=BVTZ85jLHgDANwOnUSOz3UDlp8VQDq4DoGF23BRlyWw,6032
161
162
  code_puppy/tools/agent_tools.py,sha256=snBI6FlFtR03CbYKXwu53R48c_fRSuDIwcNdVUruLcA,21020
162
163
  code_puppy/tools/command_runner.py,sha256=3qXVnVTaBPia6y2D29As47_TRKgpyCj82yMFK-8UUYc,44954
163
- code_puppy/tools/common.py,sha256=IboS6sbwN4a3FzHdfsZJtEFiyDUCszevI6LpH14ydEk,40561
164
+ code_puppy/tools/common.py,sha256=IYf-KOcP5eN2MwTlpULSXNATn7GzloAKl7_M1Uyfe4Y,40360
164
165
  code_puppy/tools/file_modifications.py,sha256=vz9n7R0AGDSdLUArZr_55yJLkyI30M8zreAppxIx02M,29380
165
166
  code_puppy/tools/file_operations.py,sha256=CqhpuBnOFOcQCIYXOujskxq2VMLWYJhibYrH0YcPSfA,35692
166
167
  code_puppy/tools/tools_content.py,sha256=bsBqW-ppd1XNAS_g50B3UHDQBWEALC1UneH6-afz1zo,2365
@@ -174,10 +175,10 @@ code_puppy/tools/browser/browser_scripts.py,sha256=sNb8eLEyzhasy5hV4B9OjM8yIVMLV
174
175
  code_puppy/tools/browser/browser_workflows.py,sha256=nitW42vCf0ieTX1gLabozTugNQ8phtoFzZbiAhw1V90,6491
175
176
  code_puppy/tools/browser/camoufox_manager.py,sha256=RZjGOEftE5sI_tsercUyXFSZI2wpStXf-q0PdYh2G3I,8680
176
177
  code_puppy/tools/browser/vqa_agent.py,sha256=DBn9HKloILqJSTSdNZzH_PYWT0B2h9VwmY6akFQI_uU,2913
177
- code_puppy-0.0.338.data/data/code_puppy/models.json,sha256=FMQdE_yvP_8y0xxt3K918UkFL9cZMYAqW1SfXcQkU_k,3105
178
- code_puppy-0.0.338.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
179
- code_puppy-0.0.338.dist-info/METADATA,sha256=kNsGvoJiQpXWHOgGVtrwjkyYvFo8sVRj6xtxXRr83Lw,27520
180
- code_puppy-0.0.338.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
181
- code_puppy-0.0.338.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
182
- code_puppy-0.0.338.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
183
- code_puppy-0.0.338.dist-info/RECORD,,
178
+ code_puppy-0.0.340.data/data/code_puppy/models.json,sha256=FMQdE_yvP_8y0xxt3K918UkFL9cZMYAqW1SfXcQkU_k,3105
179
+ code_puppy-0.0.340.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
180
+ code_puppy-0.0.340.dist-info/METADATA,sha256=QyW_MkSEm_N8B6ezU2dgW7rkkjI0mTiKhKjn37UEIZU,27550
181
+ code_puppy-0.0.340.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
182
+ code_puppy-0.0.340.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
183
+ code_puppy-0.0.340.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
184
+ code_puppy-0.0.340.dist-info/RECORD,,