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:
444
shell_memory/cli.py
Normal file
444
shell_memory/cli.py
Normal 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()
|
||||||
Reference in New Issue
Block a user