#!/usr/bin/env python3 """ Sessave — Terminal session saver with timestamps, search, and export. Save annotated terminal sessions, search them, and export to Markdown/CSV. All data stored locally in ~/.sessave/ as JSON. Usage: sessave add "description of what I just did" sessave list # list recent sessions sessave search "keyword" # search session descriptions sessave export --format md # export as markdown sessave stats # session statistics sessave tag add "bugfix" 3 # tag session #3 sessave tag list # list all tags """ import argparse import datetime import json import os import shutil import sys import textwrap from pathlib import Path VERSION = "1.0.0" # ─── Configuration ────────────────────────────────────────────────────────── DATA_DIR = Path.home() / ".sessave" DATA_FILE = DATA_DIR / "sessions.json" MAX_DESC_WIDTH = 72 # ─── Data Layer ───────────────────────────────────────────────────────────── def _ensure_dir() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) def _load() -> list: _ensure_dir() if DATA_FILE.exists(): try: return json.loads(DATA_FILE.read_text()) except (json.JSONDecodeError, ValueError): return [] return [] def _save(sessions: list) -> None: _ensure_dir() DATA_FILE.write_text(json.dumps(sessions, indent=2, default=str)) def _next_id(sessions: list) -> int: return max((s.get("id", 0) for s in sessions), default=0) + 1 def _now() -> str: return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _fmt(ts: str) -> str: return ts # ─── Color helpers ────────────────────────────────────────────────────────── def _green(t: str) -> str: return f"\033[92m{t}\033[0m" def _yellow(t: str) -> str: return f"\033[93m{t}\033[0m" def _cyan(t: str) -> str: return f"\033[96m{t}\033[0m" def _red(t: str) -> str: return f"\033[91m{t}\033[0m" def _bold(t: str) -> str: return f"\033[1m{t}\033[0m" # ─── Commands ──────────────────────────────────────────────────────────────── def cmd_add(description: str) -> None: sessions = _load() sid = _next_id(sessions) now = _now() session = { "id": sid, "description": description.strip(), "created_at": now, "tags": [], "pwd": os.getcwd(), } sessions.append(session) _save(sessions) print(f" {_green('✓')} Session #{sid} saved: {_bold(description.strip())}") print(f" at {_fmt(now)} in {_yellow(os.getcwd())}") def cmd_list(limit: int = 20, tag: str = None) -> None: sessions = _load() if not sessions: print(f" {_yellow('No sessions saved yet.')}") print(f" Try: {_cyan('sessave add \"did some work\"')}") return if tag: filtered = [s for s in sessions if tag in s.get("tags", [])] else: filtered = sessions filtered = filtered[-limit:] filtered.sort(key=lambda s: s.get("created_at", ""), reverse=True) print(f"\n {_bold(f'Recent Sessions ({len(filtered)})')}\n") for s in filtered: tags = s.get("tags", []) tag_str = f" [{', '.join(tags)}]" if tags else "" short_desc = textwrap.shorten(s.get("description", ""), width=MAX_DESC_WIDTH) sid = s["id"] print( f" {_cyan(f'#{sid}')} {short_desc}{tag_str}" ) print(f" {_fmt(s.get('created_at', ''))} {_yellow(s.get('pwd', ''))}") print() def cmd_search(query: str) -> None: sessions = _load() query_lower = query.lower() results = [ s for s in sessions if query_lower in s.get("description", "").lower() or any(query_lower in tag.lower() for tag in s.get("tags", [])) ] results.reverse() if not results: print(f" {_yellow(f'No sessions matching \"{query}\"')}") return print(f"\n {_bold(f'{len(results)} session(s) matching \"{query}\"')}\n") for s in results: sid = s["id"] print( f" {_cyan(f'#{sid}')} {textwrap.shorten(s.get('description', ''), width=MAX_DESC_WIDTH)}" ) print(f" {_fmt(s.get('created_at', ''))}") print() def cmd_export(format: str = "md") -> None: sessions = _load() if not sessions: print(f" {_yellow('No sessions to export.')}") return filename = f"sessave_export.{format}" if format == "md": lines = ["# Sessave Export", "", f"Generated: {_now()}", ""] lines.append(f"Total sessions: {len(sessions)}") lines.append("") for s in sessions: lines.append(f"## Session #{s['id']}") lines.append(f"- **Description:** {s.get('description', '')}") lines.append(f"- **When:** {_fmt(s.get('created_at', ''))}") lines.append(f"- **Directory:** {s.get('pwd', '')}") tags = s.get("tags", []) if tags: lines.append(f"- **Tags:** {', '.join(tags)}") lines.append("") elif format == "csv": lines = ["id,description,created_at,pwd,tags"] for s in sessions: desc = s.get("description", "").replace('"', '""') tags = ";".join(s.get("tags", [])) pwd = s.get("pwd", "").replace('"', '""') sid = s["id"] lines.append( f'{sid},"{desc}","{_fmt(s.get("created_at", ""))}","{pwd}","{tags}"' ) else: print(f" {_red(f'Unknown format: {format}')}") sys.exit(1) Path(filename).write_text("\n".join(lines) + "\n") print(f" {_green('✓')} Exported {len(sessions)} sessions to {_bold(filename)}") def cmd_stats() -> None: sessions = _load() if not sessions: print(f" {_yellow('No sessions yet.')}") return all_tags = [t for s in sessions for t in s.get("tags", [])] tag_counts = {} for t in all_tags: tag_counts[t] = tag_counts.get(t, 0) + 1 top_tags = sorted(tag_counts.items(), key=lambda x: -x[1])[:10] dates = [s.get("created_at", "")[:10] for s in sessions if s.get("created_at")] unique_days = len(set(dates)) print(f"\n {_bold('Sessave Statistics')}\n") print(f" Total sessions: {_cyan(str(len(sessions)))}") print(f" Active days: {_cyan(str(unique_days))}") print(f" Avg sessions/d: {_cyan(f'{len(sessions)/max(unique_days,1):.1f}')}") print(f" Data file: {_yellow(str(DATA_FILE))}") print(f" File size: {_cyan(_human_size(DATA_FILE.stat().st_size) if DATA_FILE.exists() else '0B')}") if top_tags: print(f"\n {_bold('Top Tags')}") for tag, count in top_tags: bar = "█" * count print(f" {tag:15s} {count:3d} {bar}") print() def _human_size(size: int) -> str: for unit in ("B", "KB", "MB"): if size < 1024: return f"{size:.1f}{unit}" size /= 1024 return f"{size:.1f}GB" def cmd_tag(action: str, args) -> None: sessions = _load() if action == "list": all_tags = set() for s in sessions: all_tags.update(s.get("tags", [])) if all_tags: print(f"\n {_bold('All Tags')}\n") for t in sorted(all_tags): count = sum(1 for s in sessions if t in s.get("tags", [])) print(f" {t:20s} ({count} session{'s' if count != 1 else ''})") print() else: print(f" {_yellow('No tags yet.')}") return if action in ("add", "rm"): tag_name = args.tag_name session_id = args.session_id session = next((s for s in sessions if s["id"] == session_id), None) if not session: print(f" {_red(f'Session #{session_id} not found.')}") sys.exit(1) tags = session.setdefault("tags", []) if action == "add": if tag_name not in tags: tags.append(tag_name) print(f" {_green('✓')} Added tag '{_cyan(tag_name)}' to session #{session_id}") else: print(f" {_yellow(f'Tag \"{tag_name}\" already on session #{session_id}')}") elif action == "rm": if tag_name in tags: tags.remove(tag_name) print(f" {_green('✓')} Removed tag '{_cyan(tag_name)}' from session #{session_id}") else: print(f" {_yellow(f'Tag \"{tag_name}\" not on session #{session_id}')}") _save(sessions) else: print(f" {_red(f'Unknown tag action: {action}')}") sys.exit(1) def cmd_update(): """Check for updates and update the tool.""" import json import urllib.request manifest_url = "https://neuralmint.github.io/tools/versions.json" script_name = "sessave" try: with urllib.request.urlopen(manifest_url, timeout=10) as resp: manifest = json.loads(resp.read().decode()) except Exception as e: print(f"\033[91mError fetching update manifest: {e}\033[0m") sys.exit(1) if script_name not in manifest: print(f"\033[91mNo update info found for {script_name}\033[0m") sys.exit(1) info = manifest[script_name] remote_version = info["version"] current_version = VERSION print(f"Current version: {current_version}") print(f"Available version: {remote_version}") if tuple(map(int, remote_version.split("."))) <= tuple(map(int, current_version.split("."))): print(f"\033[92mAlready up to date.\033[0m") return print(f"\033[93mUpdating from {current_version} to {remote_version}...\033[0m") download_url = info["download_url"] try: with urllib.request.urlopen(download_url, timeout=30) as resp: new_code = resp.read().decode() with open(__file__, "w") as f: f.write(new_code) print(f"\033[92mUpdated successfully to v{remote_version}! Please restart the tool.\033[0m") except Exception as e: print(f"\033[91mError downloading update: {e}\033[0m") sys.exit(1) # ─── Main ──────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Sessave — Terminal session saver with timestamps, search, and export.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ Examples: sessave add "fixed the nginx config" sessave search "nginx" sessave list sessave tag add "ops" 3 sessave tag list sessave export --format md sessave stats """), ) parser.add_argument('--version', action='version', version=f'sessave v{VERSION}') subparsers = parser.add_subparsers(dest="command") p_add = subparsers.add_parser("add", help="Save a session with a description") p_add.add_argument("description", help="What you just did") p_list = subparsers.add_parser("list", help="List recent sessions") p_list.add_argument("--limit", "-n", type=int, default=20, help="Max sessions to show") p_list.add_argument("--tag", "-t", help="Filter by tag") p_search = subparsers.add_parser("search", help="Search sessions") p_search.add_argument("query", help="Search keyword") p_export = subparsers.add_parser("export", help="Export sessions") p_export.add_argument("--format", "-f", choices=["md", "csv"], default="md", help="Export format") subparsers.add_parser("stats", help="Show session statistics") p_tag = subparsers.add_parser("tag", help="Manage tags") tag_sub = p_tag.add_subparsers(dest="tag_action") p_tag_add = tag_sub.add_parser("add", help="Add a tag to a session") p_tag_add.add_argument("tag_name", help="Tag name") p_tag_add.add_argument("session_id", type=int, help="Session ID") p_tag_rm = tag_sub.add_parser("rm", help="Remove a tag from a session") p_tag_rm.add_argument("tag_name", help="Tag name") p_tag_rm.add_argument("session_id", type=int, help="Session ID") tag_sub.add_parser("list", help="List all tags") subparsers.add_parser("update", help="Check for updates and update the tool.") args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) dispatch = { "add": lambda: cmd_add(args.description), "list": lambda: cmd_list(args.limit, args.tag), "search": lambda: cmd_search(args.query), "export": lambda: cmd_export(args.format), "stats": lambda: cmd_stats(), "update": lambda: cmd_update(), } if args.command == "tag": if not args.tag_action: p_tag.print_help() sys.exit(1) cmd_tag(args.tag_action, args) elif args.command in dispatch: dispatch[args.command]() else: parser.print_help() sys.exit(1) if __name__ == "__main__": main()