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.
- shix-0.1.0/.claude/settings.local.json +8 -0
- shix-0.1.0/.gitignore +8 -0
- shix-0.1.0/PKG-INFO +132 -0
- shix-0.1.0/README.md +101 -0
- shix-0.1.0/pyproject.toml +46 -0
- shix-0.1.0/shix/__init__.py +3 -0
- shix-0.1.0/shix/cli.py +126 -0
- shix-0.1.0/shix/fuzzy.py +138 -0
- shix-0.1.0/shix/history.py +125 -0
- shix-0.1.0/shix/ollama.py +111 -0
- shix-0.1.0/shix/sanitize.py +89 -0
- shix-0.1.0/shix/tui.py +273 -0
shix-0.1.0/.gitignore
ADDED
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"
|
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()
|
shix-0.1.0/shix/fuzzy.py
ADDED
|
@@ -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()
|