PYTHON
cli.py🐍python
"""
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()