code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
code_puppy/plugins/__init__.py
CHANGED
|
@@ -1,32 +1,186 @@
|
|
|
1
1
|
import importlib
|
|
2
|
+
import importlib.util
|
|
2
3
|
import logging
|
|
4
|
+
import sys
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
logger = logging.getLogger(__name__)
|
|
6
8
|
|
|
9
|
+
# User plugins directory
|
|
10
|
+
USER_PLUGINS_DIR = Path.home() / ".code_puppy" / "plugins"
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
# Track if plugins have already been loaded to prevent duplicate registration
|
|
13
|
+
_PLUGINS_LOADED = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_builtin_plugins(plugins_dir: Path) -> list[str]:
|
|
17
|
+
"""Load built-in plugins from the package plugins directory.
|
|
18
|
+
|
|
19
|
+
Returns list of successfully loaded plugin names.
|
|
20
|
+
"""
|
|
21
|
+
# Import safety permission check for shell_safety plugin
|
|
22
|
+
from code_puppy.config import get_safety_permission_level
|
|
23
|
+
|
|
24
|
+
loaded = []
|
|
11
25
|
|
|
12
|
-
# Iterate through all subdirectories in the plugins folder
|
|
13
26
|
for item in plugins_dir.iterdir():
|
|
14
27
|
if item.is_dir() and not item.name.startswith("_"):
|
|
15
28
|
plugin_name = item.name
|
|
16
29
|
callbacks_file = item / "register_callbacks.py"
|
|
17
30
|
|
|
18
31
|
if callbacks_file.exists():
|
|
32
|
+
# Skip shell_safety plugin unless safety_permission_level is "low" or "none"
|
|
33
|
+
if plugin_name == "shell_safety":
|
|
34
|
+
safety_level = get_safety_permission_level()
|
|
35
|
+
if safety_level not in ("none", "low"):
|
|
36
|
+
logger.debug(
|
|
37
|
+
f"Skipping shell_safety plugin - safety_permission_level is '{safety_level}' (needs 'low' or 'none')"
|
|
38
|
+
)
|
|
39
|
+
continue
|
|
40
|
+
|
|
19
41
|
try:
|
|
20
|
-
# Import the register_callbacks module dynamically
|
|
21
42
|
module_name = f"code_puppy.plugins.{plugin_name}.register_callbacks"
|
|
22
|
-
logger.debug(f"Loading plugin callbacks from {module_name}")
|
|
23
43
|
importlib.import_module(module_name)
|
|
24
|
-
|
|
25
|
-
|
|
44
|
+
loaded.append(plugin_name)
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
logger.warning(
|
|
47
|
+
f"Failed to import callbacks from built-in plugin {plugin_name}: {e}"
|
|
48
|
+
)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(
|
|
51
|
+
f"Unexpected error loading built-in plugin {plugin_name}: {e}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return loaded
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_user_plugins(user_plugins_dir: Path) -> list[str]:
|
|
58
|
+
"""Load user plugins from ~/.code_puppy/plugins/.
|
|
59
|
+
|
|
60
|
+
Each plugin should be a directory containing a register_callbacks.py file.
|
|
61
|
+
Plugins are loaded by adding their parent to sys.path and importing them.
|
|
62
|
+
|
|
63
|
+
Returns list of successfully loaded plugin names.
|
|
64
|
+
"""
|
|
65
|
+
loaded = []
|
|
66
|
+
|
|
67
|
+
if not user_plugins_dir.exists():
|
|
68
|
+
return loaded
|
|
69
|
+
|
|
70
|
+
if not user_plugins_dir.is_dir():
|
|
71
|
+
logger.warning(f"User plugins path is not a directory: {user_plugins_dir}")
|
|
72
|
+
return loaded
|
|
73
|
+
|
|
74
|
+
# Add user plugins directory to sys.path if not already there
|
|
75
|
+
user_plugins_str = str(user_plugins_dir)
|
|
76
|
+
if user_plugins_str not in sys.path:
|
|
77
|
+
sys.path.insert(0, user_plugins_str)
|
|
78
|
+
|
|
79
|
+
for item in user_plugins_dir.iterdir():
|
|
80
|
+
if (
|
|
81
|
+
item.is_dir()
|
|
82
|
+
and not item.name.startswith("_")
|
|
83
|
+
and not item.name.startswith(".")
|
|
84
|
+
):
|
|
85
|
+
plugin_name = item.name
|
|
86
|
+
callbacks_file = item / "register_callbacks.py"
|
|
87
|
+
|
|
88
|
+
if callbacks_file.exists():
|
|
89
|
+
try:
|
|
90
|
+
# Load the plugin module directly from the file
|
|
91
|
+
module_name = f"{plugin_name}.register_callbacks"
|
|
92
|
+
spec = importlib.util.spec_from_file_location(
|
|
93
|
+
module_name, callbacks_file
|
|
26
94
|
)
|
|
95
|
+
if spec is None or spec.loader is None:
|
|
96
|
+
logger.warning(
|
|
97
|
+
f"Could not create module spec for user plugin: {plugin_name}"
|
|
98
|
+
)
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
module = importlib.util.module_from_spec(spec)
|
|
102
|
+
sys.modules[module_name] = module
|
|
103
|
+
|
|
104
|
+
spec.loader.exec_module(module)
|
|
105
|
+
loaded.append(plugin_name)
|
|
106
|
+
|
|
27
107
|
except ImportError as e:
|
|
28
108
|
logger.warning(
|
|
29
|
-
f"Failed to import callbacks from plugin {plugin_name}: {e}"
|
|
109
|
+
f"Failed to import callbacks from user plugin {plugin_name}: {e}"
|
|
30
110
|
)
|
|
31
111
|
except Exception as e:
|
|
32
|
-
logger.error(
|
|
112
|
+
logger.error(
|
|
113
|
+
f"Unexpected error loading user plugin {plugin_name}: {e}",
|
|
114
|
+
exc_info=True,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
# Check if there's an __init__.py - might be a simple plugin
|
|
118
|
+
init_file = item / "__init__.py"
|
|
119
|
+
if init_file.exists():
|
|
120
|
+
try:
|
|
121
|
+
module_name = plugin_name
|
|
122
|
+
spec = importlib.util.spec_from_file_location(
|
|
123
|
+
module_name, init_file
|
|
124
|
+
)
|
|
125
|
+
if spec is None or spec.loader is None:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
module = importlib.util.module_from_spec(spec)
|
|
129
|
+
sys.modules[module_name] = module
|
|
130
|
+
spec.loader.exec_module(module)
|
|
131
|
+
loaded.append(plugin_name)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(
|
|
135
|
+
f"Unexpected error loading user plugin {plugin_name}: {e}",
|
|
136
|
+
exc_info=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return loaded
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_plugin_callbacks() -> dict[str, list[str]]:
|
|
143
|
+
"""Dynamically load register_callbacks.py from all plugin sources.
|
|
144
|
+
|
|
145
|
+
Loads plugins from:
|
|
146
|
+
1. Built-in plugins in the code_puppy/plugins/ directory
|
|
147
|
+
2. User plugins in ~/.code_puppy/plugins/
|
|
148
|
+
|
|
149
|
+
Returns dict with 'builtin' and 'user' keys containing lists of loaded plugin names.
|
|
150
|
+
|
|
151
|
+
NOTE: This function is idempotent - calling it multiple times will only
|
|
152
|
+
load plugins once. Subsequent calls return empty lists.
|
|
153
|
+
"""
|
|
154
|
+
global _PLUGINS_LOADED
|
|
155
|
+
|
|
156
|
+
# Prevent duplicate loading - plugins register callbacks at import time,
|
|
157
|
+
# so re-importing would cause duplicate registrations
|
|
158
|
+
if _PLUGINS_LOADED:
|
|
159
|
+
logger.debug("Plugins already loaded, skipping duplicate load")
|
|
160
|
+
return {"builtin": [], "user": []}
|
|
161
|
+
|
|
162
|
+
plugins_dir = Path(__file__).parent
|
|
163
|
+
|
|
164
|
+
result = {
|
|
165
|
+
"builtin": _load_builtin_plugins(plugins_dir),
|
|
166
|
+
"user": _load_user_plugins(USER_PLUGINS_DIR),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_PLUGINS_LOADED = True
|
|
170
|
+
logger.debug(f"Loaded plugins: builtin={result['builtin']}, user={result['user']}")
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_user_plugins_dir() -> Path:
|
|
176
|
+
"""Return the path to the user plugins directory."""
|
|
177
|
+
return USER_PLUGINS_DIR
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ensure_user_plugins_dir() -> Path:
|
|
181
|
+
"""Create the user plugins directory if it doesn't exist.
|
|
182
|
+
|
|
183
|
+
Returns the path to the directory.
|
|
184
|
+
"""
|
|
185
|
+
USER_PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
return USER_PLUGINS_DIR
|
|
@@ -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"]
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Multi-account manager for Antigravity OAuth with load balancing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Dict, List, Literal, Optional
|
|
9
|
+
|
|
10
|
+
from .storage import (
|
|
11
|
+
AccountMetadata,
|
|
12
|
+
AccountStorage,
|
|
13
|
+
HeaderStyle,
|
|
14
|
+
ModelFamily,
|
|
15
|
+
QuotaKey,
|
|
16
|
+
RateLimitState,
|
|
17
|
+
load_accounts,
|
|
18
|
+
save_accounts,
|
|
19
|
+
)
|
|
20
|
+
from .token import RefreshParts, parse_refresh_parts
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ManagedAccount:
|
|
27
|
+
"""In-memory representation of a managed account."""
|
|
28
|
+
|
|
29
|
+
index: int
|
|
30
|
+
email: Optional[str]
|
|
31
|
+
added_at: float
|
|
32
|
+
last_used: float
|
|
33
|
+
parts: RefreshParts
|
|
34
|
+
access_token: Optional[str] = None
|
|
35
|
+
expires_at: Optional[float] = None
|
|
36
|
+
rate_limit_reset_times: Dict[str, float] = field(default_factory=dict)
|
|
37
|
+
last_switch_reason: Optional[Literal["rate-limit", "initial", "rotation"]] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now_ms() -> float:
|
|
41
|
+
"""Current time in milliseconds."""
|
|
42
|
+
return time.time() * 1000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_quota_key(family: ModelFamily, header_style: HeaderStyle) -> QuotaKey:
|
|
46
|
+
"""Get the quota key for a model family and header style."""
|
|
47
|
+
if family == "claude":
|
|
48
|
+
return "claude"
|
|
49
|
+
return "gemini-cli" if header_style == "gemini-cli" else "gemini-antigravity"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_rate_limited_for_quota_key(account: ManagedAccount, key: QuotaKey) -> bool:
|
|
53
|
+
"""Check if account is rate limited for a specific quota key."""
|
|
54
|
+
reset_time = account.rate_limit_reset_times.get(key)
|
|
55
|
+
return reset_time is not None and _now_ms() < reset_time
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_rate_limited_for_family(account: ManagedAccount, family: ModelFamily) -> bool:
|
|
59
|
+
"""Check if account is rate limited for an entire model family."""
|
|
60
|
+
if family == "claude":
|
|
61
|
+
return _is_rate_limited_for_quota_key(account, "claude")
|
|
62
|
+
# For Gemini, both pools must be rate limited
|
|
63
|
+
return _is_rate_limited_for_quota_key(
|
|
64
|
+
account, "gemini-antigravity"
|
|
65
|
+
) and _is_rate_limited_for_quota_key(account, "gemini-cli")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clear_expired_rate_limits(account: ManagedAccount) -> None:
|
|
69
|
+
"""Clear expired rate limits from an account."""
|
|
70
|
+
now = _now_ms()
|
|
71
|
+
keys_to_remove = [
|
|
72
|
+
key
|
|
73
|
+
for key, reset_time in account.rate_limit_reset_times.items()
|
|
74
|
+
if now >= reset_time
|
|
75
|
+
]
|
|
76
|
+
for key in keys_to_remove:
|
|
77
|
+
del account.rate_limit_reset_times[key]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AccountManager:
|
|
81
|
+
"""Multi-account manager with sticky account selection and load balancing.
|
|
82
|
+
|
|
83
|
+
Uses the same account until it hits a rate limit (429), then switches.
|
|
84
|
+
Rate limits are tracked per-model-family (claude/gemini) so an account
|
|
85
|
+
rate-limited for Claude can still be used for Gemini.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
initial_refresh_token: Optional[str] = None,
|
|
91
|
+
stored: Optional[AccountStorage] = None,
|
|
92
|
+
):
|
|
93
|
+
self._accounts: List[ManagedAccount] = []
|
|
94
|
+
self._cursor = 0
|
|
95
|
+
self._current_index_by_family: Dict[ModelFamily, int] = {
|
|
96
|
+
"claude": -1,
|
|
97
|
+
"gemini": -1,
|
|
98
|
+
}
|
|
99
|
+
self._last_toast_index = -1
|
|
100
|
+
self._last_toast_time = 0.0
|
|
101
|
+
|
|
102
|
+
initial_parts = parse_refresh_parts(initial_refresh_token or "")
|
|
103
|
+
|
|
104
|
+
if stored and not stored.accounts:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if stored and stored.accounts:
|
|
108
|
+
now = _now_ms()
|
|
109
|
+
for i, acc in enumerate(stored.accounts):
|
|
110
|
+
if not acc.refresh_token:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
parts = RefreshParts(
|
|
114
|
+
refresh_token=acc.refresh_token,
|
|
115
|
+
project_id=acc.project_id,
|
|
116
|
+
managed_project_id=acc.managed_project_id,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Convert rate limits from storage
|
|
120
|
+
rate_limits: Dict[str, float] = {}
|
|
121
|
+
if acc.rate_limit_reset_times.claude:
|
|
122
|
+
rate_limits["claude"] = acc.rate_limit_reset_times.claude
|
|
123
|
+
if acc.rate_limit_reset_times.gemini_antigravity:
|
|
124
|
+
rate_limits["gemini-antigravity"] = (
|
|
125
|
+
acc.rate_limit_reset_times.gemini_antigravity
|
|
126
|
+
)
|
|
127
|
+
if acc.rate_limit_reset_times.gemini_cli:
|
|
128
|
+
rate_limits["gemini-cli"] = acc.rate_limit_reset_times.gemini_cli
|
|
129
|
+
|
|
130
|
+
self._accounts.append(
|
|
131
|
+
ManagedAccount(
|
|
132
|
+
index=i,
|
|
133
|
+
email=acc.email,
|
|
134
|
+
added_at=acc.added_at or now,
|
|
135
|
+
last_used=acc.last_used or 0,
|
|
136
|
+
parts=parts,
|
|
137
|
+
access_token=None, # Tokens loaded separately
|
|
138
|
+
expires_at=None,
|
|
139
|
+
rate_limit_reset_times=rate_limits,
|
|
140
|
+
last_switch_reason=acc.last_switch_reason,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if self._accounts:
|
|
145
|
+
self._cursor = max(0, min(stored.active_index, len(self._accounts) - 1))
|
|
146
|
+
default_idx = self._cursor
|
|
147
|
+
self._current_index_by_family["claude"] = (
|
|
148
|
+
stored.active_index_by_family.get("claude", default_idx)
|
|
149
|
+
% len(self._accounts)
|
|
150
|
+
)
|
|
151
|
+
self._current_index_by_family["gemini"] = (
|
|
152
|
+
stored.active_index_by_family.get("gemini", default_idx)
|
|
153
|
+
% len(self._accounts)
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Fallback: create single account from initial token
|
|
158
|
+
if initial_parts.refresh_token:
|
|
159
|
+
now = _now_ms()
|
|
160
|
+
self._accounts.append(
|
|
161
|
+
ManagedAccount(
|
|
162
|
+
index=0,
|
|
163
|
+
email=None,
|
|
164
|
+
added_at=now,
|
|
165
|
+
last_used=0,
|
|
166
|
+
parts=initial_parts,
|
|
167
|
+
rate_limit_reset_times={},
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
self._current_index_by_family["claude"] = 0
|
|
171
|
+
self._current_index_by_family["gemini"] = 0
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def load_from_disk(
|
|
175
|
+
cls, initial_refresh_token: Optional[str] = None
|
|
176
|
+
) -> "AccountManager":
|
|
177
|
+
"""Load account manager from disk."""
|
|
178
|
+
stored = load_accounts()
|
|
179
|
+
return cls(initial_refresh_token, stored)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def account_count(self) -> int:
|
|
183
|
+
"""Number of accounts in the pool."""
|
|
184
|
+
return len(self._accounts)
|
|
185
|
+
|
|
186
|
+
def get_accounts_snapshot(self) -> List[ManagedAccount]:
|
|
187
|
+
"""Get a snapshot of all accounts."""
|
|
188
|
+
return list(self._accounts)
|
|
189
|
+
|
|
190
|
+
def get_current_account_for_family(
|
|
191
|
+
self,
|
|
192
|
+
family: ModelFamily,
|
|
193
|
+
) -> Optional[ManagedAccount]:
|
|
194
|
+
"""Get the current active account for a model family."""
|
|
195
|
+
idx = self._current_index_by_family.get(family, -1)
|
|
196
|
+
if 0 <= idx < len(self._accounts):
|
|
197
|
+
return self._accounts[idx]
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def get_current_or_next_for_family(
|
|
201
|
+
self,
|
|
202
|
+
family: ModelFamily,
|
|
203
|
+
) -> Optional[ManagedAccount]:
|
|
204
|
+
"""Get current account if not rate limited, otherwise find next available."""
|
|
205
|
+
current = self.get_current_account_for_family(family)
|
|
206
|
+
|
|
207
|
+
if current:
|
|
208
|
+
_clear_expired_rate_limits(current)
|
|
209
|
+
if not _is_rate_limited_for_family(current, family):
|
|
210
|
+
current.last_used = _now_ms()
|
|
211
|
+
return current
|
|
212
|
+
|
|
213
|
+
# Find next available account
|
|
214
|
+
next_account = self._get_next_for_family(family)
|
|
215
|
+
if next_account:
|
|
216
|
+
self._current_index_by_family[family] = next_account.index
|
|
217
|
+
return next_account
|
|
218
|
+
|
|
219
|
+
def _get_next_for_family(self, family: ModelFamily) -> Optional[ManagedAccount]:
|
|
220
|
+
"""Get next available account for a model family."""
|
|
221
|
+
available = []
|
|
222
|
+
for acc in self._accounts:
|
|
223
|
+
_clear_expired_rate_limits(acc)
|
|
224
|
+
if not _is_rate_limited_for_family(acc, family):
|
|
225
|
+
available.append(acc)
|
|
226
|
+
|
|
227
|
+
if not available:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
account = available[self._cursor % len(available)]
|
|
231
|
+
self._cursor += 1
|
|
232
|
+
account.last_used = _now_ms()
|
|
233
|
+
return account
|
|
234
|
+
|
|
235
|
+
def mark_rate_limited(
|
|
236
|
+
self,
|
|
237
|
+
account: ManagedAccount,
|
|
238
|
+
retry_after_ms: float,
|
|
239
|
+
family: ModelFamily,
|
|
240
|
+
header_style: HeaderStyle = "antigravity",
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Mark an account as rate limited."""
|
|
243
|
+
key = _get_quota_key(family, header_style)
|
|
244
|
+
account.rate_limit_reset_times[key] = _now_ms() + retry_after_ms
|
|
245
|
+
|
|
246
|
+
def is_rate_limited_for_header_style(
|
|
247
|
+
self,
|
|
248
|
+
account: ManagedAccount,
|
|
249
|
+
family: ModelFamily,
|
|
250
|
+
header_style: HeaderStyle,
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Check if account is rate limited for a specific header style."""
|
|
253
|
+
_clear_expired_rate_limits(account)
|
|
254
|
+
key = _get_quota_key(family, header_style)
|
|
255
|
+
return _is_rate_limited_for_quota_key(account, key)
|
|
256
|
+
|
|
257
|
+
def get_available_header_style(
|
|
258
|
+
self,
|
|
259
|
+
account: ManagedAccount,
|
|
260
|
+
family: ModelFamily,
|
|
261
|
+
) -> Optional[HeaderStyle]:
|
|
262
|
+
"""Get an available header style for the account, or None if all limited."""
|
|
263
|
+
_clear_expired_rate_limits(account)
|
|
264
|
+
|
|
265
|
+
if family == "claude":
|
|
266
|
+
if not _is_rate_limited_for_quota_key(account, "claude"):
|
|
267
|
+
return "antigravity"
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
# For Gemini, try Antigravity first, then Gemini CLI
|
|
271
|
+
if not _is_rate_limited_for_quota_key(account, "gemini-antigravity"):
|
|
272
|
+
return "antigravity"
|
|
273
|
+
if not _is_rate_limited_for_quota_key(account, "gemini-cli"):
|
|
274
|
+
return "gemini-cli"
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def get_min_wait_time_for_family(self, family: ModelFamily) -> float:
|
|
278
|
+
"""Get minimum wait time until an account becomes available (in ms)."""
|
|
279
|
+
# Check if any account is already available
|
|
280
|
+
for acc in self._accounts:
|
|
281
|
+
_clear_expired_rate_limits(acc)
|
|
282
|
+
if not _is_rate_limited_for_family(acc, family):
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
# Calculate minimum wait time
|
|
286
|
+
wait_times: List[float] = []
|
|
287
|
+
now = _now_ms()
|
|
288
|
+
|
|
289
|
+
for acc in self._accounts:
|
|
290
|
+
if family == "claude":
|
|
291
|
+
reset = acc.rate_limit_reset_times.get("claude")
|
|
292
|
+
if reset is not None:
|
|
293
|
+
wait_times.append(max(0, reset - now))
|
|
294
|
+
else:
|
|
295
|
+
# For Gemini, account available when EITHER pool expires
|
|
296
|
+
ag_reset = acc.rate_limit_reset_times.get("gemini-antigravity")
|
|
297
|
+
cli_reset = acc.rate_limit_reset_times.get("gemini-cli")
|
|
298
|
+
|
|
299
|
+
ag_wait = max(0, ag_reset - now) if ag_reset else float("inf")
|
|
300
|
+
cli_wait = max(0, cli_reset - now) if cli_reset else float("inf")
|
|
301
|
+
|
|
302
|
+
account_wait = min(ag_wait, cli_wait)
|
|
303
|
+
if account_wait != float("inf"):
|
|
304
|
+
wait_times.append(account_wait)
|
|
305
|
+
|
|
306
|
+
return min(wait_times) if wait_times else 0
|
|
307
|
+
|
|
308
|
+
def add_account(
|
|
309
|
+
self,
|
|
310
|
+
refresh_token: str,
|
|
311
|
+
email: Optional[str] = None,
|
|
312
|
+
project_id: Optional[str] = None,
|
|
313
|
+
) -> ManagedAccount:
|
|
314
|
+
"""Add a new account to the pool."""
|
|
315
|
+
now = _now_ms()
|
|
316
|
+
parts = parse_refresh_parts(refresh_token)
|
|
317
|
+
if project_id:
|
|
318
|
+
parts.project_id = project_id
|
|
319
|
+
|
|
320
|
+
account = ManagedAccount(
|
|
321
|
+
index=len(self._accounts),
|
|
322
|
+
email=email,
|
|
323
|
+
added_at=now,
|
|
324
|
+
last_used=0,
|
|
325
|
+
parts=parts,
|
|
326
|
+
rate_limit_reset_times={},
|
|
327
|
+
)
|
|
328
|
+
self._accounts.append(account)
|
|
329
|
+
|
|
330
|
+
# Set as active if this is the first account
|
|
331
|
+
if len(self._accounts) == 1:
|
|
332
|
+
self._current_index_by_family["claude"] = 0
|
|
333
|
+
self._current_index_by_family["gemini"] = 0
|
|
334
|
+
|
|
335
|
+
return account
|
|
336
|
+
|
|
337
|
+
def remove_account(self, account: ManagedAccount) -> bool:
|
|
338
|
+
"""Remove an account from the pool."""
|
|
339
|
+
try:
|
|
340
|
+
idx = self._accounts.index(account)
|
|
341
|
+
except ValueError:
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
self._accounts.pop(idx)
|
|
345
|
+
|
|
346
|
+
# Re-index remaining accounts
|
|
347
|
+
for i, acc in enumerate(self._accounts):
|
|
348
|
+
acc.index = i
|
|
349
|
+
|
|
350
|
+
if not self._accounts:
|
|
351
|
+
self._cursor = 0
|
|
352
|
+
self._current_index_by_family["claude"] = -1
|
|
353
|
+
self._current_index_by_family["gemini"] = -1
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
# Adjust cursor and active indices
|
|
357
|
+
if self._cursor > idx:
|
|
358
|
+
self._cursor -= 1
|
|
359
|
+
self._cursor = self._cursor % len(self._accounts)
|
|
360
|
+
|
|
361
|
+
for family in ["claude", "gemini"]:
|
|
362
|
+
family_key: ModelFamily = family # type: ignore
|
|
363
|
+
if self._current_index_by_family[family_key] > idx:
|
|
364
|
+
self._current_index_by_family[family_key] -= 1
|
|
365
|
+
if self._current_index_by_family[family_key] >= len(self._accounts):
|
|
366
|
+
self._current_index_by_family[family_key] = -1
|
|
367
|
+
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
def save_to_disk(self) -> None:
|
|
371
|
+
"""Persist account state to disk."""
|
|
372
|
+
claude_idx = max(0, self._current_index_by_family.get("claude", 0))
|
|
373
|
+
gemini_idx = max(0, self._current_index_by_family.get("gemini", 0))
|
|
374
|
+
|
|
375
|
+
accounts: List[AccountMetadata] = []
|
|
376
|
+
for acc in self._accounts:
|
|
377
|
+
rate_limits = RateLimitState(
|
|
378
|
+
claude=acc.rate_limit_reset_times.get("claude"),
|
|
379
|
+
gemini_antigravity=acc.rate_limit_reset_times.get("gemini-antigravity"),
|
|
380
|
+
gemini_cli=acc.rate_limit_reset_times.get("gemini-cli"),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
accounts.append(
|
|
384
|
+
AccountMetadata(
|
|
385
|
+
refresh_token=acc.parts.refresh_token,
|
|
386
|
+
email=acc.email,
|
|
387
|
+
project_id=acc.parts.project_id,
|
|
388
|
+
managed_project_id=acc.parts.managed_project_id,
|
|
389
|
+
added_at=acc.added_at,
|
|
390
|
+
last_used=acc.last_used,
|
|
391
|
+
last_switch_reason=acc.last_switch_reason,
|
|
392
|
+
rate_limit_reset_times=rate_limits,
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
storage = AccountStorage(
|
|
397
|
+
version=3,
|
|
398
|
+
accounts=accounts,
|
|
399
|
+
active_index=claude_idx,
|
|
400
|
+
active_index_by_family={
|
|
401
|
+
"claude": claude_idx,
|
|
402
|
+
"gemini": gemini_idx,
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
save_accounts(storage)
|