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:
|
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 .
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
for i, pattern in enumerate(sequences[:10], 1):
|
alias_cmd = " && ".join(cmds)
|
||||||
alias_name = generate_alias_name(pattern)
|
alias_str = f"alias {alias_name}='{alias_cmd}'"
|
||||||
alias_line = generate_alias(pattern, alias_name)
|
|
||||||
|
|
||||||
console.print(f"{i}. [cyan]{alias_name}[/cyan]")
|
panel = Panel(
|
||||||
console.print(f" Command: {' -> '.join(pattern.commands)}")
|
f"[green]{alias_str}[/green]",
|
||||||
console.print(f" Alias: {alias_line}")
|
title=f"Alias {i}",
|
||||||
console.print(f" Frequency: {pattern.frequency} times\n")
|
expand=False,
|
||||||
|
)
|
||||||
|
console.print(panel)
|
||||||
|
|
||||||
if dry_run:
|
if not dry_run:
|
||||||
continue
|
if auto_create:
|
||||||
|
append_to_shell_file(alias_str)
|
||||||
if auto_create:
|
console.print(f" [green]Created alias '{alias_name}'[/green]")
|
||||||
aliases_to_create.append(alias_line)
|
else:
|
||||||
else:
|
if click.confirm(f" Create this alias?"):
|
||||||
if click.confirm(f"Create alias '{alias_name}'?", default=True):
|
append_to_shell_file(alias_str)
|
||||||
aliases_to_create.append(alias_line)
|
console.print(f" [green]Created alias '{alias_name}'[/green]")
|
||||||
|
|
||||||
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")
|
||||||
|
|||||||
@@ -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,
|
||||||
scripts_generated = 0
|
script_name=name,
|
||||||
|
output_dir=output,
|
||||||
for i, pattern in enumerate(sequences[:5], 1):
|
dry_run=dry_run,
|
||||||
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:
|
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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
Returns:
|
lines.append(f"# Pattern {i}: Command run {pattern.frequency} times")
|
||||||
Generated script filename.
|
lines.append(f"{cmd}")
|
||||||
"""
|
else:
|
||||||
parts = []
|
cmds = " && ".join(pattern.commands)
|
||||||
for cmd in pattern.commands[:3]:
|
lines.append(f"# Pattern {i}: Sequence run {pattern.frequency} times")
|
||||||
cmd_parts = cmd.split()
|
lines.append(f"{cmds}")
|
||||||
if cmd_parts:
|
lines.append("")
|
||||||
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"
|
lines.extend([
|
||||||
return f"shellhist_{name}"
|
"# End of generated script",
|
||||||
|
"echo 'Script execution completed.'",
|
||||||
|
])
|
||||||
|
|
||||||
|
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 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:
|
|
||||||
List of CommandPattern objects sorted by frequency.
|
|
||||||
"""
|
|
||||||
commands = [entry.command for entry in store.entries]
|
|
||||||
|
|
||||||
ngrams = []
|
total_ngrams = sum(ngrams.values())
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user