trax/scripts/key_manager_tui.py

644 lines
24 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()