Initial commit: Add shell-memory-cli project

A CLI tool that learns from terminal command patterns to automate repetitive workflows.

Features:
- Command recording with tags and descriptions
- Pattern detection for command sequences
- Session recording and replay
- Natural language script generation
This commit is contained in:
2026-01-30 11:56:13 +00:00
parent ceb6c0eac9
commit fbdd095092

444
shell_memory/cli.py Normal file
View File

@@ -0,0 +1,444 @@
"""Main CLI entry point for Shell Memory CLI."""
import sys
import os
from typing import Optional
import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from .database import Database
from .commands import CommandLibrary
from .patterns import PatternDetector
from .sessions import SessionRecorder
from .scripts import ScriptGenerator
console = Console()
pass_db = click.make_pass_decorator(Database)
def get_db(db_path: Optional[str] = None):
if db_path is None:
db_path = os.environ.get("SHELL_MEMORY_DB")
return Database(db_path)
def get_library(db):
return CommandLibrary(db)
def get_detector(db):
return PatternDetector(db)
def get_recorder(db):
return SessionRecorder(db)
def get_generator(db):
return ScriptGenerator(db)
def init_db_from_env():
db_path = os.environ.get("SHELL_MEMORY_DB")
if db_path:
return get_db(db_path)
return None
@click.group()
@click.option("--db", type=click.Path(), help="Path to database file")
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
@click.pass_context
def main(ctx, db, verbose):
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
if db:
ctx.obj["db"] = get_db(db)
else:
ctx.obj["db"] = get_db()
@main.group()
def cmd():
"""Manage your command library."""
pass
@cmd.command("add")
@click.argument("command")
@click.option("--description", "-d", help="Description of the command")
@click.option("--tag", "-t", multiple=True, help="Tags for the command")
@click.pass_obj
def cmd_add(obj, command, description, tag):
db = obj.get("db") or get_db()
library = get_library(db)
cmd_id = library.add(command, description, list(tag))
console.print(f"[green]✓[/green] Command saved with ID: {cmd_id}")
@cmd.command("list")
@click.option("--limit", "-l", default=20, help="Maximum number of commands to show")
@click.pass_obj
def cmd_list(obj, limit):
db = obj.get("db") or get_db()
library = get_library(db)
commands = library.list(limit)
if not commands:
console.print("[yellow]No commands found. Add some with 'shell-memory cmd add'[/yellow]")
return
table = Table(title="Your Command Library")
table.add_column("ID", justify="right", style="cyan")
table.add_column("Command", style="green")
table.add_column("Description", style="white")
table.add_column("Tags", style="magenta")
table.add_column("Uses", justify="right", style="yellow")
for cmd in commands:
tags = ", ".join(cmd.tags) if cmd.tags else "-"
table.add_row(
str(cmd.id),
cmd.command[:50] + ("..." if len(cmd.command) > 50 else ""),
cmd.description[:30] + ("..." if len(cmd.description) > 30 else "") if cmd.description else "-",
tags,
str(cmd.usage_count),
)
console.print(table)
@cmd.command("search")
@click.argument("query")
@click.option("--limit", "-l", default=10, help="Maximum number of results")
@click.pass_obj
def cmd_search(obj, query, limit):
db = obj.get("db") or get_db()
library = get_library(db)
commands = library.search(query, limit)
if not commands:
console.print(f"[yellow]No commands found for '{query}'[/yellow]")
return
console.print(f"[bold]Results for '{query}':[/bold]\n")
for cmd in commands:
tags = ", ".join(cmd.tags) if cmd.tags else ""
console.print(f"[cyan]#{cmd.id}[/cyan] {cmd.command}")
if cmd.description:
console.print(f" [dim]{cmd.description}[/dim]")
if tags:
console.print(f" [magenta]Tags: {tags}[/magenta]")
console.print()
@cmd.command("delete")
@click.argument("command_id", type=int)
@click.pass_obj
def cmd_delete(obj, command_id):
db = obj.get("db") or get_db()
library = get_library(db)
if library.delete(command_id):
console.print(f"[green]✓[/green] Command #{command_id} deleted")
else:
console.print(f"[red]✗[/red] Command #{command_id} not found")
@cmd.command("similar")
@click.argument("command")
@click.pass_obj
def cmd_similar(obj, command):
db = obj.get("db") or get_db()
library = get_library(db)
similar = library.find_similar(command)
if not similar:
console.print("[yellow]No similar commands found[/yellow]")
return
console.print(f"[bold]Similar commands:[/bold]\n")
for cmd, score in similar:
console.print(f"[cyan]#{cmd.id}[/cyan] [{score}%] {cmd.command}")
@main.group()
def pattern():
"""Detect and manage command patterns."""
pass
@pattern.command("detect")
@click.option("--min-freq", "-m", default=2, help="Minimum frequency to detect")
@click.option("--limit", "-l", default=50, help="Number of recent commands to analyze")
@click.pass_obj
def pattern_detect(obj, min_freq, limit):
db = obj.get("db") or get_db()
detector = get_detector(db)
patterns = detector.analyze_recent_commands(limit)
if not patterns:
console.print("[yellow]No patterns detected yet. Use 'shell-memory cmd add' to record more commands.[/yellow]")
return
table = Table(title="Detected Patterns")
table.add_column("ID", justify="right", style="cyan")
table.add_column("Pattern", style="green")
table.add_column("Frequency", justify="right", style="yellow")
for pattern in patterns:
pattern_str = "".join(map(str, pattern.command_ids))
table.add_row(str(pattern.id), pattern_str, str(pattern.frequency))
console.print(table)
@pattern.command("suggestions")
@click.pass_obj
def pattern_suggestions(obj):
db = obj.get("db") or get_db()
detector = get_detector(db)
shortcuts = detector.suggest_shortcuts()
if not shortcuts:
console.print("[yellow]No workflow patterns detected yet.[/yellow]")
return
console.print(Panel(Text("Suggested Shortcuts", style="bold cyan"), expand=False))
for pattern, commands in shortcuts:
console.print(f"\n[bold]Pattern (seen {pattern.frequency}x):[/bold]")
for cmd in commands:
console.print(f"{cmd.command}")
console.print()
@pattern.command("stats")
@click.option("--days", "-d", default=7, help="Number of days to analyze")
@click.pass_obj
def pattern_stats(obj, days):
db = obj.get("db") or get_db()
detector = get_detector(db)
stats = detector.get_workflow_stats(days)
console.print(Panel(
f"Total Commands: {stats['total_commands']}\n"
f"Recent Commands: {stats['recent_commands']}\n"
f"Unique Commands: {stats['unique_commands']}\n"
f"Patterns Found: {stats['patterns_found']}",
title="Workflow Statistics",
))
@main.group()
def session():
"""Record and replay terminal sessions."""
pass
@session.command("start")
@click.argument("name", required=False)
@click.pass_obj
def session_start(obj, name):
db = obj.get("db") or get_db()
recorder = get_recorder(db)
session = recorder.start_session(name)
console.print(f"[green]✓[/green] Session started: [bold]{session.name}[/bold]")
console.print("Recording commands... Use 'shell-memory session stop' to end.")
@session.command("record")
@click.argument("command")
@click.pass_obj
def session_record(obj, command):
db = obj.get("db") or get_db()
recorder = get_recorder(db)
if recorder.current_session is None:
console.print("[yellow]No active session. Start one with 'shell-memory session start'[/yellow]")
return
import subprocess
try:
proc = subprocess.run(command, shell=True, capture_output=True, text=True)
output = proc.stdout + proc.stderr
success = proc.returncode == 0
recorder.record_command(command, output, success)
status = "" if success else ""
console.print(f"[{status}] {command}")
except Exception as e:
recorder.record_command(command, str(e), False)
console.print(f"[✗] {command}")
@session.command("stop")
@click.pass_obj
def session_stop(obj):
db = obj.get("db") or get_db()
recorder = get_recorder(db)
session = recorder.stop_session()
if session:
console.print(f"[green]✓[/green] Session saved: [bold]{session.name}[/bold]")
console.print(f" Commands recorded: {len(session.commands)}")
else:
console.print("[yellow]No active session to stop[/yellow]")
@session.command("list")
@click.pass_obj
def session_list(obj):
db = obj.get("db") or get_db()
recorder = get_recorder(db)
sessions = recorder.get_sessions()
if not sessions:
console.print("[yellow]No recorded sessions[/yellow]")
return
table = Table(title="Recorded Sessions")
table.add_column("ID", justify="right", style="cyan")
table.add_column("Name", style="green")
table.add_column("Commands", justify="right", style="yellow")
table.add_column("Date", style="white")
for session in sessions:
table.add_row(
str(session.id),
session.name,
str(len(session.commands)),
session.start_time.strftime("%Y-%m-%d %H:%M"),
)
console.print(table)
@session.command("replay")
@click.argument("session_id", type=int)
@click.option("--dry-run", is_flag=True, help="Show commands without executing")
@click.option("--delay", "-d", default=0.5, help="Delay between commands (seconds)")
@click.pass_obj
def session_replay(obj, session_id, dry_run, delay):
db = obj.get("db") or get_db()
recorder = get_recorder(db)
results = recorder.replay_session(session_id, dry_run, delay)
if not results:
console.print("[red]Session not found[/red]")
return
if dry_run:
console.print("[yellow]Dry run - commands would execute:[/yellow]\n")
for result in results:
console.print(f" {result['command']}")
else:
console.print("[bold]Replay results:[/bold]\n")
for result in results:
status = "" if result["success"] else ""
console.print(f"[{status}] {result['command'][:50]}")
@session.command("export")
@click.argument("session_id", type=int)
@click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.pass_obj
def session_export(obj, session_id, output):
db = obj.get("db") or get_db()
recorder = get_recorder(db)
script = recorder.export_session(session_id)
if not script:
console.print("[red]Session not found[/red]")
return
if output:
with open(output, "w") as f:
f.write(script)
console.print(f"[green]✓[/green] Script exported to: {output}")
else:
console.print(script)
@main.group()
def script():
"""Generate shell scripts from natural language."""
pass
@script.command("generate")
@click.argument("description")
@click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.pass_obj
def script_generate(obj, description, output):
db = obj.get("db") or get_db()
generator = get_generator(db)
result = generator.generate(description)
confidence_pct = int(result.confidence * 100)
confidence_color = "green" if confidence_pct > 70 else "yellow" if confidence_pct > 40 else "red"
console.print(f"[{confidence_color}]Confidence: {confidence_pct}%[/]")
if result.matched_keywords:
console.print(f"Matched: {', '.join(result.matched_keywords)}")
console.print("\n[bold]Generated Script:[/bold]\n")
console.print(Panel(result.script, title="Shell Script"))
if output:
with open(output, "w") as f:
f.write(result.script)
f.write("\n")
console.print(f"\n[green]✓[/green] Script saved to: {output}")
@script.command("templates")
@click.pass_obj
def script_templates(obj):
db = obj.get("db") or get_db()
generator = get_generator(db)
templates = generator.list_templates()
if not templates:
console.print("[yellow]No templates found[/yellow]")
return
table = Table(title="Script Templates")
table.add_column("Keywords", style="cyan")
table.add_column("Description", style="green")
table.add_column("Template", style="white")
for template in templates:
table.add_row(
", ".join(template.keywords),
template.description or "-",
template.template[:60] + "...",
)
console.print(table)
@script.command("add-template")
@click.argument("keywords", nargs=-1)
@click.argument("description")
@click.argument("template")
@click.pass_obj
def script_add_template(obj, keywords, description, template):
db = obj.get("db") or get_db()
generator = get_generator(db)
template_id = generator.add_template(list(keywords), template, description)
console.print(f"[green]✓[/green] Template saved with ID: {template_id}")
@main.command("version")
def version():
"""Show version information."""
from . import __version__
console.print(f"Shell Memory CLI v{__version__}")
if __name__ == "__main__":
main()