Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2788ebdcd | |||
| 04dc2fb097 | |||
| 9f925a29bf | |||
| 2a63c265d2 | |||
| 182ab0ad60 | |||
| f61a58cfb5 | |||
| 7b3ceb1439 | |||
| 4fb0b6ff88 | |||
| 70dd85ff20 | |||
| 74e8995292 | |||
| a025fd4956 | |||
| 7028371275 | |||
| 32f78c6985 | |||
| 65f08d6534 | |||
| a2eea6a5ee | |||
| 89e4dbf0cb | |||
| ff295f446a | |||
| 52070216b6 | |||
| 71f7849892 | |||
| e90654ed10 | |||
| 2e5ff87d06 | |||
| 9a3f59adec | |||
| 313e2381f3 | |||
| ae7c30910d | |||
| de4dca65ae | |||
| 2d8e631a4a | |||
| f4830bac6c | |||
| 6fbe1d0424 | |||
| 9fb5941fe7 | |||
| fbc0f82b66 | |||
| fedd090b60 | |||
| f6f13c3ea3 | |||
| 40441c7898 | |||
| 0a3351d396 | |||
| 00f67ec62c | |||
| 1015caedc6 | |||
| e49f8bb5aa | |||
| 00184445fb | |||
| ce6b1b4a78 | |||
| 4250ec76db | |||
| ba78cb74f3 | |||
| 646ad2bf59 | |||
| 6392774290 | |||
| ebf667d4da | |||
| 811718024a | |||
| 0dcff6714f | |||
| c3b3473954 | |||
| b7ef86e6c6 | |||
| bab23ad85f | |||
| 6152fc1c92 |
@@ -15,5 +15,5 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: pytest shellhist/tests/ -v
|
||||
- run: pip install ruff && ruff check .
|
||||
- run: pytest tests/ -v
|
||||
- run: python -m ruff check .
|
||||
|
||||
19
.gitea/workflows/shellhist-ci.yml
Normal file
19
.gitea/workflows/shellhist-ci.yml
Normal 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 .
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ShellGen
|
||||
Copyright (c) 2024
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -4,3 +4,4 @@ python-Levenshtein>=0.12.0
|
||||
rich>=13.0.0
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
ruff>=0.1.0
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
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.patterns import patterns_command
|
||||
from shellhist.cli.alias import alias_command
|
||||
from shellhist.cli.time_analysis import analyze_time_command
|
||||
|
||||
|
||||
console = Console()
|
||||
from shellhist.cli.export import export_command
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="shellhist")
|
||||
@click.option(
|
||||
"--verbose/--quiet",
|
||||
"-v/-q",
|
||||
default=False,
|
||||
help="Enable verbose output",
|
||||
"--history",
|
||||
"-H",
|
||||
type=str,
|
||||
help="Path to history file",
|
||||
)
|
||||
@click.option(
|
||||
"--shell",
|
||||
"-s",
|
||||
type=click.Choice(["bash", "zsh"]),
|
||||
help="Shell type for parsing",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, verbose: bool) -> None:
|
||||
"""Shell History Automation Tool - Analyze shell command history to find patterns and suggest automation.
|
||||
|
||||
Commands:
|
||||
|
||||
\b
|
||||
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
|
||||
"""
|
||||
def main(
|
||||
ctx: click.Context,
|
||||
history: Optional[str],
|
||||
shell: Optional[str],
|
||||
) -> None:
|
||||
"""Shell History Automation Tool - Analyze and automate your shell workflows."""
|
||||
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(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(export_script_command, "export-script")
|
||||
main.add_command(export_command, "export-script")
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""Alias suggestion command."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from shellhist.core import HistoryLoader
|
||||
from shellhist.core.export import generate_alias, generate_alias_name
|
||||
from shellhist.core.patterns import detect_common_sequences
|
||||
from shellhist.core.patterns import (
|
||||
detect_command_pairs,
|
||||
detect_command_triplets,
|
||||
detect_repetitive_commands,
|
||||
)
|
||||
|
||||
|
||||
@click.command("suggest-aliases")
|
||||
@@ -28,16 +31,10 @@ from shellhist.core.patterns import detect_common_sequences
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
"-n",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Preview aliases without creating them",
|
||||
)
|
||||
@click.option(
|
||||
"--shell-file",
|
||||
"-f",
|
||||
type=str,
|
||||
help="Shell config file to append to (default: ~/.bashrc or ~/.zshrc)",
|
||||
help="Show what would be created without making changes",
|
||||
)
|
||||
@click.option(
|
||||
"--shell",
|
||||
@@ -45,31 +42,22 @@ from shellhist.core.patterns import detect_common_sequences
|
||||
type=click.Choice(["bash", "zsh"]),
|
||||
help="Shell type for parsing",
|
||||
)
|
||||
@click.option(
|
||||
"--min-occurrences",
|
||||
"-m",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Minimum occurrences for pattern (default: 3)",
|
||||
)
|
||||
@click.pass_context
|
||||
def suggest_aliases_command(
|
||||
def alias_command(
|
||||
ctx: click.Context,
|
||||
history: Optional[str],
|
||||
auto_create: bool,
|
||||
dry_run: bool,
|
||||
shell_file: Optional[str],
|
||||
shell: Optional[str],
|
||||
min_occurrences: int,
|
||||
) -> None:
|
||||
"""Suggest shell aliases for detected command patterns.
|
||||
"""Generate alias suggestions for detected patterns.
|
||||
|
||||
Examples:
|
||||
|
||||
\b
|
||||
shellhist suggest-aliases
|
||||
shellhist suggest-aliases --auto-create
|
||||
shellhist suggest-aliases --dry-run --shell-file ~/.bashrc
|
||||
shellhist suggest-aliases --dry-run
|
||||
"""
|
||||
console = Console()
|
||||
|
||||
@@ -81,73 +69,91 @@ def suggest_aliases_command(
|
||||
console.print("[yellow]No entries found in history.[/yellow]")
|
||||
return
|
||||
|
||||
sequences = detect_common_sequences(
|
||||
store,
|
||||
max_length=4,
|
||||
min_occurrences=min_occurrences,
|
||||
)
|
||||
pairs = detect_command_pairs(store, min_frequency=2)
|
||||
triplets = detect_command_triplets(store, min_frequency=2)
|
||||
|
||||
sequences = [
|
||||
(p.commands, p.frequency)
|
||||
for p in pairs + triplets
|
||||
if len(p.commands) >= 2
|
||||
]
|
||||
|
||||
if not sequences:
|
||||
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
|
||||
|
||||
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:
|
||||
home = os.path.expanduser("~")
|
||||
shell_file = f"{home}/.bashrc" if shell_type == "bash" else f"{home}/.zshrc"
|
||||
for i, (cmds, freq) in enumerate(sequences[:10], 1):
|
||||
seq = " && ".join(cmds)
|
||||
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):
|
||||
alias_name = generate_alias_name(pattern)
|
||||
alias_line = generate_alias(pattern, alias_name)
|
||||
panel = Panel(
|
||||
f"[green]{alias_str}[/green]",
|
||||
title=f"Alias {i}",
|
||||
expand=False,
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
console.print(f"{i}. [cyan]{alias_name}[/cyan]")
|
||||
console.print(f" Command: {' -> '.join(pattern.commands)}")
|
||||
console.print(f" Alias: {alias_line}")
|
||||
console.print(f" Frequency: {pattern.frequency} times\n")
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
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.")
|
||||
if not dry_run:
|
||||
if auto_create:
|
||||
append_to_shell_file(alias_str)
|
||||
console.print(f" [green]Created alias '{alias_name}'[/green]")
|
||||
else:
|
||||
if click.confirm(f" Create this alias?"):
|
||||
append_to_shell_file(alias_str)
|
||||
console.print(f" [green]Created alias '{alias_name}'[/green]")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
ctx.exit(1)
|
||||
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)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""Export command for generating scripts from detected patterns."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from shellhist.core import HistoryLoader
|
||||
from shellhist.core.export import generate_script, generate_script_name
|
||||
from shellhist.core.patterns import detect_common_sequences
|
||||
from shellhist.core.patterns import (
|
||||
detect_command_pairs,
|
||||
detect_command_triplets,
|
||||
detect_repetitive_commands,
|
||||
)
|
||||
from shellhist.core.export import generate_script
|
||||
|
||||
|
||||
@click.command("export-script")
|
||||
@@ -24,27 +27,21 @@ from shellhist.core.patterns import detect_common_sequences
|
||||
"-o",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Output directory for generated scripts (default: current directory)",
|
||||
help="Output directory for generated scripts",
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
"-n",
|
||||
type=str,
|
||||
help="Custom name for the script file",
|
||||
default="shellhist_script",
|
||||
help="Name for the generated script",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Preview script without writing to disk",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Overwrite existing scripts",
|
||||
help="Show script content without writing to file",
|
||||
)
|
||||
@click.option(
|
||||
"--shell",
|
||||
@@ -52,32 +49,23 @@ from shellhist.core.patterns import detect_common_sequences
|
||||
type=click.Choice(["bash", "zsh"]),
|
||||
help="Shell type for parsing",
|
||||
)
|
||||
@click.option(
|
||||
"--min-occurrences",
|
||||
"-m",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Minimum occurrences for pattern (default: 3)",
|
||||
)
|
||||
@click.pass_context
|
||||
def export_script_command(
|
||||
def export_command(
|
||||
ctx: click.Context,
|
||||
history: Optional[str],
|
||||
output: str,
|
||||
name: Optional[str],
|
||||
name: str,
|
||||
dry_run: bool,
|
||||
force: bool,
|
||||
shell: Optional[str],
|
||||
min_occurrences: int,
|
||||
) -> None:
|
||||
"""Export detected command patterns to executable shell scripts.
|
||||
"""Export detected patterns to executable shell scripts.
|
||||
|
||||
Examples:
|
||||
|
||||
\b
|
||||
shellhist export-script --output ./scripts/
|
||||
shellhist export-script --output ./scripts/ --name myroutine
|
||||
shellhist export-script --dry-run --min-occurrences 5
|
||||
shellhist export-script
|
||||
shellhist export-script --output ./scripts/ --name myscript
|
||||
shellhist export-script --dry-run
|
||||
"""
|
||||
console = Console()
|
||||
|
||||
@@ -89,63 +77,33 @@ def export_script_command(
|
||||
console.print("[yellow]No entries found in history.[/yellow]")
|
||||
return
|
||||
|
||||
sequences = detect_common_sequences(
|
||||
store,
|
||||
max_length=5,
|
||||
min_occurrences=min_occurrences,
|
||||
)
|
||||
pairs = detect_command_pairs(store, min_frequency=2)
|
||||
triplets = detect_command_triplets(store, min_frequency=2)
|
||||
repetitive = detect_repetitive_commands(store, min_frequency=2)
|
||||
|
||||
if not sequences:
|
||||
all_patterns = pairs + triplets + repetitive
|
||||
|
||||
if not all_patterns:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
for i, pattern in enumerate(sequences[:5], 1):
|
||||
script_name = name or generate_script_name(pattern)
|
||||
|
||||
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]")
|
||||
if dry_run:
|
||||
console.print(Panel(result, title="Generated Script (Dry Run)", expand=False))
|
||||
else:
|
||||
console.print(f"\n[green]Script exported to: {result}[/green]")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
ctx.exit(1)
|
||||
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)
|
||||
|
||||
@@ -11,7 +11,7 @@ from shellhist.core.patterns import (
|
||||
detect_command_pairs,
|
||||
detect_command_triplets,
|
||||
detect_repetitive_commands,
|
||||
ngram_analysis,
|
||||
CommandPattern,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ def patterns_command(
|
||||
def _display_patterns(
|
||||
console: Console,
|
||||
title: str,
|
||||
patterns: list,
|
||||
patterns: list[CommandPattern],
|
||||
total_entries: int,
|
||||
) -> None:
|
||||
"""Display detected patterns in a table."""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Search command for the shell history tool."""
|
||||
"""Search command for fuzzy searching shell history."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
@@ -9,7 +8,6 @@ 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")
|
||||
@@ -36,14 +34,15 @@ from shellhist.utils import format_timestamp
|
||||
)
|
||||
@click.option(
|
||||
"--reverse/--no-reverse",
|
||||
"-r",
|
||||
default=False,
|
||||
help="Sort by recency (newest first)",
|
||||
help="Reverse sort order (newest first)",
|
||||
)
|
||||
@click.option(
|
||||
"--recent/--no-recent",
|
||||
"--recent",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Boost scores for recent commands (last 24h)",
|
||||
help="Boost scores for recent commands (within 24h)",
|
||||
)
|
||||
@click.option(
|
||||
"--shell",
|
||||
@@ -64,8 +63,6 @@ def search_command(
|
||||
) -> None:
|
||||
"""Search shell history with fuzzy matching.
|
||||
|
||||
QUERY is the search string to match against your command history.
|
||||
|
||||
Examples:
|
||||
|
||||
\b
|
||||
@@ -76,9 +73,6 @@ def search_command(
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
if shell:
|
||||
os.environ["SHELL"] = f"/bin/{shell}"
|
||||
|
||||
loader = HistoryLoader(history_path=history)
|
||||
store = loader.load()
|
||||
|
||||
@@ -87,8 +81,8 @@ def search_command(
|
||||
return
|
||||
|
||||
results = fuzzy_search(
|
||||
store=store,
|
||||
query=query,
|
||||
store,
|
||||
query,
|
||||
threshold=threshold,
|
||||
limit=limit,
|
||||
reverse=reverse,
|
||||
@@ -96,29 +90,23 @@ def search_command(
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
console.print(f"\n[bold cyan]Search Results for '{query}'[/bold cyan]")
|
||||
|
||||
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)
|
||||
table.add_column("Match", width=6)
|
||||
table.add_column("Command", width=70)
|
||||
|
||||
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,
|
||||
)
|
||||
score_str = f"{score}%".rjust(4)
|
||||
cmd = entry.command[:68] if len(entry.command) > 68 else entry.command
|
||||
table.add_row(str(i), score_str, cmd)
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Time-based analysis command."""
|
||||
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
@@ -9,8 +8,7 @@ import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from shellhist.core import HistoryLoader
|
||||
from shellhist.utils import format_timestamp
|
||||
from shellhist.core import HistoryLoader, HistoryEntry
|
||||
|
||||
|
||||
@click.command("analyze-time")
|
||||
@@ -86,7 +84,7 @@ def analyze_time_command(
|
||||
cutoff = _parse_time_range(time_range)
|
||||
recent_entries = [
|
||||
e for e in entries_with_time
|
||||
if e.timestamp >= cutoff
|
||||
if e.timestamp is not None and e.timestamp >= cutoff
|
||||
]
|
||||
|
||||
if daily:
|
||||
@@ -121,11 +119,12 @@ def _parse_time_range(time_range: str) -> datetime:
|
||||
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."""
|
||||
hourly = defaultdict(list)
|
||||
hourly: dict[int, list[str]] = defaultdict(list)
|
||||
|
||||
for entry in entries:
|
||||
assert entry.timestamp is not None
|
||||
hour = entry.timestamp.hour
|
||||
hourly[hour].append(entry.command)
|
||||
|
||||
@@ -139,7 +138,7 @@ def _analyze_hourly_distribution(console: Console, entries: list) -> None:
|
||||
for hour in range(24):
|
||||
cmds = hourly.get(hour, [])
|
||||
if cmds:
|
||||
top = defaultdict(int)
|
||||
top: dict[str, int] = defaultdict(int)
|
||||
for cmd in cmds:
|
||||
top[cmd] += 1
|
||||
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)
|
||||
|
||||
|
||||
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."""
|
||||
daily_patterns = defaultdict(list)
|
||||
daily_patterns: dict[tuple[int, int], list[str]] = defaultdict(list)
|
||||
|
||||
for entry in entries:
|
||||
assert entry.timestamp is not None
|
||||
hour = entry.timestamp.hour
|
||||
key = (entry.timestamp.weekday(), hour)
|
||||
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"]
|
||||
|
||||
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():
|
||||
if len(cmds) >= 2:
|
||||
top = defaultdict(int)
|
||||
top: dict[str, int] = defaultdict(int)
|
||||
for cmd in cmds:
|
||||
top[cmd] += 1
|
||||
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)
|
||||
|
||||
|
||||
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."""
|
||||
weekly_patterns = defaultdict(list)
|
||||
weekly_patterns: dict[tuple[int, int, int, int], list[str]] = defaultdict(list)
|
||||
|
||||
for entry in entries:
|
||||
assert entry.timestamp is not None
|
||||
key = (
|
||||
entry.timestamp.isocalendar().year,
|
||||
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"]
|
||||
|
||||
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():
|
||||
year, week, weekday, hour = key
|
||||
if len(cmds) >= 2:
|
||||
top = defaultdict(int)
|
||||
top: dict[str, int] = defaultdict(int)
|
||||
for cmd in cmds:
|
||||
top[cmd] += 1
|
||||
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
|
||||
@@ -4,8 +4,7 @@ import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, TextIO
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -16,10 +15,10 @@ class HistoryEntry:
|
||||
line_number: int = 0
|
||||
shell_type: str = "unknown"
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.command)
|
||||
|
||||
def __eq__(self, other):
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, HistoryEntry):
|
||||
return self.command == other.command
|
||||
return False
|
||||
@@ -106,7 +105,7 @@ class HistoryLoader:
|
||||
|
||||
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."""
|
||||
line_number = 0
|
||||
pending_timestamp: Optional[datetime] = None
|
||||
@@ -132,7 +131,7 @@ class HistoryLoader:
|
||||
store.add_entry(entry)
|
||||
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."""
|
||||
line_number = 0
|
||||
|
||||
|
||||
@@ -1,139 +1,69 @@
|
||||
"""Export functionality for generating scripts from detected patterns."""
|
||||
"""Export functionality for detected patterns."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from shellhist.core import HistoryStore
|
||||
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(
|
||||
pattern: CommandPattern,
|
||||
script_name: Optional[str] = None,
|
||||
output_dir: str = ".",
|
||||
patterns: list[CommandPattern],
|
||||
script_name: str = "shellhist_script",
|
||||
output_dir: Optional[str] = None,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
"""Generate an executable shell script from a pattern.
|
||||
) -> str:
|
||||
"""Generate a shell script from detected patterns.
|
||||
|
||||
Args:
|
||||
pattern: CommandPattern to generate script from.
|
||||
script_name: Name for the script file.
|
||||
output_dir: Directory to write script to.
|
||||
dry_run: If True, return content without writing.
|
||||
force: If True, overwrite existing files.
|
||||
patterns: List of CommandPattern objects to export.
|
||||
script_name: Name for the output script (without extension).
|
||||
output_dir: Optional output directory. If not provided, uses current directory.
|
||||
dry_run: If True, return script content without writing to file.
|
||||
|
||||
Returns:
|
||||
Tuple of (script_path, content).
|
||||
Path to the generated script or script content if dry_run.
|
||||
"""
|
||||
if script_name is None:
|
||||
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,
|
||||
)
|
||||
script_content = _build_script_content(patterns, script_name)
|
||||
|
||||
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:
|
||||
raise FileExistsError(f"Script already exists: {script_path}")
|
||||
script_file = output_path / f"{script_name}.sh"
|
||||
script_file.write_text(script_content, encoding="utf-8")
|
||||
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
return script_path, content
|
||||
return str(script_file)
|
||||
|
||||
|
||||
def generate_script_name(pattern: CommandPattern) -> str:
|
||||
"""Generate a script filename from a pattern.
|
||||
def _build_script_content(patterns: list[CommandPattern], script_name: str) -> str:
|
||||
"""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:
|
||||
pattern: CommandPattern to generate name from.
|
||||
for i, pattern in enumerate(patterns, 1):
|
||||
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:
|
||||
Generated script filename.
|
||||
"""
|
||||
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])
|
||||
lines.extend([
|
||||
"# End of generated script",
|
||||
"echo 'Script execution completed.'",
|
||||
])
|
||||
|
||||
name = "_".join(parts) if parts else "script"
|
||||
return f"shellhist_{name}"
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Pattern detection algorithms for shell history analysis."""
|
||||
"""Pattern detection algorithms for shell history."""
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from shellhist.core import HistoryEntry, HistoryStore
|
||||
@@ -10,9 +10,88 @@ from shellhist.core import HistoryEntry, HistoryStore
|
||||
@dataclass
|
||||
class CommandPattern:
|
||||
"""Represents a detected command pattern."""
|
||||
commands: tuple[str, ...]
|
||||
commands: list[str]
|
||||
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(
|
||||
@@ -20,125 +99,27 @@ def ngram_analysis(
|
||||
n: int = 2,
|
||||
min_frequency: int = 2,
|
||||
) -> 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:
|
||||
store: HistoryStore to analyze.
|
||||
n: Size of n-grams (2 for pairs, 3 for triplets).
|
||||
min_frequency: Minimum occurrences to include in results.
|
||||
for i in range(len(entries) - n + 1):
|
||||
shell_types = [entries[j].shell_type for j in range(i, i + n)]
|
||||
if len(set(shell_types)) == 1:
|
||||
ngram = tuple(entries[j].command for j in range(i, i + n))
|
||||
ngrams[ngram] += 1
|
||||
|
||||
Returns:
|
||||
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)
|
||||
total_ngrams = sum(ngrams.values())
|
||||
patterns = []
|
||||
|
||||
for ngram, count in counter.most_common():
|
||||
for ngram, count in ngrams.items():
|
||||
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(
|
||||
commands=ngram,
|
||||
commands=list(ngram),
|
||||
frequency=count,
|
||||
percentage=round(percentage, 2)
|
||||
percentage=percentage
|
||||
))
|
||||
|
||||
patterns.sort(key=lambda x: x.frequency, reverse=True)
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fuzzywuzzy import fuzz, process
|
||||
from fuzzywuzzy import fuzz # type: ignore
|
||||
|
||||
from shellhist.core import HistoryEntry, HistoryStore
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from shellhist.cli import main
|
||||
class TestCLI:
|
||||
"""Test CLI main entry point."""
|
||||
|
||||
def test_version(self):
|
||||
def test_version(self) -> None:
|
||||
"""Test version option."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--version"])
|
||||
@@ -16,7 +16,7 @@ class TestCLI:
|
||||
assert result.exit_code == 0
|
||||
assert "shellhist" in result.output.lower()
|
||||
|
||||
def test_help(self):
|
||||
def test_help(self) -> None:
|
||||
"""Test help option."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--help"])
|
||||
@@ -25,42 +25,42 @@ class TestCLI:
|
||||
assert "search" 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."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["search", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_patterns_command_exists(self):
|
||||
def test_patterns_command_exists(self) -> None:
|
||||
"""Test patterns subcommand exists."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["patterns", "--help"])
|
||||
|
||||
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."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["suggest-aliases", "--help"])
|
||||
|
||||
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."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["analyze-time", "--help"])
|
||||
|
||||
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."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["export-script", "--help"])
|
||||
|
||||
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."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -71,7 +71,7 @@ class TestCLI:
|
||||
assert result.exit_code != 0
|
||||
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."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
|
||||
@@ -12,7 +12,7 @@ from shellhist.core import HistoryEntry, HistoryStore, HistoryLoader
|
||||
class TestHistoryEntry:
|
||||
"""Test HistoryEntry dataclass."""
|
||||
|
||||
def test_create_entry(self):
|
||||
def test_create_entry(self) -> None:
|
||||
"""Test creating a basic history entry."""
|
||||
entry = HistoryEntry(command="git status")
|
||||
assert entry.command == "git status"
|
||||
@@ -20,13 +20,13 @@ class TestHistoryEntry:
|
||||
assert entry.line_number == 0
|
||||
assert entry.shell_type == "unknown"
|
||||
|
||||
def test_entry_with_timestamp(self):
|
||||
def test_entry_with_timestamp(self) -> None:
|
||||
"""Test creating an entry with timestamp."""
|
||||
ts = datetime.now()
|
||||
entry = HistoryEntry(command="ls -la", timestamp=ts)
|
||||
assert entry.timestamp == ts
|
||||
|
||||
def test_entry_equality(self):
|
||||
def test_entry_equality(self) -> None:
|
||||
"""Test entry equality based on command."""
|
||||
entry1 = HistoryEntry(command="git status")
|
||||
entry2 = HistoryEntry(command="git status")
|
||||
@@ -35,7 +35,7 @@ class TestHistoryEntry:
|
||||
assert entry1 == entry2
|
||||
assert entry1 != entry3
|
||||
|
||||
def test_entry_hash(self):
|
||||
def test_entry_hash(self) -> None:
|
||||
"""Test entry hash for use in sets/dicts."""
|
||||
entry1 = HistoryEntry(command="git status")
|
||||
entry2 = HistoryEntry(command="git status")
|
||||
@@ -47,13 +47,13 @@ class TestHistoryEntry:
|
||||
class TestHistoryStore:
|
||||
"""Test HistoryStore class."""
|
||||
|
||||
def test_empty_store(self):
|
||||
def test_empty_store(self) -> None:
|
||||
"""Test creating an empty store."""
|
||||
store = HistoryStore()
|
||||
assert len(store.entries) == 0
|
||||
assert len(store.command_frequency) == 0
|
||||
|
||||
def test_add_entry(self):
|
||||
def test_add_entry(self) -> None:
|
||||
"""Test adding entries to the store."""
|
||||
store = HistoryStore()
|
||||
entry = HistoryEntry(command="git status")
|
||||
@@ -63,7 +63,7 @@ class TestHistoryStore:
|
||||
assert len(store.entries) == 1
|
||||
assert store.get_frequency("git status") == 1
|
||||
|
||||
def test_frequency_tracking(self):
|
||||
def test_frequency_tracking(self) -> None:
|
||||
"""Test command frequency tracking."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -76,7 +76,7 @@ class TestHistoryStore:
|
||||
assert store.get_frequency("git log") == 1
|
||||
assert store.get_frequency("unknown") == 0
|
||||
|
||||
def test_most_frequent(self):
|
||||
def test_most_frequent(self) -> None:
|
||||
"""Test getting most frequent commands."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -91,7 +91,7 @@ class TestHistoryStore:
|
||||
assert most_frequent[0] == ("git status", 3)
|
||||
assert most_frequent[1] == ("git log", 1)
|
||||
|
||||
def test_get_unique_commands(self):
|
||||
def test_get_unique_commands(self) -> None:
|
||||
"""Test getting unique commands."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestHistoryStore:
|
||||
class TestHistoryLoader:
|
||||
"""Test HistoryLoader class."""
|
||||
|
||||
def test_load_bash_history(self):
|
||||
def test_load_bash_history(self) -> None:
|
||||
"""Test parsing bash history format."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='_bash_history', delete=False) as f:
|
||||
f.write("git status\n")
|
||||
@@ -132,7 +132,7 @@ class TestHistoryLoader:
|
||||
finally:
|
||||
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."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='_bash_history', delete=False) as f:
|
||||
f.write("#1700000000\n")
|
||||
@@ -150,7 +150,7 @@ class TestHistoryLoader:
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_load_zsh_history(self):
|
||||
def test_load_zsh_history(self) -> None:
|
||||
"""Test parsing zsh history format."""
|
||||
import os
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='_zsh_history', delete=False) as f:
|
||||
@@ -170,14 +170,14 @@ class TestHistoryLoader:
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_file_not_found(self):
|
||||
def test_file_not_found(self) -> None:
|
||||
"""Test error handling for missing file."""
|
||||
loader = HistoryLoader(history_path="/nonexistent/path")
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
loader.load()
|
||||
|
||||
def test_from_file_convenience_method(self):
|
||||
def test_from_file_convenience_method(self) -> None:
|
||||
"""Test the from_file class method."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='_history', delete=False) as f:
|
||||
f.write("echo test\n")
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from shellhist.core import HistoryEntry, HistoryStore
|
||||
from shellhist.core.export import (
|
||||
generate_alias,
|
||||
generate_alias_name,
|
||||
@@ -18,7 +16,7 @@ from shellhist.core.patterns import CommandPattern
|
||||
class TestGenerateAlias:
|
||||
"""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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("git status",),
|
||||
@@ -31,7 +29,7 @@ class TestGenerateAlias:
|
||||
assert alias.startswith("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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("git add .", "git commit", "git push"),
|
||||
@@ -45,7 +43,7 @@ class TestGenerateAlias:
|
||||
assert "git commit" 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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("echo test",),
|
||||
@@ -61,7 +59,7 @@ class TestGenerateAlias:
|
||||
class TestGenerateAliasName:
|
||||
"""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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("git status",),
|
||||
@@ -73,7 +71,7 @@ class TestGenerateAliasName:
|
||||
|
||||
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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("git add .", "git commit", "git push"),
|
||||
@@ -89,7 +87,7 @@ class TestGenerateAliasName:
|
||||
class TestGenerateScript:
|
||||
"""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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("echo hello", "echo world"),
|
||||
@@ -103,7 +101,7 @@ class TestGenerateScript:
|
||||
assert "echo world" in content
|
||||
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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("ls -la",),
|
||||
@@ -119,7 +117,7 @@ class TestGenerateScript:
|
||||
|
||||
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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("echo test",),
|
||||
@@ -141,7 +139,7 @@ class TestGenerateScript:
|
||||
|
||||
assert "echo test" in saved_content
|
||||
|
||||
def test_script_permissions(self):
|
||||
def test_script_permissions(self) -> None:
|
||||
"""Test that generated scripts are executable."""
|
||||
pattern = CommandPattern(
|
||||
commands=("echo test",),
|
||||
@@ -163,7 +161,7 @@ class TestGenerateScript:
|
||||
class TestGenerateScriptName:
|
||||
"""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."""
|
||||
pattern = CommandPattern(
|
||||
commands=("git status", "git log"),
|
||||
@@ -176,7 +174,7 @@ class TestGenerateScriptName:
|
||||
assert len(name) > 0
|
||||
assert name.startswith("shellhist_")
|
||||
|
||||
def test_script_name_sanitization(self):
|
||||
def test_script_name_sanitization(self) -> None:
|
||||
"""Test that script names are sanitized."""
|
||||
pattern = CommandPattern(
|
||||
commands=("ls -la /tmp",),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for pattern detection algorithms."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shellhist.core import HistoryEntry, HistoryStore
|
||||
from shellhist.core.patterns import (
|
||||
@@ -15,14 +14,14 @@ from shellhist.core.patterns import (
|
||||
class TestNgramAnalysis:
|
||||
"""Test n-gram analysis functionality."""
|
||||
|
||||
def test_empty_store(self):
|
||||
def test_empty_store(self) -> None:
|
||||
"""Test with empty store."""
|
||||
store = HistoryStore()
|
||||
results = ngram_analysis(store, n=2)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
def test_simple_pairs(self):
|
||||
def test_simple_pairs(self) -> None:
|
||||
"""Test detecting command pairs."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -42,7 +41,7 @@ class TestNgramAnalysis:
|
||||
pair = results[0]
|
||||
assert "git status -> git log" in " -> ".join(pair.commands)
|
||||
|
||||
def test_triplets(self):
|
||||
def test_triplets(self) -> None:
|
||||
"""Test detecting command triplets."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -63,7 +62,7 @@ class TestNgramAnalysis:
|
||||
assert len(results) == 1
|
||||
assert results[0].frequency == 2
|
||||
|
||||
def test_min_frequency_filter(self):
|
||||
def test_min_frequency_filter(self) -> None:
|
||||
"""Test minimum frequency filtering."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -84,7 +83,7 @@ class TestNgramAnalysis:
|
||||
class TestDetectRepetitiveCommands:
|
||||
"""Test repetitive command detection."""
|
||||
|
||||
def test_detect_repetitive(self):
|
||||
def test_detect_repetitive(self) -> None:
|
||||
"""Test detecting repetitive single commands."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -100,7 +99,7 @@ class TestDetectRepetitiveCommands:
|
||||
assert results[0].commands == ("git status",)
|
||||
assert results[0].frequency == 5
|
||||
|
||||
def test_no_repetitive_commands(self):
|
||||
def test_no_repetitive_commands(self) -> None:
|
||||
"""Test when no commands are repetitive."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -115,7 +114,7 @@ class TestDetectRepetitiveCommands:
|
||||
class TestDetectCommandPairs:
|
||||
"""Test command pair detection."""
|
||||
|
||||
def test_detect_pairs(self):
|
||||
def test_detect_pairs(self) -> None:
|
||||
"""Test detecting command pairs."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -137,7 +136,7 @@ class TestDetectCommandPairs:
|
||||
class TestDetectCommandTriplets:
|
||||
"""Test command triplet detection."""
|
||||
|
||||
def test_detect_triplets(self):
|
||||
def test_detect_triplets(self) -> None:
|
||||
"""Test detecting command triplets."""
|
||||
store = HistoryStore()
|
||||
|
||||
@@ -162,7 +161,7 @@ class TestDetectCommandTriplets:
|
||||
class TestDetectCommonSequences:
|
||||
"""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."""
|
||||
store = HistoryStore()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for search functionality."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shellhist.core import HistoryEntry, HistoryStore
|
||||
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:
|
||||
"""Test fuzzy search functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
def setup_method(self) -> None:
|
||||
"""Set up test fixtures."""
|
||||
self.store = HistoryStore()
|
||||
|
||||
@@ -30,32 +29,32 @@ class TestFuzzySearch:
|
||||
entry = HistoryEntry(command=cmd, line_number=i)
|
||||
self.store.add_entry(entry)
|
||||
|
||||
def test_basic_search(self):
|
||||
def test_basic_search(self) -> None:
|
||||
"""Test basic fuzzy search."""
|
||||
results = fuzzy_search(self.store, "git status", threshold=50)
|
||||
|
||||
assert len(results) > 0
|
||||
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."""
|
||||
results = fuzzy_search(self.store, "git commit message", threshold=80)
|
||||
|
||||
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."""
|
||||
results = fuzzy_search(self.store, "xyz123nonexistent", threshold=70)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
def test_search_limit(self):
|
||||
def test_search_limit(self) -> None:
|
||||
"""Test search result limit."""
|
||||
results = fuzzy_search(self.store, "git", threshold=50, limit=3)
|
||||
|
||||
assert len(results) <= 3
|
||||
|
||||
def test_search_reverse_sort(self):
|
||||
def test_search_reverse_sort(self) -> None:
|
||||
"""Test search with reverse sorting."""
|
||||
results_normal = fuzzy_search(self.store, "git", threshold=50, reverse=False)
|
||||
results_reverse = fuzzy_search(self.store, "git", threshold=50, reverse=True)
|
||||
@@ -63,7 +62,7 @@ class TestFuzzySearch:
|
||||
if len(results_normal) > 1:
|
||||
assert results_normal != results_reverse
|
||||
|
||||
def test_search_recent_boost(self):
|
||||
def test_search_recent_boost(self) -> None:
|
||||
"""Test that recent commands get boosted."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -93,7 +92,7 @@ class TestFuzzySearch:
|
||||
class TestRankByFrequency:
|
||||
"""Test frequency ranking functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
def setup_method(self) -> None:
|
||||
"""Set up test fixtures."""
|
||||
self.store = HistoryStore()
|
||||
|
||||
@@ -105,7 +104,7 @@ class TestRankByFrequency:
|
||||
|
||||
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."""
|
||||
entry = HistoryEntry(command="git status")
|
||||
results = [(entry, 100)]
|
||||
@@ -115,7 +114,7 @@ class TestRankByFrequency:
|
||||
assert len(ranked) == 1
|
||||
assert ranked[0][2] == 5
|
||||
|
||||
def test_rank_multiple(self):
|
||||
def test_rank_multiple(self) -> None:
|
||||
"""Test ranking multiple results."""
|
||||
entry1 = HistoryEntry(command="git status")
|
||||
entry2 = HistoryEntry(command="git log")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from shellhist.core import HistoryEntry, HistoryStore
|
||||
|
||||
@@ -11,7 +10,7 @@ from shellhist.core import HistoryEntry, HistoryStore
|
||||
class TestTimeUtils:
|
||||
"""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."""
|
||||
from shellhist.cli.time_analysis import _parse_time_range
|
||||
|
||||
@@ -20,7 +19,7 @@ class TestTimeUtils:
|
||||
|
||||
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."""
|
||||
from shellhist.cli.time_analysis import _parse_time_range
|
||||
|
||||
@@ -29,7 +28,7 @@ class TestTimeUtils:
|
||||
|
||||
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."""
|
||||
from shellhist.cli.time_analysis import _parse_time_range
|
||||
|
||||
@@ -42,7 +41,7 @@ class TestTimeUtils:
|
||||
class TestHourlyDistribution:
|
||||
"""Test hourly distribution analysis."""
|
||||
|
||||
def test_hourly_analysis(self):
|
||||
def test_hourly_analysis(self) -> None:
|
||||
"""Test basic hourly distribution."""
|
||||
from shellhist.cli.time_analysis import _analyze_hourly_distribution
|
||||
from rich.console import Console
|
||||
|
||||
@@ -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 typing import Optional
|
||||
|
||||
|
||||
def format_timestamp(ts: Optional[datetime]) -> str:
|
||||
"""Format a datetime for display."""
|
||||
if ts is None:
|
||||
return "unknown"
|
||||
return ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
def normalize_command(command: str) -> str:
|
||||
"""Normalize a shell command for comparison."""
|
||||
return command.strip()
|
||||
|
||||
|
||||
def format_duration_hours(hours: float) -> str:
|
||||
"""Format duration in hours to human readable format."""
|
||||
if hours < 1:
|
||||
return f"{int(hours * 60)}m"
|
||||
elif hours < 24:
|
||||
return f"{int(hours)}h"
|
||||
else:
|
||||
return f"{int(hours / 24)}d"
|
||||
def extract_command_keywords(command: str) -> list[str]:
|
||||
"""Extract keywords from a command."""
|
||||
parts = command.split()
|
||||
keywords = [p for p in parts if not p.startswith("-")]
|
||||
return keywords
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user