Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.
ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux, macOS Ghostty, Terminal.app, or iTerm2. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.

[![PyPI version](https://badge.fury.io/py/shell-sage.svg)](https://badge.fury.io/py/shell-sage)
[![Python 3.8+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
Expand All @@ -12,9 +12,9 @@ ShellSage is an AI-powered command-line assistant that integrates seamlessly wit

## Overview

ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.
ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux, macOS Ghostty, Terminal.app, or iTerm2. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.

ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses tmux to automatically read your terminal history or multiple pane histories to provide contextual assistance. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference.
ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or on macOS from Ghostty, Terminal.app, or iTerm2; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference.

## Installation

Expand Down Expand Up @@ -82,6 +82,8 @@ export OPENAI_API_KEY=sk...

ShellSage works best with a properly configured tmux environment. I’ve created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt.

On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal; see [Using Terminal Context](#using-terminal-context) for provider details and limitations.

## Getting Started

### Your First Command
Expand Down Expand Up @@ -154,7 +156,7 @@ ShellSage will provide the command, explain how it works, and give you practical

### Using Terminal Context

ShellSage automatically reads your tmux history to understand what you’re working on:
ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`) and captures its full screen buffer: scrollback plus visible content, including alternate-screen apps. Its capture action temporarily replaces the macOS clipboard with a screen-file path; ShellSage restores previous text only if that path is still present and attempts to scrub the captured file after reading, but cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context:

``` python
# After running some commands that produced errors (e.g. find -name "*.tmp" .)
Expand Down Expand Up @@ -204,6 +206,26 @@ ShellSage automatically reads your tmux history to understand what you’re work
Tip: If you forget the path, find defaults to the current directory, so find
-name "*.tmp" also works!

### Clearing Context

Ctrl-L looks like it clears the terminal, but it only wipes the visible screen – tmux’s history still holds everything, so ShellSage keeps seeing it. The [`ssage_clear`](https://github.com/AnswerDotAI/shell_sage/blob/main/ssage_clear.sh) binding gives ctrl-L real “clear” semantics for ShellSage without destroying anything: it scrolls the screen into history and records a mark, and ShellSage captures never reach past the most recent clear, while your own scrollback stays intact.

Save the script from the link above, then add to your `.bashrc`:

``` sh
source /path/to/ssage_clear.sh
bind -x '"\C-l": ssage_clear'
```

Or to your `.zshrc`:

``` sh
source /path/to/ssage_clear.sh
zle -N ssage_clear && bindkey '^L' ssage_clear
```

The binding is only active at the shell prompt, so full-screen apps still receive a plain ctrl-L, and outside tmux it falls back to a normal clear.

### Piping Content for Analysis

One of ShellSage’s most powerful features is analyzing piped input:
Expand Down Expand Up @@ -255,7 +277,7 @@ One of ShellSage’s most powerful features is analyzing piped input:

### Working with Multiple Tmux Panes

When you have multiple panes open, you can reference specific ones by their ID (shown in your status bar):
When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty, Terminal.app, and iTerm2 macOS support capture only the front, focused terminal for now.

![btop output](./screenshots/btop_output.png)

Expand Down Expand Up @@ -328,7 +350,7 @@ ShellSage can be customized through a configuration file located at `~/.config/s
base_url = '' # Alternative API base URL
api_key = '' # API key override instead of the default env var
vendor_name = '' # Explicit fastllm vendor name when needed
history_lines = -1 # Lines of terminal history to include. -1 means include all
history_lines = -1 # Lines of terminal history; -1 uses tmux limit or 3000 outside tmux
code_theme = "monokai" # Syntax highlighting theme
code_lexer = "python" # Default lexer for inline code blocks
log = False # Enable SQLite logging (required for code extraction)
Expand Down
208 changes: 194 additions & 14 deletions nbs/00_core.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@
"from datetime import datetime\n",
"from fastcore.script import *\n",
"from fastcore.tools import *\n",
"try: from fastcore.tools import view_file, create_file, file_str_replace, file_insert_line\n",
"except ImportError:\n",
" from fastcore.tools import view as view_file, create as create_file\n",
" from fastcore.tools import str_replace as file_str_replace, insert as file_insert_line\n",
"from fastcore.utils import *\n",
"from fastcore.meta import delegates\n",
"from fastlite import database\n",
"from functools import partial, wraps\n",
"from functools import lru_cache, partial, wraps\n",
"from rich.live import Live\n",
"from rich.spinner import Spinner\n",
"from rich.console import Console\n",
Expand All @@ -54,7 +58,7 @@
"from fastllm.chat import AsyncChat\n",
"\n",
"import rgapi\n",
"import asyncio,os,pyperclip,re,subprocess,sys,builtins\n",
"import asyncio,os,pyperclip,re,stat,subprocess,sys,tempfile,time,builtins\n",
"from typing import Annotated"
]
},
Expand Down Expand Up @@ -493,6 +497,163 @@
"outputs": [],
"source": [
"#| export\n",
"_DEFAULT_TERMINAL_HISTORY_LINES = 3000\n",
"_GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024\n",
"_GHOSTTY_POLL_INTERVAL = 0.05\n",
"\n",
"GHOSTTY_SCREEN_SCRIPT = \"\"\"\n",
"tell application \"Ghostty\"\n",
" perform action \"write_screen_file:copy,plain\" on focused terminal of selected tab of front window\n",
"end tell\n",
"\"\"\"\n",
"\n",
"MACOS_TERMINAL_HISTORY_SCRIPTS = {\n",
" 'Apple_Terminal': 'tell application \"Terminal\" to get the contents of the selected tab of the front window',\n",
" 'iTerm.app': 'tell application id \"com.googlecode.iterm2\" to get contents of current session of current tab of current window',\n",
"}\n",
"\n",
"\n",
"def _pbpaste():\n",
" try:\n",
" return co(['pbpaste'], text=True, stderr=DEVNULL)\n",
" except Exception:\n",
" return None\n",
"\n",
"\n",
"def _pbcopy(clip):\n",
" if clip is None:\n",
" return\n",
" try:\n",
" subprocess.run(['pbcopy'], input=clip, text=True, check=False, stdout=DEVNULL, stderr=DEVNULL)\n",
" except Exception:\n",
" pass\n",
"\n",
"\n",
"@lru_cache(maxsize=1)\n",
"def _ghostty_history_temp_roots():\n",
" roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'}\n",
" roots |= {'/private' + root for root in roots if root.startswith('/var/')}\n",
" roots |= {root.removeprefix('/private') for root in roots if root.startswith('/private/')}\n",
" return tuple(Path(root).resolve() for root in roots if root)\n",
"\n",
"\n",
"def _valid_ghostty_history_path(candidate):\n",
" try:\n",
" path = Path(str(candidate).strip())\n",
" info = path.stat()\n",
" owner_uid = getattr(os, 'getuid', lambda: info.st_uid)()\n",
" if (path.name != 'screen.txt' or not stat.S_ISREG(info.st_mode) or\n",
" info.st_size > _GHOSTTY_HISTORY_MAX_BYTES or info.st_nlink != 1 or info.st_uid != owner_uid):\n",
" return False\n",
" path = path.resolve()\n",
" return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots())\n",
" except Exception:\n",
" return False\n",
"\n",
"\n",
"def _wait_for_ghostty_history_path(old_clip=None, timeout=1.0):\n",
" start = time.time()\n",
" deadline = start + timeout\n",
" old_clip = (old_clip or '').strip()\n",
" while time.time() < deadline:\n",
" candidate = (_pbpaste() or '').strip()\n",
" if _valid_ghostty_history_path(candidate):\n",
" if candidate != old_clip:\n",
" return candidate\n",
" try:\n",
" if Path(candidate).stat().st_mtime >= start:\n",
" return candidate\n",
" except OSError:\n",
" pass\n",
" time.sleep(_GHOSTTY_POLL_INTERVAL)\n",
" return None\n",
"\n",
"\n",
"def _tail_lines(text, n):\n",
" if n is None or n < 0:\n",
" return text\n",
" if n == 0:\n",
" return ''\n",
" return '\\n'.join(text.splitlines()[-n:])\n",
"\n",
"\n",
"# [tag:ghostty_history_fd_validation] Validate and read the same descriptor so path swaps cannot escape temp roots.\n",
"def _read_ghostty_history_file(candidate, n):\n",
" descriptor = None\n",
" validated = False\n",
" try:\n",
" path = Path(str(candidate).strip())\n",
" if path.name != 'screen.txt':\n",
" return None\n",
" flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)\n",
" descriptor = os.open(path, flags)\n",
" opened = os.fstat(descriptor)\n",
" owner_uid = getattr(os, 'getuid', lambda: opened.st_uid)()\n",
" if (not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES or\n",
" opened.st_nlink != 1 or opened.st_uid != owner_uid):\n",
" return None\n",
"\n",
" resolved = path.resolve(strict=True)\n",
" if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in _ghostty_history_temp_roots()):\n",
" return None\n",
" current = resolved.stat()\n",
" if ((opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino) or\n",
" current.st_nlink != 1 or current.st_uid != owner_uid):\n",
" return None\n",
" validated = True\n",
"\n",
" chunks = []\n",
" remaining = _GHOSTTY_HISTORY_MAX_BYTES + 1\n",
" while remaining:\n",
" chunk = os.read(descriptor, min(64 * 1024, remaining))\n",
" if not chunk:\n",
" break\n",
" chunks.append(chunk)\n",
" remaining -= len(chunk)\n",
" data = b''.join(chunks)\n",
" if len(data) > _GHOSTTY_HISTORY_MAX_BYTES:\n",
" return None\n",
" return _tail_lines(data.decode('utf-8', errors='replace'), n)\n",
" except Exception:\n",
" return None\n",
" finally:\n",
" if descriptor is not None:\n",
" # [tag:ghostty_history_cleanup] Scrub the validated descriptor; unlinking by path would reintroduce a swap race.\n",
" if validated:\n",
" try:\n",
" latest = os.fstat(descriptor)\n",
" if latest.st_nlink == 1 and latest.st_uid == owner_uid:\n",
" os.ftruncate(descriptor, 0)\n",
" except OSError:\n",
" pass\n",
" try:\n",
" os.close(descriptor)\n",
" except OSError:\n",
" pass\n",
"\n",
"\n",
"def get_ghostty_history_macos(n):\n",
" old_clip = _pbpaste()\n",
" history_path = None\n",
" try:\n",
" subprocess.run(['osascript', '-e', GHOSTTY_SCREEN_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n",
" history_path = _wait_for_ghostty_history_path(old_clip)\n",
" if not history_path:\n",
" return None\n",
" return _read_ghostty_history_file(history_path, n)\n",
" except Exception:\n",
" return None\n",
" finally:\n",
" # [tag:ghostty_clipboard_restore] Never overwrite clipboard content changed while capture was running.\n",
" current_clip = _pbpaste() if old_clip is not None and history_path else None\n",
" if current_clip is not None and current_clip.strip() == str(history_path).strip():\n",
" _pbcopy(old_clip)\n",
"\n",
"\n",
"def is_ghostty():\n",
" return os.environ.get('TERM_PROGRAM') == 'ghostty' or os.environ.get('TERM', '').startswith('xterm-ghostty')\n",
"\n",
"\n",
"def get_hist_tmux(n, pid='current'):\n",
" if not os.environ.get('TMUX'): return None\n",
" try:\n",
Expand Down Expand Up @@ -526,12 +687,8 @@
"source": [
"#| export\n",
"def get_hist_osa(n, pid=''):\n",
" script = ({\n",
" 'Apple_Terminal':'tell application \"Terminal\" to get the contents of the selected tab of the front window',\n",
" 'iTerm.app':'tell application id \"com.googlecode.iterm2\" to get text of current session of current tab of current window',\n",
" }).get(os.getenv('TERM_PROGRAM'))\n",
" if not script: return None\n",
" return \"\\n\".join(co(['osascript', '-e', script], text=True).splitlines()[:n])"
" \"Backwards-compatible wrapper for macOS terminal history capture.\"\n",
" return get_macos_terminal_history(n) if _is_current_macos_terminal(pid or 'current') else None"
]
},
{
Expand All @@ -553,7 +710,33 @@
"source": [
"#| export\n",
"def get_history(n, pid='current'):\n",
" return get_hist_tmux(n, pid) or get_hist_osa(n)"
" \"Backwards-compatible entry point for terminal history dispatch.\"\n",
" return get_terminal_history(n, pid)\n",
"\n",
"\n",
"def _is_current_macos_terminal(pid):\n",
" return sys.platform == 'darwin' and pid in ('current', None)\n",
"\n",
"\n",
"def get_macos_terminal_history(n):\n",
" script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM'))\n",
" if not script or sys.platform != 'darwin':\n",
" return None\n",
" try:\n",
" return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n)\n",
" except Exception:\n",
" return None\n",
"\n",
"\n",
"def get_terminal_history(n, pid='current'):\n",
" if os.environ.get('TMUX'):\n",
" n = tmux_history_lim() if n is None or n < 0 else n\n",
" return get_hist_tmux(n, pid)\n",
" if not _is_current_macos_terminal(pid):\n",
" return None\n",
"\n",
" n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n\n",
" return get_ghostty_history_macos(n) if is_ghostty() else get_macos_terminal_history(n)"
]
},
{
Expand Down Expand Up @@ -1046,7 +1229,7 @@
" v: Annotated[str, \"Print version\", dict(action='version')] = '%(prog)s ' + __version__,\n",
" pid: str = 'current', # `current`, `all` or tmux pane_id (e.g. %0) for context\n",
" skip_system: bool = False, # Whether to skip system information in the AI's context\n",
" history_lines: int = None, # Number of history lines. Defaults to tmux scrollback history length\n",
" history_lines: int = None, # Number of terminal history lines; defaults to tmux limit or 3000 outside tmux\n",
" mode: str = 'default', # Available ShellSage modes: ['default', 'sassy']\n",
" model: str = None, # The LLM model that will be invoked on the LLM provider\n",
" vendor_name: str = None, # Vendor name for non auto-resolved models (e.g. 'codex', 'fireworks_ai', 'moonshot', 'deepseek', ...)\n",
Expand Down Expand Up @@ -1082,10 +1265,7 @@
" query = ' '.join(query)\n",
" ctxt = '' if skip_system else _sys_info()\n",
"\n",
" # Get tmux history if in a tmux session\n",
" if opts.history_lines is None or opts.history_lines < 0:\n",
" opts.history_lines = tmux_history_lim()\n",
" history = get_history(opts.history_lines, pid)\n",
" history = get_terminal_history(opts.history_lines, pid)\n",
" if history: ctxt += f'<terminal_history>\\n{history}\\n</terminal_history>'\n",
"\n",
" # Read from redirect stdin if available\n",
Expand Down
Loading