Mirrowel commited on
Commit
8eb4988
·
1 Parent(s): c5dfecd

feat(credentials): ✨ add comprehensive credential management interface

Browse files

Implement 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.

Files changed (1) hide show
  1. src/rotator_library/credential_tool.py +841 -20
src/rotator_library/credential_tool.py CHANGED
@@ -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(provider_text, title="Available Providers for API Key", style="bold blue")
 
 
 
 
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(cred_text, title="Available Gemini CLI Credentials", style="bold blue")
 
 
 
 
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(cred_text, title="Available Qwen Code Credentials", style="bold blue")
 
 
 
 
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(cred_text, title="Available iFlow Credentials", style="bold blue")
 
 
 
 
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(cred_text, title="Available Antigravity Credentials", style="bold blue")
 
 
 
 
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\n2. Add API Key\n3. Export Credentials"
 
 
 
1172
  ),
1173
- title="Choose credential type",
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 = oauth_friendly_names.get(
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 = oauth_friendly_names.get(
1230
  provider_name, provider_name.replace("_", " ").title()
1231
  )
 
 
 
 
1232
  console.print(
1233
- f"\nStarting OAuth setup for [bold cyan]{display_name}[/bold cyan]..."
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
  """