Compare commits

..

17 Commits

Author SHA1 Message Date
mike
6ea1e6c00c chore: update 9 file(s)
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Tests / test (bash) (push) Has been cancelled
Tests / test (zsh) (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / docker (push) Has been cancelled
2025-12-29 22:17:35 +01:00
mike
05bb74a5ee Merge branch 'main' of 192.168.1.159:tour/finish
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Tests / test (bash) (push) Has been cancelled
Tests / test (zsh) (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / docker (push) Has been cancelled
2025-12-29 21:43:10 +01:00
45615c6a2f Update src/finish.py
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Tests / test (bash) (push) Has been cancelled
Tests / test (zsh) (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / docker (push) Has been cancelled
2025-12-29 17:32:52 +01:00
851e6638b1 Update src/finish.py
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Tests / test (bash) (push) Has been cancelled
Tests / test (zsh) (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / docker (push) Has been cancelled
2025-12-29 17:32:27 +01:00
mike
768832d981 rag 2025-12-21 05:50:12 +01:00
mike
909d5ad345 update_path
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 9s
Tests / test (bash) (push) Failing after 14s
Tests / test (zsh) (push) Failing after 8s
Tests / lint (push) Successful in 7s
Tests / docker (push) Successful in 5s
2025-12-11 17:00:00 +01:00
mike
2a0375b5bb update_path
Some checks failed
Tests / test (bash) (push) Failing after 51s
Tests / test (zsh) (push) Failing after 12s
Tests / lint (push) Successful in 7s
Tests / docker (push) Successful in 5s
Docker Build and Push / build-and-push (push) Failing after 11m46s
2025-12-11 16:46:02 +01:00
mike
2e2de4773b update_path
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 10s
Tests / test (bash) (push) Failing after 8s
Tests / test (zsh) (push) Failing after 8s
Tests / lint (push) Successful in 7s
Tests / docker (push) Successful in 4s
2025-12-11 16:42:56 +01:00
mike
47b90a2e90 update_path
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 9s
Tests / test (bash) (push) Failing after 8s
Tests / test (zsh) (push) Failing after 10s
Tests / lint (push) Successful in 8s
Tests / docker (push) Successful in 5s
2025-12-11 16:39:22 +01:00
mike
66034eda34 Crazy! Added support for ALT+\
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 10s
Tests / test (bash) (push) Failing after 8s
Tests / test (zsh) (push) Failing after 8s
Tests / lint (push) Successful in 7s
Tests / docker (push) Successful in 6s
2025-12-11 16:23:40 +01:00
mike
54d265067e update_path
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 10s
Tests / test (bash) (push) Failing after 8s
Tests / test (zsh) (push) Failing after 8s
Tests / lint (push) Successful in 8s
Tests / docker (push) Successful in 5s
2025-12-11 16:10:34 +01:00
mike
607592466a Crazy! Added support for ALT+\
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 10s
Tests / test (bash) (push) Failing after 11s
Tests / test (zsh) (push) Failing after 8s
Tests / lint (push) Successful in 7s
Tests / docker (push) Successful in 5s
2025-12-11 15:58:55 +01:00
mike
b9ceaecc3d message
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 9s
Tests / test (bash) (push) Failing after 9s
Tests / test (zsh) (push) Failing after 9s
Tests / lint (push) Successful in 9s
Tests / docker (push) Successful in 5s
2025-12-11 13:56:14 +01:00
mike
f4ef534e3f Crazy! Added support for ALT+\
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 10s
Tests / test (bash) (push) Failing after 9s
Tests / test (zsh) (push) Failing after 9s
Tests / lint (push) Successful in 7s
Tests / docker (push) Successful in 5s
2025-12-11 12:50:16 +01:00
mike
91d4592272 -init py- 2025-12-11 12:34:09 +01:00
mike
f906e6666a -init-
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 10s
Tests / test (bash) (push) Failing after 9s
Tests / test (zsh) (push) Failing after 10s
Tests / lint (push) Successful in 8s
Tests / docker (push) Successful in 6s
2025-12-11 09:14:52 +01:00
mike
3cb1b57ac5 all 2025-12-11 08:48:13 +01:00
13 changed files with 771 additions and 1403 deletions

2
.gitignore vendored
View File

@@ -33,3 +33,5 @@ __pycache__/
*.tmp *.tmp
temp/ temp/
dist/ dist/
.aider*
*.iml

72
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,72 @@
User Input (Tab×2 or CLI)
┌───────────────────────────────────────┐
│ Entry Points │
├───────────────────────────────────────┤
│ • _finishsh() - Tab completion │
│ • command_command() - CLI mode │
└───────────────┬───────────────────────┘
┌───────────────────────────────────────┐
│ Configuration Layer │
├───────────────────────────────────────┤
│ acsh_load_config() │
│ • Read ~/.finish/config │
│ • Set ACSH_PROVIDER, ACSH_ENDPOINT │
│ • Set ACSH_ACTIVE_API_KEY (if needed) │
└───────────────┬───────────────────────┘
┌───────────────────────────────────────┐
│ Cache Layer │
├───────────────────────────────────────┤
│ • Check cache_dir/acsh-{hash}.txt │
│ • Return cached completions if exists │
└───────────────┬───────────────────────┘
↓ (cache miss)
┌───────────────────────────────────────┐
│ Context Builder │
├───────────────────────────────────────┤
│ _build_prompt() │
│ • Command history (sanitized) │
│ • Terminal info (env vars, cwd) │
│ • Recent files (ls -ld) │
│ • Help message (cmd --help) │
└───────────────┬───────────────────────┘
┌───────────────────────────────────────┐
│ Payload Builder │
├───────────────────────────────────────┤
│ _build_payload() │
│ • Format: {model, messages, temp} │
│ • Provider-specific options: │
│ - Ollama: {format:"json"} │
│ - LMStudio: {stream:true} │
│ - OpenAI: {response_format} │
└───────────────┬───────────────────────┘
┌───────────────────────────────────────┐
│ LLM Provider Layer │
├───────────────────────────────────────┤
│ openai_completion() │
│ • curl to endpoint │
│ • Ollama: no auth header │
│ • Others: Authorization header if key │
│ • Parse response (JSON/streaming) │
└───────────────┬───────────────────────┘
┌───────────────────────────────────────┐
│ Response Parser │
├───────────────────────────────────────┤
│ • Extract completions array │
│ • Fallback parsing for non-JSON │
│ • Filter command-like lines │
└───────────────┬───────────────────────┘
┌───────────────────────────────────────┐
│ Output Layer │
├───────────────────────────────────────┤
│ • Write to cache │
│ • Log usage (tokens, cost) │
│ • Return COMPREPLY array (completion) │
│ • or stdout (CLI mode) │
└───────────────────────────────────────┘

View File

@@ -26,7 +26,7 @@ RUN apt-get update && \
WORKDIR /opt/finish WORKDIR /opt/finish
# Copy application files # Copy application files
COPY finish.sh /usr/bin/finish COPY src/finish.sh /usr/bin/finish
COPY README.md LICENSE ./ COPY README.md LICENSE ./
# Make script executable # Make script executable

24
LICENSE
View File

@@ -1,24 +0,0 @@
BSD 2-Clause License
Copyright (c) 2025, finish contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

353
README.md
View File

@@ -1,300 +1,149 @@
# finish.sh ```markdown
# finish
Command-line completion powered by local LLMs. Press `Tab` twice and get intelligent suggestions based on your terminal context. AI-powered shell completion that runs 100% on your machine.
## Quick Start One command and your terminal learns what you type next.
### One-Line Install (Recommended) ## Install
```bash ```bash
# On Ubuntu/Debian, first ensure python3-venv is installed
sudo apt-get install python3-venv
# Then run the installer
curl -sSL https://git.appmodel.nl/tour/finish/raw/branch/main/docs/install.sh | bash curl -sSL https://git.appmodel.nl/tour/finish/raw/branch/main/docs/install.sh | bash
source ~/.bashrc # or ~/.zshrc
# for plato special, add plato.lan to /etc/hosts:
sudo nano /etc/hosts - 192.168.1.74 plato.lan
``` ```
### Manual Installation Press **Alt+\\** after typing any command to get intelligent completions—no cloud, no data leak, instant results.
## How it works
1. Captures your current directory, recent history, env vars, and available tools
2. Analyzes your intent and builds a context-aware prompt
3. Queries your local LLM (LM Studio, Ollama, or any OpenAI-compatible endpoint)
4. Returns 2-5 ranked completions with an interactive picker
5. Results are cached for instant replay
## Usage
Type a command, then press **Alt+\\**:
```bash ```bash
git clone https://git.appmodel.nl/tour/finish.git finish # Natural language commands
cd finish show gpu status # → nvidia-smi
./finish.sh install resolve title of website google.com # → curl -s https://google.com | grep -oP '<title>\K[^<]+'
source ~/.bashrc make file about dogs # → echo "About Dogs" > dogs.md
# Partial commands
git commit # → git commit -m "..."
docker run # → docker run -it --rm ubuntu bash
find large files # → find . -type f -size +100M
``` ```
### Other Installation Methods Navigate with ↑↓, press Enter to accept, Esc to cancel.
See [Installation](#installation) below for package managers, Docker, and more options. ## Configure
## What It Does View current configuration:
finish.sh enhances your terminal with context-aware command suggestions. It analyzes:
- Current directory and recent files
- Command history
- Environment variables
- Command help output
Then generates relevant completions using a local LLM.
## Features
**Local by Default**
Runs with LM-Studio on localhost. No external API calls, no data leaving your machine.
**Context-Aware**
Understands what you're doing based on your environment and history.
**Cached**
Stores recent queries locally for instant responses.
**Flexible**
Switch between different models and providers easily.
## Configuration
View current settings:
```bash ```bash
finish config finish config
``` ```
Change the endpoint or model: Edit configuration file:
```bash ```bash
finish config set endpoint http://plato.lan:1234/v1/chat/completions nano ~/.finish/finish.json
finish config set model your-model-name
``` ```
Select a model interactively: ## Configuration Examples
```bash ### Local Ollama
finish model ```json
{
"provider": "ollama",
"model": "llama3:latest",
"endpoint": "http://localhost:11434/api/chat",
"temperature": 0.0,
"api_prompt_cost": 0.0,
"api_completion_cost": 0.0,
"max_history_commands": 20,
"max_recent_files": 20,
"cache_size": 100
}
``` ```
## Usage ### LM Studio
```json
Type a command and press `Tab` twice: {
"provider": "lmstudio",
```bash "model": "dolphin3.0-llama3.1-8b@q4_k_m",
docker <TAB><TAB> "endpoint": "http://localhost:1234/v1/chat/completions",
git commit <TAB><TAB> "temperature": 0.0,
ffmpeg <TAB><TAB> "api_prompt_cost": 0.0,
"api_completion_cost": 0.0,
"max_history_commands": 20,
"max_recent_files": 20,
"cache_size": 100
}
``` ```
Natural language works too: ### OpenAI (or compatible API)
```json
```bash {
# find large files <TAB><TAB> "provider": "lmstudio",
# compress to zip <TAB><TAB> "model": "gpt-4",
``` "endpoint": "https://api.openai.com/v1/chat/completions",
"api_key": "sk-...",
Test it without executing: "temperature": 0.0,
"api_prompt_cost": 0.03,
```bash "api_completion_cost": 0.06,
finish command "your command" "max_history_commands": 20,
finish command --dry-run "your command" "max_recent_files": 20,
``` "cache_size": 100
}
## Installation
### Quick Install (Linux/macOS)
The fastest way to get started:
```bash
curl -sSL https://git.appmodel.nl/tour/finish/raw/branch/main/docs/install.sh | bash
source ~/.bashrc # or ~/.zshrc for zsh
finish model
```
### Package Managers
#### Homebrew (macOS)
```bash
brew tap appmodel/finish
brew install finish
finish install
source ~/.bashrc
```
#### APT (Debian/Ubuntu)
Download the `.deb` package from [releases](https://git.appmodel.nl/tour/finish/releases):
```bash
sudo dpkg -i finish_*.deb
sudo apt-get install -f # Install dependencies
finish install
source ~/.bashrc
```
### Docker
Run finish.sh in a container:
```bash
# Build the image
docker build -t finish .
# Run interactively
docker run -it finish
# Or use docker-compose
docker-compose up -d finish
docker-compose exec finish bash
```
Inside the container:
```bash
finish install
source ~/.bashrc
finish model # Configure your LLM endpoint
```
### From Source
```bash
git clone https://git.appmodel.nl/tour/finish.git finish
cd finish
chmod +x finish.sh
sudo ln -s $PWD/finish.sh /usr/local/bin/finish
finish install
source ~/.bashrc
``` ```
## Requirements ## Requirements
- Bash 4.0+ or Zsh 5.0+ - Python 3.7+
- curl - Bash ≥4 or Zsh ≥5
- jq - A local LLM running (Ollama, LM Studio, etc.) or API access
- bc
- bash-completion (recommended)
- LM-Studio or Ollama (for LLM inference)
## Directory Structure Python dependencies (installed automatically):
- httpx
``` - prompt_toolkit
~/.finish/ - rich
├── config # Configuration file
├── finish.log # Usage log
└── cache/ # Cached completions
```
## Commands ## Commands
```bash ```bash
finish install # Set up finish finish install # Set up Alt+\ keybinding
finish remove # Uninstall finish config # Show current configuration
finish config # Show/edit configuration finish command "text" # Test completions manually
finish model # Select model
finish enable # Enable completions
finish disable # Disable completions
finish clear # Clear cache and logs
finish usage # Show usage statistics
finish system # Show system information
finish --help # Show help
``` ```
## Providers ## Advanced
Currently supports:
- **LM-Studio** (default) - Local models via OpenAI-compatible API
- **Ollama** - Local models via Ollama API
Add custom providers by editing `finish.sh` and adding entries to `_finish_modellist`.
## Development
### Local Development
Clone and link for development:
### Debug mode
```bash ```bash
git clone https://git.appmodel.nl/tour/finish.git finish export FINISH_DEBUG=1
cd finish finish command "your command here"
ln -s $PWD/finish.sh $HOME/.local/bin/finish cat ~/.finish/finish.log
finish install
``` ```
### Running Tests ### Clear cache
```bash ```bash
# Install BATS testing framework rm -rf ~/.finish/cache/*.json
sudo apt-get install bats # Ubuntu/Debian
# Run tests
./run_tests.sh
# Or use Docker
docker build -f Dockerfile.test -t finish:test .
docker run --rm finish:test
``` ```
### Building Packages
#### Debian Package
```bash
# Install build dependencies
sudo apt-get install debhelper devscripts
# Build the package
dpkg-buildpackage -us -uc -b
# Package will be created in parent directory
ls ../*.deb
```
#### Docker Images
```bash
# Production image
docker build -t finish:latest .
# Test image
docker build -f Dockerfile.test -t finish:test .
# Using docker-compose
docker-compose build
```
## Distribution & Publishing
### Creating a Release
1. Update version in `finish.sh` (ACSH_VERSION)
2. Update version in `debian/changelog`
3. Update version in `homebrew/finish.rb`
4. Create and push a git tag:
```bash
git tag -a v0.5.0 -m "Release version 0.5.0"
git push origin v0.5.0
```
This triggers the GitHub Actions workflow which:
- Builds release artifacts
- Creates Debian packages
- Publishes Docker images to GitHub Container Registry
- Creates a GitHub release with downloadable assets
### Homebrew Formula
After creating a release, update the Homebrew formula:
1. Calculate SHA256 of the release tarball:
```bash
curl -sL https://git.appmodel.nl/tour/finish/archive/v0.5.0.tar.gz | sha256sum
```
2. Update `homebrew/finish.rb` with the new SHA256 and version
3. Submit to your Homebrew tap or the main Homebrew repository
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License ## License
BSD 2-Clause License. See LICENSE file. BSD 2-Clause.
```

30
debian/copyright vendored
View File

@@ -1,30 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: finish
Upstream-Contact: https://git.appmodel.nl/tour/finish/finish
Source: https://git.appmodel.nl/tour/finish
Files: *
Copyright: 2024-2025 finish.sh Contributors
License: BSD-2-Clause
License: BSD-2-Clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -12,7 +12,7 @@ services:
volumes: volumes:
- finish-data:/root/.finish - finish-data:/root/.finish
environment: environment:
- ACSH_ENDPOINT=http://plato.lan:1234/v1/chat/completions - ACSH_ENDPOINT=http://llm.plato/v1/chat/completions
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
command: /bin/bash command: /bin/bash

View File

@@ -4,7 +4,7 @@
set -e set -e
ACSH_VERSION="v0.5.1" ACSH_VERSION="v0.6.0"
BRANCH_OR_VERSION=${1:-main} BRANCH_OR_VERSION=${1:-main}
REPO_URL="https://git.appmodel.nl/tour/finish/raw/branch" REPO_URL="https://git.appmodel.nl/tour/finish/raw/branch"
@@ -97,11 +97,9 @@ main() {
SHELL_TYPE=$(detect_shell) SHELL_TYPE=$(detect_shell)
case "$SHELL_TYPE" in case "$SHELL_TYPE" in
zsh) zsh)
SCRIPT_NAME="finish.zsh"
RC_FILE="$HOME/.zshrc" RC_FILE="$HOME/.zshrc"
;; ;;
bash) bash)
SCRIPT_NAME="finish.sh"
RC_FILE="$HOME/.bashrc" RC_FILE="$HOME/.bashrc"
;; ;;
*) *)
@@ -112,6 +110,29 @@ main() {
echo "Detected shell: $SHELL_TYPE" echo "Detected shell: $SHELL_TYPE"
# Check Python and venv
echo "Checking Python installation..."
if ! command -v python3 > /dev/null 2>&1; then
echo_error "Python 3 is required but not found"
echo "Install it with:"
echo " Ubuntu/Debian: sudo apt-get install python3 python3-pip python3-venv"
echo " macOS: brew install python3"
exit 1
fi
echo_green "✓ Python 3 found: $(python3 --version)"
# Check if venv module is available
if ! python3 -m venv --help > /dev/null 2>&1; then
echo_error "python3-venv module is not installed"
echo "Install it with:"
echo " Ubuntu/Debian: sudo apt-get install python3-venv"
echo " CentOS/RHEL: sudo yum install python3-venv"
echo " macOS: (included with python3)"
exit 1
fi
echo_green "✓ python3-venv available"
echo ""
# Check dependencies # Check dependencies
echo "Checking dependencies..." echo "Checking dependencies..."
if ! check_dependencies; then if ! check_dependencies; then
@@ -144,17 +165,73 @@ main() {
exit 1 exit 1
fi fi
# Create directory if needed # Create directories if needed
mkdir -p "$(dirname "$INSTALL_LOCATION")" mkdir -p "$(dirname "$INSTALL_LOCATION")"
mkdir -p "$HOME/.venvs"
# Download script # Download Python script
echo "Downloading finish.sh..." echo "Downloading finish.py..."
URL="$REPO_URL/$BRANCH_OR_VERSION/$SCRIPT_NAME" URL="$REPO_URL/$BRANCH_OR_VERSION/src/finish.py"
if ! download_file "$URL" "$INSTALL_LOCATION"; then if ! download_file "$URL" "$INSTALL_LOCATION"; then
echo_error "Failed to download from $URL" echo_error "Failed to download from $URL"
exit 1 exit 1
fi fi
# Download requirements.txt
echo "Downloading requirements.txt..."
TEMP_REQ="/tmp/finish_requirements.txt"
REQ_URL="$REPO_URL/$BRANCH_OR_VERSION/requirements.txt"
if ! download_file "$REQ_URL" "$TEMP_REQ"; then
echo_error "Failed to download requirements.txt from $REQ_URL"
exit 1
fi
# Create virtualenv and install dependencies
echo "Creating virtual environment..."
VENV_PATH="$HOME/.venvs/finish"
if [ -d "$VENV_PATH" ]; then
echo "Virtual environment already exists, removing old one..."
rm -rf "$VENV_PATH"
fi
if ! python3 -m venv "$VENV_PATH"; then
echo_error "Failed to create virtual environment"
echo "Make sure python3-venv is installed:"
echo " Ubuntu/Debian: sudo apt-get install python3-venv"
exit 1
fi
echo_green "✓ Virtual environment created at $VENV_PATH"
echo "Installing Python dependencies..."
if ! "$VENV_PATH/bin/pip" install --quiet --upgrade pip; then
echo_error "Failed to upgrade pip"
exit 1
fi
if ! "$VENV_PATH/bin/pip" install --quiet -r "$TEMP_REQ"; then
echo_error "Failed to install dependencies"
cat "$TEMP_REQ"
exit 1
fi
echo_green "✓ Dependencies installed"
rm -f "$TEMP_REQ"
# Update shebang to use venv python (compatible with macOS and Linux)
if sed --version >/dev/null 2>&1; then
# GNU sed (Linux)
sed -i "1s|.*|#!$VENV_PATH/bin/python3|" "$INSTALL_LOCATION"
else
# BSD sed (macOS)
sed -i '' "1s|.*|#!$VENV_PATH/bin/python3|" "$INSTALL_LOCATION"
fi
# Verify shebang was updated correctly
SHEBANG=$(head -n 1 "$INSTALL_LOCATION")
if [ "$SHEBANG" != "#!$VENV_PATH/bin/python3" ]; then
echo_error "Failed to update shebang. Got: $SHEBANG"
exit 1
fi
chmod +x "$INSTALL_LOCATION" chmod +x "$INSTALL_LOCATION"
echo_green "✓ Installed to $INSTALL_LOCATION" echo_green "✓ Installed to $INSTALL_LOCATION"
echo "" echo ""
@@ -183,8 +260,18 @@ main() {
fi fi
fi fi
# Run finish install # Test that finish works
echo "Running finish installation..." echo "Testing installation..."
if ! "$INSTALL_LOCATION" --version > /dev/null 2>&1; then
echo_error "finish command failed to execute"
echo "Testing with direct python call..."
"$VENV_PATH/bin/python3" "$INSTALL_LOCATION" --version
exit 1
fi
echo_green "✓ finish command works"
# Run finish install to set up keybinding
echo "Setting up shell keybinding..."
if "$INSTALL_LOCATION" install; then if "$INSTALL_LOCATION" install; then
echo "" echo ""
echo_green "==========================================" echo_green "=========================================="
@@ -195,10 +282,18 @@ main() {
echo " 1. Reload your shell configuration:" echo " 1. Reload your shell configuration:"
echo " source $RC_FILE" echo " source $RC_FILE"
echo "" echo ""
echo " 2. Select a language model:" echo " 2. Configure your LLM endpoint:"
echo " finish model" echo " finish config"
echo "" echo ""
echo " 3. Start using by pressing Tab twice after any command" echo " 3. Edit config if needed:"
echo " nano ~/.finish/finish.json"
echo ""
echo " 4. Start using by pressing Alt+\\ after typing a command"
echo ""
echo "Example:"
echo " Type: show gpu status"
echo " Press: Alt+\\"
echo " Select completion with ↑↓ arrows, Enter to accept"
echo "" echo ""
echo "Documentation: https://git.appmodel.nl/tour/finish" echo "Documentation: https://git.appmodel.nl/tour/finish"
else else

42
docs/uninstall.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/sh
# finish uninstaller
echo "Removing finish installation..."
# Remove binary
if [ -f "$HOME/.local/bin/finish" ]; then
rm -f "$HOME/.local/bin/finish"
echo "✓ Removed $HOME/.local/bin/finish"
fi
# Remove venv
if [ -d "$HOME/.venvs/finish" ]; then
rm -rf "$HOME/.venvs/finish"
echo "✓ Removed $HOME/.venvs/finish"
fi
# Remove config and cache
if [ -d "$HOME/.finish" ]; then
rm -rf "$HOME/.finish"
echo "✓ Removed $HOME/.finish"
fi
# Remove keybinding from .bashrc
if [ -f "$HOME/.bashrc" ]; then
if grep -q "# finish.py key-binding" "$HOME/.bashrc"; then
sed -i '/# finish.py key-binding/,+8d' "$HOME/.bashrc"
echo "✓ Removed keybinding from ~/.bashrc"
fi
fi
# Remove keybinding from .zshrc
if [ -f "$HOME/.zshrc" ]; then
if grep -q "# finish.py key-binding" "$HOME/.zshrc"; then
sed -i '/# finish.py key-binding/,+8d' "$HOME/.zshrc"
echo "✓ Removed keybinding from ~/.zshrc"
fi
fi
echo ""
echo "Uninstall complete!"
echo "Run 'source ~/.bashrc' to reload your shell"

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.git" />
<excludeFolder url="file://$MODULE_DIR$/tests" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

1071
finish.sh

File diff suppressed because it is too large Load Diff

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
httpx
prompt_toolkit
rich

442
src/finish.py Executable file
View File

@@ -0,0 +1,442 @@
#!/usr/bin/env python3
"""
finish.py - AI shell completions that never leave your machine.
"""
from __future__ import annotations
import hashlib
import argparse, asyncio, json, os, re, shlex, subprocess, sys, time
from pathlib import Path
from typing import List, Dict, Optional
import httpx
from prompt_toolkit import ANSI, Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import (
ConditionalContainer, FormattedTextControl, HSplit, Layout, Window
)
from prompt_toolkit.widgets import Label
from rich.console import Console
from rich.spinner import Spinner
VERSION = "0.5.0"
CFG_DIR = Path.home()/".finish"
CFG_FILE = CFG_DIR/"finish.json"
CACHE_DIR = CFG_DIR/"cache"
LOG_FILE = CFG_DIR/"finish.log"
console = Console()
# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #
DEFAULT_CFG = {
"provider": "ollama",
"model": "llama3:latest",
"endpoint": os.getenv("LLM_FINISH_URL", "http://localhost:11434/api/chat"),
"temperature": 0.0,
"api_prompt_cost": 0.0,
"api_completion_cost": 0.0,
"max_history_commands": 20,
"max_recent_files": 20,
"cache_size": 100,
}
def cfg() -> dict:
if not CFG_FILE.exists():
CFG_DIR.mkdir(exist_ok=True)
CFG_FILE.write_text(json.dumps(DEFAULT_CFG, indent=2))
return json.loads(CFG_FILE.read_text())
# --------------------------------------------------------------------------- #
# Context builders
# --------------------------------------------------------------------------- #
def _sanitise_history() -> str:
hist = subprocess.check_output(["bash", "-ic", "history"]).decode()
# scrub tokens / hashes
for pat in [
r"\b[0-9a-f]{32,40}\b", # long hex
r"\b[A-Za-z0-9-]{36}\b", # uuid
r"\b[A-Za-z0-9]{16,40}\b", # api-keyish
]:
hist = re.sub(pat, "REDACTED", hist)
return "\n".join(hist.splitlines()[-cfg()["max_history_commands"]:])
def _recent_files() -> str:
try:
files = subprocess.check_output(
["find", ".", "-maxdepth", "1", "-type", "f", "-printf", "%T@ %p\n"],
stderr=subprocess.DEVNULL,
).decode()
return "\n".join(sorted(files.splitlines(), reverse=True)[: cfg()["max_recent_files"]])
except Exception:
return ""
def _installed_tools() -> str:
"""Check for commonly used tools that might be relevant"""
tools = ["curl", "wget", "jq", "grep", "awk", "sed", "python3", "node", "docker", "git", "nvcc", "nvidia-smi"]
available = []
for tool in tools:
try:
subprocess.run(["which", tool], capture_output=True, timeout=0.5, check=True)
available.append(tool)
except Exception:
pass
return ", ".join(available) if available else "standard shell tools"
def _get_context_info() -> dict:
"""Gather comprehensive shell context"""
return {
"user": os.getenv("USER", "unknown"),
"pwd": os.getcwd(),
"home": os.getenv("HOME", ""),
"hostname": os.getenv("HOSTNAME", os.getenv("HOST", "localhost")),
"shell": os.getenv("SHELL", "bash"),
"tools": _installed_tools(),
}
def build_prompt(user_input: str) -> str:
"""Build an enhanced prompt with better context and instructions"""
c = cfg()
ctx = _get_context_info()
# Analyze user intent
user_words = user_input.lower().split()
intent_hints = []
if any(word in user_words for word in ["resolve", "get", "fetch", "download", "curl", "wget"]):
intent_hints.append("The user wants to fetch/download data")
if any(word in user_words for word in ["website", "url", "http", "html", "title"]):
intent_hints.append("The user is working with web content")
if any(word in user_words for word in ["parse", "extract", "grep", "find"]):
intent_hints.append("The user wants to extract/parse data")
if any(word in user_words for word in ["create", "make", "new", "write", "edit"]):
intent_hints.append("The user wants to create or edit files")
if any(word in user_words for word in ["file", "document", "text", "story", "about"]):
intent_hints.append("The user is working with files/documents")
if any(word in user_words for word in ["install", "setup", "configure", "apt", "pip"]):
intent_hints.append("The user wants to install or configure software")
if any(word in user_words for word in ["gpu", "nvidia", "cuda", "memory", "cpu"]):
intent_hints.append("The user wants system/hardware information")
intent_context = "\n".join(f"- {hint}" for hint in intent_hints) if intent_hints else "- General command completion"
prompt = f"""You are an intelligent bash completion assistant. Analyze the user's intent and provide practical, executable commands.
USER INPUT: {user_input}
DETECTED INTENT:
{intent_context}
SHELL CONTEXT:
- User: {ctx['user']}
- Working directory: {ctx['pwd']}
- Hostname: {ctx['hostname']}
- Available tools: {ctx['tools']}
RECENT COMMAND HISTORY:
{_sanitise_history()}
INSTRUCTIONS:
1. Understand what the user wants to accomplish (not just the literal words)
2. Generate 2-5 practical bash commands that achieve the user's goal
3. Prefer common tools (curl, wget, grep, awk, sed, jq, python)
4. For file creation: use echo with heredoc, cat, or nano/vim
5. For web tasks: use curl with headers, pipe to grep/sed/awk for parsing
6. For complex parsing: suggest one-liners with proper escaping
7. Commands should be copy-paste ready and executable
8. Order by most likely to least likely intent
EXAMPLES:
- "resolve title of website example.com" → curl -s https://example.com | grep -oP '<title>\\K[^<]+'
- "show gpu status" → nvidia-smi
- "download json from api.com" → curl -s https://api.com/data | jq .
- "make a new file story.txt" → nano story.txt
- "create file and write hello" → echo "hello" > file.txt
- "write about dogs to file" → cat > dogs.txt << 'EOF' (then Ctrl+D to finish)
OUTPUT FORMAT (JSON only, no other text):
{{"completions":["command1", "command2", "command3"]}}"""
return prompt
# --------------------------------------------------------------------------- #
# LLM call
# --------------------------------------------------------------------------- #
async def llm_complete(prompt: str) -> List[str]:
c = cfg()
payload = {
"model": c["model"],
"temperature": c["temperature"],
"messages": [
{"role": "system", "content": "You are a helpful bash completion assistant."},
{"role": "user", "content": prompt},
],
"response_format": {"type": "text"},
}
if c["provider"] == "ollama":
payload["format"] = "json"
payload["stream"] = False
headers = {"Content-Type": "application/json"}
if c.get("api_key"):
headers["Authorization"] = f"Bearer {c['api_key']}"
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(c["endpoint"], headers=headers, json=payload)
resp.raise_for_status()
body = resp.json()
except httpx.ConnectError as e:
console.print(f"[red]Error:[/] Cannot connect to {c['endpoint']}")
console.print(f"[yellow]Check that your LLM server is running and endpoint is correct.[/]")
console.print(f"[yellow]Run 'finish config' to see current config.[/]")
return []
except Exception as e:
console.print(f"[red]Error:[/] {e}")
return []
# extract content
if c["provider"] == "ollama":
raw = body["message"]["content"]
else:
raw = body["choices"][0]["message"]["content"]
# Debug logging
if os.getenv("FINISH_DEBUG"):
LOG_FILE.parent.mkdir(exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
f.write(f"Raw response:\n{raw}\n")
# try json first - use strict=False to be more lenient
try:
result = json.loads(raw, strict=False)["completions"]
if os.getenv("FINISH_DEBUG"):
with open(LOG_FILE, "a") as f:
f.write(f"Parsed completions: {result}\n")
return result
except Exception as e:
if os.getenv("FINISH_DEBUG"):
with open(LOG_FILE, "a") as f:
f.write(f"JSON parse failed: {e}\n")
f.write(f"Attempting manual extraction...\n")
# Try to extract JSON array manually using regex
try:
match = re.search(r'"completions"\s*:\s*\[(.*?)\]', raw, re.DOTALL)
if match:
# Extract commands from the array - handle escaped quotes
array_content = match.group(1)
# Find all quoted strings
commands = re.findall(r'"([^"\\]*(?:\\.[^"\\]*)*)"', array_content)
if commands:
if os.getenv("FINISH_DEBUG"):
with open(LOG_FILE, "a") as f:
f.write(f"Manual extraction succeeded: {commands}\n")
return commands[:5]
except Exception as e2:
if os.getenv("FINISH_DEBUG"):
with open(LOG_FILE, "a") as f:
f.write(f"Manual extraction failed: {e2}\n")
# fallback: grep command-like lines
fallback = [ln for ln in raw.splitlines() if re.match(r"^(ls|cd|find|cat|grep|echo|mkdir|rm|cp|mv|pwd|chmod|chown|nano|vim|touch)\b", ln)][:5]
if os.getenv("FINISH_DEBUG"):
with open(LOG_FILE, "a") as f:
f.write(f"Fallback completions: {fallback}\n")
return fallback
# --------------------------------------------------------------------------- #
# TUI picker
# --------------------------------------------------------------------------- #
async def select_completion(completions: List[str]) -> Optional[str]:
if not completions:
return None
if len(completions) == 1:
return completions[0]
# If not in an interactive terminal, return first completion
if not sys.stdin.isatty():
return completions[0]
# Import here to avoid issues when not needed
from prompt_toolkit.input import create_input
from prompt_toolkit.output import create_output
kb = KeyBindings()
current = 0
def get_text():
return [
("", "Select completion (↑↓ navigate, Enter accept, Esc cancel)\n\n"),
*[
("[SetCursorPosition]" if i == current else "", f"{c}\n")
for i, c in enumerate(completions)
],
]
@kb.add("up")
def _up(event):
nonlocal current
current = (current - 1) % len(completions)
@kb.add("down")
def _down(event):
nonlocal current
current = (current + 1) % len(completions)
@kb.add("enter")
def _accept(event):
event.app.exit(result=completions[current])
@kb.add("escape")
def _cancel(event):
event.app.exit(result=None)
control = FormattedTextControl(get_text)
# Force output to /dev/tty to avoid interfering with bash command substitution
try:
output = create_output(stdout=open('/dev/tty', 'w'))
input_obj = create_input(stdin=open('/dev/tty', 'r'))
except Exception:
# Fallback to first completion if tty not available
return completions[0]
app = Application(
layout=Layout(HSplit([Window(control, height=len(completions) + 2)])),
key_bindings=kb,
mouse_support=False,
erase_when_done=True,
output=output,
input=input_obj,
)
return await app.run_async()
# --------------------------------------------------------------------------- #
# Cache
# --------------------------------------------------------------------------- #
def cached(key: str) -> Optional[List[str]]:
if not CACHE_DIR.exists():
CACHE_DIR.mkdir(parents=True)
f = CACHE_DIR / f"{key}.json"
if f.exists():
return json.loads(f.read_text())
return None
def store_cache(key: str, completions: List[str]) -> None:
f = CACHE_DIR / f"{key}.json"
f.write_text(json.dumps(completions))
# LRU eviction
all_files = sorted(CACHE_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)
for old in all_files[: -cfg()["cache_size"]]:
old.unlink(missing_ok=True)
# --------------------------------------------------------------------------- #
# Main entry
# --------------------------------------------------------------------------- #
async def complete_line(line: str) -> Optional[str]:
key = hashlib.md5(line.encode()).hexdigest()
comps = cached(key)
if comps is None:
# Write to /dev/tty if available to avoid interfering with command substitution
try:
status_console = Console(file=open('/dev/tty', 'w'), stderr=False)
except Exception:
status_console = console
with status_console.status("[green]Thinking…[/]"):
comps = await llm_complete(build_prompt(line))
store_cache(key, comps)
return await select_completion(comps)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def install_keybinding():
r"""Inject Bash binding Alt+\ -> finish"""
rc = Path.home()/".bashrc"
marker = "# finish.py key-binding"
# Use bind -x to allow modifying READLINE_LINE
snippet = f'''{marker}
_finish_complete() {{
local result
result=$(finish --readline-complete "$READLINE_LINE" 2>/dev/null)
if [[ -n "$result" ]]; then
READLINE_LINE="$result"
READLINE_POINT=${{#READLINE_LINE}}
fi
}}
bind -x '"\\e\\\\": _finish_complete'
'''
text = rc.read_text() if rc.exists() else ""
if marker in text:
return
rc.write_text(text + "\n" + snippet)
console.print("[green]Key-binding installed (Alt+\\)[/] - restart your shell.")
def main():
# Handle readline-complete flag before argparse (for bash bind -x)
if "--readline-complete" in sys.argv:
idx = sys.argv.index("--readline-complete")
line = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
if line.strip():
choice = asyncio.run(complete_line(line))
if choice:
print(choice)
return
# Legacy flag support
if sys.argv[-1] == "--accept-current-line":
line = os.environ.get("READLINE_LINE", "")
if line.strip():
choice = asyncio.run(complete_line(line))
if choice:
sys.stdout.write(f"\x1b]0;\a")
sys.stdout.write(f"\x1b[2K\r")
sys.stdout.write(choice)
sys.stdout.flush()
return
parser = argparse.ArgumentParser(prog="finish", description="AI shell completions")
parser.add_argument("--version", action="version", version=VERSION)
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("install", help="add Alt+\\ key-binding to ~/.bashrc")
sub.add_parser("config", help="show current config")
p = sub.add_parser("command", help="simulate double-tab")
p.add_argument("words", nargs="*", help="partial command")
p.add_argument("--dry-run", action="store_true", help="show prompt only")
args = parser.parse_args()
if args.cmd == "install":
install_keybinding()
return
if args.cmd == "config":
console.print_json(json.dumps(cfg()))
return
if args.cmd == "command":
line = " ".join(args.words)
if args.dry_run:
console.print(build_prompt(line))
return
choice = asyncio.run(complete_line(line))
if choice:
# Check if we're in an interactive shell
if os.isatty(sys.stdout.fileno()):
console.print(f"[dim]Selected:[/] [cyan]{choice}[/]")
console.print(f"\n[yellow]Tip:[/] Use [bold]Alt+\\[/] keybinding for seamless completion!")
else:
print(choice)
return
if len(sys.argv) == 1:
parser.print_help()
return
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass