code-puppy 0.0.325__py3-none-any.whl → 0.0.341__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 (52) hide show
  1. code_puppy/agents/base_agent.py +110 -124
  2. code_puppy/claude_cache_client.py +208 -2
  3. code_puppy/cli_runner.py +152 -32
  4. code_puppy/command_line/add_model_menu.py +4 -0
  5. code_puppy/command_line/autosave_menu.py +23 -24
  6. code_puppy/command_line/clipboard.py +527 -0
  7. code_puppy/command_line/colors_menu.py +5 -0
  8. code_puppy/command_line/config_commands.py +24 -1
  9. code_puppy/command_line/core_commands.py +85 -0
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/install_menu.py +5 -1
  13. code_puppy/command_line/model_settings_menu.py +5 -0
  14. code_puppy/command_line/motd.py +13 -7
  15. code_puppy/command_line/onboarding_slides.py +180 -0
  16. code_puppy/command_line/onboarding_wizard.py +340 -0
  17. code_puppy/command_line/prompt_toolkit_completion.py +118 -0
  18. code_puppy/config.py +3 -2
  19. code_puppy/http_utils.py +201 -279
  20. code_puppy/keymap.py +10 -8
  21. code_puppy/mcp_/managed_server.py +7 -11
  22. code_puppy/messaging/messages.py +3 -0
  23. code_puppy/messaging/rich_renderer.py +114 -22
  24. code_puppy/model_factory.py +102 -15
  25. code_puppy/models.json +2 -2
  26. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  27. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
  29. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  30. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  31. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  32. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  33. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  34. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  35. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  36. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  37. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  38. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  39. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  40. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  41. code_puppy/reopenable_async_client.py +8 -8
  42. code_puppy/terminal_utils.py +295 -3
  43. code_puppy/tools/command_runner.py +43 -54
  44. code_puppy/tools/common.py +3 -9
  45. code_puppy/uvx_detection.py +242 -0
  46. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
  47. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
  48. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
  49. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
  50. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
  51. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
  52. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
code_puppy/keymap.py CHANGED
@@ -55,11 +55,19 @@ class KeymapError(Exception):
55
55
  def get_cancel_agent_key() -> str:
56
56
  """Get the configured cancel agent key from config.
57
57
 
58
+ On Windows when launched via uvx, this automatically returns "ctrl+k"
59
+ to work around uvx capturing Ctrl+C before it reaches Python.
60
+
58
61
  Returns:
59
62
  The key name (e.g., "ctrl+c", "ctrl+k") from config,
60
63
  or the default if not configured.
61
64
  """
62
65
  from code_puppy.config import get_value
66
+ from code_puppy.uvx_detection import should_use_alternate_cancel_key
67
+
68
+ # On Windows + uvx, force ctrl+k to bypass uvx's SIGINT capture
69
+ if should_use_alternate_cancel_key():
70
+ return "ctrl+k"
63
71
 
64
72
  key = get_value("cancel_agent_key")
65
73
  if key is None or key.strip() == "":
@@ -86,15 +94,9 @@ def cancel_agent_uses_signal() -> bool:
86
94
  """Check if the cancel agent key uses SIGINT (Ctrl+C).
87
95
 
88
96
  Returns:
89
- True if the cancel key is ctrl+c AND we're not on Windows
90
- (uses SIGINT handler), False if it uses keyboard listener approach.
97
+ True if the cancel key is ctrl+c (uses SIGINT handler),
98
+ False if it uses keyboard listener approach.
91
99
  """
92
- import sys
93
-
94
- # On Windows, always use keyboard listener - SIGINT is unreliable
95
- if sys.platform == "win32":
96
- return False
97
-
98
100
  return get_cancel_agent_key() == "ctrl+c"
99
101
 
100
102
 
@@ -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
@@ -209,6 +209,9 @@ class ShellStartMessage(BaseMessage):
209
209
  default=None, description="Working directory for the command"
210
210
  )
211
211
  timeout: int = Field(default=60, description="Timeout in seconds")
212
+ background: bool = Field(
213
+ default=False, description="Whether command runs in background mode"
214
+ )
212
215
 
213
216
 
214
217
  class ShellLineMessage(BaseMessage):
@@ -348,7 +348,17 @@ class RichConsoleRenderer:
348
348
  # =========================================================================
349
349
 
350
350
  def _render_file_listing(self, msg: FileListingMessage) -> None:
351
- """Render a directory listing matching the old Rich-formatted output."""
351
+ """Render a compact directory listing with directory summaries.
352
+
353
+ Instead of listing every file, we group by directory and show:
354
+ - Directory name
355
+ - Number of files
356
+ - Total size
357
+ - Number of subdirectories
358
+ """
359
+ import os
360
+ from collections import defaultdict
361
+
352
362
  # Header on single line
353
363
  rec_flag = f"(recursive={msg.recursive})"
354
364
  banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
@@ -357,32 +367,104 @@ class RichConsoleRenderer:
357
367
  f"📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
358
368
  )
359
369
 
360
- # Directory header
361
- dir_name = msg.directory.rstrip("/").split("/")[-1] or msg.directory
362
- self._console.print(f"📁 [bold blue]{dir_name}[/bold blue]")
370
+ # Build a tree structure: {parent_path: {files: [], dirs: set(), size: int}}
371
+ # Each key is a directory path, value contains direct children stats
372
+ dir_stats: dict = defaultdict(
373
+ lambda: {"files": [], "subdirs": set(), "total_size": 0}
374
+ )
375
+
376
+ # Root directory is represented as ""
377
+ root_key = ""
363
378
 
364
- # Build tree structure from flat list
365
379
  for entry in msg.files:
366
- # Calculate indentation based on depth
367
- prefix = ""
368
- for d in range(entry.depth + 1):
369
- if d == entry.depth:
370
- prefix += "└── "
371
- else:
372
- prefix += " "
380
+ path = entry.path
381
+ parent = os.path.dirname(path) if os.path.dirname(path) else root_key
373
382
 
374
383
  if entry.type == "dir":
375
- self._console.print(f"{prefix}📁 [bold blue]{entry.path}/[/bold blue]")
384
+ # Register this dir as a subdir of its parent
385
+ dir_stats[parent]["subdirs"].add(path)
386
+ # Ensure the dir itself exists in stats (even if empty)
387
+ _ = dir_stats[path]
376
388
  else:
377
- icon = self._get_file_icon(entry.path)
378
- if entry.size > 0:
379
- size_str = f" [dim]({self._format_size(entry.size)})[/dim]"
380
- else:
381
- size_str = ""
389
+ # It's a file - add to parent's stats
390
+ dir_stats[parent]["files"].append(entry)
391
+ dir_stats[parent]["total_size"] += entry.size
392
+
393
+ def render_dir_tree(dir_path: str, depth: int = 0) -> None:
394
+ """Recursively render directory with compact summary."""
395
+ stats = dir_stats.get(
396
+ dir_path, {"files": [], "subdirs": set(), "total_size": 0}
397
+ )
398
+ files = stats["files"]
399
+ subdirs = sorted(stats["subdirs"])
400
+
401
+ # Calculate total size including subdirectories (recursive)
402
+ def get_recursive_size(d: str) -> int:
403
+ s = dir_stats.get(d, {"files": [], "subdirs": set(), "total_size": 0})
404
+ size = s["total_size"]
405
+ for sub in s["subdirs"]:
406
+ size += get_recursive_size(sub)
407
+ return size
408
+
409
+ def get_recursive_file_count(d: str) -> int:
410
+ s = dir_stats.get(d, {"files": [], "subdirs": set(), "total_size": 0})
411
+ count = len(s["files"])
412
+ for sub in s["subdirs"]:
413
+ count += get_recursive_file_count(sub)
414
+ return count
415
+
416
+ indent = " " * depth
417
+
418
+ # For root level, just show contents
419
+ if dir_path == root_key:
420
+ # Show files at root level (depth 0)
421
+ for f in sorted(files, key=lambda x: x.path):
422
+ icon = self._get_file_icon(f.path)
423
+ name = os.path.basename(f.path)
424
+ size_str = (
425
+ f" [dim]({self._format_size(f.size)})[/dim]"
426
+ if f.size > 0
427
+ else ""
428
+ )
429
+ self._console.print(
430
+ f"{indent}{icon} [green]{name}[/green]{size_str}"
431
+ )
432
+
433
+ # Show subdirs at root level
434
+ for subdir in subdirs:
435
+ render_dir_tree(subdir, depth)
436
+ else:
437
+ # Show directory with summary
438
+ dir_name = os.path.basename(dir_path)
439
+ rec_size = get_recursive_size(dir_path)
440
+ rec_file_count = get_recursive_file_count(dir_path)
441
+ subdir_count = len(subdirs)
442
+
443
+ # Build summary parts
444
+ parts = []
445
+ if rec_file_count > 0:
446
+ parts.append(
447
+ f"{rec_file_count} file{'s' if rec_file_count != 1 else ''}"
448
+ )
449
+ if subdir_count > 0:
450
+ parts.append(
451
+ f"{subdir_count} subdir{'s' if subdir_count != 1 else ''}"
452
+ )
453
+ if rec_size > 0:
454
+ parts.append(self._format_size(rec_size))
455
+
456
+ summary = f" [dim]({', '.join(parts)})[/dim]" if parts else ""
382
457
  self._console.print(
383
- f"{prefix}{icon} [green]{entry.path}[/green]{size_str}"
458
+ f"{indent}📁 [bold blue]{dir_name}/[/bold blue]{summary}"
384
459
  )
385
460
 
461
+ # Recursively show subdirectories
462
+ for subdir in subdirs:
463
+ render_dir_tree(subdir, depth + 1)
464
+
465
+ # Render the tree starting from root
466
+ render_dir_tree(root_key, 0)
467
+
386
468
  # Summary
387
469
  self._console.print("\n[bold cyan]Summary:[/bold cyan]")
388
470
  self._console.print(
@@ -538,15 +620,25 @@ class RichConsoleRenderer:
538
620
  safe_command = escape_rich_markup(msg.command)
539
621
  # Header showing command is starting
540
622
  banner = self._format_banner("shell_command", "SHELL COMMAND")
541
- self._console.print(f"\n{banner} 🚀 [dim]$ {safe_command}[/dim]")
623
+
624
+ # Add background indicator if running in background mode
625
+ if msg.background:
626
+ self._console.print(
627
+ f"\n{banner} 🚀 [dim]$ {safe_command}[/dim] [bold magenta][BACKGROUND 🌙][/bold magenta]"
628
+ )
629
+ else:
630
+ self._console.print(f"\n{banner} 🚀 [dim]$ {safe_command}[/dim]")
542
631
 
543
632
  # Show working directory if specified
544
633
  if msg.cwd:
545
634
  safe_cwd = escape_rich_markup(msg.cwd)
546
635
  self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")
547
636
 
548
- # Show timeout
549
- self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
637
+ # Show timeout or background status
638
+ if msg.background:
639
+ self._console.print("[dim]⏱ Runs detached (no timeout)[/dim]")
640
+ else:
641
+ self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
550
642
 
551
643
  def _render_shell_line(self, msg: ShellLineMessage) -> None:
552
644
  """Render shell output line preserving ANSI codes."""
@@ -4,7 +4,6 @@ import os
4
4
  import pathlib
5
5
  from typing import Any, Dict
6
6
 
7
- import httpx
8
7
  from anthropic import AsyncAnthropic
9
8
  from openai import AsyncAzureOpenAI
10
9
  from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
@@ -212,6 +211,7 @@ class ModelFactory:
212
211
 
213
212
  # Import OAuth model file paths from main config
214
213
  from code_puppy.config import (
214
+ ANTIGRAVITY_MODELS_FILE,
215
215
  CHATGPT_MODELS_FILE,
216
216
  CLAUDE_MODELS_FILE,
217
217
  GEMINI_MODELS_FILE,
@@ -223,6 +223,7 @@ class ModelFactory:
223
223
  (pathlib.Path(CHATGPT_MODELS_FILE), "ChatGPT OAuth models", False),
224
224
  (pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
225
225
  (pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
226
+ (pathlib.Path(ANTIGRAVITY_MODELS_FILE), "Antigravity OAuth models", False),
226
227
  ]
227
228
 
228
229
  for source_path, label, use_filtered in extra_sources:
@@ -387,6 +388,20 @@ class ModelFactory:
387
388
  return AnthropicModel(model_name=model_config["name"], provider=provider)
388
389
  elif model_type == "claude_code":
389
390
  url, headers, verify, api_key = get_custom_config(model_config)
391
+ if model_config.get("oauth_source") == "claude-code-plugin":
392
+ try:
393
+ from code_puppy.plugins.claude_code_oauth.utils import (
394
+ get_valid_access_token,
395
+ )
396
+
397
+ refreshed_token = get_valid_access_token()
398
+ if refreshed_token:
399
+ api_key = refreshed_token
400
+ custom_endpoint = model_config.get("custom_endpoint")
401
+ if isinstance(custom_endpoint, dict):
402
+ custom_endpoint["api_key"] = refreshed_token
403
+ except ImportError:
404
+ pass
390
405
  if not api_key:
391
406
  emit_warning(
392
407
  f"API key is not set for Claude Code endpoint; skipping model '{model_config.get('name')}'."
@@ -556,24 +571,94 @@ class ModelFactory:
556
571
  f"API key is not set for custom Gemini endpoint; skipping model '{model_config.get('name')}'."
557
572
  )
558
573
  return None
559
- os.environ["GEMINI_API_KEY"] = api_key
560
574
 
561
- class CustomGoogleGLAProvider(GoogleProvider):
562
- def __init__(self, *args, **kwargs):
563
- super().__init__(*args, **kwargs)
575
+ # Check if this is an Antigravity model
576
+ if model_config.get("antigravity"):
577
+ try:
578
+ from code_puppy.plugins.antigravity_oauth.token import (
579
+ is_token_expired,
580
+ refresh_access_token,
581
+ )
582
+ from code_puppy.plugins.antigravity_oauth.transport import (
583
+ create_antigravity_client,
584
+ )
585
+ from code_puppy.plugins.antigravity_oauth.utils import (
586
+ load_stored_tokens,
587
+ save_tokens,
588
+ )
564
589
 
565
- @property
566
- def base_url(self):
567
- return url
590
+ # Try to import custom model for thinking signatures
591
+ try:
592
+ from code_puppy.plugins.antigravity_oauth.antigravity_model import (
593
+ AntigravityModel,
594
+ )
595
+ except ImportError:
596
+ AntigravityModel = None
568
597
 
569
- @property
570
- def client(self) -> httpx.AsyncClient:
571
- _client = create_async_client(headers=headers, verify=verify)
572
- _client.base_url = self.base_url
573
- return _client
598
+ # Get fresh access token (refresh if needed)
599
+ tokens = load_stored_tokens()
600
+ if not tokens:
601
+ emit_warning(
602
+ "Antigravity tokens not found; run /antigravity-auth first."
603
+ )
604
+ return None
574
605
 
575
- google_gla = CustomGoogleGLAProvider(api_key=api_key)
576
- model = GoogleModel(model_name=model_config["name"], provider=google_gla)
606
+ access_token = tokens.get("access_token", "")
607
+ refresh_token = tokens.get("refresh_token", "")
608
+ expires_at = tokens.get("expires_at")
609
+
610
+ # Refresh if expired or about to expire
611
+ if is_token_expired(expires_at):
612
+ new_tokens = refresh_access_token(refresh_token)
613
+ if new_tokens:
614
+ access_token = new_tokens.access_token
615
+ tokens["access_token"] = new_tokens.access_token
616
+ tokens["refresh_token"] = new_tokens.refresh_token
617
+ tokens["expires_at"] = new_tokens.expires_at
618
+ save_tokens(tokens)
619
+ else:
620
+ emit_warning(
621
+ "Failed to refresh Antigravity token; run /antigravity-auth again."
622
+ )
623
+ return None
624
+
625
+ project_id = tokens.get(
626
+ "project_id", model_config.get("project_id", "")
627
+ )
628
+ client = create_antigravity_client(
629
+ access_token=access_token,
630
+ project_id=project_id,
631
+ model_name=model_config["name"],
632
+ base_url=url,
633
+ headers=headers,
634
+ )
635
+
636
+ provider = GoogleProvider(
637
+ api_key=api_key, base_url=url, http_client=client
638
+ )
639
+
640
+ # Use custom model if available to preserve thinking signatures
641
+ if AntigravityModel:
642
+ model = AntigravityModel(
643
+ model_name=model_config["name"], provider=provider
644
+ )
645
+ else:
646
+ model = GoogleModel(
647
+ model_name=model_config["name"], provider=provider
648
+ )
649
+
650
+ return model
651
+
652
+ except ImportError:
653
+ emit_warning(
654
+ f"Antigravity transport not available; skipping model '{model_config.get('name')}'."
655
+ )
656
+ return None
657
+ else:
658
+ client = create_async_client(headers=headers, verify=verify)
659
+
660
+ provider = GoogleProvider(api_key=api_key, base_url=url, http_client=client)
661
+ model = GoogleModel(model_name=model_config["name"], provider=provider)
577
662
  return model
578
663
  elif model_type == "cerebras":
579
664
 
@@ -592,6 +677,8 @@ class ModelFactory:
592
677
  f"API key is not set for Cerebras endpoint; skipping model '{model_config.get('name')}'."
593
678
  )
594
679
  return None
680
+ # Add Cerebras 3rd party integration header
681
+ headers["X-Cerebras-3rd-Party-Integration"] = "code-puppy"
595
682
  client = create_async_client(headers=headers, verify=verify)
596
683
  provider_args = dict(
597
684
  api_key=api_key,
code_puppy/models.json CHANGED
@@ -55,9 +55,9 @@
55
55
  "supported_settings": ["reasoning_effort", "verbosity"],
56
56
  "supports_xhigh_reasoning": true
57
57
  },
58
- "Cerebras-GLM-4.6": {
58
+ "Cerebras-GLM-4.7": {
59
59
  "type": "cerebras",
60
- "name": "zai-glm-4.6",
60
+ "name": "zai-glm-4.7",
61
61
  "custom_endpoint": {
62
62
  "url": "https://api.cerebras.ai/v1",
63
63
  "api_key": "$CEREBRAS_API_KEY"
@@ -0,0 +1,10 @@
1
+ """Antigravity OAuth Plugin for Code Puppy.
2
+
3
+ Enables authentication with Google/Antigravity APIs to access Gemini and Claude models
4
+ via Google credentials. Supports multi-account load balancing and automatic failover.
5
+ """
6
+
7
+ from .config import ANTIGRAVITY_OAUTH_CONFIG
8
+ from .register_callbacks import * # noqa: F401, F403
9
+
10
+ __all__ = ["ANTIGRAVITY_OAUTH_CONFIG"]