diff --git a/shellhist/cli/search.py b/shellhist/cli/search.py new file mode 100644 index 0000000..7eee1d0 --- /dev/null +++ b/shellhist/cli/search.py @@ -0,0 +1,127 @@ +"""Search command for the shell history tool.""" + +import os +from typing import Optional + +import click +from rich.console import Console +from rich.table import Table + +from shellhist.core import HistoryLoader +from shellhist.core.search import fuzzy_search +from shellhist.utils import format_timestamp + + +@click.command("search") +@click.argument("query", type=str) +@click.option( + "--history", + "-H", + type=str, + help="Path to history file", +) +@click.option( + "--threshold", + "-t", + type=int, + default=70, + help="Minimum similarity threshold (0-100, default: 70)", +) +@click.option( + "--limit", + "-l", + type=int, + default=20, + help="Maximum number of results (default: 20)", +) +@click.option( + "--reverse/--no-reverse", + "-r", + default=False, + help="Sort by recency (newest first)", +) +@click.option( + "--recent/--no-recent", + default=False, + help="Boost scores for recent commands (last 24h)", +) +@click.option( + "--shell", + "-s", + type=click.Choice(["bash", "zsh"]), + help="Shell type for parsing", +) +@click.pass_context +def search_command( + ctx: click.Context, + query: str, + history: Optional[str], + threshold: int, + limit: int, + reverse: bool, + recent: bool, + shell: Optional[str], +) -> None: + """Search shell history with fuzzy matching. + + QUERY is the search string to match against your command history. + + Examples: + + \b + shellhist search "git commit" + shellhist search "npm run" --threshold 80 --limit 10 + shellhist search "docker ps" --recent + """ + console = Console() + + try: + if shell: + os.environ["SHELL"] = f"/bin/{shell}" + + loader = HistoryLoader(history_path=history) + store = loader.load() + + if not store.entries: + console.print("[yellow]No entries found in history.[/yellow]") + return + + results = fuzzy_search( + store=store, + query=query, + threshold=threshold, + limit=limit, + reverse=reverse, + recent=recent, + ) + + if not results: + console.print(f"[yellow]No commands found matching '{query}' with threshold {threshold}[/yellow]") + return + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("#", width=4) + table.add_column("Match %", width=8) + table.add_column("Command", width=60) + table.add_column("Last Used", width=20) + + for i, (entry, score) in enumerate(results, 1): + timestamp = format_timestamp(entry.timestamp) + table.add_row( + str(i), + f"{score}%", + entry.command[:58] + ".." if len(entry.command) > 60 else entry.command, + timestamp, + ) + + console.print(table) + + total_unique = len(store.get_unique_commands()) + console.print(f"\n[dim]Found {len(results)} matches from {total_unique} unique commands[/dim]") + + except FileNotFoundError as e: + console.print(f"[red]Error: {e}[/red]") + ctx.exit(1) + except Exception as e: + console.print(f"[red]Error searching history: {e}[/red]") + ctx.exit(1)