Core

Imports


CodeBlock.__rich_console__

def __rich_console__(
    console, options
):

Jupyter does work with rich.live.Live, this fixes it.

@contextmanager
def Live(start, **kw):
    print(start)
    def update(s, refresh=False): clear_output(True);print(s)
    yield NS(update=update)
async def print_md(md_stream):
    "Print streamed markdown"
    with Live(Spinner("dots", text="Connecting..."), auto_refresh=False) as live:
        async for part in md_stream: live.update(Markdown(part), refresh=True)

Model Setup

System Environment

# aliases = _aliases('bash')
# print(aliases)
# print(_sys_info())

Terminal history

The most cross platfrom way is to use tmux

The ssage_clear shell binding (see ssage_clear.sh in the repo root) gives ctrl-L “clear” semantics without destroying anything: it scrolls the visible screen into tmux history, then records the pane’s absolute history line in ~/.cache/shell_sage/mark-<pane>. Because it’s a readline binding, it is only active at the shell prompt — full-screen apps still receive a plain ctrl-L. get_pane treats any recorded mark as a context boundary: captures never reach past the most recent clear, while your own scrollback stays intact.


pane_mark

def pane_mark(
    pid:NoneType=None
):

Absolute history line recorded by the ssage_clear shell binding, or None


get_pane

def get_pane(
    n, pid:NoneType=None
):

Get output from a tmux pane, never reaching past an ssage_clear mark

# p = get_pane(20)
# print(p[:512])

get_panes

def get_panes(
    n
):
# ps = get_panes(20)
# print(ps[:512])
co(['tmux', 'display-message', '-p', '#{history-limit}'], text=True).strip()
'2000'

tmux_history_lim

def tmux_history_lim():
tmux_history_lim()
2000

get_hist_tmux

def get_hist_tmux(
    n, pid:str='current'
):

We can also use the terminal app’s API to get the history. Here is how to do it for Apple Terminal and iTerm.app.

Due to macOS sandboxing, osascript can only script the terminal app it was executed from and cannot access other terminal apps. So, the easiest way to test this is to run these commands manually in the terminal:

osascript -e 'tell application "Terminal" to get the contents of the selected tab of the front window'
osascript -e 'tell application id "com.googlecode.iterm2" to get text of current session of current tab of current window'

get_hist_osa

def get_hist_osa(
    n, pid:str=''
):
#get_hist_osa(3)

get_history

def get_history(
    n, pid:str='current'
):

Options and ShellSage


get_opts

def get_opts(
    **opts
):
opts = get_opts(model=None, log=None, api_base=None, api_key=''); opts
{'api_base': None, 'api_key': '', 'log': True, 'model': 'claude-opus-4-6'}

Rich’s Live display and input() can’t coexist — Live manages the terminal cursor, and input() needs it too. When Live.stop() is called, it prints its current renderable as static text, which causes duplication since get_res accumulates the full response. The fix: clear Live’s renderable before stopping, flush the accumulated response as static markdown, and reset the buffer. This way streaming resumes fresh after the tool interaction with no overlap.


with_permission

def with_permission(
    action_desc
):

fd

def fd(
    pattern:str=None, # Regex matched against file basenames
    root:str='.', # Directory to walk recursively
    max_depth:int=None, # Directory depth limit
    limit:int=200, # Cap on returned paths
):

Find files recursively by name (gitignore respected)


ls

def ls(
    path:str='.', # Directory to list
    hidden:bool=False, # Include hidden entries?
):

List a directory, one level, like ls -l


rg

def rg(
    pattern:str, # Regex to search file contents for
    root:str='.', # Directory or file to search
    context:int=0, # Lines of context around each match
    max_results:int=None, # Cap on matched lines
):

Search file contents recursively (ripgrep semantics, gitignore respected)

print(tools[1]('.'))
About to View file/directory with the following arguments: {'args': ['.'], 'kwargs': {}}
Directory contents of /Users/rensdimmendaal/aai/ws/shell_sage/nbs:
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/00_core.ipynb (28.9k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/_quarto.yml (0.3k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/sidebar.yml (0.1k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/styles.css (0.6k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/CNAME (0.0k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/01_config.ipynb (4.1k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/tmux.conf (1.4k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/nbdev.yml (0.2k)
/Users/rensdimmendaal/aai/ws/shell_sage/nbs/index.ipynb (65.2k)

get_sage

def get_sage(
    model, # LiteLLM compatible model name
    mode:str='default', search:bool=False, # Search (l,m,h), if model supports it
    use_safecmd:bool=False, custom_instructions:NoneType=None, *, sp:str='', # System prompt
    temp:NoneType=None, # Temperature
    tools:list=None, # Add tools
    hist:list=None, # Chat history
    ns:Optional[dict]=None, # Custom namespace for tool calling
    cache:bool=False, # Anthropic prompt caching
    cache_idxs:list=[-1], # Anthropic cache breakpoint idxs, use `0` for sys prompt if provided
    ttl:NoneType=None, # Anthropic prompt caching ttl
    api_name:NoneType=None, # API to use, one of ApiName: openai (responses), openai_chat, anthropic, gemini
    vendor_name:NoneType=None, # Vendor name, one of vendor_mapping which resolves api_base/api_key automatically
    api_key:NoneType=None, # API key when model can't be resolved or vendor_name is not known or codex
    oauth_token:NoneType=None, # A subscription's OAuth token (Codex, Claude Code) in place of an API key
    base_url:NoneType=None, # API base url when model can't be resolved or vendor_name is not known
    endpoint:NoneType=None, # Override the transport's request path, for a server mounting Responses at a custom location
    extra_headers:NoneType=None, # Extra HTTP headers for custom providers
    use_previous_response_id:bool=False, # Continue tool rounds with Responses IDs instead of replaying history
    markup:int=0, # Cost markup multiplier (e.g. 0.5 for 50%)
    showthink:bool=False, # Stamp streamed thinking parts to display their text rather than 🧠 glyphs
    cbs:list=None, # Chat callbacks
    default_cbs:bool=True, # Whether to include default callbacks
):

LiteLLM chat client.

# m = 'ollama_chat/qwen3:8b'
# ssage = get_sage(m)
# ssage('Howdy!')
m = 'claude-sonnet-4-6'
ssage = get_sage(m, search='l', use_safecmd=True)
await ssage('Hi, how are ya?', think='l')

I’m doing great, thanks for asking! Ready to help you navigate the command line. 🖥️

What shell commands or sysadmin topics can I help you with today?

  • model: claude-sonnet-4-6
  • finish_reason: stop
  • usage: Usage(prompt_tokens=3811, completion_tokens=43, total_tokens=3854, cached_tokens=0, cache_creation_tokens=0, reasoning_tokens=0, raw={'input_tokens': 3811, 'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'cache_creation': {'ephemeral_5m_input_tokens': 0, 'ephemeral_1h_input_tokens': 0}, 'output_tokens': 43, 'output_tokens_details': {'thinking_tokens': 0}, 'service_tier': 'standard', 'inference_geo': 'global'})

get_res

def get_res(
    sage, q, opts, *, msg:NoneType=None, step:int=1, search:NoneType=None, tool_choice:NoneType=None,
    initial_body:NoneType=None, api_name:NoneType=None, vendor_name:NoneType=None, api_key:NoneType=None,
    oauth_token:NoneType=None, base_url:NoneType=None, endpoint:NoneType=None, xtra_body:NoneType=None,
    xtra_hdrs:NoneType=None, stream:bool=False, previous_response_id:NoneType=None, stop_callables:NoneType=None,
    retries:int=2, retry_delay:float=0.5, system:NoneType=None, max_tokens:NoneType=None, temperature:NoneType=None,
    tools:NoneType=None, parallel_tool_calls:NoneType=None, reasoning_effort:NoneType=None,
    web_search_options:NoneType=None, cache_idxs:NoneType=None, ttl:NoneType=None
):

One model turn plus its tool round, recursing for the next; turn-constant options come from turn_opts

Queries and their context are literal user text, even when a diff or terminal history contains fastllm tool or usage markers. They must not be parsed as assistant history.

opts=NS(base_url='', api_key='', think='l')
[o async for o in get_res(ssage, 'Use tools to check if we have a  .git in the current directory. Respond with yes/no', opts)]
['No.']
await print_md(get_res(ssage, 'Hi!', opts))
Hi! What shell or sysadmin topic can I help you with today? 😊                                                     

Here we use bash tool instead of view as it includes file sizes too which are changing:

await print_md(get_res(ssage, 'Please use your bash tool to list the files in this directory. Only respond with a single paragraph', opts))
The current directory contains the following files: _quarto.yml, 00_core.ipynb, 01_config.ipynb, CNAME,            
index.ipynb, nbdev.yml, sidebar.yml, styles.css, and tmux.conf.                                                    
await print_md(get_res(ssage, 'Please search the web for interesting facts about Linux. Only respond with a single paragraph.', opts, web_search_options=dict(type="web_search_20250305")));
Here are some fascinating facts about Linux: *Linus Torvalds, a Finnish computer science student, created the first
version of Linux in 1991 as a hobby project while studying at the University of Helsinki. *He originally wanted to 
call it "FreaX" (a combination of "free", "freak", and "Unix"), but was persuaded otherwise by the owner of the    
server hosting his early code, who preferred the name "Linux." *The first Linux release weighed in at less than 1MB
uncompressed — quite a contrast to today, and *all 500 of the fastest supercomputers in the world now run Linux.   
*The International Space Station has been running Linux since 2013. *Linux is written over 95% in C language, and  
*today over 80% of Linux contributions come from developers paid by big enterprises, with Intel topping the list of
contributors for most kernel releases. On a fun note, *Linux is also a genuine washing powder brand in Switzerland,
and *one of the unexpected workloads for Linux has been milking cows — the DeLaval "Voluntary Milking System" lets 
cows decide when they'll be milked, all managed by a single-board computer running Linux!                          

Logging


mk_db

def mk_db():

Log

def Log(
    *args, **kwargs
):
# db = mk_db()
# log = db.logs.insert(Log(timestamp=datetime.now().isoformat(), query='Hi, who are you?', model='llama3.2',
#                          response='I am ShellSage, a command-line teaching assistant!', mode='default'))
# log

Main


main

async def main(
    query:str, # The query to send to the LLM
    v:str='%(prog)s 1.1.3', # Print version
    pid:str='current', # `current`, `all` or tmux pane_id (e.g. %0) for context
    skip_system:bool=False, # Whether to skip system information in the AI's context
    history_lines:int=None, # Number of history lines. Defaults to tmux scrollback history length
    mode:str='default', # Available ShellSage modes: ['default', 'sassy']
    model:str=None, # The LLM model, optionally vendor-prefixed (e.g. 'codex/gpt-5.5', 'claude_code/claude-sonnet-4-6')
    search:str=None, # Wheather to allow the LLM to search the internet
    base_url:str=None, # If using a custom LLM base url
    api_key:str=None, # If don't have the default environment variables set
    think:str=None, # Reasoning effort level: 'l', 'm', 'h' (for supported models)
    trust:str=None, # Comma-delimited list of tools to always allow (e.g. "view_file,rg")
    code_theme:str=None, # The code theme to use when rendering ShellSage's responses
    code_lexer:str=None, # The lexer to use for inline code markdown blocks
    raw:bool=False, # Skip markdown rendering and print plain text
    custom_instructions:str=None, # Extra instructions appended to the system prompt
):
await main(['Do you have a `bash` tool?.'], history_lines=0)
No, I don't have a bash tool. My available tools are:                                                              

rg — run ripgrep searches                                                                                       
view — view files/directories                                                                                   
create / str_replace / insert — edit files                                                                      
web_search — search the web                                                                                     

So I can read and edit files, and search through code, but I can't execute arbitrary shell commands. That's by     
design — the idea is for you to run the commands in your terminal, which keeps you in the driver's seat for        
learning.                                                                                                          

If you need me to look something up in your project files, I can do that. But for running commands, I'll give you  
the snippet to paste and run yourself.                                                                             
await main(['Hello!'], history_lines=0, custom_instructions='Talk like a pirate. Start every reply with "Arrr,"')
test_eq(_res.startswith('Arrr,'), True)
Arrr, ahoy there, matey! 🏴‍☠️                                                                                        

Welcome aboard! Looks like ye've just been editin' yer Shell Sage config file. What can this ol' sea dog help ye   
with today? Whether it be shell commands, system administration, or navigatin' the treacherous waters of the       
terminal — I be at yer service!                                                                                    
r = f'''
Hello, user! Here are some code blocks:

```python
for i in range(10): print(i)
```

```
This doesn't even have a language definition!
```

```bash
ls **/*
```
'''
db = mk_db()
db.logs.insert(Log(timestamp=datetime.now().isoformat(), query='', response=r, model='', mode=''))
Log(id=799, timestamp='2026-05-28T15:23:20.783718', query='', response="\nHello, user! Here are some code blocks:\n\n```python\nfor i in range(10): print(i)\n```\n\n```\nThis doesn't even have a language definition!\n```\n\n```bash\nls **/*\n```\n", model='', mode='')

extract_cf

def extract_cf(
    idx
):
extract_cf(0)
'for i in range(10): print(i)'

extract

def extract(
    idx:int, # Index of code block to extract
    copy:bool=False, # Copy to clipboard
    do_print:bool=False, # Print (useful for readline custom shortcuts)
):

Extracts the idx’th codefence from the last shell sage response and sends it to tmux by default