#!/usr/bin/env python3 """ API Key Manager TUI - Interactive Terminal Interface Beautiful and intuitive key management interface """ import os import sys import json from pathlib import Path from typing import Dict, List, Optional, Tuple from datetime import datetime import argparse from collections import defaultdict import subprocess import shutil from getpass import getpass # Rich TUI components from rich.console import Console from rich.table import Table from rich.panel import Panel from rich.layout import Layout from rich.live import Live from rich.prompt import Prompt, Confirm, IntPrompt from rich.text import Text from rich.style import Style from rich.columns import Columns from rich.syntax import Syntax from rich.tree import Tree from rich import box from rich.align import Align from rich.padding import Padding console = Console() class KeyManagerTUI: """Interactive TUI for API Key Management""" def __init__(self): self.workspace_root = Path(__file__).parent.parent.parent.parent self.keys_file = self.workspace_root / "config" / "consolidated_keys.json" self.keys_file.parent.mkdir(parents=True, exist_ok=True) self.current_view = "main" self.selected_category = None self.keys_data = self.load_keys() def load_keys(self) -> Dict: """Load consolidated keys from JSON""" if self.keys_file.exists(): with open(self.keys_file, 'r') as f: return json.load(f) return {"keys": {}, "consolidated_at": None, "total_keys": 0} def save_keys(self): """Save keys back to JSON""" self.keys_data["consolidated_at"] = datetime.now().isoformat() self.keys_data["total_keys"] = sum(len(cat) for cat in self.keys_data.get("keys", {}).values()) with open(self.keys_file, 'w') as f: json.dump(self.keys_data, f, indent=2) def clear_screen(self): """Clear the terminal screen""" os.system('clear' if os.name == 'posix' else 'cls') def display_header(self): """Display the application header""" header = Panel( Align.center( Text("🔐 API Key Manager", style="bold cyan", justify="center") ), box=box.DOUBLE, style="cyan", padding=(1, 2) ) console.print(header) # Display status bar if self.keys_data.get("consolidated_at"): last_update = self.keys_data["consolidated_at"][:19].replace("T", " ") status = f"📊 Total Keys: {self.keys_data.get('total_keys', 0)} | 📅 Last Update: {last_update}" else: status = "⚠️ No keys consolidated yet" console.print(Panel(status, style="dim", box=box.MINIMAL)) def display_main_menu(self): """Display the main menu""" menu_items = [ ("1", "🔍 View Keys", "Browse keys by category"), ("2", "➕ Add Key", "Add a new API key"), ("3", "✏️ Edit Key", "Modify an existing key"), ("4", "🗑️ Delete Key", "Remove a key"), ("5", "🔄 Scan Projects", "Scan all projects for .env files"), ("6", "📤 Export Keys", "Export to .env format"), ("7", "📊 Statistics", "View key statistics"), ("8", "🔍 Search", "Search for a specific key"), ("9", "⚙️ Settings", "Configure settings"), ("0", "🚪 Exit", "Exit the application") ] table = Table( title="Main Menu", box=box.ROUNDED, show_header=False, padding=(0, 2), style="cyan" ) table.add_column("Option", style="bold yellow", width=8) table.add_column("Action", style="bold white", width=20) table.add_column("Description", style="dim") for option, action, desc in menu_items: table.add_row(option, action, desc) console.print(Padding(table, (1, 0))) def view_keys(self): """View keys organized by category""" self.clear_screen() self.display_header() if not self.keys_data.get("keys"): console.print(Panel("⚠️ No keys found. Use option 5 to scan projects.", style="yellow")) Prompt.ask("\nPress Enter to continue") return # Create a tree view tree = Tree("📁 API Keys", style="bold cyan") categories = self.keys_data.get("keys", {}) for category, keys in categories.items(): branch = tree.add(f"📂 {category.upper()} ({len(keys)} keys)", style="yellow") # Show first 5 keys in each category for i, (key_name, value) in enumerate(sorted(keys.items())[:5]): masked_value = value[:8] + "..." if value and len(value) > 8 else value or "(empty)" branch.add(f"🔑 {key_name}: {masked_value}", style="dim white") if len(keys) > 5: branch.add(f"... and {len(keys) - 5} more", style="dim italic") console.print(Padding(tree, (1, 2))) # Category selection console.print("\n[bold]Select a category to view all keys:[/bold]") cat_list = list(categories.keys()) for i, cat in enumerate(cat_list, 1): console.print(f" {i}. {cat.upper()} ({len(categories[cat])} keys)") console.print(" 0. Back to main menu") choice = Prompt.ask("\nYour choice", default="0") if choice.isdigit() and 0 < int(choice) <= len(cat_list): self.view_category_keys(cat_list[int(choice) - 1]) def view_category_keys(self, category: str): """View all keys in a specific category""" self.clear_screen() self.display_header() keys = self.keys_data.get("keys", {}).get(category, {}) console.print(Panel(f"Category: {category.upper()}", style="bold yellow")) table = Table( title=f"{len(keys)} Keys", box=box.SIMPLE, show_lines=True, style="cyan" ) table.add_column("#", style="dim", width=4) table.add_column("Key Name", style="bold white", width=30) table.add_column("Value", style="green") for i, (key_name, value) in enumerate(sorted(keys.items()), 1): display_value = value[:50] + "..." if value and len(value) > 50 else value or "(empty)" table.add_row(str(i), key_name, display_value) console.print(table) Prompt.ask("\nPress Enter to continue") def add_key(self): """Add a new API key""" self.clear_screen() self.display_header() console.print(Panel("➕ Add New API Key", style="bold green")) # Get key details key_name = Prompt.ask("\n[bold]Key name[/bold]").strip().upper() if not key_name: console.print("[red]Invalid key name[/red]") Prompt.ask("\nPress Enter to continue") return # Check if key exists for category, keys in self.keys_data.get("keys", {}).items(): if key_name in keys: console.print(f"[yellow]⚠️ Key '{key_name}' already exists in category '{category}'[/yellow]") if not Confirm.ask("Do you want to update it?"): return # Get value (hidden input for security) console.print("[dim]Enter key value (input will be hidden):[/dim]") key_value = getpass("Value: ") # Select category categories = ["ai", "services", "database", "settings", "custom"] console.print("\n[bold]Select category:[/bold]") for i, cat in enumerate(categories, 1): console.print(f" {i}. {cat}") cat_choice = IntPrompt.ask("Category", default=5) category = categories[cat_choice - 1] if 1 <= cat_choice <= len(categories) else "custom" # Add to data structure if "keys" not in self.keys_data: self.keys_data["keys"] = {} if category not in self.keys_data["keys"]: self.keys_data["keys"][category] = {} self.keys_data["keys"][category][key_name] = key_value self.save_keys() console.print(f"\n[green]✅ Key '{key_name}' added to category '{category}'[/green]") Prompt.ask("\nPress Enter to continue") def edit_key(self): """Edit an existing key""" self.clear_screen() self.display_header() console.print(Panel("✏️ Edit API Key", style="bold yellow")) # Search for key search = Prompt.ask("\n[bold]Enter key name to edit (partial match OK)[/bold]").strip().upper() matches = [] for category, keys in self.keys_data.get("keys", {}).items(): for key_name in keys: if search in key_name: matches.append((category, key_name, keys[key_name])) if not matches: console.print(f"[red]No keys found matching '{search}'[/red]") Prompt.ask("\nPress Enter to continue") return if len(matches) == 1: category, key_name, old_value = matches[0] else: # Multiple matches, let user choose console.print(f"\n[bold]Found {len(matches)} matches:[/bold]") for i, (cat, name, val) in enumerate(matches, 1): masked = val[:8] + "..." if val and len(val) > 8 else val or "(empty)" console.print(f" {i}. {name} ({cat}): {masked}") choice = IntPrompt.ask("Select key to edit", default=1) if 1 <= choice <= len(matches): category, key_name, old_value = matches[choice - 1] else: return # Show current value console.print(f"\n[bold]Editing: {key_name}[/bold]") console.print(f"Category: {category}") console.print(f"Current value: {old_value[:20]}..." if old_value and len(old_value) > 20 else f"Current value: {old_value or '(empty)'}") # Get new value console.print("\n[dim]Enter new value (input will be hidden, leave empty to keep current):[/dim]") new_value = getpass("New value: ") if new_value: self.keys_data["keys"][category][key_name] = new_value self.save_keys() console.print(f"\n[green]✅ Key '{key_name}' updated[/green]") else: console.print("\n[yellow]No changes made[/yellow]") Prompt.ask("\nPress Enter to continue") def delete_key(self): """Delete a key""" self.clear_screen() self.display_header() console.print(Panel("🗑️ Delete API Key", style="bold red")) # Search for key search = Prompt.ask("\n[bold]Enter key name to delete (partial match OK)[/bold]").strip().upper() matches = [] for category, keys in self.keys_data.get("keys", {}).items(): for key_name in keys: if search in key_name: matches.append((category, key_name)) if not matches: console.print(f"[red]No keys found matching '{search}'[/red]") Prompt.ask("\nPress Enter to continue") return if len(matches) == 1: category, key_name = matches[0] else: # Multiple matches console.print(f"\n[bold]Found {len(matches)} matches:[/bold]") for i, (cat, name) in enumerate(matches, 1): console.print(f" {i}. {name} ({cat})") choice = IntPrompt.ask("Select key to delete", default=1) if 1 <= choice <= len(matches): category, key_name = matches[choice - 1] else: return # Confirm deletion if Confirm.ask(f"\n[bold red]Delete '{key_name}' from '{category}'?[/bold red]"): del self.keys_data["keys"][category][key_name] # Remove category if empty if not self.keys_data["keys"][category]: del self.keys_data["keys"][category] self.save_keys() console.print(f"\n[green]✅ Key '{key_name}' deleted[/green]") else: console.print("\n[yellow]Deletion cancelled[/yellow]") Prompt.ask("\nPress Enter to continue") def scan_projects(self): """Scan all projects for .env files""" self.clear_screen() self.display_header() console.print(Panel("🔄 Scanning Projects for API Keys", style="bold cyan")) projects = { "root": self.workspace_root, "trax": self.workspace_root / "apps" / "trax", "youtube-summarizer": self.workspace_root / "apps" / "youtube-summarizer", "pdf-translator": self.workspace_root / "pdf-translator", "directus-mcp": self.workspace_root / "tools" / "directus-mcp-server", } all_keys = defaultdict(dict) found_count = 0 with console.status("[bold green]Scanning projects...") as status: for project_name, project_path in projects.items(): env_file = project_path / ".env" if env_file.exists(): status.update(f"[bold green]Scanning {project_name}...") with open(env_file, 'r') as f: for line in f: line = line.strip() if line and not line.startswith('#'): if '=' in line: key, value = line.split('=', 1) key = key.strip() value = value.strip().strip('"').strip("'") all_keys[key][project_name] = value found_count += 1 console.print(f" ✅ {project_name}: Found keys") else: console.print(f" ⚠️ {project_name}: No .env file") # Consolidate keys console.print(f"\n[bold]Found {len(all_keys)} unique keys across {found_count} total entries[/bold]") if Confirm.ask("\nDo you want to consolidate these keys?"): # Organize by category categorized = { "ai": {}, "services": {}, "database": {}, "settings": {}, "custom": {} } ai_prefixes = ["ANTHROPIC", "DEEPSEEK", "OPENAI", "PERPLEXITY", "OPENROUTER", "GOOGLE_API", "XAI", "MISTRAL", "QWEN", "DASHSCOPE", "MODEL_STUDIO"] service_prefixes = ["SLACK", "GITHUB", "GITEA", "YOUTUBE", "DIRECTUS", "MICROSOFT"] db_prefixes = ["DATABASE", "REDIS", "POSTGRES"] for key_name, values in all_keys.items(): # Pick the most common value or root if "root" in values: final_value = values["root"] else: final_value = max(values.values(), key=lambda x: list(values.values()).count(x)) # Categorize if any(key_name.startswith(prefix) for prefix in ai_prefixes): categorized["ai"][key_name] = final_value elif any(key_name.startswith(prefix) for prefix in service_prefixes): categorized["services"][key_name] = final_value elif any(key_name.startswith(prefix) for prefix in db_prefixes): categorized["database"][key_name] = final_value elif any(keyword in key_name for keyword in ["JWT", "SECRET", "TOKEN", "KEY"]): categorized["settings"][key_name] = final_value else: categorized["custom"][key_name] = final_value self.keys_data["keys"] = categorized self.save_keys() console.print("\n[green]✅ Keys consolidated successfully![/green]") Prompt.ask("\nPress Enter to continue") def export_keys(self): """Export keys to .env format""" self.clear_screen() self.display_header() console.print(Panel("📤 Export Keys", style="bold green")) if not self.keys_data.get("keys"): console.print("[red]No keys to export[/red]") Prompt.ask("\nPress Enter to continue") return # Choose export options console.print("\n[bold]Export Options:[/bold]") console.print(" 1. All keys") console.print(" 2. Specific category") console.print(" 3. Specific project requirements") choice = IntPrompt.ask("\nYour choice", default=1) filter_category = None if choice == 2: categories = list(self.keys_data["keys"].keys()) console.print("\n[bold]Select category:[/bold]") for i, cat in enumerate(categories, 1): console.print(f" {i}. {cat}") cat_choice = IntPrompt.ask("Category", default=1) if 1 <= cat_choice <= len(categories): filter_category = categories[cat_choice - 1] # Get output file default_path = "exported_keys.env" output_path = Prompt.ask("\n[bold]Output file path[/bold]", default=default_path) # Export with open(output_path, 'w') as f: f.write(f"# Exported API Keys\n") f.write(f"# Generated: {datetime.now().isoformat()}\n\n") for category, keys in self.keys_data["keys"].items(): if filter_category and category != filter_category: continue f.write(f"# {category.upper()} KEYS\n") for key_name, value in sorted(keys.items()): f.write(f"{key_name}={value}\n") f.write("\n") console.print(f"\n[green]✅ Exported to {output_path}[/green]") # Show preview if Confirm.ask("\nDo you want to preview the exported file?"): with open(output_path, 'r') as f: lines = f.readlines()[:20] syntax = Syntax("".join(lines), "bash", theme="monokai", line_numbers=True) console.print(syntax) Prompt.ask("\nPress Enter to continue") def show_statistics(self): """Show key statistics""" self.clear_screen() self.display_header() console.print(Panel("📊 Key Statistics", style="bold cyan")) if not self.keys_data.get("keys"): console.print("[red]No keys found[/red]") Prompt.ask("\nPress Enter to continue") return # Calculate statistics categories = self.keys_data["keys"] total_keys = sum(len(keys) for keys in categories.values()) # Category breakdown table = Table( title="Keys by Category", box=box.SIMPLE_HEAD, show_lines=True, style="cyan" ) table.add_column("Category", style="bold yellow", width=15) table.add_column("Count", style="white", justify="right", width=10) table.add_column("Percentage", style="green", justify="right", width=12) table.add_column("Status", style="white", width=20) for category, keys in sorted(categories.items()): count = len(keys) percentage = (count / total_keys * 100) if total_keys > 0 else 0 # Status indicator if count == 0: status = "❌ Empty" elif count < 5: status = "⚠️ Few keys" else: status = "✅ Good" table.add_row( category.upper(), str(count), f"{percentage:.1f}%", status ) table.add_section() table.add_row( "TOTAL", str(total_keys), "100.0%", "📊 All Keys", style="bold" ) console.print(table) # Key analysis console.print("\n[bold]Key Analysis:[/bold]") # Find empty keys empty_keys = [] for category, keys in categories.items(): for key_name, value in keys.items(): if not value: empty_keys.append(f"{key_name} ({category})") if empty_keys: console.print(f" ⚠️ Empty keys: {len(empty_keys)}") for key in empty_keys[:5]: console.print(f" - {key}", style="yellow") if len(empty_keys) > 5: console.print(f" ... and {len(empty_keys) - 5} more", style="dim") else: console.print(" ✅ No empty keys found", style="green") # Last update if self.keys_data.get("consolidated_at"): last_update = self.keys_data["consolidated_at"][:19].replace("T", " ") console.print(f"\n 📅 Last consolidation: {last_update}") Prompt.ask("\nPress Enter to continue") def search_keys(self): """Search for specific keys""" self.clear_screen() self.display_header() console.print(Panel("🔍 Search Keys", style="bold cyan")) search_term = Prompt.ask("\n[bold]Enter search term[/bold]").strip().upper() if not search_term: return matches = [] for category, keys in self.keys_data.get("keys", {}).items(): for key_name, value in keys.items(): if search_term in key_name: matches.append((category, key_name, value)) if not matches: console.print(f"\n[red]No keys found matching '{search_term}'[/red]") else: console.print(f"\n[bold]Found {len(matches)} matches:[/bold]\n") table = Table(box=box.SIMPLE, show_lines=True) table.add_column("Category", style="yellow", width=12) table.add_column("Key Name", style="bold white", width=30) table.add_column("Value", style="green") for category, key_name, value in matches: display_value = value[:40] + "..." if value and len(value) > 40 else value or "(empty)" table.add_row(category, key_name, display_value) console.print(table) Prompt.ask("\nPress Enter to continue") def run(self): """Main TUI loop""" while True: self.clear_screen() self.display_header() self.display_main_menu() choice = Prompt.ask("\n[bold cyan]Select an option[/bold cyan]", default="0") if choice == "1": self.view_keys() elif choice == "2": self.add_key() elif choice == "3": self.edit_key() elif choice == "4": self.delete_key() elif choice == "5": self.scan_projects() elif choice == "6": self.export_keys() elif choice == "7": self.show_statistics() elif choice == "8": self.search_keys() elif choice == "9": console.print("\n[yellow]Settings not implemented yet[/yellow]") Prompt.ask("\nPress Enter to continue") elif choice == "0": if Confirm.ask("\n[bold red]Are you sure you want to exit?[/bold red]"): self.clear_screen() console.print("[bold green]Goodbye! 👋[/bold green]") break def main(): """Entry point""" try: tui = KeyManagerTUI() tui.run() except KeyboardInterrupt: console.print("\n\n[yellow]Interrupted by user[/yellow]") except Exception as e: console.print(f"\n[red]Error: {e}[/red]") if __name__ == "__main__": main()