Spaces:
Paused
feat(credentials): ✨ add comprehensive credential management interface
Browse filesImplement a new credential management submenu that provides a unified view and control panel for all credentials (API keys and OAuth). This significantly improves the user experience for managing credentials across all providers.
Key additions:
- New credentials summary dashboard displaying API keys and OAuth credentials side-by-side with tier breakdowns
- Delete functionality for both API keys and OAuth credentials with safety confirmations
- Edit capability for OAuth credential email fields (particularly useful for Qwen Code)
- Tier counting and display for OAuth providers that support tier information
- Numeric sorting for API keys (properly handles KEY_1, KEY_10, etc.)
- Safety mechanisms for API key deletion with backup/restore on unexpected changes
- Improved visual presentation using Rich tables and columns
The credential tool now provides complete CRUD operations for all credential types through an intuitive menu system, with proper error handling and user confirmations for destructive operations.
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
import os
|
|
|
|
| 6 |
import time
|
| 7 |
from pathlib import Path
|
| 8 |
from dotenv import set_key, get_key
|
|
@@ -11,7 +12,8 @@ from dotenv import set_key, get_key
|
|
| 11 |
# to avoid 6-7 second delay before showing loading screen
|
| 12 |
from rich.console import Console
|
| 13 |
from rich.panel import Panel
|
| 14 |
-
from rich.prompt import Prompt
|
|
|
|
| 15 |
from rich.text import Text
|
| 16 |
|
| 17 |
from .utils.paths import get_oauth_dir, get_data_file
|
|
@@ -48,6 +50,782 @@ def _ensure_providers_loaded():
|
|
| 48 |
return _provider_factory, _provider_plugins
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def clear_screen():
|
| 52 |
"""
|
| 53 |
Cross-platform terminal clear that works robustly on both
|
|
@@ -83,6 +861,7 @@ async def setup_api_key():
|
|
| 83 |
"""
|
| 84 |
Interactively sets up a new API key for a provider.
|
| 85 |
"""
|
|
|
|
| 86 |
console.print(Panel("[bold cyan]API Key Setup[/bold cyan]", expand=False))
|
| 87 |
|
| 88 |
# Debug toggle: Set to True to see env var names next to each provider
|
|
@@ -211,7 +990,11 @@ async def setup_api_key():
|
|
| 211 |
provider_text.append(f" {i + 1}. {provider_name}\n")
|
| 212 |
|
| 213 |
console.print(
|
| 214 |
-
Panel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
)
|
| 216 |
|
| 217 |
choice = Prompt.ask(
|
|
@@ -386,6 +1169,7 @@ async def export_gemini_cli_to_env():
|
|
| 386 |
Export a Gemini CLI credential JSON file to .env format.
|
| 387 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 388 |
"""
|
|
|
|
| 389 |
console.print(
|
| 390 |
Panel(
|
| 391 |
"[bold cyan]Export Gemini CLI Credential to .env[/bold cyan]", expand=False
|
|
@@ -418,7 +1202,11 @@ async def export_gemini_cli_to_env():
|
|
| 418 |
)
|
| 419 |
|
| 420 |
console.print(
|
| 421 |
-
Panel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
)
|
| 423 |
|
| 424 |
choice = Prompt.ask(
|
|
@@ -481,6 +1269,7 @@ async def export_qwen_code_to_env():
|
|
| 481 |
Export a Qwen Code credential JSON file to .env format.
|
| 482 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 483 |
"""
|
|
|
|
| 484 |
console.print(
|
| 485 |
Panel(
|
| 486 |
"[bold cyan]Export Qwen Code Credential to .env[/bold cyan]", expand=False
|
|
@@ -513,7 +1302,11 @@ async def export_qwen_code_to_env():
|
|
| 513 |
)
|
| 514 |
|
| 515 |
console.print(
|
| 516 |
-
Panel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
)
|
| 518 |
|
| 519 |
choice = Prompt.ask(
|
|
@@ -575,6 +1368,7 @@ async def export_iflow_to_env():
|
|
| 575 |
Export an iFlow credential JSON file to .env format.
|
| 576 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 577 |
"""
|
|
|
|
| 578 |
console.print(
|
| 579 |
Panel("[bold cyan]Export iFlow Credential to .env[/bold cyan]", expand=False)
|
| 580 |
)
|
|
@@ -605,7 +1399,11 @@ async def export_iflow_to_env():
|
|
| 605 |
)
|
| 606 |
|
| 607 |
console.print(
|
| 608 |
-
Panel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
)
|
| 610 |
|
| 611 |
choice = Prompt.ask(
|
|
@@ -667,6 +1465,7 @@ async def export_antigravity_to_env():
|
|
| 667 |
Export an Antigravity credential JSON file to .env format.
|
| 668 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 669 |
"""
|
|
|
|
| 670 |
console.print(
|
| 671 |
Panel(
|
| 672 |
"[bold cyan]Export Antigravity Credential to .env[/bold cyan]", expand=False
|
|
@@ -699,7 +1498,11 @@ async def export_antigravity_to_env():
|
|
| 699 |
)
|
| 700 |
|
| 701 |
console.print(
|
| 702 |
-
Panel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
)
|
| 704 |
|
| 705 |
choice = Prompt.ask(
|
|
@@ -762,6 +1565,7 @@ async def export_all_provider_credentials(provider_name: str):
|
|
| 762 |
Export all credentials for a specific provider to individual .env files.
|
| 763 |
Uses the auth class's list_credentials() and export_credential_to_env() methods.
|
| 764 |
"""
|
|
|
|
| 765 |
# Get auth instance for this provider
|
| 766 |
provider_factory, _ = _ensure_providers_loaded()
|
| 767 |
try:
|
|
@@ -830,6 +1634,7 @@ async def combine_provider_credentials(provider_name: str):
|
|
| 830 |
Combine all credentials for a specific provider into a single .env file.
|
| 831 |
Uses the auth class's list_credentials() and build_env_lines() methods.
|
| 832 |
"""
|
|
|
|
| 833 |
# Get auth instance for this provider
|
| 834 |
provider_factory, _ = _ensure_providers_loaded()
|
| 835 |
try:
|
|
@@ -914,6 +1719,7 @@ async def combine_all_credentials():
|
|
| 914 |
Combine ALL credentials from ALL providers into a single .env file.
|
| 915 |
Uses auth class list_credentials() and build_env_lines() methods.
|
| 916 |
"""
|
|
|
|
| 917 |
console.print(
|
| 918 |
Panel("[bold cyan]Combine All Provider Credentials[/bold cyan]", expand=False)
|
| 919 |
)
|
|
@@ -1150,7 +1956,6 @@ async def main(clear_on_start=True):
|
|
| 1150 |
Panel(
|
| 1151 |
"[bold cyan]Interactive Credential Setup[/bold cyan]",
|
| 1152 |
title="--- API Key Proxy ---",
|
| 1153 |
-
expand=False,
|
| 1154 |
)
|
| 1155 |
)
|
| 1156 |
|
|
@@ -1161,16 +1966,21 @@ async def main(clear_on_start=True):
|
|
| 1161 |
Panel(
|
| 1162 |
"[bold cyan]Interactive Credential Setup[/bold cyan]",
|
| 1163 |
title="--- API Key Proxy ---",
|
| 1164 |
-
expand=False,
|
| 1165 |
)
|
| 1166 |
)
|
| 1167 |
|
|
|
|
|
|
|
|
|
|
| 1168 |
console.print(
|
| 1169 |
Panel(
|
| 1170 |
Text.from_markup(
|
| 1171 |
-
"1. Add OAuth Credential\
|
|
|
|
|
|
|
|
|
|
| 1172 |
),
|
| 1173 |
-
title="Choose
|
| 1174 |
style="bold blue",
|
| 1175 |
)
|
| 1176 |
)
|
|
@@ -1179,7 +1989,7 @@ async def main(clear_on_start=True):
|
|
| 1179 |
Text.from_markup(
|
| 1180 |
"[bold]Please select an option or type [red]'q'[/red] to quit[/bold]"
|
| 1181 |
),
|
| 1182 |
-
choices=["1", "2", "3", "q"],
|
| 1183 |
show_choices=False,
|
| 1184 |
)
|
| 1185 |
|
|
@@ -1187,18 +1997,22 @@ async def main(clear_on_start=True):
|
|
| 1187 |
break
|
| 1188 |
|
| 1189 |
if setup_type == "1":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1190 |
provider_factory, _ = _ensure_providers_loaded()
|
| 1191 |
available_providers = provider_factory.get_available_providers()
|
| 1192 |
-
oauth_friendly_names = {
|
| 1193 |
-
"gemini_cli": "Gemini CLI (OAuth)",
|
| 1194 |
-
"qwen_code": "Qwen Code (OAuth - also supports API keys)",
|
| 1195 |
-
"iflow": "iFlow (OAuth - also supports API keys)",
|
| 1196 |
-
"antigravity": "Antigravity (OAuth)",
|
| 1197 |
-
}
|
| 1198 |
|
| 1199 |
provider_text = Text()
|
| 1200 |
for i, provider in enumerate(available_providers):
|
| 1201 |
-
display_name =
|
| 1202 |
provider, provider.replace("_", " ").title()
|
| 1203 |
)
|
| 1204 |
provider_text.append(f" {i + 1}. {display_name}\n")
|
|
@@ -1226,11 +2040,15 @@ async def main(clear_on_start=True):
|
|
| 1226 |
choice_index = int(choice) - 1
|
| 1227 |
if 0 <= choice_index < len(available_providers):
|
| 1228 |
provider_name = available_providers[choice_index]
|
| 1229 |
-
display_name =
|
| 1230 |
provider_name, provider_name.replace("_", " ").title()
|
| 1231 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1232 |
console.print(
|
| 1233 |
-
f"
|
| 1234 |
)
|
| 1235 |
await setup_new_credential(provider_name)
|
| 1236 |
# Don't clear after OAuth - user needs to see full flow
|
|
@@ -1255,6 +2073,9 @@ async def main(clear_on_start=True):
|
|
| 1255 |
elif setup_type == "3":
|
| 1256 |
await export_credentials_submenu()
|
| 1257 |
|
|
|
|
|
|
|
|
|
|
| 1258 |
|
| 1259 |
def run_credential_tool(from_launcher=False):
|
| 1260 |
"""
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
+
import re
|
| 7 |
import time
|
| 8 |
from pathlib import Path
|
| 9 |
from dotenv import set_key, get_key
|
|
|
|
| 12 |
# to avoid 6-7 second delay before showing loading screen
|
| 13 |
from rich.console import Console
|
| 14 |
from rich.panel import Panel
|
| 15 |
+
from rich.prompt import Prompt, Confirm
|
| 16 |
+
from rich.table import Table
|
| 17 |
from rich.text import Text
|
| 18 |
|
| 19 |
from .utils.paths import get_oauth_dir, get_data_file
|
|
|
|
| 50 |
return _provider_factory, _provider_plugins
|
| 51 |
|
| 52 |
|
| 53 |
+
# OAuth provider display names mapping (no "(OAuth)" suffix - context makes it clear)
|
| 54 |
+
OAUTH_FRIENDLY_NAMES = {
|
| 55 |
+
"gemini_cli": "Gemini CLI",
|
| 56 |
+
"qwen_code": "Qwen Code",
|
| 57 |
+
"iflow": "iFlow",
|
| 58 |
+
"antigravity": "Antigravity",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _extract_key_number(key_name: str) -> int:
|
| 63 |
+
"""Extract the numeric suffix from a key name for proper sorting.
|
| 64 |
+
|
| 65 |
+
Examples:
|
| 66 |
+
GEMINI_API_KEY_1 -> 1
|
| 67 |
+
GEMINI_API_KEY_10 -> 10
|
| 68 |
+
GEMINI_API_KEY -> 0
|
| 69 |
+
"""
|
| 70 |
+
match = re.search(r"_(\d+)$", key_name)
|
| 71 |
+
return int(match.group(1)) if match else 0
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _normalize_tier_name(tier: str) -> str:
|
| 75 |
+
"""Normalize tier names for consistent display.
|
| 76 |
+
|
| 77 |
+
Examples:
|
| 78 |
+
"free-tier" -> "free"
|
| 79 |
+
"FREE_TIER" -> "free"
|
| 80 |
+
"PAID" -> "paid"
|
| 81 |
+
"standard" -> "standard"
|
| 82 |
+
None -> "unknown"
|
| 83 |
+
"""
|
| 84 |
+
if not tier:
|
| 85 |
+
return "unknown"
|
| 86 |
+
|
| 87 |
+
# Lowercase and remove common suffixes/prefixes
|
| 88 |
+
normalized = tier.lower().strip()
|
| 89 |
+
normalized = normalized.replace("-tier", "").replace("_tier", "")
|
| 90 |
+
normalized = normalized.replace("-", "").replace("_", "")
|
| 91 |
+
|
| 92 |
+
return normalized
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _count_tiers(credentials: list) -> dict:
|
| 96 |
+
"""Count credentials by tier.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
credentials: List of credential info dicts with optional 'tier' key
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Dict mapping normalized tier names to counts, e.g. {"free": 15, "paid": 2}
|
| 103 |
+
"""
|
| 104 |
+
tier_counts = {}
|
| 105 |
+
for cred in credentials:
|
| 106 |
+
tier = cred.get("tier")
|
| 107 |
+
if tier:
|
| 108 |
+
normalized = _normalize_tier_name(tier)
|
| 109 |
+
tier_counts[normalized] = tier_counts.get(normalized, 0) + 1
|
| 110 |
+
return tier_counts
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _format_tier_counts(tier_counts: dict) -> str:
|
| 114 |
+
"""Format tier counts as a compact string.
|
| 115 |
+
|
| 116 |
+
Examples:
|
| 117 |
+
{"free": 15, "paid": 2} -> "(15 free, 2 paid)"
|
| 118 |
+
{"free": 5} -> "(5 free)"
|
| 119 |
+
{} -> ""
|
| 120 |
+
"""
|
| 121 |
+
if not tier_counts:
|
| 122 |
+
return ""
|
| 123 |
+
|
| 124 |
+
# Sort by count descending, then alphabetically
|
| 125 |
+
sorted_tiers = sorted(tier_counts.items(), key=lambda x: (-x[1], x[0]))
|
| 126 |
+
parts = [f"{count} {tier}" for tier, count in sorted_tiers]
|
| 127 |
+
return f"({', '.join(parts)})"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _get_api_keys_from_env() -> dict:
|
| 131 |
+
"""
|
| 132 |
+
Parse the .env file and return a dictionary of API keys grouped by provider.
|
| 133 |
+
Keys are sorted numerically within each provider.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Dict mapping provider names to lists of (key_name, key_value) tuples.
|
| 137 |
+
Example: {"GEMINI": [("GEMINI_API_KEY_1", "abc123"), ("GEMINI_API_KEY_2", "def456")]}
|
| 138 |
+
"""
|
| 139 |
+
api_keys = {}
|
| 140 |
+
env_file = _get_env_file()
|
| 141 |
+
|
| 142 |
+
if not env_file.is_file():
|
| 143 |
+
return api_keys
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
with open(env_file, "r") as f:
|
| 147 |
+
for line in f:
|
| 148 |
+
line = line.strip()
|
| 149 |
+
# Skip comments and empty lines
|
| 150 |
+
if not line or line.startswith("#"):
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
# Look for lines with API_KEY pattern
|
| 154 |
+
if "_API_KEY" in line and "=" in line:
|
| 155 |
+
key_name, _, key_value = line.partition("=")
|
| 156 |
+
key_name = key_name.strip()
|
| 157 |
+
key_value = key_value.strip().strip('"').strip("'")
|
| 158 |
+
|
| 159 |
+
# Skip PROXY_API_KEY and empty values
|
| 160 |
+
if key_name == "PROXY_API_KEY" or not key_value:
|
| 161 |
+
continue
|
| 162 |
+
|
| 163 |
+
# Skip placeholder values
|
| 164 |
+
if key_value.startswith("YOUR_") or key_value == "":
|
| 165 |
+
continue
|
| 166 |
+
|
| 167 |
+
# Extract provider name (everything before _API_KEY)
|
| 168 |
+
# Handle cases like GEMINI_API_KEY_1 -> GEMINI
|
| 169 |
+
parts = key_name.split("_API_KEY")
|
| 170 |
+
if parts:
|
| 171 |
+
provider_name = parts[0]
|
| 172 |
+
if provider_name not in api_keys:
|
| 173 |
+
api_keys[provider_name] = []
|
| 174 |
+
api_keys[provider_name].append((key_name, key_value))
|
| 175 |
+
|
| 176 |
+
# Sort keys numerically within each provider
|
| 177 |
+
for provider_name in api_keys:
|
| 178 |
+
api_keys[provider_name].sort(key=lambda x: _extract_key_number(x[0]))
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
console.print(f"[bold red]Error reading .env file: {e}[/bold red]")
|
| 182 |
+
|
| 183 |
+
return api_keys
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _delete_api_key_from_env(key_name: str) -> bool:
|
| 187 |
+
"""
|
| 188 |
+
Delete an API key from the .env file with safety backup and comparison.
|
| 189 |
+
|
| 190 |
+
This function creates a backup of all API keys before deletion,
|
| 191 |
+
performs the deletion, and then verifies no unintended keys were lost.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
key_name: The exact key name to delete (e.g., "GEMINI_API_KEY_2")
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
True if deletion was successful and verified, False otherwise
|
| 198 |
+
"""
|
| 199 |
+
env_file = _get_env_file()
|
| 200 |
+
|
| 201 |
+
if not env_file.is_file():
|
| 202 |
+
console.print("[bold red]Error: .env file not found[/bold red]")
|
| 203 |
+
return False
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
# Step 1: Read all lines and backup all API keys
|
| 207 |
+
with open(env_file, "r") as f:
|
| 208 |
+
original_lines = f.readlines()
|
| 209 |
+
|
| 210 |
+
# Create backup of all API keys before modification
|
| 211 |
+
api_keys_before = _get_api_keys_from_env()
|
| 212 |
+
all_keys_before = set()
|
| 213 |
+
for provider_keys in api_keys_before.values():
|
| 214 |
+
for kn, kv in provider_keys:
|
| 215 |
+
all_keys_before.add((kn, kv))
|
| 216 |
+
|
| 217 |
+
# Step 2: Find and remove the target key
|
| 218 |
+
new_lines = []
|
| 219 |
+
key_found = False
|
| 220 |
+
deleted_key_value = None
|
| 221 |
+
|
| 222 |
+
for line in original_lines:
|
| 223 |
+
stripped = line.strip()
|
| 224 |
+
# Check if this line contains our target key
|
| 225 |
+
if stripped.startswith(f"{key_name}="):
|
| 226 |
+
key_found = True
|
| 227 |
+
# Store the value being deleted for verification
|
| 228 |
+
_, _, deleted_key_value = stripped.partition("=")
|
| 229 |
+
deleted_key_value = deleted_key_value.strip().strip('"').strip("'")
|
| 230 |
+
continue # Skip this line (delete it)
|
| 231 |
+
new_lines.append(line)
|
| 232 |
+
|
| 233 |
+
if not key_found:
|
| 234 |
+
console.print(
|
| 235 |
+
f"[bold red]Error: Key '{key_name}' not found in .env file[/bold red]"
|
| 236 |
+
)
|
| 237 |
+
return False
|
| 238 |
+
|
| 239 |
+
# Step 3: Write the modified content
|
| 240 |
+
with open(env_file, "w") as f:
|
| 241 |
+
f.writelines(new_lines)
|
| 242 |
+
|
| 243 |
+
# Step 4: Verify the deletion - compare before and after
|
| 244 |
+
api_keys_after = _get_api_keys_from_env()
|
| 245 |
+
all_keys_after = set()
|
| 246 |
+
for provider_keys in api_keys_after.values():
|
| 247 |
+
for kn, kv in provider_keys:
|
| 248 |
+
all_keys_after.add((kn, kv))
|
| 249 |
+
|
| 250 |
+
# Check that only the intended key was removed
|
| 251 |
+
expected_remaining = all_keys_before - {(key_name, deleted_key_value)}
|
| 252 |
+
|
| 253 |
+
if all_keys_after != expected_remaining:
|
| 254 |
+
# Something went wrong - restore from backup
|
| 255 |
+
console.print(
|
| 256 |
+
"[bold red]Error: Unexpected keys were affected during deletion![/bold red]"
|
| 257 |
+
)
|
| 258 |
+
console.print("[bold yellow]Restoring original file...[/bold yellow]")
|
| 259 |
+
with open(env_file, "w") as f:
|
| 260 |
+
f.writelines(original_lines)
|
| 261 |
+
return False
|
| 262 |
+
|
| 263 |
+
return True
|
| 264 |
+
|
| 265 |
+
except Exception as e:
|
| 266 |
+
console.print(f"[bold red]Error during API key deletion: {e}[/bold red]")
|
| 267 |
+
return False
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _get_oauth_credentials_summary() -> dict:
|
| 271 |
+
"""
|
| 272 |
+
Get a summary of all OAuth credentials for all providers.
|
| 273 |
+
|
| 274 |
+
Returns:
|
| 275 |
+
Dict mapping provider names to lists of credential info dicts.
|
| 276 |
+
Example: {"gemini_cli": [{"email": "user@example.com", "tier": "free-tier", ...}, ...]}
|
| 277 |
+
"""
|
| 278 |
+
provider_factory, _ = _ensure_providers_loaded()
|
| 279 |
+
oauth_providers = ["gemini_cli", "qwen_code", "iflow", "antigravity"]
|
| 280 |
+
oauth_summary = {}
|
| 281 |
+
|
| 282 |
+
for provider_name in oauth_providers:
|
| 283 |
+
try:
|
| 284 |
+
auth_class = provider_factory.get_provider_auth_class(provider_name)
|
| 285 |
+
auth_instance = auth_class()
|
| 286 |
+
credentials = auth_instance.list_credentials(_get_oauth_base_dir())
|
| 287 |
+
oauth_summary[provider_name] = credentials
|
| 288 |
+
except Exception:
|
| 289 |
+
oauth_summary[provider_name] = []
|
| 290 |
+
|
| 291 |
+
return oauth_summary
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _get_all_credentials_summary() -> dict:
|
| 295 |
+
"""
|
| 296 |
+
Get a complete summary of all credentials (API keys and OAuth).
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
Dict with "api_keys" and "oauth" sections containing credential summaries.
|
| 300 |
+
"""
|
| 301 |
+
return {
|
| 302 |
+
"api_keys": _get_api_keys_from_env(),
|
| 303 |
+
"oauth": _get_oauth_credentials_summary(),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _display_credentials_summary():
|
| 308 |
+
"""
|
| 309 |
+
Display a compact 2-column summary of all configured credentials.
|
| 310 |
+
API Keys on the left, OAuth credentials on the right.
|
| 311 |
+
Handles cases where only one type exists or neither.
|
| 312 |
+
"""
|
| 313 |
+
from rich.columns import Columns
|
| 314 |
+
|
| 315 |
+
summary = _get_all_credentials_summary()
|
| 316 |
+
api_keys = summary["api_keys"]
|
| 317 |
+
oauth_creds = summary["oauth"]
|
| 318 |
+
|
| 319 |
+
# Calculate totals
|
| 320 |
+
total_api_keys = sum(len(keys) for keys in api_keys.values())
|
| 321 |
+
total_oauth = sum(len(creds) for creds in oauth_creds.values() if creds)
|
| 322 |
+
|
| 323 |
+
# Handle empty case
|
| 324 |
+
if total_api_keys == 0 and total_oauth == 0:
|
| 325 |
+
console.print("[dim]No credentials configured yet.[/dim]\n")
|
| 326 |
+
return
|
| 327 |
+
|
| 328 |
+
# Build API Keys table (left column)
|
| 329 |
+
api_table = None
|
| 330 |
+
if total_api_keys > 0:
|
| 331 |
+
api_table = Table(
|
| 332 |
+
title="API Keys", box=None, padding=(0, 1), title_style="bold cyan"
|
| 333 |
+
)
|
| 334 |
+
api_table.add_column("Provider", style="yellow", no_wrap=True)
|
| 335 |
+
api_table.add_column("Count", style="green", justify="right")
|
| 336 |
+
|
| 337 |
+
for provider, keys in sorted(api_keys.items()):
|
| 338 |
+
api_table.add_row(provider, str(len(keys)))
|
| 339 |
+
|
| 340 |
+
# Add total row
|
| 341 |
+
api_table.add_row("─" * 12, "─" * 5, style="dim")
|
| 342 |
+
api_table.add_row("Total", str(total_api_keys), style="bold")
|
| 343 |
+
|
| 344 |
+
# Build OAuth table (right column)
|
| 345 |
+
oauth_table = None
|
| 346 |
+
if total_oauth > 0:
|
| 347 |
+
oauth_table = Table(
|
| 348 |
+
title="OAuth Credentials", box=None, padding=(0, 1), title_style="bold cyan"
|
| 349 |
+
)
|
| 350 |
+
oauth_table.add_column("Provider", style="yellow", no_wrap=True)
|
| 351 |
+
oauth_table.add_column("Count", style="green", justify="right")
|
| 352 |
+
oauth_table.add_column("Tiers", style="dim", no_wrap=True)
|
| 353 |
+
|
| 354 |
+
for provider, creds in sorted(oauth_creds.items()):
|
| 355 |
+
if not creds:
|
| 356 |
+
continue
|
| 357 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider, provider.title())
|
| 358 |
+
count = len(creds)
|
| 359 |
+
|
| 360 |
+
# Count and format tiers for providers that have tier info
|
| 361 |
+
tier_counts = _count_tiers(creds)
|
| 362 |
+
tier_str = _format_tier_counts(tier_counts)
|
| 363 |
+
|
| 364 |
+
oauth_table.add_row(display_name, str(count), tier_str)
|
| 365 |
+
|
| 366 |
+
# Add total row
|
| 367 |
+
oauth_table.add_row("─" * 12, "─" * 5, "", style="dim")
|
| 368 |
+
oauth_table.add_row("Total", str(total_oauth), "", style="bold")
|
| 369 |
+
|
| 370 |
+
# Display based on what's available
|
| 371 |
+
if api_table and oauth_table:
|
| 372 |
+
# Both columns - use Columns for side-by-side layout
|
| 373 |
+
console.print(Columns([api_table, oauth_table], padding=(0, 4), expand=False))
|
| 374 |
+
elif api_table:
|
| 375 |
+
# Only API keys
|
| 376 |
+
console.print(api_table)
|
| 377 |
+
elif oauth_table:
|
| 378 |
+
# Only OAuth
|
| 379 |
+
console.print(oauth_table)
|
| 380 |
+
|
| 381 |
+
console.print("") # Blank line after summary
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _display_oauth_providers_summary():
|
| 385 |
+
"""
|
| 386 |
+
Display a compact summary of OAuth providers only (used when adding OAuth credentials).
|
| 387 |
+
"""
|
| 388 |
+
oauth_summary = _get_oauth_credentials_summary()
|
| 389 |
+
|
| 390 |
+
total = sum(len(creds) for creds in oauth_summary.values())
|
| 391 |
+
|
| 392 |
+
# Build compact table
|
| 393 |
+
table = Table(
|
| 394 |
+
title="Current OAuth Credentials",
|
| 395 |
+
box=None,
|
| 396 |
+
padding=(0, 1),
|
| 397 |
+
title_style="bold cyan",
|
| 398 |
+
)
|
| 399 |
+
table.add_column("Provider", style="yellow", no_wrap=True)
|
| 400 |
+
table.add_column("Count", style="green", justify="right")
|
| 401 |
+
|
| 402 |
+
for provider, creds in sorted(oauth_summary.items()):
|
| 403 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider, provider.title())
|
| 404 |
+
table.add_row(display_name, str(len(creds)))
|
| 405 |
+
|
| 406 |
+
if total > 0:
|
| 407 |
+
table.add_row("─" * 12, "─" * 5, style="dim")
|
| 408 |
+
table.add_row("Total", str(total), style="bold")
|
| 409 |
+
|
| 410 |
+
console.print(table)
|
| 411 |
+
console.print("")
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _display_provider_credentials(provider_name: str):
|
| 415 |
+
"""
|
| 416 |
+
Display all credentials for a specific OAuth provider.
|
| 417 |
+
|
| 418 |
+
Args:
|
| 419 |
+
provider_name: The provider key (e.g., "gemini_cli", "qwen_code")
|
| 420 |
+
"""
|
| 421 |
+
provider_factory, _ = _ensure_providers_loaded()
|
| 422 |
+
|
| 423 |
+
try:
|
| 424 |
+
auth_class = provider_factory.get_provider_auth_class(provider_name)
|
| 425 |
+
auth_instance = auth_class()
|
| 426 |
+
credentials = auth_instance.list_credentials(_get_oauth_base_dir())
|
| 427 |
+
except Exception:
|
| 428 |
+
credentials = []
|
| 429 |
+
|
| 430 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider_name, provider_name.title())
|
| 431 |
+
|
| 432 |
+
if not credentials:
|
| 433 |
+
console.print(f"\n[dim]No existing credentials for {display_name}[/dim]\n")
|
| 434 |
+
return
|
| 435 |
+
|
| 436 |
+
console.print(f"\n[bold cyan]Existing {display_name} Credentials:[/bold cyan]")
|
| 437 |
+
|
| 438 |
+
table = Table(box=None, padding=(0, 2))
|
| 439 |
+
table.add_column("#", style="dim", width=3)
|
| 440 |
+
table.add_column("File", style="yellow")
|
| 441 |
+
table.add_column("Email/Identifier", style="cyan")
|
| 442 |
+
|
| 443 |
+
# Add tier/project columns for Google OAuth providers
|
| 444 |
+
if provider_name in ["gemini_cli", "antigravity"]:
|
| 445 |
+
table.add_column("Tier", style="green")
|
| 446 |
+
table.add_column("Project", style="dim")
|
| 447 |
+
|
| 448 |
+
for i, cred in enumerate(credentials, 1):
|
| 449 |
+
file_name = Path(cred["file_path"]).name
|
| 450 |
+
email = cred.get("email", "unknown")
|
| 451 |
+
|
| 452 |
+
if provider_name in ["gemini_cli", "antigravity"]:
|
| 453 |
+
tier = cred.get("tier", "-")
|
| 454 |
+
project = cred.get("project_id", "-")
|
| 455 |
+
if project and len(project) > 20:
|
| 456 |
+
project = project[:17] + "..."
|
| 457 |
+
table.add_row(str(i), file_name, email, tier or "-", project or "-")
|
| 458 |
+
else:
|
| 459 |
+
table.add_row(str(i), file_name, email)
|
| 460 |
+
|
| 461 |
+
console.print(table)
|
| 462 |
+
console.print("")
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
async def _edit_oauth_credential_email(provider_name: str):
|
| 466 |
+
"""
|
| 467 |
+
Edit the email field of an OAuth credential.
|
| 468 |
+
|
| 469 |
+
Args:
|
| 470 |
+
provider_name: The provider key (e.g., "qwen_code")
|
| 471 |
+
"""
|
| 472 |
+
provider_factory, _ = _ensure_providers_loaded()
|
| 473 |
+
|
| 474 |
+
try:
|
| 475 |
+
auth_class = provider_factory.get_provider_auth_class(provider_name)
|
| 476 |
+
auth_instance = auth_class()
|
| 477 |
+
credentials = auth_instance.list_credentials(_get_oauth_base_dir())
|
| 478 |
+
except Exception as e:
|
| 479 |
+
console.print(f"[bold red]Error loading credentials: {e}[/bold red]")
|
| 480 |
+
return
|
| 481 |
+
|
| 482 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider_name, provider_name.title())
|
| 483 |
+
|
| 484 |
+
if not credentials:
|
| 485 |
+
console.print(
|
| 486 |
+
f"[bold yellow]No {display_name} credentials found.[/bold yellow]"
|
| 487 |
+
)
|
| 488 |
+
return
|
| 489 |
+
|
| 490 |
+
# Display credentials for selection
|
| 491 |
+
_display_provider_credentials(provider_name)
|
| 492 |
+
|
| 493 |
+
choice = Prompt.ask(
|
| 494 |
+
Text.from_markup(
|
| 495 |
+
"[bold]Select credential to edit or type [red]'b'[/red] to go back[/bold]"
|
| 496 |
+
),
|
| 497 |
+
choices=[str(i) for i in range(1, len(credentials) + 1)] + ["b"],
|
| 498 |
+
show_choices=False,
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
if choice.lower() == "b":
|
| 502 |
+
return
|
| 503 |
+
|
| 504 |
+
try:
|
| 505 |
+
idx = int(choice) - 1
|
| 506 |
+
cred_info = credentials[idx]
|
| 507 |
+
cred_path = cred_info["file_path"]
|
| 508 |
+
current_email = cred_info.get("email", "unknown")
|
| 509 |
+
|
| 510 |
+
console.print(f"\nCurrent email: [cyan]{current_email}[/cyan]")
|
| 511 |
+
new_email = Prompt.ask("Enter new email/identifier")
|
| 512 |
+
|
| 513 |
+
if not new_email.strip():
|
| 514 |
+
console.print("[bold yellow]No changes made (empty input).[/bold yellow]")
|
| 515 |
+
return
|
| 516 |
+
|
| 517 |
+
# Load and update the credential file
|
| 518 |
+
with open(cred_path, "r") as f:
|
| 519 |
+
creds = json.load(f)
|
| 520 |
+
|
| 521 |
+
if "_proxy_metadata" not in creds:
|
| 522 |
+
creds["_proxy_metadata"] = {}
|
| 523 |
+
|
| 524 |
+
old_email = creds["_proxy_metadata"].get("email")
|
| 525 |
+
creds["_proxy_metadata"]["email"] = new_email.strip()
|
| 526 |
+
|
| 527 |
+
# Save the updated credentials
|
| 528 |
+
with open(cred_path, "w") as f:
|
| 529 |
+
json.dump(creds, f, indent=2)
|
| 530 |
+
|
| 531 |
+
console.print(
|
| 532 |
+
Panel(
|
| 533 |
+
f"Email updated from [yellow]'{old_email}'[/yellow] to [green]'{new_email.strip()}'[/green]",
|
| 534 |
+
style="bold green",
|
| 535 |
+
title="Success",
|
| 536 |
+
expand=False,
|
| 537 |
+
)
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
except Exception as e:
|
| 541 |
+
console.print(f"[bold red]Error editing credential: {e}[/bold red]")
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
async def manage_credentials_submenu():
|
| 545 |
+
"""
|
| 546 |
+
Submenu for viewing and managing all credentials (API keys and OAuth).
|
| 547 |
+
Allows deletion of any credential and editing email for OAuth credentials.
|
| 548 |
+
"""
|
| 549 |
+
while True:
|
| 550 |
+
clear_screen()
|
| 551 |
+
console.print(
|
| 552 |
+
Panel(
|
| 553 |
+
"[bold cyan]View / Manage All Credentials[/bold cyan]",
|
| 554 |
+
title="--- API Key Proxy ---",
|
| 555 |
+
)
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# Display full summary
|
| 559 |
+
_display_credentials_summary()
|
| 560 |
+
|
| 561 |
+
console.print(
|
| 562 |
+
Panel(
|
| 563 |
+
Text.from_markup(
|
| 564 |
+
"[bold]Actions:[/bold]\n"
|
| 565 |
+
"1. Delete an API Key\n"
|
| 566 |
+
"2. Delete an OAuth Credential\n"
|
| 567 |
+
"3. Edit OAuth Credential Email [dim](Qwen Code recommended)[/dim]"
|
| 568 |
+
),
|
| 569 |
+
title="Choose action",
|
| 570 |
+
style="bold blue",
|
| 571 |
+
)
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
action = Prompt.ask(
|
| 575 |
+
Text.from_markup(
|
| 576 |
+
"[bold]Select an option or type [red]'b'[/red] to go back[/bold]"
|
| 577 |
+
),
|
| 578 |
+
choices=["1", "2", "3", "b"],
|
| 579 |
+
show_choices=False,
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
if action.lower() == "b":
|
| 583 |
+
break
|
| 584 |
+
|
| 585 |
+
if action == "1":
|
| 586 |
+
# Delete API Key
|
| 587 |
+
await _delete_api_key_menu()
|
| 588 |
+
console.print("\n[dim]Press Enter to continue...[/dim]")
|
| 589 |
+
input()
|
| 590 |
+
|
| 591 |
+
elif action == "2":
|
| 592 |
+
# Delete OAuth Credential
|
| 593 |
+
await _delete_oauth_credential_menu()
|
| 594 |
+
console.print("\n[dim]Press Enter to continue...[/dim]")
|
| 595 |
+
input()
|
| 596 |
+
|
| 597 |
+
elif action == "3":
|
| 598 |
+
# Edit OAuth Credential Email
|
| 599 |
+
await _edit_oauth_credential_menu()
|
| 600 |
+
console.print("\n[dim]Press Enter to continue...[/dim]")
|
| 601 |
+
input()
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
async def _delete_api_key_menu():
|
| 605 |
+
"""Menu for deleting an API key from the .env file."""
|
| 606 |
+
clear_screen()
|
| 607 |
+
api_keys = _get_api_keys_from_env()
|
| 608 |
+
|
| 609 |
+
if not api_keys:
|
| 610 |
+
console.print("[bold yellow]No API keys configured.[/bold yellow]")
|
| 611 |
+
return
|
| 612 |
+
|
| 613 |
+
# Build a flat list of all keys for selection
|
| 614 |
+
all_keys = []
|
| 615 |
+
console.print("\n[bold cyan]Configured API Keys:[/bold cyan]")
|
| 616 |
+
|
| 617 |
+
table = Table(box=None, padding=(0, 2))
|
| 618 |
+
table.add_column("#", style="dim", width=3)
|
| 619 |
+
table.add_column("Key Name", style="yellow")
|
| 620 |
+
table.add_column("Provider", style="cyan")
|
| 621 |
+
table.add_column("Value", style="dim")
|
| 622 |
+
|
| 623 |
+
idx = 1
|
| 624 |
+
for provider, keys in sorted(api_keys.items()):
|
| 625 |
+
for key_name, key_value in keys:
|
| 626 |
+
masked = f"****{key_value[-4:]}" if len(key_value) > 4 else "****"
|
| 627 |
+
table.add_row(str(idx), key_name, provider, masked)
|
| 628 |
+
all_keys.append((key_name, key_value, provider))
|
| 629 |
+
idx += 1
|
| 630 |
+
|
| 631 |
+
console.print(table)
|
| 632 |
+
|
| 633 |
+
choice = Prompt.ask(
|
| 634 |
+
Text.from_markup(
|
| 635 |
+
"\n[bold]Select API key to delete or type [red]'b'[/red] to go back[/bold]"
|
| 636 |
+
),
|
| 637 |
+
choices=[str(i) for i in range(1, len(all_keys) + 1)] + ["b"],
|
| 638 |
+
show_choices=False,
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
if choice.lower() == "b":
|
| 642 |
+
return
|
| 643 |
+
|
| 644 |
+
try:
|
| 645 |
+
idx = int(choice) - 1
|
| 646 |
+
key_name, key_value, provider = all_keys[idx]
|
| 647 |
+
|
| 648 |
+
# Confirmation prompt
|
| 649 |
+
masked = f"****{key_value[-4:]}" if len(key_value) > 4 else "****"
|
| 650 |
+
confirmed = Confirm.ask(
|
| 651 |
+
f"[bold red]Delete[/bold red] [yellow]{key_name}[/yellow] ({masked})?"
|
| 652 |
+
)
|
| 653 |
+
|
| 654 |
+
if not confirmed:
|
| 655 |
+
console.print("[dim]Deletion cancelled.[/dim]")
|
| 656 |
+
return
|
| 657 |
+
|
| 658 |
+
if _delete_api_key_from_env(key_name):
|
| 659 |
+
console.print(
|
| 660 |
+
Panel(
|
| 661 |
+
f"Successfully deleted [yellow]{key_name}[/yellow]",
|
| 662 |
+
style="bold green",
|
| 663 |
+
title="Success",
|
| 664 |
+
expand=False,
|
| 665 |
+
)
|
| 666 |
+
)
|
| 667 |
+
else:
|
| 668 |
+
console.print(
|
| 669 |
+
Panel(
|
| 670 |
+
f"Failed to delete [yellow]{key_name}[/yellow]",
|
| 671 |
+
style="bold red",
|
| 672 |
+
title="Error",
|
| 673 |
+
expand=False,
|
| 674 |
+
)
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
except Exception as e:
|
| 678 |
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
async def _delete_oauth_credential_menu():
|
| 682 |
+
"""Menu for deleting an OAuth credential file."""
|
| 683 |
+
clear_screen()
|
| 684 |
+
oauth_summary = _get_oauth_credentials_summary()
|
| 685 |
+
|
| 686 |
+
# Check if there are any credentials
|
| 687 |
+
total = sum(len(creds) for creds in oauth_summary.values())
|
| 688 |
+
if total == 0:
|
| 689 |
+
console.print("[bold yellow]No OAuth credentials configured.[/bold yellow]")
|
| 690 |
+
return
|
| 691 |
+
|
| 692 |
+
# First, select provider
|
| 693 |
+
console.print("\n[bold cyan]Select OAuth Provider:[/bold cyan]")
|
| 694 |
+
|
| 695 |
+
providers_with_creds = [(p, c) for p, c in oauth_summary.items() if c]
|
| 696 |
+
for i, (provider, creds) in enumerate(providers_with_creds, 1):
|
| 697 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider, provider.title())
|
| 698 |
+
console.print(f" {i}. {display_name} ({len(creds)} credential(s))")
|
| 699 |
+
|
| 700 |
+
provider_choice = Prompt.ask(
|
| 701 |
+
Text.from_markup(
|
| 702 |
+
"\n[bold]Select provider or type [red]'b'[/red] to go back[/bold]"
|
| 703 |
+
),
|
| 704 |
+
choices=[str(i) for i in range(1, len(providers_with_creds) + 1)] + ["b"],
|
| 705 |
+
show_choices=False,
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
if provider_choice.lower() == "b":
|
| 709 |
+
return
|
| 710 |
+
|
| 711 |
+
try:
|
| 712 |
+
provider_idx = int(provider_choice) - 1
|
| 713 |
+
provider_name, credentials = providers_with_creds[provider_idx]
|
| 714 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider_name, provider_name.title())
|
| 715 |
+
|
| 716 |
+
# Now select credential
|
| 717 |
+
_display_provider_credentials(provider_name)
|
| 718 |
+
|
| 719 |
+
cred_choice = Prompt.ask(
|
| 720 |
+
Text.from_markup(
|
| 721 |
+
"[bold]Select credential to delete or type [red]'b'[/red] to go back[/bold]"
|
| 722 |
+
),
|
| 723 |
+
choices=[str(i) for i in range(1, len(credentials) + 1)] + ["b"],
|
| 724 |
+
show_choices=False,
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
if cred_choice.lower() == "b":
|
| 728 |
+
return
|
| 729 |
+
|
| 730 |
+
cred_idx = int(cred_choice) - 1
|
| 731 |
+
cred_info = credentials[cred_idx]
|
| 732 |
+
cred_path = cred_info["file_path"]
|
| 733 |
+
email = cred_info.get("email", "unknown")
|
| 734 |
+
|
| 735 |
+
# Confirmation prompt
|
| 736 |
+
confirmed = Confirm.ask(
|
| 737 |
+
f"[bold red]Delete[/bold red] credential for [cyan]{email}[/cyan] from {display_name}?"
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
if not confirmed:
|
| 741 |
+
console.print("[dim]Deletion cancelled.[/dim]")
|
| 742 |
+
return
|
| 743 |
+
|
| 744 |
+
# Use the auth class's delete method
|
| 745 |
+
provider_factory, _ = _ensure_providers_loaded()
|
| 746 |
+
auth_class = provider_factory.get_provider_auth_class(provider_name)
|
| 747 |
+
auth_instance = auth_class()
|
| 748 |
+
|
| 749 |
+
if auth_instance.delete_credential(cred_path):
|
| 750 |
+
console.print(
|
| 751 |
+
Panel(
|
| 752 |
+
f"Successfully deleted credential for [cyan]{email}[/cyan]",
|
| 753 |
+
style="bold green",
|
| 754 |
+
title="Success",
|
| 755 |
+
expand=False,
|
| 756 |
+
)
|
| 757 |
+
)
|
| 758 |
+
else:
|
| 759 |
+
console.print(
|
| 760 |
+
Panel(
|
| 761 |
+
f"Failed to delete credential for [cyan]{email}[/cyan]",
|
| 762 |
+
style="bold red",
|
| 763 |
+
title="Error",
|
| 764 |
+
expand=False,
|
| 765 |
+
)
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
except Exception as e:
|
| 769 |
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
async def _edit_oauth_credential_menu():
|
| 773 |
+
"""Menu for editing an OAuth credential's email field."""
|
| 774 |
+
clear_screen()
|
| 775 |
+
oauth_summary = _get_oauth_credentials_summary()
|
| 776 |
+
|
| 777 |
+
# Check if there are any credentials
|
| 778 |
+
total = sum(len(creds) for creds in oauth_summary.values())
|
| 779 |
+
if total == 0:
|
| 780 |
+
console.print("[bold yellow]No OAuth credentials configured.[/bold yellow]")
|
| 781 |
+
return
|
| 782 |
+
|
| 783 |
+
# Show warning about editing
|
| 784 |
+
console.print(
|
| 785 |
+
Panel(
|
| 786 |
+
Text.from_markup(
|
| 787 |
+
"[bold yellow]Warning:[/bold yellow] Editing OAuth credentials is generally not recommended.\n"
|
| 788 |
+
"This is mainly useful for [bold]Qwen Code[/bold] where you manually enter an email identifier.\n\n"
|
| 789 |
+
"For Google OAuth providers (Gemini CLI, Antigravity), the email is automatically\n"
|
| 790 |
+
"retrieved during authentication and changing it may cause confusion."
|
| 791 |
+
),
|
| 792 |
+
style="yellow",
|
| 793 |
+
title="Edit OAuth Credential",
|
| 794 |
+
expand=False,
|
| 795 |
+
)
|
| 796 |
+
)
|
| 797 |
+
|
| 798 |
+
# First, select provider
|
| 799 |
+
console.print("\n[bold cyan]Select OAuth Provider:[/bold cyan]")
|
| 800 |
+
|
| 801 |
+
providers_with_creds = [(p, c) for p, c in oauth_summary.items() if c]
|
| 802 |
+
for i, (provider, creds) in enumerate(providers_with_creds, 1):
|
| 803 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(provider, provider.title())
|
| 804 |
+
recommended = " [green](recommended)[/green]" if provider == "qwen_code" else ""
|
| 805 |
+
console.print(
|
| 806 |
+
f" {i}. {display_name} ({len(creds)} credential(s)){recommended}"
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
provider_choice = Prompt.ask(
|
| 810 |
+
Text.from_markup(
|
| 811 |
+
"\n[bold]Select provider or type [red]'b'[/red] to go back[/bold]"
|
| 812 |
+
),
|
| 813 |
+
choices=[str(i) for i in range(1, len(providers_with_creds) + 1)] + ["b"],
|
| 814 |
+
show_choices=False,
|
| 815 |
+
)
|
| 816 |
+
|
| 817 |
+
if provider_choice.lower() == "b":
|
| 818 |
+
return
|
| 819 |
+
|
| 820 |
+
try:
|
| 821 |
+
provider_idx = int(provider_choice) - 1
|
| 822 |
+
provider_name, _ = providers_with_creds[provider_idx]
|
| 823 |
+
await _edit_oauth_credential_email(provider_name)
|
| 824 |
+
|
| 825 |
+
except Exception as e:
|
| 826 |
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
| 827 |
+
|
| 828 |
+
|
| 829 |
def clear_screen():
|
| 830 |
"""
|
| 831 |
Cross-platform terminal clear that works robustly on both
|
|
|
|
| 861 |
"""
|
| 862 |
Interactively sets up a new API key for a provider.
|
| 863 |
"""
|
| 864 |
+
clear_screen()
|
| 865 |
console.print(Panel("[bold cyan]API Key Setup[/bold cyan]", expand=False))
|
| 866 |
|
| 867 |
# Debug toggle: Set to True to see env var names next to each provider
|
|
|
|
| 990 |
provider_text.append(f" {i + 1}. {provider_name}\n")
|
| 991 |
|
| 992 |
console.print(
|
| 993 |
+
Panel(
|
| 994 |
+
provider_text,
|
| 995 |
+
title="Available Providers for API Key",
|
| 996 |
+
style="bold blue",
|
| 997 |
+
)
|
| 998 |
)
|
| 999 |
|
| 1000 |
choice = Prompt.ask(
|
|
|
|
| 1169 |
Export a Gemini CLI credential JSON file to .env format.
|
| 1170 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 1171 |
"""
|
| 1172 |
+
clear_screen()
|
| 1173 |
console.print(
|
| 1174 |
Panel(
|
| 1175 |
"[bold cyan]Export Gemini CLI Credential to .env[/bold cyan]", expand=False
|
|
|
|
| 1202 |
)
|
| 1203 |
|
| 1204 |
console.print(
|
| 1205 |
+
Panel(
|
| 1206 |
+
cred_text,
|
| 1207 |
+
title="Available Gemini CLI Credentials",
|
| 1208 |
+
style="bold blue",
|
| 1209 |
+
)
|
| 1210 |
)
|
| 1211 |
|
| 1212 |
choice = Prompt.ask(
|
|
|
|
| 1269 |
Export a Qwen Code credential JSON file to .env format.
|
| 1270 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 1271 |
"""
|
| 1272 |
+
clear_screen()
|
| 1273 |
console.print(
|
| 1274 |
Panel(
|
| 1275 |
"[bold cyan]Export Qwen Code Credential to .env[/bold cyan]", expand=False
|
|
|
|
| 1302 |
)
|
| 1303 |
|
| 1304 |
console.print(
|
| 1305 |
+
Panel(
|
| 1306 |
+
cred_text,
|
| 1307 |
+
title="Available Qwen Code Credentials",
|
| 1308 |
+
style="bold blue",
|
| 1309 |
+
)
|
| 1310 |
)
|
| 1311 |
|
| 1312 |
choice = Prompt.ask(
|
|
|
|
| 1368 |
Export an iFlow credential JSON file to .env format.
|
| 1369 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 1370 |
"""
|
| 1371 |
+
clear_screen()
|
| 1372 |
console.print(
|
| 1373 |
Panel("[bold cyan]Export iFlow Credential to .env[/bold cyan]", expand=False)
|
| 1374 |
)
|
|
|
|
| 1399 |
)
|
| 1400 |
|
| 1401 |
console.print(
|
| 1402 |
+
Panel(
|
| 1403 |
+
cred_text,
|
| 1404 |
+
title="Available iFlow Credentials",
|
| 1405 |
+
style="bold blue",
|
| 1406 |
+
)
|
| 1407 |
)
|
| 1408 |
|
| 1409 |
choice = Prompt.ask(
|
|
|
|
| 1465 |
Export an Antigravity credential JSON file to .env format.
|
| 1466 |
Uses the auth class's build_env_lines() and list_credentials() methods.
|
| 1467 |
"""
|
| 1468 |
+
clear_screen()
|
| 1469 |
console.print(
|
| 1470 |
Panel(
|
| 1471 |
"[bold cyan]Export Antigravity Credential to .env[/bold cyan]", expand=False
|
|
|
|
| 1498 |
)
|
| 1499 |
|
| 1500 |
console.print(
|
| 1501 |
+
Panel(
|
| 1502 |
+
cred_text,
|
| 1503 |
+
title="Available Antigravity Credentials",
|
| 1504 |
+
style="bold blue",
|
| 1505 |
+
)
|
| 1506 |
)
|
| 1507 |
|
| 1508 |
choice = Prompt.ask(
|
|
|
|
| 1565 |
Export all credentials for a specific provider to individual .env files.
|
| 1566 |
Uses the auth class's list_credentials() and export_credential_to_env() methods.
|
| 1567 |
"""
|
| 1568 |
+
clear_screen()
|
| 1569 |
# Get auth instance for this provider
|
| 1570 |
provider_factory, _ = _ensure_providers_loaded()
|
| 1571 |
try:
|
|
|
|
| 1634 |
Combine all credentials for a specific provider into a single .env file.
|
| 1635 |
Uses the auth class's list_credentials() and build_env_lines() methods.
|
| 1636 |
"""
|
| 1637 |
+
clear_screen()
|
| 1638 |
# Get auth instance for this provider
|
| 1639 |
provider_factory, _ = _ensure_providers_loaded()
|
| 1640 |
try:
|
|
|
|
| 1719 |
Combine ALL credentials from ALL providers into a single .env file.
|
| 1720 |
Uses auth class list_credentials() and build_env_lines() methods.
|
| 1721 |
"""
|
| 1722 |
+
clear_screen()
|
| 1723 |
console.print(
|
| 1724 |
Panel("[bold cyan]Combine All Provider Credentials[/bold cyan]", expand=False)
|
| 1725 |
)
|
|
|
|
| 1956 |
Panel(
|
| 1957 |
"[bold cyan]Interactive Credential Setup[/bold cyan]",
|
| 1958 |
title="--- API Key Proxy ---",
|
|
|
|
| 1959 |
)
|
| 1960 |
)
|
| 1961 |
|
|
|
|
| 1966 |
Panel(
|
| 1967 |
"[bold cyan]Interactive Credential Setup[/bold cyan]",
|
| 1968 |
title="--- API Key Proxy ---",
|
|
|
|
| 1969 |
)
|
| 1970 |
)
|
| 1971 |
|
| 1972 |
+
# Display credentials summary at the top
|
| 1973 |
+
_display_credentials_summary()
|
| 1974 |
+
|
| 1975 |
console.print(
|
| 1976 |
Panel(
|
| 1977 |
Text.from_markup(
|
| 1978 |
+
"1. Add OAuth Credential\n"
|
| 1979 |
+
"2. Add API Key\n"
|
| 1980 |
+
"3. Export Credentials\n"
|
| 1981 |
+
"4. View / Manage All Credentials"
|
| 1982 |
),
|
| 1983 |
+
title="Choose action",
|
| 1984 |
style="bold blue",
|
| 1985 |
)
|
| 1986 |
)
|
|
|
|
| 1989 |
Text.from_markup(
|
| 1990 |
"[bold]Please select an option or type [red]'q'[/red] to quit[/bold]"
|
| 1991 |
),
|
| 1992 |
+
choices=["1", "2", "3", "4", "q"],
|
| 1993 |
show_choices=False,
|
| 1994 |
)
|
| 1995 |
|
|
|
|
| 1997 |
break
|
| 1998 |
|
| 1999 |
if setup_type == "1":
|
| 2000 |
+
# Clear and show OAuth providers summary before listing providers
|
| 2001 |
+
clear_screen()
|
| 2002 |
+
console.print(
|
| 2003 |
+
Panel(
|
| 2004 |
+
"[bold cyan]Add OAuth Credential[/bold cyan]",
|
| 2005 |
+
title="--- API Key Proxy ---",
|
| 2006 |
+
)
|
| 2007 |
+
)
|
| 2008 |
+
_display_oauth_providers_summary()
|
| 2009 |
+
|
| 2010 |
provider_factory, _ = _ensure_providers_loaded()
|
| 2011 |
available_providers = provider_factory.get_available_providers()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2012 |
|
| 2013 |
provider_text = Text()
|
| 2014 |
for i, provider in enumerate(available_providers):
|
| 2015 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(
|
| 2016 |
provider, provider.replace("_", " ").title()
|
| 2017 |
)
|
| 2018 |
provider_text.append(f" {i + 1}. {display_name}\n")
|
|
|
|
| 2040 |
choice_index = int(choice) - 1
|
| 2041 |
if 0 <= choice_index < len(available_providers):
|
| 2042 |
provider_name = available_providers[choice_index]
|
| 2043 |
+
display_name = OAUTH_FRIENDLY_NAMES.get(
|
| 2044 |
provider_name, provider_name.replace("_", " ").title()
|
| 2045 |
)
|
| 2046 |
+
|
| 2047 |
+
# Show existing credentials for this provider before proceeding
|
| 2048 |
+
_display_provider_credentials(provider_name)
|
| 2049 |
+
|
| 2050 |
console.print(
|
| 2051 |
+
f"Starting OAuth setup for [bold cyan]{display_name}[/bold cyan]..."
|
| 2052 |
)
|
| 2053 |
await setup_new_credential(provider_name)
|
| 2054 |
# Don't clear after OAuth - user needs to see full flow
|
|
|
|
| 2073 |
elif setup_type == "3":
|
| 2074 |
await export_credentials_submenu()
|
| 2075 |
|
| 2076 |
+
elif setup_type == "4":
|
| 2077 |
+
await manage_credentials_submenu()
|
| 2078 |
+
|
| 2079 |
|
| 2080 |
def run_credential_tool(from_launcher=False):
|
| 2081 |
"""
|