PYTHONPython

cli

real world projects / password manager / pwm

PYTHON
cli.py🐍
"""
Command-line interface for password manager.
"""

import sys
from pathlib import Path
from typing import Optional

import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Prompt, Confirm

from .manager import PasswordManager
from .generator import calculate_password_strength
from .utils import copy_to_clipboard, mask_password, format_time_ago

console = Console()

# Global manager instance
_manager: Optional[PasswordManager] = None


def get_manager(vault_path: Optional[str] = None) -> PasswordManager:
    """Get or create password manager instance."""
    global _manager
    
    if _manager is None:
        path = Path(vault_path) if vault_path else None
        _manager = PasswordManager(path)
    
    return _manager


def require_unlock(func):
    """Decorator to require unlocked vault."""
    def wrapper(*args, **kwargs):
        manager = get_manager()
        
        if not manager.is_initialized:
            console.print("[red]Vault not initialized. Run 'pwm init' first.[/red]")
            sys.exit(1)
        
        if not manager.is_unlocked:
            password = Prompt.ask("Master password", password=True)
            try:
                manager.unlock(password)
            except ValueError:
                console.print("[red]Incorrect password[/red]")
                sys.exit(1)
        
        return func(*args, **kwargs)
    
    return wrapper


@click.group()
@click.option("--vault", "-v", help="Path to vault file")
@click.pass_context
def cli(ctx, vault: str):
    """Password Manager - Secure password storage."""
    ctx.ensure_object(dict)
    if vault:
        get_manager(vault)


@cli.command()
def init():
    """Initialize a new vault."""
    manager = get_manager()
    
    if manager.is_initialized:
        if not Confirm.ask("Vault already exists. Delete and create new?"):
            return
        manager.storage.delete()
    
    console.print(Panel("Create Your Master Password", title="🔐 Vault Setup"))
    console.print("Choose a strong master password. This will be the only password you need to remember.")
    console.print("")
    
    while True:
        password = Prompt.ask("Master password", password=True)
        confirm = Prompt.ask("Confirm password", password=True)
        
        if password != confirm:
            console.print("[red]Passwords don't match[/red]")
            continue
        
        strength = calculate_password_strength(password)
        
        if strength["score"] < 40:
            console.print(f"[yellow]Password is weak ({strength['level']})[/yellow]")
            for fb in strength["feedback"]:
                console.print(f"  • {fb}")
            
            if not Confirm.ask("Use this password anyway?"):
                continue
        
        break
    
    manager.initialize(password)
    console.print("[green]✓ Vault created successfully![/green]")


@cli.command()
@click.argument("name")
@click.option("--username", "-u", default="", help="Username or email")
@click.option("--password", "-p", default="", help="Password (omit to generate)")
@click.option("--url", default="", help="Website URL")
@click.option("--notes", "-n", default="", help="Additional notes")
@click.option("--category", "-c", default="General", help="Category")
@click.option("--tags", "-t", default="", help="Comma-separated tags")
@click.option("--generate", "-g", is_flag=True, help="Generate password")
@click.option("--length", "-l", default=16, help="Generated password length")
@require_unlock
def add(name: str, username: str, password: str, url: str, notes: str,
        category: str, tags: str, generate: bool, length: int):
    """Add a new password entry."""
    manager = get_manager()
    
    # Check if exists
    if manager.get(name):
        console.print(f"[red]Entry '{name}' already exists[/red]")
        return
    
    # Generate password if requested
    if generate or not password:
        password = manager.generate_password(length=length)
        console.print(f"Generated password: [cyan]{password}[/cyan]")
        copy_to_clipboard(password)
        console.print("[dim]Password copied to clipboard (clears in 30s)[/dim]")
    
    # Parse tags
    tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
    
    manager.add(
        name=name,
        username=username,
        password=password,
        url=url,
        notes=notes,
        category=category,
        tags=tag_list
    )
    
    console.print(f"[green]✓ Added '{name}'[/green]")


@cli.command()
@click.argument("name")
@click.option("--show", "-s", is_flag=True, help="Show password in terminal")
@require_unlock
def get(name: str, show: bool):
    """Get a password entry."""
    manager = get_manager()
    
    entry = manager.get(name)
    if not entry:
        console.print(f"[red]Entry '{name}' not found[/red]")
        return
    
    table = Table(title=f"🔑 {entry.name}")
    table.add_column("Field", style="cyan")
    table.add_column("Value")
    
    table.add_row("Username", entry.username or "-")
    table.add_row("Password", entry.password if show else mask_password(entry.password))
    table.add_row("URL", entry.url or "-")
    table.add_row("Category", entry.category)
    table.add_row("Tags", ", ".join(entry.tags) if entry.tags else "-")
    table.add_row("Notes", entry.notes or "-")
    table.add_row("Updated", format_time_ago(entry.updated_at))
    
    console.print(table)
    
    # Copy to clipboard
    if entry.password:
        copy_to_clipboard(entry.password)
        console.print("\n[dim]Password copied to clipboard (clears in 30s)[/dim]")


@cli.command("list")
@click.option("--category", "-c", default="", help="Filter by category")
@click.option("--tags", "-t", default="", help="Filter by tags")
@require_unlock
def list_entries(category: str, tags: str):
    """List all password entries."""
    manager = get_manager()
    
    tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
    entries = manager.search(category=category, tags=tag_list)
    
    if not entries:
        console.print("[dim]No entries found[/dim]")
        return
    
    table = Table(title=f"📋 Password Entries ({len(entries)})")
    table.add_column("Name", style="cyan")
    table.add_column("Username")
    table.add_column("Category", style="green")
    table.add_column("Updated", style="dim")
    
    for entry in sorted(entries, key=lambda e: e.name.lower()):
        table.add_row(
            entry.name,
            entry.username or "-",
            entry.category,
            format_time_ago(entry.updated_at)
        )
    
    console.print(table)


@cli.command()
@click.argument("query")
@require_unlock
def search(query: str):
    """Search password entries."""
    manager = get_manager()
    
    results = manager.search(query=query)
    
    if not results:
        console.print(f"[dim]No entries matching '{query}'[/dim]")
        return
    
    table = Table(title=f"🔍 Search Results for '{query}'")
    table.add_column("Name", style="cyan")
    table.add_column("Username")
    table.add_column("URL")
    
    for entry in results:
        table.add_row(entry.name, entry.username, entry.url or "-")
    
    console.print(table)


@cli.command()
@click.argument("name")
@click.option("--username", "-u", help="New username")
@click.option("--password", "-p", help="New password")
@click.option("--url", help="New URL")
@click.option("--notes", "-n", help="New notes")
@click.option("--category", "-c", help="New category")
@click.option("--generate", "-g", is_flag=True, help="Generate new password")
@require_unlock
def update(name: str, username: str, password: str, url: str, notes: str,
           category: str, generate: bool):
    """Update a password entry."""
    manager = get_manager()
    
    entry = manager.get(name)
    if not entry:
        console.print(f"[red]Entry '{name}' not found[/red]")
        return
    
    updates = {}
    
    if username is not None:
        updates["username"] = username
    if url is not None:
        updates["url"] = url
    if notes is not None:
        updates["notes"] = notes
    if category is not None:
        updates["category"] = category
    
    if generate:
        password = manager.generate_password()
        console.print(f"Generated password: [cyan]{password}[/cyan]")
        copy_to_clipboard(password)
    
    if password is not None:
        updates["password"] = password
    
    if updates:
        manager.update(name, **updates)
        console.print(f"[green]✓ Updated '{name}'[/green]")
    else:
        console.print("[yellow]No changes specified[/yellow]")


@cli.command()
@click.argument("name")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
@require_unlock
def delete(name: str, force: bool):
    """Delete a password entry."""
    manager = get_manager()
    
    entry = manager.get(name)
    if not entry:
        console.print(f"[red]Entry '{name}' not found[/red]")
        return
    
    if not force:
        if not Confirm.ask(f"Delete '{name}'?"):
            return
    
    manager.delete(name)
    console.print(f"[green]✓ Deleted '{name}'[/green]")


@cli.command()
@click.option("--length", "-l", default=16, help="Password length")
@click.option("--no-symbols", is_flag=True, help="Exclude symbols")
@click.option("--passphrase", is_flag=True, help="Generate passphrase instead")
@click.option("--words", "-w", default=4, help="Number of words for passphrase")
def generate(length: int, no_symbols: bool, passphrase: bool, words: int):
    """Generate a random password."""
    manager = get_manager()
    
    if passphrase:
        password = manager.generate_passphrase(words=words)
    else:
        password = manager.generate_password(length=length, symbols=not no_symbols)
    
    console.print(f"\nGenerated: [cyan bold]{password}[/cyan bold]\n")
    
    strength = calculate_password_strength(password)
    console.print(f"Strength: {strength['level']} ({strength['score']}/100)")
    
    copy_to_clipboard(password)
    console.print("\n[dim]Copied to clipboard (clears in 30s)[/dim]")


@cli.command()
@click.argument("password", required=False)
def strength(password: str):
    """Check password strength."""
    if not password:
        password = Prompt.ask("Password to check", password=True)
    
    result = calculate_password_strength(password)
    
    # Color based on score
    if result["score"] < 40:
        color = "red"
    elif result["score"] < 60:
        color = "yellow"
    elif result["score"] < 80:
        color = "blue"
    else:
        color = "green"
    
    console.print(f"\nStrength: [{color}]{result['level']}[/{color}] ({result['score']}/100)")
    console.print(f"Length: {result['length']} characters")
    
    checks = [
        ("Lowercase", result["has_lowercase"]),
        ("Uppercase", result["has_uppercase"]),
        ("Digits", result["has_digits"]),
        ("Symbols", result["has_symbols"])
    ]
    
    for name, has in checks:
        status = "[green]✓[/green]" if has else "[red]✗[/red]"
        console.print(f"  {status} {name}")
    
    if result["feedback"]:
        console.print("\nSuggestions:")
        for fb in result["feedback"]:
            console.print(f"  • {fb}")


@cli.command()
@require_unlock
def audit():
    """Audit passwords for security issues."""
    manager = get_manager()
    
    console.print("\n[bold]🔍 Running security audit...[/bold]\n")
    
    issues = manager.audit()
    total_issues = sum(len(v) for v in issues.values())
    
    if total_issues == 0:
        console.print("[green]✓ No security issues found![/green]")
        return
    
    console.print(f"[yellow]Found {total_issues} issue(s):[/yellow]\n")
    
    if issues["weak_passwords"]:
        console.print("[red]Weak Passwords:[/red]")
        for item in issues["weak_passwords"]:
            console.print(f"  • {item['name']} ({item['level']})")
    
    if issues["duplicate_passwords"]:
        console.print("\n[yellow]Duplicate Passwords:[/yellow]")
        for item in issues["duplicate_passwords"]:
            console.print(f"  • {' & '.join(item['entries'])}")
    
    if issues["missing_passwords"]:
        console.print("\n[yellow]Missing Passwords:[/yellow]")
        for name in issues["missing_passwords"]:
            console.print(f"  • {name}")


@cli.command()
def lock():
    """Lock the vault."""
    manager = get_manager()
    manager.lock()
    console.print("[green]✓ Vault locked[/green]")


@cli.command("change-master")
@require_unlock
def change_master():
    """Change the master password."""
    manager = get_manager()
    
    console.print(Panel("Change Master Password", title="🔐"))
    
    while True:
        new_password = Prompt.ask("New master password", password=True)
        confirm = Prompt.ask("Confirm new password", password=True)
        
        if new_password != confirm:
            console.print("[red]Passwords don't match[/red]")
            continue
        
        strength = calculate_password_strength(new_password)
        
        if strength["score"] < 40:
            console.print(f"[yellow]Password is weak ({strength['level']})[/yellow]")
            if not Confirm.ask("Use this password anyway?"):
                continue
        
        break
    
    manager.change_master_password(new_password)
    console.print("[green]✓ Master password changed[/green]")


def main():
    """Main entry point."""
    cli()


if __name__ == "__main__":
    main()
PreviousNext