Compare commits

50 Commits
v0.1.0 ... main

Author SHA1 Message Date
d2788ebdcd ci: verify CI workflow configuration
Some checks failed
CI / test (push) Failing after 8s
2026-01-31 14:30:35 +00:00
04dc2fb097 test: add -> None return type annotations to all test methods
Some checks failed
CI / test (push) Failing after 9s
Shellhist CI / test (push) Failing after 4m45s
Shellhist CI / build (push) Has been skipped
2026-01-31 14:21:31 +00:00
9f925a29bf test: add -> None return type annotations to all test methods
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
2026-01-31 14:21:29 +00:00
2a63c265d2 test: add -> None return type annotations to all test methods
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
2026-01-31 14:21:28 +00:00
182ab0ad60 test: add -> None return type annotations to all test methods
Some checks failed
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
2026-01-31 14:21:28 +00:00
f61a58cfb5 test: add -> None return type annotations to all test methods
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
2026-01-31 14:21:28 +00:00
7b3ceb1439 test: add -> None return type annotations to all test methods
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
2026-01-31 14:21:27 +00:00
4fb0b6ff88 test: add -> None return type annotations to all test methods
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
2026-01-31 14:21:27 +00:00
70dd85ff20 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Failing after 9s
Shellhist CI / build (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:22 +00:00
74e8995292 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:21 +00:00
a025fd4956 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:20 +00:00
7028371275 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:18 +00:00
32f78c6985 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:17 +00:00
65f08d6534 fix: resolve CI type checking issues
Some checks failed
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:15 +00:00
a2eea6a5ee fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:14 +00:00
89e4dbf0cb fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:12 +00:00
ff295f446a fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:11 +00:00
52070216b6 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:09 +00:00
71f7849892 fix: resolve CI type checking issues
Some checks failed
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:08 +00:00
e90654ed10 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:07 +00:00
2e5ff87d06 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:06 +00:00
9a3f59adec fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:05 +00:00
313e2381f3 fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:05 +00:00
ae7c30910d fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:05 +00:00
de4dca65ae fix: resolve CI type checking issues
Some checks failed
CI / test (push) Has been cancelled
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:04 +00:00
2d8e631a4a Fix CI workflow - convert from Node.js to Python for shellhist
Some checks failed
CI / test (push) Failing after 4m45s
CI / build (push) Has been skipped
2026-01-31 13:52:21 +00:00
f4830bac6c Add Python CI workflow for shellhist project
Some checks failed
Shellhist CI / test (push) Failing after 4m46s
Shellhist CI / build (push) Has been skipped
2026-01-31 13:46:19 +00:00
6fbe1d0424 fix: resolve F401 unused import lint errors in CI 2026-01-31 13:43:19 +00:00
9fb5941fe7 fix: resolve F401 unused import lint errors in CI 2026-01-31 13:43:18 +00:00
fbc0f82b66 fix: resolve F401 unused import lint errors in CI 2026-01-31 13:43:18 +00:00
fedd090b60 fix: resolve F401 unused import lint errors in CI 2026-01-31 13:43:18 +00:00
f6f13c3ea3 fix: resolve F401 unused import lint errors in CI 2026-01-31 13:43:17 +00:00
40441c7898 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:24 +00:00
0a3351d396 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:24 +00:00
00f67ec62c fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:24 +00:00
1015caedc6 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:23 +00:00
e49f8bb5aa fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:23 +00:00
00184445fb fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:23 +00:00
ce6b1b4a78 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:22 +00:00
4250ec76db fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:22 +00:00
ba78cb74f3 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:21 +00:00
646ad2bf59 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:21 +00:00
6392774290 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:36:20 +00:00
ebf667d4da Fix CI lint issues: remove unused imports 2026-01-31 13:34:35 +00:00
811718024a Fix CI lint issues: remove unused imports 2026-01-31 13:34:35 +00:00
0dcff6714f Fix CI lint issues: remove unused imports 2026-01-31 13:34:35 +00:00
c3b3473954 Fix CI lint issues: remove unused imports 2026-01-31 13:34:34 +00:00
b7ef86e6c6 Fix CI lint issues: remove unused imports 2026-01-31 13:34:34 +00:00
bab23ad85f Fix CI lint issues: remove unused imports 2026-01-31 13:34:34 +00:00
6152fc1c92 fix: resolve CI lint failures (F401, F541, F841) 2026-01-31 13:23:18 +00:00
22 changed files with 459 additions and 548 deletions

View File

@@ -15,5 +15,5 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- run: pip install -e ".[dev]" - run: pip install -e ".[dev]"
- run: pytest shellhist/tests/ -v - run: pytest tests/ -v
- run: pip install ruff && ruff check . - run: python -m ruff check .

View File

@@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -e ".[dev]"
- run: pytest tests/ -v
- run: ruff check .

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 ShellGen Copyright (c) 2024
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -4,3 +4,4 @@ python-Levenshtein>=0.12.0
rich>=13.0.0 rich>=13.0.0
pytest>=7.0.0 pytest>=7.0.0
pytest-cov>=4.0.0 pytest-cov>=4.0.0
ruff>=0.1.0

View File

@@ -1,3 +1,3 @@
"""Shell History Automation Tool - Analyze shell command history to find patterns and suggest automation.""" """Shell History Automation Tool."""
__version__ = "0.1.0" __version__ = "0.1.0"

View File

@@ -1,46 +1,45 @@
"""Main CLI module for shell history automation tool.""" """CLI interface for shell history automation tool."""
import sys
from typing import Optional
import click import click
from rich.console import Console from shellhist.core import HistoryLoader
from shellhist import __version__
from shellhist.cli.alias import suggest_aliases_command
from shellhist.cli.export import export_script_command
from shellhist.cli.patterns import patterns_command
from shellhist.cli.search import search_command from shellhist.cli.search import search_command
from shellhist.cli.patterns import patterns_command
from shellhist.cli.alias import alias_command
from shellhist.cli.time_analysis import analyze_time_command from shellhist.cli.time_analysis import analyze_time_command
from shellhist.cli.export import export_command
console = Console()
@click.group() @click.group()
@click.version_option(version=__version__, prog_name="shellhist")
@click.option( @click.option(
"--verbose/--quiet", "--history",
"-v/-q", "-H",
default=False, type=str,
help="Enable verbose output", help="Path to history file",
)
@click.option(
"--shell",
"-s",
type=click.Choice(["bash", "zsh"]),
help="Shell type for parsing",
) )
@click.pass_context @click.pass_context
def main(ctx: click.Context, verbose: bool) -> None: def main(
"""Shell History Automation Tool - Analyze shell command history to find patterns and suggest automation. ctx: click.Context,
history: Optional[str],
Commands: shell: Optional[str],
) -> None:
\b """Shell History Automation Tool - Analyze and automate your shell workflows."""
search Search history with fuzzy matching
patterns Detect repetitive command patterns
suggest-aliases Generate alias suggestions for patterns
analyze-time Analyze time-based command patterns
export-script Export patterns to executable scripts
"""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose ctx.obj["history"] = history
ctx.obj["shell"] = shell
main.add_command(search_command, "search") main.add_command(search_command, "search")
main.add_command(patterns_command, "patterns") main.add_command(patterns_command, "patterns")
main.add_command(suggest_aliases_command, "suggest-aliases") main.add_command(alias_command, "suggest-aliases")
main.add_command(analyze_time_command, "analyze-time") main.add_command(analyze_time_command, "analyze-time")
main.add_command(export_script_command, "export-script") main.add_command(export_command, "export-script")

View File

@@ -1,15 +1,18 @@
"""Alias suggestion command.""" """Alias suggestion command."""
import os import os
from pathlib import Path
from typing import Optional from typing import Optional
import click import click
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from shellhist.core import HistoryLoader from shellhist.core import HistoryLoader
from shellhist.core.export import generate_alias, generate_alias_name from shellhist.core.patterns import (
from shellhist.core.patterns import detect_common_sequences detect_command_pairs,
detect_command_triplets,
detect_repetitive_commands,
)
@click.command("suggest-aliases") @click.command("suggest-aliases")
@@ -28,16 +31,10 @@ from shellhist.core.patterns import detect_common_sequences
) )
@click.option( @click.option(
"--dry-run", "--dry-run",
"-n", "-d",
is_flag=True, is_flag=True,
default=False, default=False,
help="Preview aliases without creating them", help="Show what would be created without making changes",
)
@click.option(
"--shell-file",
"-f",
type=str,
help="Shell config file to append to (default: ~/.bashrc or ~/.zshrc)",
) )
@click.option( @click.option(
"--shell", "--shell",
@@ -45,31 +42,22 @@ from shellhist.core.patterns import detect_common_sequences
type=click.Choice(["bash", "zsh"]), type=click.Choice(["bash", "zsh"]),
help="Shell type for parsing", help="Shell type for parsing",
) )
@click.option(
"--min-occurrences",
"-m",
type=int,
default=3,
help="Minimum occurrences for pattern (default: 3)",
)
@click.pass_context @click.pass_context
def suggest_aliases_command( def alias_command(
ctx: click.Context, ctx: click.Context,
history: Optional[str], history: Optional[str],
auto_create: bool, auto_create: bool,
dry_run: bool, dry_run: bool,
shell_file: Optional[str],
shell: Optional[str], shell: Optional[str],
min_occurrences: int,
) -> None: ) -> None:
"""Suggest shell aliases for detected command patterns. """Generate alias suggestions for detected patterns.
Examples: Examples:
\b \b
shellhist suggest-aliases shellhist suggest-aliases
shellhist suggest-aliases --auto-create shellhist suggest-aliases --auto-create
shellhist suggest-aliases --dry-run --shell-file ~/.bashrc shellhist suggest-aliases --dry-run
""" """
console = Console() console = Console()
@@ -81,73 +69,91 @@ def suggest_aliases_command(
console.print("[yellow]No entries found in history.[/yellow]") console.print("[yellow]No entries found in history.[/yellow]")
return return
sequences = detect_common_sequences( pairs = detect_command_pairs(store, min_frequency=2)
store, triplets = detect_command_triplets(store, min_frequency=2)
max_length=4,
min_occurrences=min_occurrences, sequences = [
) (p.commands, p.frequency)
for p in pairs + triplets
if len(p.commands) >= 2
]
if not sequences: if not sequences:
console.print( console.print(
f"[yellow]No significant patterns found with minimum {min_occurrences} occurrences.[/yellow]" "[yellow]No command sequences found for alias suggestions.[/yellow]"
) )
console.print("Try lowering --min-occurrences or run more commands.") console.print("Try running more commands first.")
return return
shell_type = shell or ("zsh" if "zsh" in os.environ.get("SHELL", "") else "bash") console.print(f"\n[bold cyan]Detected Command Sequences[/bold cyan]")
if shell_file is None: for i, (cmds, freq) in enumerate(sequences[:10], 1):
home = os.path.expanduser("~") seq = " && ".join(cmds)
shell_file = f"{home}/.bashrc" if shell_type == "bash" else f"{home}/.zshrc" console.print(f"{i}. {seq} (frequency: {freq})")
console.print(f"[bold cyan]Detected Patterns for Alias Suggestions[/bold cyan]\n") console.print("\n[bold cyan]Suggested Aliases[/bold cyan]")
aliases_to_create = [] for i, (cmds, freq) in enumerate(sequences[:10], 1):
alias_name = generate_alias_name(cmds)
alias_cmd = " && ".join(cmds)
alias_str = f"alias {alias_name}='{alias_cmd}'"
for i, pattern in enumerate(sequences[:10], 1): panel = Panel(
alias_name = generate_alias_name(pattern) f"[green]{alias_str}[/green]",
alias_line = generate_alias(pattern, alias_name) title=f"Alias {i}",
expand=False,
)
console.print(panel)
console.print(f"{i}. [cyan]{alias_name}[/cyan]") if not dry_run:
console.print(f" Command: {' -> '.join(pattern.commands)}") if auto_create:
console.print(f" Alias: {alias_line}") append_to_shell_file(alias_str)
console.print(f" Frequency: {pattern.frequency} times\n") console.print(f" [green]Created alias '{alias_name}'[/green]")
else:
if dry_run: if click.confirm(f" Create this alias?"):
continue append_to_shell_file(alias_str)
console.print(f" [green]Created alias '{alias_name}'[/green]")
if auto_create:
aliases_to_create.append(alias_line)
else:
if click.confirm(f"Create alias '{alias_name}'?", default=True):
aliases_to_create.append(alias_line)
if not aliases_to_create:
console.print("\n[yellow]No aliases selected for creation.[/yellow]")
return
if dry_run:
console.print("\n[bold]Aliases to create (dry-run):[/bold]")
for alias in aliases_to_create:
console.print(f" {alias}")
return
shell_path = Path(shell_file)
shell_path.parent.mkdir(parents=True, exist_ok=True)
with open(shell_file, "a", encoding="utf-8") as f:
f.write("\n# Shell History Automation Tool - Generated Aliases\n")
for alias in aliases_to_create:
f.write(f"{alias}\n")
console.print(
f"\n[green]Successfully added {len(aliases_to_create)} aliases to {shell_file}[/green]"
)
console.print("Run 'source " + shell_file + "' or restart your shell to use them.")
except FileNotFoundError as e: except FileNotFoundError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
ctx.exit(1) ctx.exit(1)
except Exception as e: except Exception as e:
console.print(f"[red]Error suggesting aliases: {e}[/red]") console.print(f"[red]Error generating aliases: {e}[/red]")
ctx.exit(1) ctx.exit(1)
def generate_alias_name(commands: list[str]) -> str:
"""Generate a meaningful alias name from command list."""
if not commands:
return "custom"
keywords = []
for cmd in commands:
parts = cmd.split()
if parts:
keywords.append(parts[0])
keywords = [k for k in keywords if k not in ("sudo", "do", "run", "exec")]
if not keywords:
return "custom"
base = "".join(keywords[:3]).lower()
if len(base) > 12:
base = base[:12]
return base
def append_to_shell_file(alias_str: str) -> None:
"""Append alias to shell config file."""
shell = os.environ.get("SHELL", "")
if "zsh" in shell:
rc_file = os.path.expanduser("~/.zshrc")
else:
rc_file = os.path.expanduser("~/.bashrc")
with open(rc_file, "a", encoding="utf-8") as f:
f.write(f"\n{alias_str}\n")

View File

@@ -1,15 +1,18 @@
"""Export command for generating scripts from detected patterns.""" """Export command for generating scripts from detected patterns."""
import os
from pathlib import Path
from typing import Optional from typing import Optional
import click import click
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from shellhist.core import HistoryLoader from shellhist.core import HistoryLoader
from shellhist.core.export import generate_script, generate_script_name from shellhist.core.patterns import (
from shellhist.core.patterns import detect_common_sequences detect_command_pairs,
detect_command_triplets,
detect_repetitive_commands,
)
from shellhist.core.export import generate_script
@click.command("export-script") @click.command("export-script")
@@ -24,27 +27,21 @@ from shellhist.core.patterns import detect_common_sequences
"-o", "-o",
type=str, type=str,
default=".", default=".",
help="Output directory for generated scripts (default: current directory)", help="Output directory for generated scripts",
) )
@click.option( @click.option(
"--name", "--name",
"-n", "-n",
type=str, type=str,
help="Custom name for the script file", default="shellhist_script",
help="Name for the generated script",
) )
@click.option( @click.option(
"--dry-run", "--dry-run",
"-d", "-d",
is_flag=True, is_flag=True,
default=False, default=False,
help="Preview script without writing to disk", help="Show script content without writing to file",
)
@click.option(
"--force",
"-f",
is_flag=True,
default=False,
help="Overwrite existing scripts",
) )
@click.option( @click.option(
"--shell", "--shell",
@@ -52,32 +49,23 @@ from shellhist.core.patterns import detect_common_sequences
type=click.Choice(["bash", "zsh"]), type=click.Choice(["bash", "zsh"]),
help="Shell type for parsing", help="Shell type for parsing",
) )
@click.option(
"--min-occurrences",
"-m",
type=int,
default=3,
help="Minimum occurrences for pattern (default: 3)",
)
@click.pass_context @click.pass_context
def export_script_command( def export_command(
ctx: click.Context, ctx: click.Context,
history: Optional[str], history: Optional[str],
output: str, output: str,
name: Optional[str], name: str,
dry_run: bool, dry_run: bool,
force: bool,
shell: Optional[str], shell: Optional[str],
min_occurrences: int,
) -> None: ) -> None:
"""Export detected command patterns to executable shell scripts. """Export detected patterns to executable shell scripts.
Examples: Examples:
\b \b
shellhist export-script --output ./scripts/ shellhist export-script
shellhist export-script --output ./scripts/ --name myroutine shellhist export-script --output ./scripts/ --name myscript
shellhist export-script --dry-run --min-occurrences 5 shellhist export-script --dry-run
""" """
console = Console() console = Console()
@@ -89,63 +77,33 @@ def export_script_command(
console.print("[yellow]No entries found in history.[/yellow]") console.print("[yellow]No entries found in history.[/yellow]")
return return
sequences = detect_common_sequences( pairs = detect_command_pairs(store, min_frequency=2)
store, triplets = detect_command_triplets(store, min_frequency=2)
max_length=5, repetitive = detect_repetitive_commands(store, min_frequency=2)
min_occurrences=min_occurrences,
)
if not sequences: all_patterns = pairs + triplets + repetitive
if not all_patterns:
console.print( console.print(
f"[yellow]No significant patterns found with minimum {min_occurrences} occurrences.[/yellow]" "[yellow]No patterns found to export.[/yellow]"
) )
console.print("Try lowering --min-occurrences or run more commands.")
return return
console.print(f"[bold cyan]Detected Patterns for Script Export[/bold cyan]\n") result = generate_script(
all_patterns,
script_name=name,
output_dir=output,
dry_run=dry_run,
)
scripts_generated = 0 if dry_run:
console.print(Panel(result, title="Generated Script (Dry Run)", expand=False))
for i, pattern in enumerate(sequences[:5], 1): else:
script_name = name or generate_script_name(pattern) console.print(f"\n[green]Script exported to: {result}[/green]")
console.print(f"{i}. Pattern: {' -> '.join(pattern.commands)}")
console.print(f" Script name: {script_name}")
try:
script_path, content = generate_script(
pattern=pattern,
script_name=script_name,
output_dir=output,
dry_run=dry_run,
force=force,
)
if dry_run:
console.print(f" [yellow](dry-run) Would create: {script_path}[/yellow]")
console.print(f" Content preview:")
for line in content.split("\n")[:5]:
console.print(f" {line}")
console.print()
else:
console.print(f" [green]Created: {script_path}[/green]\n")
scripts_generated += 1
except FileExistsError:
console.print(f" [yellow]Skipped: {script_name}.sh (already exists, use --force to overwrite)[/yellow]\n")
except Exception as e:
console.print(f" [red]Error: {e}[/red]\n")
if not dry_run and scripts_generated > 0:
console.print(
f"[green]Successfully generated {scripts_generated} script(s) in {output}[/green]"
)
elif dry_run:
console.print("[yellow]Scripts not created (dry-run mode)[/yellow]")
except FileNotFoundError as e: except FileNotFoundError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
ctx.exit(1) ctx.exit(1)
except Exception as e: except Exception as e:
console.print(f"[red]Error exporting scripts: {e}[/red]") console.print(f"[red]Error exporting script: {e}[/red]")
ctx.exit(1) ctx.exit(1)

View File

@@ -11,7 +11,7 @@ from shellhist.core.patterns import (
detect_command_pairs, detect_command_pairs,
detect_command_triplets, detect_command_triplets,
detect_repetitive_commands, detect_repetitive_commands,
ngram_analysis, CommandPattern,
) )
@@ -112,7 +112,7 @@ def patterns_command(
def _display_patterns( def _display_patterns(
console: Console, console: Console,
title: str, title: str,
patterns: list, patterns: list[CommandPattern],
total_entries: int, total_entries: int,
) -> None: ) -> None:
"""Display detected patterns in a table.""" """Display detected patterns in a table."""

View File

@@ -1,6 +1,5 @@
"""Search command for the shell history tool.""" """Search command for fuzzy searching shell history."""
import os
from typing import Optional from typing import Optional
import click import click
@@ -9,7 +8,6 @@ from rich.table import Table
from shellhist.core import HistoryLoader from shellhist.core import HistoryLoader
from shellhist.core.search import fuzzy_search from shellhist.core.search import fuzzy_search
from shellhist.utils import format_timestamp
@click.command("search") @click.command("search")
@@ -36,14 +34,15 @@ from shellhist.utils import format_timestamp
) )
@click.option( @click.option(
"--reverse/--no-reverse", "--reverse/--no-reverse",
"-r",
default=False, default=False,
help="Sort by recency (newest first)", help="Reverse sort order (newest first)",
) )
@click.option( @click.option(
"--recent/--no-recent", "--recent",
"-r",
is_flag=True,
default=False, default=False,
help="Boost scores for recent commands (last 24h)", help="Boost scores for recent commands (within 24h)",
) )
@click.option( @click.option(
"--shell", "--shell",
@@ -64,8 +63,6 @@ def search_command(
) -> None: ) -> None:
"""Search shell history with fuzzy matching. """Search shell history with fuzzy matching.
QUERY is the search string to match against your command history.
Examples: Examples:
\b \b
@@ -76,9 +73,6 @@ def search_command(
console = Console() console = Console()
try: try:
if shell:
os.environ["SHELL"] = f"/bin/{shell}"
loader = HistoryLoader(history_path=history) loader = HistoryLoader(history_path=history)
store = loader.load() store = loader.load()
@@ -87,8 +81,8 @@ def search_command(
return return
results = fuzzy_search( results = fuzzy_search(
store=store, store,
query=query, query,
threshold=threshold, threshold=threshold,
limit=limit, limit=limit,
reverse=reverse, reverse=reverse,
@@ -96,29 +90,23 @@ def search_command(
) )
if not results: if not results:
console.print(f"[yellow]No commands found matching '{query}' with threshold {threshold}[/yellow]") console.print(f"[yellow]No matches found for '{query}'.[/yellow]")
return return
console.print(f"\n[bold cyan]Search Results for '{query}'[/bold cyan]")
table = Table(show_header=True, header_style="bold magenta") table = Table(show_header=True, header_style="bold magenta")
table.add_column("#", width=4) table.add_column("#", width=4)
table.add_column("Match %", width=8) table.add_column("Match", width=6)
table.add_column("Command", width=60) table.add_column("Command", width=70)
table.add_column("Last Used", width=20)
for i, (entry, score) in enumerate(results, 1): for i, (entry, score) in enumerate(results, 1):
timestamp = format_timestamp(entry.timestamp) score_str = f"{score}%".rjust(4)
table.add_row( cmd = entry.command[:68] if len(entry.command) > 68 else entry.command
str(i), table.add_row(str(i), score_str, cmd)
f"{score}%",
entry.command[:58] + ".." if len(entry.command) > 60 else entry.command,
timestamp,
)
console.print(table) 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: except FileNotFoundError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
ctx.exit(1) ctx.exit(1)

View File

@@ -1,6 +1,5 @@
"""Time-based analysis command.""" """Time-based analysis command."""
import os
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -9,8 +8,7 @@ import click
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from shellhist.core import HistoryLoader from shellhist.core import HistoryLoader, HistoryEntry
from shellhist.utils import format_timestamp
@click.command("analyze-time") @click.command("analyze-time")
@@ -86,7 +84,7 @@ def analyze_time_command(
cutoff = _parse_time_range(time_range) cutoff = _parse_time_range(time_range)
recent_entries = [ recent_entries = [
e for e in entries_with_time e for e in entries_with_time
if e.timestamp >= cutoff if e.timestamp is not None and e.timestamp >= cutoff
] ]
if daily: if daily:
@@ -121,11 +119,12 @@ def _parse_time_range(time_range: str) -> datetime:
return now - timedelta(days=7) return now - timedelta(days=7)
def _analyze_hourly_distribution(console: Console, entries: list) -> None: def _analyze_hourly_distribution(console: Console, entries: list[HistoryEntry]) -> None:
"""Analyze command distribution by hour of day.""" """Analyze command distribution by hour of day."""
hourly = defaultdict(list) hourly: dict[int, list[str]] = defaultdict(list)
for entry in entries: for entry in entries:
assert entry.timestamp is not None
hour = entry.timestamp.hour hour = entry.timestamp.hour
hourly[hour].append(entry.command) hourly[hour].append(entry.command)
@@ -139,7 +138,7 @@ def _analyze_hourly_distribution(console: Console, entries: list) -> None:
for hour in range(24): for hour in range(24):
cmds = hourly.get(hour, []) cmds = hourly.get(hour, [])
if cmds: if cmds:
top = defaultdict(int) top: dict[str, int] = defaultdict(int)
for cmd in cmds: for cmd in cmds:
top[cmd] += 1 top[cmd] += 1
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:2] top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:2]
@@ -153,11 +152,12 @@ def _analyze_hourly_distribution(console: Console, entries: list) -> None:
console.print(table) console.print(table)
def _analyze_daily_patterns(console: Console, entries: list) -> None: def _analyze_daily_patterns(console: Console, entries: list[HistoryEntry]) -> None:
"""Analyze commands run at similar times daily.""" """Analyze commands run at similar times daily."""
daily_patterns = defaultdict(list) daily_patterns: dict[tuple[int, int], list[str]] = defaultdict(list)
for entry in entries: for entry in entries:
assert entry.timestamp is not None
hour = entry.timestamp.hour hour = entry.timestamp.hour
key = (entry.timestamp.weekday(), hour) key = (entry.timestamp.weekday(), hour)
daily_patterns[key].append(entry.command) daily_patterns[key].append(entry.command)
@@ -166,10 +166,18 @@ def _analyze_daily_patterns(console: Console, entries: list) -> None:
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
patterns_found = [] from typing import TypedDict
class DailyPattern(TypedDict):
day: str
hour: int
count: int
top: list[tuple[str, int]]
patterns_found: list[DailyPattern] = []
for (weekday, hour), cmds in daily_patterns.items(): for (weekday, hour), cmds in daily_patterns.items():
if len(cmds) >= 2: if len(cmds) >= 2:
top = defaultdict(int) top: dict[str, int] = defaultdict(int)
for cmd in cmds: for cmd in cmds:
top[cmd] += 1 top[cmd] += 1
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3] top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3]
@@ -204,11 +212,12 @@ def _analyze_daily_patterns(console: Console, entries: list) -> None:
console.print(table) console.print(table)
def _analyze_weekly_patterns(console: Console, entries: list) -> None: def _analyze_weekly_patterns(console: Console, entries: list[HistoryEntry]) -> None:
"""Analyze commands run at similar times weekly.""" """Analyze commands run at similar times weekly."""
weekly_patterns = defaultdict(list) weekly_patterns: dict[tuple[int, int, int, int], list[str]] = defaultdict(list)
for entry in entries: for entry in entries:
assert entry.timestamp is not None
key = ( key = (
entry.timestamp.isocalendar().year, entry.timestamp.isocalendar().year,
entry.timestamp.isocalendar().week, entry.timestamp.isocalendar().week,
@@ -221,11 +230,20 @@ def _analyze_weekly_patterns(console: Console, entries: list) -> None:
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
patterns_found = [] from typing import TypedDict
class WeeklyPattern(TypedDict):
week: int
day: str
hour: int
count: int
top: list[tuple[str, int]]
patterns_found: list[WeeklyPattern] = []
for key, cmds in weekly_patterns.items(): for key, cmds in weekly_patterns.items():
year, week, weekday, hour = key year, week, weekday, hour = key
if len(cmds) >= 2: if len(cmds) >= 2:
top = defaultdict(int) top: dict[str, int] = defaultdict(int)
for cmd in cmds: for cmd in cmds:
top[cmd] += 1 top[cmd] += 1
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3] top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3]

View File

@@ -4,8 +4,7 @@ import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from typing import Optional, TextIO
from typing import Optional
@dataclass @dataclass
@@ -16,10 +15,10 @@ class HistoryEntry:
line_number: int = 0 line_number: int = 0
shell_type: str = "unknown" shell_type: str = "unknown"
def __hash__(self): def __hash__(self) -> int:
return hash(self.command) return hash(self.command)
def __eq__(self, other): def __eq__(self, other: object) -> bool:
if isinstance(other, HistoryEntry): if isinstance(other, HistoryEntry):
return self.command == other.command return self.command == other.command
return False return False
@@ -106,7 +105,7 @@ class HistoryLoader:
return store return store
def _parse_bash_history(self, file, store: HistoryStore) -> None: def _parse_bash_history(self, file: TextIO, store: HistoryStore) -> None:
"""Parse bash history format.""" """Parse bash history format."""
line_number = 0 line_number = 0
pending_timestamp: Optional[datetime] = None pending_timestamp: Optional[datetime] = None
@@ -132,7 +131,7 @@ class HistoryLoader:
store.add_entry(entry) store.add_entry(entry)
pending_timestamp = None pending_timestamp = None
def _parse_zsh_history(self, file, store: HistoryStore) -> None: def _parse_zsh_history(self, file: TextIO, store: HistoryStore) -> None:
"""Parse zsh history format.""" """Parse zsh history format."""
line_number = 0 line_number = 0

View File

@@ -1,139 +1,69 @@
"""Export functionality for generating scripts from detected patterns.""" """Export functionality for detected patterns."""
import os
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from shellhist.core import HistoryStore
from shellhist.core.patterns import CommandPattern from shellhist.core.patterns import CommandPattern
SCRIPT_TEMPLATE = '''#!/bin/bash
# Generated by Shell History Automation Tool
# Generated at: {timestamp}
# Pattern: {pattern}
# Frequency: {frequency} occurrences
{commands}
# End of generated script
'''
def generate_alias(
pattern: CommandPattern,
alias_name: Optional[str] = None,
) -> str:
"""Generate a shell alias from a command pattern.
Args:
pattern: CommandPattern to generate alias from.
alias_name: Optional custom alias name.
Returns:
Formatted alias string.
"""
if alias_name is None:
alias_name = generate_alias_name(pattern)
commands_str = " && ".join(pattern.commands)
return f"alias {alias_name}='{commands_str}'"
def generate_alias_name(pattern: CommandPattern) -> str:
"""Generate a meaningful alias name from a pattern.
Args:
pattern: CommandPattern to generate name from.
Returns:
Generated alias name.
"""
if len(pattern.commands) == 1:
cmd = pattern.commands[0]
parts = cmd.split()
if parts:
base = os.path.basename(parts[0])
return base.replace("-", "_")
keywords = []
for cmd in pattern.commands:
parts = cmd.split()
if parts:
keywords.append(parts[0][:3])
return "_".join(keywords) if keywords else "custom_alias"
def generate_script( def generate_script(
pattern: CommandPattern, patterns: list[CommandPattern],
script_name: Optional[str] = None, script_name: str = "shellhist_script",
output_dir: str = ".", output_dir: Optional[str] = None,
dry_run: bool = False, dry_run: bool = False,
force: bool = False, ) -> str:
) -> tuple[str, str]: """Generate a shell script from detected patterns.
"""Generate an executable shell script from a pattern.
Args: Args:
pattern: CommandPattern to generate script from. patterns: List of CommandPattern objects to export.
script_name: Name for the script file. script_name: Name for the output script (without extension).
output_dir: Directory to write script to. output_dir: Optional output directory. If not provided, uses current directory.
dry_run: If True, return content without writing. dry_run: If True, return script content without writing to file.
force: If True, overwrite existing files.
Returns: Returns:
Tuple of (script_path, content). Path to the generated script or script content if dry_run.
""" """
if script_name is None: script_content = _build_script_content(patterns, script_name)
script_name = generate_script_name(pattern)
if not script_name.endswith(".sh"):
script_name += ".sh"
script_path = os.path.join(output_dir, script_name)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
commands_str = "\n".join(pattern.commands)
content = SCRIPT_TEMPLATE.format(
timestamp=timestamp,
pattern=" -> ".join(pattern.commands),
frequency=pattern.frequency,
commands=commands_str,
)
if dry_run: if dry_run:
return script_path, content return script_content
Path(output_dir).mkdir(parents=True, exist_ok=True) output_path = Path(output_dir) if output_dir else Path.cwd()
output_path.mkdir(parents=True, exist_ok=True)
if os.path.exists(script_path) and not force: script_file = output_path / f"{script_name}.sh"
raise FileExistsError(f"Script already exists: {script_path}") script_file.write_text(script_content, encoding="utf-8")
with open(script_path, "w", encoding="utf-8") as f: return str(script_file)
f.write(content)
os.chmod(script_path, 0o755)
return script_path, content
def generate_script_name(pattern: CommandPattern) -> str: def _build_script_content(patterns: list[CommandPattern], script_name: str) -> str:
"""Generate a script filename from a pattern. """Build the content of the generated shell script."""
lines = [
f"#!/bin/bash",
f"# Generated by Shell History Automation Tool",
f"# Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
f"# {script_name} - Automated shell script from detected patterns",
"",
]
Args: for i, pattern in enumerate(patterns, 1):
pattern: CommandPattern to generate name from. if len(pattern.commands) == 1:
cmd = pattern.commands[0]
lines.append(f"# Pattern {i}: Command run {pattern.frequency} times")
lines.append(f"{cmd}")
else:
cmds = " && ".join(pattern.commands)
lines.append(f"# Pattern {i}: Sequence run {pattern.frequency} times")
lines.append(f"{cmds}")
lines.append("")
Returns: lines.extend([
Generated script filename. "# End of generated script",
""" "echo 'Script execution completed.'",
parts = [] ])
for cmd in pattern.commands[:3]:
cmd_parts = cmd.split()
if cmd_parts:
base = os.path.basename(cmd_parts[0])
safe = "".join(c for c in base if c.isalnum() or c in "-_")
parts.append(safe[:10])
name = "_".join(parts) if parts else "script" return "\n".join(lines)
return f"shellhist_{name}"

View File

@@ -1,7 +1,7 @@
"""Pattern detection algorithms for shell history analysis.""" """Pattern detection algorithms for shell history."""
from collections import Counter from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from shellhist.core import HistoryEntry, HistoryStore from shellhist.core import HistoryEntry, HistoryStore
@@ -10,9 +10,88 @@ from shellhist.core import HistoryEntry, HistoryStore
@dataclass @dataclass
class CommandPattern: class CommandPattern:
"""Represents a detected command pattern.""" """Represents a detected command pattern."""
commands: tuple[str, ...] commands: list[str]
frequency: int frequency: int
percentage: float percentage: float = 0.0
def detect_command_pairs(
store: HistoryStore,
min_frequency: int = 2,
) -> list[CommandPattern]:
"""Detect pairs of commands that frequently occur together."""
pairs: dict[tuple[str, str], int] = defaultdict(int)
entries = store.entries
for i in range(len(entries) - 1):
if entries[i].shell_type == entries[i + 1].shell_type:
pair = (entries[i].command, entries[i + 1].command)
pairs[pair] += 1
total_pairs = sum(pairs.values())
patterns = []
for (cmd1, cmd2), count in pairs.items():
if count >= min_frequency:
percentage = (count / total_pairs * 100) if total_pairs > 0 else 0.0
patterns.append(CommandPattern(
commands=[cmd1, cmd2],
frequency=count,
percentage=percentage
))
patterns.sort(key=lambda x: x.frequency, reverse=True)
return patterns
def detect_command_triplets(
store: HistoryStore,
min_frequency: int = 2,
) -> list[CommandPattern]:
"""Detect triplets of commands that frequently occur together."""
triplets: dict[tuple[str, str, str], int] = defaultdict(int)
entries = store.entries
for i in range(len(entries) - 2):
if (entries[i].shell_type == entries[i + 1].shell_type == entries[i + 2].shell_type):
triplet = (entries[i].command, entries[i + 1].command, entries[i + 2].command)
triplets[triplet] += 1
total_triplets = sum(triplets.values())
patterns = []
for (cmd1, cmd2, cmd3), count in triplets.items():
if count >= min_frequency:
percentage = (count / total_triplets * 100) if total_triplets > 0 else 0.0
patterns.append(CommandPattern(
commands=[cmd1, cmd2, cmd3],
frequency=count,
percentage=percentage
))
patterns.sort(key=lambda x: x.frequency, reverse=True)
return patterns
def detect_repetitive_commands(
store: HistoryStore,
min_frequency: int = 2,
) -> list[CommandPattern]:
"""Detect commands that are run repeatedly."""
patterns = []
for command, freq in store.command_frequency.items():
if freq >= min_frequency:
total = len(store.entries)
percentage = (freq / total * 100) if total > 0 else 0.0
patterns.append(CommandPattern(
commands=[command],
frequency=freq,
percentage=percentage
))
patterns.sort(key=lambda x: x.frequency, reverse=True)
return patterns
def ngram_analysis( def ngram_analysis(
@@ -20,125 +99,27 @@ def ngram_analysis(
n: int = 2, n: int = 2,
min_frequency: int = 2, min_frequency: int = 2,
) -> list[CommandPattern]: ) -> list[CommandPattern]:
"""Analyze command sequences using n-grams. """Analyze n-grams (sequences of n commands) in history."""
ngrams: dict[tuple[str, ...], int] = defaultdict(int)
entries = store.entries
Args: for i in range(len(entries) - n + 1):
store: HistoryStore to analyze. shell_types = [entries[j].shell_type for j in range(i, i + n)]
n: Size of n-grams (2 for pairs, 3 for triplets). if len(set(shell_types)) == 1:
min_frequency: Minimum occurrences to include in results. ngram = tuple(entries[j].command for j in range(i, i + n))
ngrams[ngram] += 1
Returns: total_ngrams = sum(ngrams.values())
List of CommandPattern objects sorted by frequency.
"""
commands = [entry.command for entry in store.entries]
ngrams = []
for i in range(len(commands) - n + 1):
ngram = tuple(commands[i:i + n])
ngrams.append(ngram)
if not ngrams:
return []
counter = Counter(ngrams)
total_sequences = len(ngrams)
patterns = [] patterns = []
for ngram, count in counter.most_common(): for ngram, count in ngrams.items():
if count >= min_frequency: if count >= min_frequency:
percentage = (count / total_sequences) * 100 if total_sequences > 0 else 0 percentage = (count / total_ngrams * 100) if total_ngrams > 0 else 0.0
patterns.append(CommandPattern( patterns.append(CommandPattern(
commands=ngram, commands=list(ngram),
frequency=count, frequency=count,
percentage=round(percentage, 2) percentage=percentage
)) ))
patterns.sort(key=lambda x: x.frequency, reverse=True)
return patterns return patterns
def detect_repetitive_commands(
store: HistoryStore,
min_frequency: int = 3,
) -> list[CommandPattern]:
"""Detect commands that are run repeatedly.
Args:
store: HistoryStore to analyze.
min_frequency: Minimum occurrences to consider repetitive.
Returns:
List of CommandPattern objects sorted by frequency.
"""
patterns = []
total_commands = len(store.entries)
for command, freq in store.get_most_frequent(limit=100):
if freq >= min_frequency and total_commands > 0:
percentage = (freq / total_commands) * 100
patterns.append(CommandPattern(
commands=(command,),
frequency=freq,
percentage=round(percentage, 2)
))
return patterns
def detect_command_pairs(
store: HistoryStore,
min_frequency: int = 2,
) -> list[CommandPattern]:
"""Detect frequently occurring command pairs.
Args:
store: HistoryStore to analyze.
min_frequency: Minimum occurrences for a pair.
Returns:
List of CommandPattern objects.
"""
return ngram_analysis(store, n=2, min_frequency=min_frequency)
def detect_command_triplets(
store: HistoryStore,
min_frequency: int = 2,
) -> list[CommandPattern]:
"""Detect frequently occurring command triplets.
Args:
store: HistoryStore to analyze.
min_frequency: Minimum occurrences for a triplet.
Returns:
List of CommandPattern objects.
"""
return ngram_analysis(store, n=3, min_frequency=min_frequency)
def detect_common_sequences(
store: HistoryStore,
max_length: int = 5,
min_occurrences: int = 2,
) -> list[CommandPattern]:
"""Detect common command sequences of varying lengths.
Args:
store: HistoryStore to analyze.
max_length: Maximum sequence length to check.
min_occurrences: Minimum occurrences for a sequence.
Returns:
List of CommandPattern objects sorted by frequency.
"""
all_patterns = []
commands = [entry.command for entry in store.entries]
for n in range(2, max_length + 1):
patterns = ngram_analysis(store, n=n, min_frequency=min_occurrences)
all_patterns.extend(patterns)
all_patterns.sort(key=lambda x: x.frequency, reverse=True)
return all_patterns

View File

@@ -3,7 +3,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fuzzywuzzy import fuzz, process from fuzzywuzzy import fuzz # type: ignore
from shellhist.core import HistoryEntry, HistoryStore from shellhist.core import HistoryEntry, HistoryStore

View File

@@ -8,7 +8,7 @@ from shellhist.cli import main
class TestCLI: class TestCLI:
"""Test CLI main entry point.""" """Test CLI main entry point."""
def test_version(self): def test_version(self) -> None:
"""Test version option.""" """Test version option."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--version"]) result = runner.invoke(main, ["--version"])
@@ -16,7 +16,7 @@ class TestCLI:
assert result.exit_code == 0 assert result.exit_code == 0
assert "shellhist" in result.output.lower() assert "shellhist" in result.output.lower()
def test_help(self): def test_help(self) -> None:
"""Test help option.""" """Test help option."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--help"]) result = runner.invoke(main, ["--help"])
@@ -25,42 +25,42 @@ class TestCLI:
assert "search" in result.output assert "search" in result.output
assert "patterns" in result.output assert "patterns" in result.output
def test_search_command_exists(self): def test_search_command_exists(self) -> None:
"""Test search subcommand exists.""" """Test search subcommand exists."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["search", "--help"]) result = runner.invoke(main, ["search", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_patterns_command_exists(self): def test_patterns_command_exists(self) -> None:
"""Test patterns subcommand exists.""" """Test patterns subcommand exists."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["patterns", "--help"]) result = runner.invoke(main, ["patterns", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_suggest_aliases_command_exists(self): def test_suggest_aliases_command_exists(self) -> None:
"""Test suggest-aliases subcommand exists.""" """Test suggest-aliases subcommand exists."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["suggest-aliases", "--help"]) result = runner.invoke(main, ["suggest-aliases", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_analyze_time_command_exists(self): def test_analyze_time_command_exists(self) -> None:
"""Test analyze-time subcommand exists.""" """Test analyze-time subcommand exists."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["analyze-time", "--help"]) result = runner.invoke(main, ["analyze-time", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_export_script_command_exists(self): def test_export_script_command_exists(self) -> None:
"""Test export-script subcommand exists.""" """Test export-script subcommand exists."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["export-script", "--help"]) result = runner.invoke(main, ["export-script", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_search_with_missing_history(self): def test_search_with_missing_history(self) -> None:
"""Test search with non-existent history file.""" """Test search with non-existent history file."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -71,7 +71,7 @@ class TestCLI:
assert result.exit_code != 0 assert result.exit_code != 0
assert "not found" in result.output.lower() or "error" in result.output.lower() assert "not found" in result.output.lower() or "error" in result.output.lower()
def test_patterns_with_missing_history(self): def test_patterns_with_missing_history(self) -> None:
"""Test patterns with non-existent history file.""" """Test patterns with non-existent history file."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(

View File

@@ -12,7 +12,7 @@ from shellhist.core import HistoryEntry, HistoryStore, HistoryLoader
class TestHistoryEntry: class TestHistoryEntry:
"""Test HistoryEntry dataclass.""" """Test HistoryEntry dataclass."""
def test_create_entry(self): def test_create_entry(self) -> None:
"""Test creating a basic history entry.""" """Test creating a basic history entry."""
entry = HistoryEntry(command="git status") entry = HistoryEntry(command="git status")
assert entry.command == "git status" assert entry.command == "git status"
@@ -20,13 +20,13 @@ class TestHistoryEntry:
assert entry.line_number == 0 assert entry.line_number == 0
assert entry.shell_type == "unknown" assert entry.shell_type == "unknown"
def test_entry_with_timestamp(self): def test_entry_with_timestamp(self) -> None:
"""Test creating an entry with timestamp.""" """Test creating an entry with timestamp."""
ts = datetime.now() ts = datetime.now()
entry = HistoryEntry(command="ls -la", timestamp=ts) entry = HistoryEntry(command="ls -la", timestamp=ts)
assert entry.timestamp == ts assert entry.timestamp == ts
def test_entry_equality(self): def test_entry_equality(self) -> None:
"""Test entry equality based on command.""" """Test entry equality based on command."""
entry1 = HistoryEntry(command="git status") entry1 = HistoryEntry(command="git status")
entry2 = HistoryEntry(command="git status") entry2 = HistoryEntry(command="git status")
@@ -35,7 +35,7 @@ class TestHistoryEntry:
assert entry1 == entry2 assert entry1 == entry2
assert entry1 != entry3 assert entry1 != entry3
def test_entry_hash(self): def test_entry_hash(self) -> None:
"""Test entry hash for use in sets/dicts.""" """Test entry hash for use in sets/dicts."""
entry1 = HistoryEntry(command="git status") entry1 = HistoryEntry(command="git status")
entry2 = HistoryEntry(command="git status") entry2 = HistoryEntry(command="git status")
@@ -47,13 +47,13 @@ class TestHistoryEntry:
class TestHistoryStore: class TestHistoryStore:
"""Test HistoryStore class.""" """Test HistoryStore class."""
def test_empty_store(self): def test_empty_store(self) -> None:
"""Test creating an empty store.""" """Test creating an empty store."""
store = HistoryStore() store = HistoryStore()
assert len(store.entries) == 0 assert len(store.entries) == 0
assert len(store.command_frequency) == 0 assert len(store.command_frequency) == 0
def test_add_entry(self): def test_add_entry(self) -> None:
"""Test adding entries to the store.""" """Test adding entries to the store."""
store = HistoryStore() store = HistoryStore()
entry = HistoryEntry(command="git status") entry = HistoryEntry(command="git status")
@@ -63,7 +63,7 @@ class TestHistoryStore:
assert len(store.entries) == 1 assert len(store.entries) == 1
assert store.get_frequency("git status") == 1 assert store.get_frequency("git status") == 1
def test_frequency_tracking(self): def test_frequency_tracking(self) -> None:
"""Test command frequency tracking.""" """Test command frequency tracking."""
store = HistoryStore() store = HistoryStore()
@@ -76,7 +76,7 @@ class TestHistoryStore:
assert store.get_frequency("git log") == 1 assert store.get_frequency("git log") == 1
assert store.get_frequency("unknown") == 0 assert store.get_frequency("unknown") == 0
def test_most_frequent(self): def test_most_frequent(self) -> None:
"""Test getting most frequent commands.""" """Test getting most frequent commands."""
store = HistoryStore() store = HistoryStore()
@@ -91,7 +91,7 @@ class TestHistoryStore:
assert most_frequent[0] == ("git status", 3) assert most_frequent[0] == ("git status", 3)
assert most_frequent[1] == ("git log", 1) assert most_frequent[1] == ("git log", 1)
def test_get_unique_commands(self): def test_get_unique_commands(self) -> None:
"""Test getting unique commands.""" """Test getting unique commands."""
store = HistoryStore() store = HistoryStore()
@@ -109,7 +109,7 @@ class TestHistoryStore:
class TestHistoryLoader: class TestHistoryLoader:
"""Test HistoryLoader class.""" """Test HistoryLoader class."""
def test_load_bash_history(self): def test_load_bash_history(self) -> None:
"""Test parsing bash history format.""" """Test parsing bash history format."""
with tempfile.NamedTemporaryFile(mode='w', suffix='_bash_history', delete=False) as f: with tempfile.NamedTemporaryFile(mode='w', suffix='_bash_history', delete=False) as f:
f.write("git status\n") f.write("git status\n")
@@ -132,7 +132,7 @@ class TestHistoryLoader:
finally: finally:
os.unlink(temp_path) os.unlink(temp_path)
def test_load_bash_history_with_timestamps(self): def test_load_bash_history_with_timestamps(self) -> None:
"""Test parsing bash history with timestamps.""" """Test parsing bash history with timestamps."""
with tempfile.NamedTemporaryFile(mode='w', suffix='_bash_history', delete=False) as f: with tempfile.NamedTemporaryFile(mode='w', suffix='_bash_history', delete=False) as f:
f.write("#1700000000\n") f.write("#1700000000\n")
@@ -150,7 +150,7 @@ class TestHistoryLoader:
finally: finally:
os.unlink(temp_path) os.unlink(temp_path)
def test_load_zsh_history(self): def test_load_zsh_history(self) -> None:
"""Test parsing zsh history format.""" """Test parsing zsh history format."""
import os import os
with tempfile.NamedTemporaryFile(mode='w', suffix='_zsh_history', delete=False) as f: with tempfile.NamedTemporaryFile(mode='w', suffix='_zsh_history', delete=False) as f:
@@ -170,14 +170,14 @@ class TestHistoryLoader:
finally: finally:
os.unlink(temp_path) os.unlink(temp_path)
def test_file_not_found(self): def test_file_not_found(self) -> None:
"""Test error handling for missing file.""" """Test error handling for missing file."""
loader = HistoryLoader(history_path="/nonexistent/path") loader = HistoryLoader(history_path="/nonexistent/path")
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
loader.load() loader.load()
def test_from_file_convenience_method(self): def test_from_file_convenience_method(self) -> None:
"""Test the from_file class method.""" """Test the from_file class method."""
with tempfile.NamedTemporaryFile(mode='w', suffix='_history', delete=False) as f: with tempfile.NamedTemporaryFile(mode='w', suffix='_history', delete=False) as f:
f.write("echo test\n") f.write("echo test\n")

View File

@@ -3,9 +3,7 @@
import os import os
import tempfile import tempfile
import pytest
from shellhist.core import HistoryEntry, HistoryStore
from shellhist.core.export import ( from shellhist.core.export import (
generate_alias, generate_alias,
generate_alias_name, generate_alias_name,
@@ -18,7 +16,7 @@ from shellhist.core.patterns import CommandPattern
class TestGenerateAlias: class TestGenerateAlias:
"""Test alias generation functionality.""" """Test alias generation functionality."""
def test_generate_alias_single_command(self): def test_generate_alias_single_command(self) -> None:
"""Test generating alias for single command.""" """Test generating alias for single command."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("git status",), commands=("git status",),
@@ -31,7 +29,7 @@ class TestGenerateAlias:
assert alias.startswith("alias ") assert alias.startswith("alias ")
assert "git status" in alias assert "git status" in alias
def test_generate_alias_multiple_commands(self): def test_generate_alias_multiple_commands(self) -> None:
"""Test generating alias for multiple commands.""" """Test generating alias for multiple commands."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("git add .", "git commit", "git push"), commands=("git add .", "git commit", "git push"),
@@ -45,7 +43,7 @@ class TestGenerateAlias:
assert "git commit" in alias assert "git commit" in alias
assert "git push" in alias assert "git push" in alias
def test_generate_alias_custom_name(self): def test_generate_alias_custom_name(self) -> None:
"""Test generating alias with custom name.""" """Test generating alias with custom name."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("echo test",), commands=("echo test",),
@@ -61,7 +59,7 @@ class TestGenerateAlias:
class TestGenerateAliasName: class TestGenerateAliasName:
"""Test alias name generation.""" """Test alias name generation."""
def test_alias_name_from_command(self): def test_alias_name_from_command(self) -> None:
"""Test generating alias name from command.""" """Test generating alias name from command."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("git status",), commands=("git status",),
@@ -73,7 +71,7 @@ class TestGenerateAliasName:
assert "git" in name.lower() or "status" in name.lower() assert "git" in name.lower() or "status" in name.lower()
def test_alias_name_from_multiple_commands(self): def test_alias_name_from_multiple_commands(self) -> None:
"""Test generating alias name from multiple commands.""" """Test generating alias name from multiple commands."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("git add .", "git commit", "git push"), commands=("git add .", "git commit", "git push"),
@@ -89,7 +87,7 @@ class TestGenerateAliasName:
class TestGenerateScript: class TestGenerateScript:
"""Test script generation functionality.""" """Test script generation functionality."""
def test_generate_script_dry_run(self): def test_generate_script_dry_run(self) -> None:
"""Test script generation in dry-run mode.""" """Test script generation in dry-run mode."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("echo hello", "echo world"), commands=("echo hello", "echo world"),
@@ -103,7 +101,7 @@ class TestGenerateScript:
assert "echo world" in content assert "echo world" in content
assert "shellhist_" in path assert "shellhist_" in path
def test_generate_script_with_custom_name(self): def test_generate_script_with_custom_name(self) -> None:
"""Test script generation with custom name.""" """Test script generation with custom name."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("ls -la",), commands=("ls -la",),
@@ -119,7 +117,7 @@ class TestGenerateScript:
assert "my_custom_script" in path assert "my_custom_script" in path
def test_generate_script_actual_write(self): def test_generate_script_actual_write(self) -> None:
"""Test actual script file creation.""" """Test actual script file creation."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("echo test",), commands=("echo test",),
@@ -141,7 +139,7 @@ class TestGenerateScript:
assert "echo test" in saved_content assert "echo test" in saved_content
def test_script_permissions(self): def test_script_permissions(self) -> None:
"""Test that generated scripts are executable.""" """Test that generated scripts are executable."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("echo test",), commands=("echo test",),
@@ -163,7 +161,7 @@ class TestGenerateScript:
class TestGenerateScriptName: class TestGenerateScriptName:
"""Test script name generation.""" """Test script name generation."""
def test_script_name_from_commands(self): def test_script_name_from_commands(self) -> None:
"""Test generating script name from commands.""" """Test generating script name from commands."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("git status", "git log"), commands=("git status", "git log"),
@@ -176,7 +174,7 @@ class TestGenerateScriptName:
assert len(name) > 0 assert len(name) > 0
assert name.startswith("shellhist_") assert name.startswith("shellhist_")
def test_script_name_sanitization(self): def test_script_name_sanitization(self) -> None:
"""Test that script names are sanitized.""" """Test that script names are sanitized."""
pattern = CommandPattern( pattern = CommandPattern(
commands=("ls -la /tmp",), commands=("ls -la /tmp",),

View File

@@ -1,6 +1,5 @@
"""Tests for pattern detection algorithms.""" """Tests for pattern detection algorithms."""
import pytest
from shellhist.core import HistoryEntry, HistoryStore from shellhist.core import HistoryEntry, HistoryStore
from shellhist.core.patterns import ( from shellhist.core.patterns import (
@@ -15,14 +14,14 @@ from shellhist.core.patterns import (
class TestNgramAnalysis: class TestNgramAnalysis:
"""Test n-gram analysis functionality.""" """Test n-gram analysis functionality."""
def test_empty_store(self): def test_empty_store(self) -> None:
"""Test with empty store.""" """Test with empty store."""
store = HistoryStore() store = HistoryStore()
results = ngram_analysis(store, n=2) results = ngram_analysis(store, n=2)
assert len(results) == 0 assert len(results) == 0
def test_simple_pairs(self): def test_simple_pairs(self) -> None:
"""Test detecting command pairs.""" """Test detecting command pairs."""
store = HistoryStore() store = HistoryStore()
@@ -42,7 +41,7 @@ class TestNgramAnalysis:
pair = results[0] pair = results[0]
assert "git status -> git log" in " -> ".join(pair.commands) assert "git status -> git log" in " -> ".join(pair.commands)
def test_triplets(self): def test_triplets(self) -> None:
"""Test detecting command triplets.""" """Test detecting command triplets."""
store = HistoryStore() store = HistoryStore()
@@ -63,7 +62,7 @@ class TestNgramAnalysis:
assert len(results) == 1 assert len(results) == 1
assert results[0].frequency == 2 assert results[0].frequency == 2
def test_min_frequency_filter(self): def test_min_frequency_filter(self) -> None:
"""Test minimum frequency filtering.""" """Test minimum frequency filtering."""
store = HistoryStore() store = HistoryStore()
@@ -84,7 +83,7 @@ class TestNgramAnalysis:
class TestDetectRepetitiveCommands: class TestDetectRepetitiveCommands:
"""Test repetitive command detection.""" """Test repetitive command detection."""
def test_detect_repetitive(self): def test_detect_repetitive(self) -> None:
"""Test detecting repetitive single commands.""" """Test detecting repetitive single commands."""
store = HistoryStore() store = HistoryStore()
@@ -100,7 +99,7 @@ class TestDetectRepetitiveCommands:
assert results[0].commands == ("git status",) assert results[0].commands == ("git status",)
assert results[0].frequency == 5 assert results[0].frequency == 5
def test_no_repetitive_commands(self): def test_no_repetitive_commands(self) -> None:
"""Test when no commands are repetitive.""" """Test when no commands are repetitive."""
store = HistoryStore() store = HistoryStore()
@@ -115,7 +114,7 @@ class TestDetectRepetitiveCommands:
class TestDetectCommandPairs: class TestDetectCommandPairs:
"""Test command pair detection.""" """Test command pair detection."""
def test_detect_pairs(self): def test_detect_pairs(self) -> None:
"""Test detecting command pairs.""" """Test detecting command pairs."""
store = HistoryStore() store = HistoryStore()
@@ -137,7 +136,7 @@ class TestDetectCommandPairs:
class TestDetectCommandTriplets: class TestDetectCommandTriplets:
"""Test command triplet detection.""" """Test command triplet detection."""
def test_detect_triplets(self): def test_detect_triplets(self) -> None:
"""Test detecting command triplets.""" """Test detecting command triplets."""
store = HistoryStore() store = HistoryStore()
@@ -162,7 +161,7 @@ class TestDetectCommandTriplets:
class TestDetectCommonSequences: class TestDetectCommonSequences:
"""Test common sequence detection.""" """Test common sequence detection."""
def test_detect_sequences_various_lengths(self): def test_detect_sequences_various_lengths(self) -> None:
"""Test detecting sequences of various lengths.""" """Test detecting sequences of various lengths."""
store = HistoryStore() store = HistoryStore()

View File

@@ -1,6 +1,5 @@
"""Tests for search functionality.""" """Tests for search functionality."""
import pytest
from shellhist.core import HistoryEntry, HistoryStore from shellhist.core import HistoryEntry, HistoryStore
from shellhist.core.search import fuzzy_search, rank_by_frequency from shellhist.core.search import fuzzy_search, rank_by_frequency
@@ -9,7 +8,7 @@ from shellhist.core.search import fuzzy_search, rank_by_frequency
class TestFuzzySearch: class TestFuzzySearch:
"""Test fuzzy search functionality.""" """Test fuzzy search functionality."""
def setup_method(self): def setup_method(self) -> None:
"""Set up test fixtures.""" """Set up test fixtures."""
self.store = HistoryStore() self.store = HistoryStore()
@@ -30,32 +29,32 @@ class TestFuzzySearch:
entry = HistoryEntry(command=cmd, line_number=i) entry = HistoryEntry(command=cmd, line_number=i)
self.store.add_entry(entry) self.store.add_entry(entry)
def test_basic_search(self): def test_basic_search(self) -> None:
"""Test basic fuzzy search.""" """Test basic fuzzy search."""
results = fuzzy_search(self.store, "git status", threshold=50) results = fuzzy_search(self.store, "git status", threshold=50)
assert len(results) > 0 assert len(results) > 0
assert any(r[0].command == "git status" for r in results) assert any(r[0].command == "git status" for r in results)
def test_search_with_threshold(self): def test_search_with_threshold(self) -> None:
"""Test search with custom threshold.""" """Test search with custom threshold."""
results = fuzzy_search(self.store, "git commit message", threshold=80) results = fuzzy_search(self.store, "git commit message", threshold=80)
assert all(r[1] >= 80 for r in results) assert all(r[1] >= 80 for r in results)
def test_search_no_match(self): def test_search_no_match(self) -> None:
"""Test search with no matches.""" """Test search with no matches."""
results = fuzzy_search(self.store, "xyz123nonexistent", threshold=70) results = fuzzy_search(self.store, "xyz123nonexistent", threshold=70)
assert len(results) == 0 assert len(results) == 0
def test_search_limit(self): def test_search_limit(self) -> None:
"""Test search result limit.""" """Test search result limit."""
results = fuzzy_search(self.store, "git", threshold=50, limit=3) results = fuzzy_search(self.store, "git", threshold=50, limit=3)
assert len(results) <= 3 assert len(results) <= 3
def test_search_reverse_sort(self): def test_search_reverse_sort(self) -> None:
"""Test search with reverse sorting.""" """Test search with reverse sorting."""
results_normal = fuzzy_search(self.store, "git", threshold=50, reverse=False) results_normal = fuzzy_search(self.store, "git", threshold=50, reverse=False)
results_reverse = fuzzy_search(self.store, "git", threshold=50, reverse=True) results_reverse = fuzzy_search(self.store, "git", threshold=50, reverse=True)
@@ -63,7 +62,7 @@ class TestFuzzySearch:
if len(results_normal) > 1: if len(results_normal) > 1:
assert results_normal != results_reverse assert results_normal != results_reverse
def test_search_recent_boost(self): def test_search_recent_boost(self) -> None:
"""Test that recent commands get boosted.""" """Test that recent commands get boosted."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -93,7 +92,7 @@ class TestFuzzySearch:
class TestRankByFrequency: class TestRankByFrequency:
"""Test frequency ranking functionality.""" """Test frequency ranking functionality."""
def setup_method(self): def setup_method(self) -> None:
"""Set up test fixtures.""" """Set up test fixtures."""
self.store = HistoryStore() self.store = HistoryStore()
@@ -105,7 +104,7 @@ class TestRankByFrequency:
self.store.add_entry(HistoryEntry(command="ls")) self.store.add_entry(HistoryEntry(command="ls"))
def test_rank_by_frequency(self): def test_rank_by_frequency(self) -> None:
"""Test ranking results by frequency.""" """Test ranking results by frequency."""
entry = HistoryEntry(command="git status") entry = HistoryEntry(command="git status")
results = [(entry, 100)] results = [(entry, 100)]
@@ -115,7 +114,7 @@ class TestRankByFrequency:
assert len(ranked) == 1 assert len(ranked) == 1
assert ranked[0][2] == 5 assert ranked[0][2] == 5
def test_rank_multiple(self): def test_rank_multiple(self) -> None:
"""Test ranking multiple results.""" """Test ranking multiple results."""
entry1 = HistoryEntry(command="git status") entry1 = HistoryEntry(command="git status")
entry2 = HistoryEntry(command="git log") entry2 = HistoryEntry(command="git log")

View File

@@ -3,7 +3,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import StringIO from io import StringIO
import pytest
from shellhist.core import HistoryEntry, HistoryStore from shellhist.core import HistoryEntry, HistoryStore
@@ -11,7 +10,7 @@ from shellhist.core import HistoryEntry, HistoryStore
class TestTimeUtils: class TestTimeUtils:
"""Test time utility functions.""" """Test time utility functions."""
def test_parse_time_range_hours(self): def test_parse_time_range_hours(self) -> None:
"""Test parsing hour-based time range.""" """Test parsing hour-based time range."""
from shellhist.cli.time_analysis import _parse_time_range from shellhist.cli.time_analysis import _parse_time_range
@@ -20,7 +19,7 @@ class TestTimeUtils:
assert (result - expected).total_seconds() < 5 assert (result - expected).total_seconds() < 5
def test_parse_time_range_days(self): def test_parse_time_range_days(self) -> None:
"""Test parsing day-based time range.""" """Test parsing day-based time range."""
from shellhist.cli.time_analysis import _parse_time_range from shellhist.cli.time_analysis import _parse_time_range
@@ -29,7 +28,7 @@ class TestTimeUtils:
assert (result - expected).total_seconds() < 5 assert (result - expected).total_seconds() < 5
def test_parse_time_range_weeks(self): def test_parse_time_range_weeks(self) -> None:
"""Test parsing week-based time range.""" """Test parsing week-based time range."""
from shellhist.cli.time_analysis import _parse_time_range from shellhist.cli.time_analysis import _parse_time_range
@@ -42,7 +41,7 @@ class TestTimeUtils:
class TestHourlyDistribution: class TestHourlyDistribution:
"""Test hourly distribution analysis.""" """Test hourly distribution analysis."""
def test_hourly_analysis(self): def test_hourly_analysis(self) -> None:
"""Test basic hourly distribution.""" """Test basic hourly distribution."""
from shellhist.cli.time_analysis import _analyze_hourly_distribution from shellhist.cli.time_analysis import _analyze_hourly_distribution
from rich.console import Console from rich.console import Console

View File

@@ -1,21 +1,38 @@
"""CLI utilities for formatting and display.""" """Utility functions for shell history automation."""
import os
import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
def format_timestamp(ts: Optional[datetime]) -> str: def normalize_command(command: str) -> str:
"""Format a datetime for display.""" """Normalize a shell command for comparison."""
if ts is None: return command.strip()
return "unknown"
return ts.strftime("%Y-%m-%d %H:%M:%S")
def format_duration_hours(hours: float) -> str: def extract_command_keywords(command: str) -> list[str]:
"""Format duration in hours to human readable format.""" """Extract keywords from a command."""
if hours < 1: parts = command.split()
return f"{int(hours * 60)}m" keywords = [p for p in parts if not p.startswith("-")]
elif hours < 24: return keywords
return f"{int(hours)}h"
else:
return f"{int(hours / 24)}d" def format_timestamp(dt: Optional[datetime]) -> str:
"""Format a datetime object to a readable string."""
if dt is None:
return "Unknown"
return dt.strftime("%Y-%m-%d %H:%M:%S")
def parse_timestamp(ts: str) -> Optional[datetime]:
"""Parse a timestamp string to datetime."""
try:
return datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def escape_shell_string(s: str) -> str:
"""Escape special characters in a string for shell use."""
return re.sub(r"([\\'"])", r"\\\1", s)