shix 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(pip3 install:*)",
5
+ "Bash(python3 -m pip show textual)"
6
+ ]
7
+ }
8
+ }
shix-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ *.egg
8
+ .DS_Store
shix-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: shix
3
+ Version: 0.1.0
4
+ Summary: Find and run shell commands using natural language, powered by your shell history and local AI
5
+ Project-URL: Homepage, https://github.com/nicla97/shix
6
+ Project-URL: Repository, https://github.com/nicla97/shix
7
+ Project-URL: Issues, https://github.com/nicla97/shix/issues
8
+ Author-email: Nicolaj Larsen <nicla97@github.com>
9
+ License-Expression: MIT
10
+ Keywords: cli,command,history,search,shell,terminal,tui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Shells
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pyperclip>=1.8.0
25
+ Requires-Dist: rich>=13.0.0
26
+ Requires-Dist: textual>=0.50.0
27
+ Requires-Dist: typer>=0.9.0
28
+ Provides-Extra: local
29
+ Requires-Dist: httpx>=0.25.0; extra == 'local'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # shix
33
+
34
+ Find and run shell commands using natural language — powered by your own shell history.
35
+
36
+ shix searches your shell history to suggest relevant commands based on what you describe. No cloud, no API keys, just your local history.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install git+https://github.com/nicla97/shix.git
42
+ ```
43
+
44
+ Or clone and install locally:
45
+
46
+ ```bash
47
+ git clone https://github.com/nicla97/shix.git
48
+ cd shix
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ # Describe what you want to do
56
+ shix ask "docker compose restart"
57
+ shix ask "git push"
58
+ shix ask "activate virtual environment"
59
+
60
+ # Show more results
61
+ shix ask "git" --top 15
62
+
63
+ # Use a local AI model via Ollama (optional)
64
+ shix ask "find all large files" --local
65
+ ```
66
+
67
+ An interactive TUI opens where you can:
68
+
69
+ - **Arrow keys** to navigate between suggestions
70
+ - **Enter** or **click** to copy a command to clipboard
71
+ - **Mouse hover** to highlight a suggestion
72
+ - **Scroll** through many results
73
+ - **Esc** or **q** to quit
74
+
75
+ ## Modes
76
+
77
+ ### Offline (default)
78
+
79
+ Fuzzy keyword search on your shell history. Instant results, no dependencies beyond Python.
80
+
81
+ ```bash
82
+ shix ask "docker compose"
83
+ ```
84
+
85
+ ### Local AI (`--local`)
86
+
87
+ Sends your sanitized history to a local [Ollama](https://ollama.com) model for smarter, context-aware suggestions.
88
+
89
+ ```bash
90
+ # First time setup
91
+ ollama pull qwen2.5:7b
92
+ ollama serve # keep running in another terminal
93
+
94
+ # OR RUN THE OLLAMA APP
95
+
96
+ # Then use --local
97
+ shix ask "find files older than 7 days" --local
98
+ ```
99
+
100
+ Requires the optional `httpx` dependency:
101
+
102
+ ```bash
103
+ pip install shix[local]
104
+ # or just: pip install httpx
105
+ ```
106
+
107
+ ## Options
108
+
109
+ | Flag | Short | Description |
110
+ |------|-------|-------------|
111
+ | `--top N` | `-t` | Number of suggestions (default: 5) |
112
+ | `--local` | `-l` | Use local Ollama model |
113
+ | `--model NAME` | `-m` | Ollama model to use (default: qwen2.5:7b) |
114
+ | `--history N` | `-n` | Limit history lines (0 = all, default: 0) |
115
+ | `--version` | `-v` | Show version |
116
+
117
+ ## How it works
118
+
119
+ 1. Reads your shell history (`~/.zsh_history`, `~/.bash_history`, or PowerShell history on Windows)
120
+ 2. Sanitizes secrets (API keys, passwords, tokens) before processing
121
+ 3. Matches your query against history using fuzzy search (or sends to Ollama with `--local`)
122
+ 4. Displays results in an interactive TUI for quick selection
123
+
124
+ ## Supported shells
125
+
126
+ - **zsh** (macOS/Linux)
127
+ - **bash** (macOS/Linux)
128
+ - **PowerShell** (Windows)
129
+
130
+ ## License
131
+
132
+ MIT
shix-0.1.0/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # shix
2
+
3
+ Find and run shell commands using natural language — powered by your own shell history.
4
+
5
+ shix searches your shell history to suggest relevant commands based on what you describe. No cloud, no API keys, just your local history.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install git+https://github.com/nicla97/shix.git
11
+ ```
12
+
13
+ Or clone and install locally:
14
+
15
+ ```bash
16
+ git clone https://github.com/nicla97/shix.git
17
+ cd shix
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Describe what you want to do
25
+ shix ask "docker compose restart"
26
+ shix ask "git push"
27
+ shix ask "activate virtual environment"
28
+
29
+ # Show more results
30
+ shix ask "git" --top 15
31
+
32
+ # Use a local AI model via Ollama (optional)
33
+ shix ask "find all large files" --local
34
+ ```
35
+
36
+ An interactive TUI opens where you can:
37
+
38
+ - **Arrow keys** to navigate between suggestions
39
+ - **Enter** or **click** to copy a command to clipboard
40
+ - **Mouse hover** to highlight a suggestion
41
+ - **Scroll** through many results
42
+ - **Esc** or **q** to quit
43
+
44
+ ## Modes
45
+
46
+ ### Offline (default)
47
+
48
+ Fuzzy keyword search on your shell history. Instant results, no dependencies beyond Python.
49
+
50
+ ```bash
51
+ shix ask "docker compose"
52
+ ```
53
+
54
+ ### Local AI (`--local`)
55
+
56
+ Sends your sanitized history to a local [Ollama](https://ollama.com) model for smarter, context-aware suggestions.
57
+
58
+ ```bash
59
+ # First time setup
60
+ ollama pull qwen2.5:7b
61
+ ollama serve # keep running in another terminal
62
+
63
+ # OR RUN THE OLLAMA APP
64
+
65
+ # Then use --local
66
+ shix ask "find files older than 7 days" --local
67
+ ```
68
+
69
+ Requires the optional `httpx` dependency:
70
+
71
+ ```bash
72
+ pip install shix[local]
73
+ # or just: pip install httpx
74
+ ```
75
+
76
+ ## Options
77
+
78
+ | Flag | Short | Description |
79
+ |------|-------|-------------|
80
+ | `--top N` | `-t` | Number of suggestions (default: 5) |
81
+ | `--local` | `-l` | Use local Ollama model |
82
+ | `--model NAME` | `-m` | Ollama model to use (default: qwen2.5:7b) |
83
+ | `--history N` | `-n` | Limit history lines (0 = all, default: 0) |
84
+ | `--version` | `-v` | Show version |
85
+
86
+ ## How it works
87
+
88
+ 1. Reads your shell history (`~/.zsh_history`, `~/.bash_history`, or PowerShell history on Windows)
89
+ 2. Sanitizes secrets (API keys, passwords, tokens) before processing
90
+ 3. Matches your query against history using fuzzy search (or sends to Ollama with `--local`)
91
+ 4. Displays results in an interactive TUI for quick selection
92
+
93
+ ## Supported shells
94
+
95
+ - **zsh** (macOS/Linux)
96
+ - **bash** (macOS/Linux)
97
+ - **PowerShell** (Windows)
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shix"
7
+ version = "0.1.0"
8
+ description = "Find and run shell commands using natural language, powered by your shell history and local AI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Nicolaj Larsen", email = "nicla97@github.com" },
14
+ ]
15
+ keywords = ["cli", "shell", "history", "command", "search", "terminal", "tui"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: System :: Shells",
28
+ "Topic :: Utilities",
29
+ ]
30
+ dependencies = [
31
+ "typer>=0.9.0",
32
+ "rich>=13.0.0",
33
+ "textual>=0.50.0",
34
+ "pyperclip>=1.8.0",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ local = ["httpx>=0.25.0"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/nicla97/shix"
42
+ Repository = "https://github.com/nicla97/shix"
43
+ Issues = "https://github.com/nicla97/shix/issues"
44
+
45
+ [project.scripts]
46
+ shix = "shix.cli:app"
@@ -0,0 +1,3 @@
1
+ """shix — find and run shell commands using natural language."""
2
+
3
+ __version__ = "0.1.0"
shix-0.1.0/shix/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """shix CLI — find shell commands using natural language."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from shix import __version__
11
+ from shix.history import read_history
12
+ from shix.sanitize import sanitize
13
+ from shix.tui import SuggestionItem, run_tui
14
+
15
+ app = typer.Typer(
16
+ name="shix",
17
+ help="Find and run shell commands using natural language.",
18
+ add_completion=False,
19
+ no_args_is_help=True,
20
+ )
21
+ console = Console()
22
+
23
+
24
+ @app.command()
25
+ def ask(
26
+ query: str = typer.Argument(..., help="Describe what you want to do"),
27
+ top: int = typer.Option(5, "--top", "-t", help="Number of suggestions to show"),
28
+ local: bool = typer.Option(False, "--local", "-l", help="Use local Ollama model instead of offline search"),
29
+ model: str = typer.Option("qwen2.5:7b", "--model", "-m", help="Ollama model (only with --local)"),
30
+ base_url: str = typer.Option("http://localhost:11434", "--url", "-u", help="Ollama server URL (only with --local)"),
31
+ history_lines: int = typer.Option(0, "--history", "-n", help="Number of history lines to read (0 = all)"),
32
+ ) -> None:
33
+ """Describe what you want to do and get command suggestions."""
34
+ with console.status("[bold cyan] Reading shell history..."):
35
+ history = read_history(max_lines=history_lines)
36
+
37
+ if not history:
38
+ console.print("[yellow] No shell history found.[/yellow]")
39
+ if local:
40
+ history = []
41
+ else:
42
+ console.print("[yellow] Offline mode needs history. Try --local for AI suggestions.[/yellow]")
43
+ raise typer.Exit(1)
44
+
45
+ clean_history = sanitize(history)
46
+
47
+ if local:
48
+ _run_local(clean_history, query, model, base_url, top)
49
+ else:
50
+ _run_offline(history, query, top)
51
+
52
+
53
+ def _run_offline(history: list[str], query: str, top: int = 5) -> None:
54
+ """Fuzzy search through history — no model, instant results."""
55
+ from shix.fuzzy import fuzzy_search, _tokenize
56
+
57
+ results = fuzzy_search(history, query, max_results=top)
58
+
59
+ query_tokens = _tokenize(query)
60
+ history_blob = " ".join(history).lower()
61
+ missing = [t for t in query_tokens if t not in history_blob]
62
+
63
+ items = [
64
+ SuggestionItem(command=r.command, explanation=r.reason)
65
+ for r in results
66
+ ]
67
+
68
+ result = run_tui(items, query, "offline", missing if missing else None)
69
+ if result:
70
+ console.print(f"[green]Copied:[/green] {result}")
71
+
72
+
73
+ def _run_local(history: list[str], query: str, model: str, base_url: str, top: int = 5) -> None:
74
+ """Query local Ollama model for suggestions."""
75
+ try:
76
+ from shix.ollama import get_suggestions
77
+ except ImportError:
78
+ console.print(
79
+ "[red] Missing dependency for --local mode.[/red]\n"
80
+ " Install with: [bold]pip install shix\\[local][/bold] (or: pip install httpx)"
81
+ )
82
+ raise typer.Exit(1)
83
+
84
+ with console.status("[bold cyan] Thinking..."):
85
+ try:
86
+ suggestions = get_suggestions(
87
+ history=history,
88
+ query=query,
89
+ model=model,
90
+ base_url=base_url,
91
+ count=top,
92
+ )
93
+ except Exception as e:
94
+ error_msg = str(e)
95
+ if "ConnectError" in type(e).__name__ or "Connection" in error_msg:
96
+ console.print(
97
+ "[red] Could not connect to Ollama.[/red]\n"
98
+ " Make sure Ollama is running: [bold]ollama serve[/bold]\n"
99
+ f" And that the model is pulled: [bold]ollama pull {model}[/bold]"
100
+ )
101
+ else:
102
+ console.print(f"[red] Error: {error_msg}[/red]")
103
+ raise typer.Exit(1)
104
+
105
+ items = [
106
+ SuggestionItem(command=s.command, explanation=s.explanation)
107
+ for s in suggestions
108
+ ]
109
+
110
+ result = run_tui(items, query, f"local ({model})")
111
+ if result:
112
+ console.print(f"[green]Copied:[/green] {result}")
113
+
114
+
115
+ @app.callback(invoke_without_command=True)
116
+ def main(
117
+ version: Optional[bool] = typer.Option(None, "--version", "-v", help="Show version", is_eager=True),
118
+ ) -> None:
119
+ """shix — find shell commands using natural language."""
120
+ if version:
121
+ console.print(f"shix {__version__}")
122
+ raise typer.Exit()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ app()
@@ -0,0 +1,138 @@
1
+ """Offline fuzzy search engine for shell history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ # Map natural language terms to common command patterns
9
+ COMMAND_ALIASES: dict[str, list[str]] = {
10
+ "find": ["find", "locate", "fd", "ls", "grep", "rg"],
11
+ "delete": ["rm", "rmdir", "trash", "del"],
12
+ "copy": ["cp", "rsync", "scp"],
13
+ "move": ["mv"],
14
+ "search": ["grep", "rg", "ag", "ack", "find"],
15
+ "install": ["brew install", "apt install", "pip install", "npm install", "cargo install"],
16
+ "process": ["ps", "top", "htop", "kill", "pkill"],
17
+ "network": ["curl", "wget", "ping", "netstat", "ss", "nmap", "dig", "nslookup"],
18
+ "disk": ["df", "du", "ncdu"],
19
+ "docker": ["docker", "docker-compose", "podman"],
20
+ "git": ["git"],
21
+ "size": ["du", "ls -l", "wc", "stat"],
22
+ "permission": ["chmod", "chown", "chgrp"],
23
+ "compress": ["tar", "zip", "gzip", "bzip2", "xz", "7z"],
24
+ "extract": ["tar", "unzip", "gunzip"],
25
+ "edit": ["vim", "nvim", "nano", "code", "emacs"],
26
+ "serve": ["python -m http.server", "npx serve", "nginx"],
27
+ "port": ["lsof -i", "netstat", "ss"],
28
+ "log": ["tail", "less", "journalctl", "cat"],
29
+ "ssh": ["ssh", "scp", "sftp"],
30
+ "env": ["env", "printenv", "export", "set"],
31
+ "start": ["systemctl start", "docker start", "npm start", "brew services start"],
32
+ "stop": ["systemctl stop", "docker stop", "kill", "pkill", "brew services stop"],
33
+ "restart": ["systemctl restart", "docker restart", "brew services restart"],
34
+ "list": ["ls", "ll", "exa", "tree"],
35
+ "download": ["curl", "wget", "aria2c"],
36
+ "upload": ["scp", "rsync", "curl -X POST"],
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class FuzzyResult:
42
+ command: str
43
+ score: float
44
+ reason: str
45
+
46
+
47
+ def _tokenize(text: str) -> list[str]:
48
+ """Split text into lowercase tokens."""
49
+ return re.findall(r"[a-zA-Z0-9_./-]+", text.lower())
50
+
51
+
52
+ def _score_command(command: str, query_tokens: list[str], alias_commands: set[str]) -> tuple[float, str]:
53
+ """Score a command against the query. Returns (score, reason)."""
54
+ cmd_lower = command.lower()
55
+ cmd_tokens = _tokenize(command)
56
+ score = 0.0
57
+ reasons: list[str] = []
58
+
59
+ # Direct token matches — substring in the full command text
60
+ matched_tokens = [t for t in query_tokens if t in cmd_lower]
61
+ unmatched_tokens = [t for t in query_tokens if t not in cmd_lower]
62
+
63
+ if matched_tokens:
64
+ score += len(matched_tokens) * 3.0
65
+ reasons.append(f"matches: {', '.join(matched_tokens)}")
66
+
67
+ # Bonus: reward commands that match ALL query tokens (multi-term relevance)
68
+ if len(query_tokens) > 1 and matched_tokens:
69
+ coverage = len(matched_tokens) / len(query_tokens)
70
+ score += coverage * 5.0 # full match = +5, half match = +2.5
71
+
72
+ # Alias matches — only match against the first token (the actual command)
73
+ cmd_base = cmd_tokens[0] if cmd_tokens else ""
74
+ for alias_cmd in alias_commands:
75
+ alias_parts = alias_cmd.lower().split()
76
+ if len(alias_parts) == 1 and cmd_base == alias_parts[0]:
77
+ score += 2.0
78
+ reasons.append(f"relates to: {alias_cmd}")
79
+ break
80
+ elif len(alias_parts) > 1 and cmd_lower.startswith(alias_cmd.lower()):
81
+ score += 2.5
82
+ reasons.append(f"relates to: {alias_cmd}")
83
+ break
84
+
85
+ # Partial/substring matches on unmatched tokens — check against first few command tokens
86
+ cmd_head_tokens = cmd_tokens[:3]
87
+ for token in unmatched_tokens:
88
+ if len(token) >= 4:
89
+ for cmd_token in cmd_head_tokens:
90
+ if token in cmd_token or cmd_token in token:
91
+ score += 1.0
92
+ reasons.append(f"~{token}")
93
+ break
94
+
95
+ if score == 0:
96
+ return 0.0, ""
97
+
98
+ reason = "; ".join(reasons) if reasons else "partial match"
99
+ return score, reason
100
+
101
+
102
+ def fuzzy_search(history: list[str], query: str, max_results: int = 3) -> list[FuzzyResult]:
103
+ """Search shell history using fuzzy keyword matching.
104
+
105
+ Matches query tokens against commands and uses alias mappings
106
+ to connect natural language terms to shell commands.
107
+ """
108
+ query_tokens = _tokenize(query)
109
+ if not query_tokens:
110
+ return []
111
+
112
+ # Build set of relevant command patterns from aliases
113
+ alias_commands: set[str] = set()
114
+ for token in query_tokens:
115
+ for alias_key, commands in COMMAND_ALIASES.items():
116
+ if token in alias_key or alias_key in token:
117
+ alias_commands.update(commands)
118
+
119
+ # Score all history commands
120
+ scored: list[FuzzyResult] = []
121
+ for cmd in history:
122
+ score, reason = _score_command(cmd, query_tokens, alias_commands)
123
+ if score > 0:
124
+ scored.append(FuzzyResult(command=cmd, score=score, reason=reason))
125
+
126
+ # Sort by score descending, deduplicate
127
+ scored.sort(key=lambda r: r.score, reverse=True)
128
+
129
+ seen: set[str] = set()
130
+ results: list[FuzzyResult] = []
131
+ for r in scored:
132
+ if r.command not in seen:
133
+ seen.add(r.command)
134
+ results.append(r)
135
+ if len(results) >= max_results:
136
+ break
137
+
138
+ return results
@@ -0,0 +1,125 @@
1
+ """Cross-platform shell history reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ from pathlib import Path
8
+
9
+
10
+ def _get_history_paths() -> list[Path]:
11
+ """Return candidate history file paths for the current platform."""
12
+ home = Path.home()
13
+ system = platform.system()
14
+
15
+ if system == "Windows":
16
+ # PowerShell PSReadLine history
17
+ appdata = os.environ.get("APPDATA", "")
18
+ if appdata:
19
+ ps_history = Path(appdata) / "Microsoft" / "Windows" / "PowerShell" / "PSReadLine" / "ConsoleHost_history.txt"
20
+ return [ps_history]
21
+ return []
22
+
23
+ # macOS / Linux — prefer zsh, fall back to bash
24
+ return [
25
+ home / ".zsh_history",
26
+ home / ".bash_history",
27
+ ]
28
+
29
+
30
+ def _parse_zsh_history_line(raw: str) -> str | None:
31
+ """Parse a single zsh history line, handling the extended format.
32
+
33
+ Zsh extended history format: `: <timestamp>:<duration>;<command>`
34
+ Regular format: just the command.
35
+ Multi-line commands use trailing backslash continuation.
36
+ """
37
+ line = raw.strip()
38
+ if not line:
39
+ return None
40
+
41
+ # Extended history format
42
+ if line.startswith(": ") and ";" in line:
43
+ _, _, rest = line.partition(";")
44
+ return rest.strip() or None
45
+
46
+ return line
47
+
48
+
49
+ def _read_file_tail(path: Path, max_lines: int = 0) -> list[str]:
50
+ """Read lines from a file. If max_lines > 0, return only the last N lines."""
51
+ try:
52
+ raw = path.read_bytes()
53
+ except (OSError, PermissionError):
54
+ return []
55
+
56
+ # Try utf-8 first, fall back to latin-1 (never fails)
57
+ try:
58
+ text = raw.decode("utf-8")
59
+ except UnicodeDecodeError:
60
+ text = raw.decode("latin-1")
61
+
62
+ lines = text.splitlines()
63
+ return lines[-max_lines:] if max_lines > 0 else lines
64
+
65
+
66
+ def read_history(max_lines: int = 0) -> list[str]:
67
+ """Read the most recent shell commands from history.
68
+
69
+ Tries platform-appropriate history files in priority order.
70
+ Returns deduplicated commands preserving most-recent order.
71
+ """
72
+ paths = _get_history_paths()
73
+
74
+ for path in paths:
75
+ if not path.exists():
76
+ continue
77
+
78
+ raw_lines = _read_file_tail(path, max_lines * 2 if max_lines > 0 else 0)
79
+
80
+ # Determine if this is a zsh history file
81
+ is_zsh = "zsh" in path.name.lower()
82
+
83
+ commands: list[str] = []
84
+ if is_zsh:
85
+ # Handle multi-line commands (backslash continuation)
86
+ buffer = ""
87
+ for raw in raw_lines:
88
+ if buffer:
89
+ buffer += "\n" + raw.rstrip("\\").strip()
90
+ if not raw.endswith("\\"):
91
+ commands.append(buffer)
92
+ buffer = ""
93
+ continue
94
+
95
+ if raw.rstrip().endswith("\\"):
96
+ parsed = _parse_zsh_history_line(raw.rstrip("\\"))
97
+ if parsed:
98
+ buffer = parsed
99
+ continue
100
+
101
+ parsed = _parse_zsh_history_line(raw)
102
+ if parsed:
103
+ commands.append(parsed)
104
+
105
+ if buffer:
106
+ commands.append(buffer)
107
+ else:
108
+ # bash / PowerShell — plain line-per-command
109
+ for raw in raw_lines:
110
+ line = raw.strip()
111
+ if line:
112
+ commands.append(line)
113
+
114
+ # Deduplicate keeping last occurrence (most recent)
115
+ seen: set[str] = set()
116
+ unique: list[str] = []
117
+ for cmd in reversed(commands):
118
+ if cmd not in seen:
119
+ seen.add(cmd)
120
+ unique.append(cmd)
121
+ unique.reverse()
122
+
123
+ return unique[-max_lines:] if max_lines > 0 else unique
124
+
125
+ return []
@@ -0,0 +1,111 @@
1
+ """Ollama integration for generating command suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ import httpx
10
+
11
+ DEFAULT_MODEL = "qwen2.5:7b"
12
+ DEFAULT_BASE_URL = "http://localhost:11434"
13
+
14
+ SYSTEM_PROMPT = """\
15
+ You are shix, a shell command assistant. The user will describe what they want to do, \
16
+ and you will suggest shell commands based on their shell history and request.
17
+
18
+ Rules:
19
+ - Return the number of suggestions the user asks for, ranked by relevance
20
+ - Each suggestion has a "command" and a short "explanation" (one sentence)
21
+ - Only suggest commands that are likely to work on the user's system based on their history
22
+ - If the history shows usage of specific tools (e.g. docker, kubectl, git), prefer those
23
+ - Output valid JSON only, no markdown fences, no extra text
24
+
25
+ Output format (JSON array):
26
+ [
27
+ {"command": "...", "explanation": "..."},
28
+ ...
29
+ ]
30
+ """
31
+
32
+
33
+ @dataclass
34
+ class Suggestion:
35
+ command: str
36
+ explanation: str
37
+
38
+
39
+ def _build_prompt(history: list[str], query: str, count: int = 5) -> str:
40
+ """Build the user prompt with history context and query."""
41
+ history_block = "\n".join(history[-200:]) # trim to fit context
42
+ return (
43
+ f"Here are my recent shell commands for context:\n"
44
+ f"```\n{history_block}\n```\n\n"
45
+ f"I want to: {query}\n\n"
46
+ f"Suggest {count} commands as a JSON array."
47
+ )
48
+
49
+
50
+ def _parse_suggestions(text: str) -> list[Suggestion]:
51
+ """Parse the model response into Suggestion objects."""
52
+ # Try to find JSON array in the response
53
+ # Strip markdown code fences if present
54
+ cleaned = re.sub(r"```(?:json)?\s*", "", text)
55
+ cleaned = cleaned.strip().rstrip("`")
56
+
57
+ # Find the JSON array
58
+ match = re.search(r"\[.*\]", cleaned, re.DOTALL)
59
+ if not match:
60
+ return []
61
+
62
+ try:
63
+ data = json.loads(match.group())
64
+ except json.JSONDecodeError:
65
+ return []
66
+
67
+ suggestions = []
68
+ for item in data:
69
+ if isinstance(item, dict) and "command" in item:
70
+ suggestions.append(
71
+ Suggestion(
72
+ command=str(item["command"]),
73
+ explanation=str(item.get("explanation", "")),
74
+ )
75
+ )
76
+ return suggestions
77
+
78
+
79
+ def get_suggestions(
80
+ history: list[str],
81
+ query: str,
82
+ model: str = DEFAULT_MODEL,
83
+ base_url: str = DEFAULT_BASE_URL,
84
+ count: int = 5,
85
+ ) -> list[Suggestion]:
86
+ """Query Ollama for command suggestions.
87
+
88
+ Raises httpx.ConnectError if Ollama is not running.
89
+ """
90
+ prompt = _build_prompt(history, query, count)
91
+
92
+ response = httpx.post(
93
+ f"{base_url}/api/chat",
94
+ json={
95
+ "model": model,
96
+ "messages": [
97
+ {"role": "system", "content": SYSTEM_PROMPT},
98
+ {"role": "user", "content": prompt},
99
+ ],
100
+ "stream": False,
101
+ "options": {
102
+ "temperature": 0.3,
103
+ },
104
+ },
105
+ timeout=120.0,
106
+ )
107
+ response.raise_for_status()
108
+
109
+ body = response.json()
110
+ content = body.get("message", {}).get("content", "")
111
+ return _parse_suggestions(content)
@@ -0,0 +1,89 @@
1
+ """Sanitize shell history by redacting secrets, tokens, and passwords."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ REDACTED = "<REDACTED>"
8
+
9
+ # Patterns that match common secret formats in shell commands
10
+ _PATTERNS: list[re.Pattern[str]] = [
11
+ # API keys / tokens passed as env vars or flags (KEY=value, --token=value, --token value)
12
+ re.compile(
13
+ r"(?i)(api[_-]?key|api[_-]?secret|token|secret|password|passwd|pwd|auth)"
14
+ r"[\s=:]+['\"]?([A-Za-z0-9_\-./+]{8,})['\"]?"
15
+ ),
16
+ # Bearer tokens
17
+ re.compile(r"(?i)(bearer\s+)([A-Za-z0-9_\-./+]{8,})"),
18
+ # AWS-style keys (AKIA...)
19
+ re.compile(r"AKIA[0-9A-Z]{16}"),
20
+ # Generic long hex/base64 strings that look like secrets (32+ chars)
21
+ re.compile(r"(?i)(?:key|token|secret|password|credential)s?\s*[=:]\s*['\"]?([A-Za-z0-9_\-./+]{32,})['\"]?"),
22
+ # URLs with embedded credentials (https://user:pass@host)
23
+ re.compile(r"(https?://)([^:]+):([^@]+)@"),
24
+ # export VAR=secret patterns
25
+ re.compile(r"(?i)export\s+\w*(secret|token|key|password|passwd|api|auth)\w*\s*=\s*['\"]?(.+?)['\"]?\s*$"),
26
+ # -p password or --password=xxx
27
+ re.compile(r"(?i)(-p\s+|--password[=\s]+)['\"]?(\S+)['\"]?"),
28
+ # SSH private key paths are fine, but inline keys are not
29
+ re.compile(r"-----BEGIN[A-Z ]+PRIVATE KEY-----"),
30
+ # GitHub/GitLab personal access tokens (ghp_, glpat-, etc.)
31
+ re.compile(r"(ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9\-]{20,})"),
32
+ # npm tokens
33
+ re.compile(r"npm_[A-Za-z0-9]{36}"),
34
+ ]
35
+
36
+
37
+ def _redact_line(line: str) -> str:
38
+ """Redact secrets from a single history line."""
39
+ result = line
40
+
41
+ # URL credentials: https://user:pass@host -> https://***:***@host
42
+ result = re.sub(
43
+ r"(https?://)([^:]+):([^@]+)@",
44
+ rf"\1{REDACTED}:{REDACTED}@",
45
+ result,
46
+ )
47
+
48
+ # -p password flag
49
+ result = re.sub(
50
+ r"(?i)(-p\s+|--password[=\s]+)['\"]?\S+['\"]?",
51
+ rf"\1{REDACTED}",
52
+ result,
53
+ )
54
+
55
+ # export SECRET_VAR=value
56
+ result = re.sub(
57
+ r"(?i)(export\s+\w*(?:secret|token|key|password|passwd|api|auth)\w*\s*=\s*)['\"]?.+?['\"]?\s*$",
58
+ rf"\1{REDACTED}",
59
+ result,
60
+ )
61
+
62
+ # KEY=value, --token=value, --token value
63
+ result = re.sub(
64
+ r"(?i)((?:api[_-]?key|api[_-]?secret|token|secret|password|passwd|pwd|auth)[\s=:]+)['\"]?[A-Za-z0-9_\-./+]{8,}['\"]?",
65
+ rf"\1{REDACTED}",
66
+ result,
67
+ )
68
+
69
+ # Bearer tokens
70
+ result = re.sub(
71
+ r"(?i)(bearer\s+)[A-Za-z0-9_\-./+]{8,}",
72
+ rf"\1{REDACTED}",
73
+ result,
74
+ )
75
+
76
+ # AWS keys
77
+ result = re.sub(r"AKIA[0-9A-Z]{16}", REDACTED, result)
78
+
79
+ # GitHub/GitLab tokens
80
+ result = re.sub(r"ghp_[A-Za-z0-9]{36}", REDACTED, result)
81
+ result = re.sub(r"glpat-[A-Za-z0-9\-]{20,}", REDACTED, result)
82
+ result = re.sub(r"npm_[A-Za-z0-9]{36}", REDACTED, result)
83
+
84
+ return result
85
+
86
+
87
+ def sanitize(commands: list[str]) -> list[str]:
88
+ """Sanitize a list of shell commands by redacting secrets."""
89
+ return [_redact_line(cmd) for cmd in commands]
shix-0.1.0/shix/tui.py ADDED
@@ -0,0 +1,273 @@
1
+ """Textual TUI app for shix — interactive command suggestion browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Container, Horizontal
10
+ from textual.message import Message
11
+ from textual.widgets import Static, Label
12
+
13
+
14
+ @dataclass
15
+ class SuggestionItem:
16
+ command: str
17
+ explanation: str
18
+
19
+
20
+ class CommandPill(Static, can_focus=True):
21
+ """A focusable pill-shaped command suggestion."""
22
+
23
+ BINDINGS = [
24
+ Binding("up,left,k", "app.focus_prev", show=False),
25
+ Binding("down,right,j", "app.focus_next", show=False),
26
+ Binding("enter", "app.select", show=False),
27
+ ]
28
+
29
+ class Selected(Message):
30
+ def __init__(self, command: str) -> None:
31
+ self.command = command
32
+ super().__init__()
33
+
34
+ def __init__(self, command: str, **kwargs) -> None:
35
+ display = command.split("\n")[0]
36
+ super().__init__(display, **kwargs)
37
+ self.command = command
38
+
39
+ def on_enter(self, event) -> None:
40
+ self.focus(scroll_visible=False)
41
+
42
+ def on_click(self) -> None:
43
+ self.post_message(self.Selected(self.command))
44
+
45
+
46
+ class ResultRow(Horizontal):
47
+ """A row containing an index label and a command pill."""
48
+
49
+ DEFAULT_CSS = """
50
+ ResultRow {
51
+ height: 1;
52
+ width: 100%;
53
+ margin-bottom: 1;
54
+ }
55
+ """
56
+
57
+ def on_enter(self, event) -> None:
58
+ pill = self.query_one(CommandPill)
59
+ pill.focus(scroll_visible=False)
60
+
61
+ def on_click(self) -> None:
62
+ pill = self.query_one(CommandPill)
63
+ pill.post_message(CommandPill.Selected(pill.command))
64
+
65
+
66
+ class PillsContainer(Container, can_focus=False):
67
+ """A container that does not capture key events."""
68
+
69
+ DEFAULT_CSS = """
70
+ PillsContainer {
71
+ height: 1fr;
72
+ overflow-y: auto;
73
+ }
74
+ """
75
+
76
+
77
+ class ShixApp(App):
78
+ """Interactive command suggestion browser."""
79
+
80
+ TITLE = "shix"
81
+ COMMAND_PALETTE_BINDING = "ctrl+p"
82
+
83
+ CSS = """
84
+ Screen {
85
+ background: #0d1117;
86
+ padding: 1 2;
87
+ }
88
+
89
+ #title-text {
90
+ color: #58a6ff;
91
+ height: 1;
92
+ }
93
+
94
+ #query-text {
95
+ color: #e6edf3;
96
+ height: 1;
97
+ margin-bottom: 1;
98
+ }
99
+
100
+ #warning {
101
+ color: #d29922;
102
+ height: 1;
103
+ margin-top: 1;
104
+ display: none;
105
+ }
106
+
107
+ #warning.visible {
108
+ display: block;
109
+ }
110
+
111
+ #pills-label {
112
+ color: #484f58;
113
+ height: 1;
114
+ margin-bottom: 1;
115
+ }
116
+
117
+ .pill-index {
118
+ width: 4;
119
+ height: 1;
120
+ color: #484f58;
121
+ padding: 0 1 0 0;
122
+ content-align: right middle;
123
+ }
124
+
125
+ CommandPill {
126
+ width: auto;
127
+ height: 1;
128
+ max-width: 1fr;
129
+ padding: 0 2;
130
+ color: #8b949e;
131
+ background: #21262d;
132
+ }
133
+
134
+ CommandPill:hover {
135
+ background: #30363d;
136
+ color: #c9d1d9;
137
+ }
138
+
139
+ CommandPill:focus {
140
+ background: #1f6feb;
141
+ color: #ffffff;
142
+ text-style: bold;
143
+ }
144
+
145
+ #status-bar {
146
+ dock: bottom;
147
+ height: 1;
148
+ background: #010409;
149
+ color: #484f58;
150
+ padding: 0 2;
151
+ }
152
+
153
+ #status-bar.copied {
154
+ background: #238636;
155
+ color: #ffffff;
156
+ text-style: bold;
157
+ }
158
+
159
+ Footer {
160
+ display: none;
161
+ }
162
+ """
163
+
164
+ BINDINGS = [
165
+ Binding("up,left,k", "focus_prev", show=False),
166
+ Binding("down,right,j", "focus_next", show=False),
167
+ Binding("enter", "select", show=False),
168
+ Binding("escape,q", "quit", show=False),
169
+ ]
170
+
171
+ def __init__(
172
+ self,
173
+ items: list[SuggestionItem],
174
+ query: str,
175
+ mode: str,
176
+ missing_tokens: list[str] | None = None,
177
+ ) -> None:
178
+ super().__init__()
179
+ self.items = items
180
+ self.search_query = query
181
+ self.mode = mode
182
+ self.missing_tokens = missing_tokens
183
+ self.copied_command: str | None = None
184
+
185
+ def compose(self) -> ComposeResult:
186
+ yield Label(f"[bold #58a6ff]shix[/] [italic #484f58]{self.mode}[/]", id="title-text")
187
+ yield Label(f"[#58a6ff]>[/] [bold #e6edf3]{self.search_query}[/]", id="query-text")
188
+
189
+ warning = Label("", id="warning")
190
+ if self.missing_tokens:
191
+ warning.update(f"[#d29922]no history for: {', '.join(self.missing_tokens)}[/]")
192
+ warning.add_class("visible")
193
+ yield warning
194
+
195
+ if self.items:
196
+ yield Label(f"[#484f58]{len(self.items)} results[/]", id="pills-label")
197
+ with PillsContainer(id="pills-area"):
198
+ for i, item in enumerate(self.items, 1):
199
+ with ResultRow():
200
+ yield Label(f"[#484f58]{i:>3}[/]", classes="pill-index")
201
+ yield CommandPill(item.command)
202
+ else:
203
+ yield Label("[#d29922]No matches found. Try different keywords.[/]")
204
+
205
+ yield Label("[#58a6ff]arrows[/] [#484f58]navigate[/] [#58a6ff]enter/click[/] [#484f58]copy[/] [#58a6ff]esc[/] [#484f58]quit[/]", id="status-bar")
206
+
207
+ def on_mount(self) -> None:
208
+ pills = self.query(CommandPill)
209
+ if pills:
210
+ pills[0].focus()
211
+
212
+ def action_focus_next(self) -> None:
213
+ pills = self.query(CommandPill)
214
+ if not pills:
215
+ return
216
+ focused = self.screen.focused
217
+ current = -1
218
+ for i, p in enumerate(pills):
219
+ if p is focused:
220
+ current = i
221
+ break
222
+ nxt = current + 1 if current < len(pills) - 1 else 0
223
+ pills[nxt].focus(scroll_visible=False)
224
+ pills[nxt].scroll_visible(animate=False)
225
+
226
+ def action_focus_prev(self) -> None:
227
+ pills = self.query(CommandPill)
228
+ if not pills:
229
+ return
230
+ focused = self.screen.focused
231
+ current = -1
232
+ for i, p in enumerate(pills):
233
+ if p is focused:
234
+ current = i
235
+ break
236
+ prev = current - 1 if current > 0 else len(pills) - 1
237
+ pills[prev].focus(scroll_visible=False)
238
+ pills[prev].scroll_visible(animate=False)
239
+
240
+ def action_select(self) -> None:
241
+ focused = self.screen.focused
242
+ if isinstance(focused, CommandPill):
243
+ self._copy_command(focused.command)
244
+
245
+ def on_command_pill_selected(self, message: CommandPill.Selected) -> None:
246
+ self._copy_command(message.command)
247
+
248
+ def _copy_command(self, command: str) -> None:
249
+ self.copied_command = command
250
+ try:
251
+ import pyperclip
252
+ pyperclip.copy(command)
253
+ except Exception:
254
+ pass
255
+
256
+ status = self.query_one("#status-bar", Label)
257
+ status.update(f"[bold #ffffff]Copied:[/] {command}")
258
+ status.add_class("copied")
259
+ self.set_timer(0.4, self._exit_after_copy)
260
+
261
+ def _exit_after_copy(self) -> None:
262
+ self.exit(self.copied_command)
263
+
264
+
265
+ def run_tui(
266
+ items: list[SuggestionItem],
267
+ query: str,
268
+ mode: str,
269
+ missing_tokens: list[str] | None = None,
270
+ ) -> str | None:
271
+ """Run the TUI and return the copied command (or None)."""
272
+ app = ShixApp(items, query, mode, missing_tokens)
273
+ return app.run()