Refactor to v3.0.0: Modular architecture, advanced tools (Filesystem, Web, Python, Media), and professional REPL
This commit is contained in:
245
README.md
245
README.md
@@ -1,234 +1,65 @@
|
||||
```markdown
|
||||
# ollama-cli
|
||||
# Ollama CLI v3.0
|
||||
|
||||
A feature-rich interactive command-line client for Ollama’s chat API. This script lets you chat with local models, manage conversation sessions, integrate with MCP and language servers, and even control it remotely via ntfy.sh notifications.[file:1]
|
||||
|
||||
---
|
||||
An extremely powerful, modular, and commercially-ready CLI for local Ollama LLMs. Inspired by Gemini CLI and Kiro CLI.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive chat** with streaming responses against your local Ollama server (`/api/chat`).[file:1]
|
||||
- **Model management**: list models, switch default model, and exclude models you do not want to use.[file:1]
|
||||
- **Session management**: save, load, and list past chat sessions stored under `~/.ollama-cli-sessions`.[file:1]
|
||||
- **Config file support** at `~/.ollama-cli-config.json` for excluded models, Ollama URL, MCP servers, git/Gitea, and ntfy settings.[file:1]
|
||||
- **Context files**: add local files as context for the assistant to use.[file:1]
|
||||
- **Knowledge base**: simple built-in knowledge storage and search.[file:1]
|
||||
- **MCP integration**: connect to MCP servers over stdio and expose their tools to the assistant.[file:1]
|
||||
- **LSP integration**: start language servers (Python, TypeScript/JavaScript, Rust, Go, etc.) via built-in commands.[file:1]
|
||||
- **Remote control via ntfy.sh**: send notifications to a topic and get answers back as notifications.[file:1]
|
||||
- **Export to Markdown** and basic git/Gitea helpers for working with repositories.[file:1]
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+.[file:1]
|
||||
- A running Ollama server (e.g. `ollama serve`) reachable at `http://localhost:11434` or another configured URL.[file:1]
|
||||
- Optional tools (only needed if you use the respective features):[file:1]
|
||||
- MCP servers (any binaries you want to connect via `/mcp connect`).[file:1]
|
||||
- Language servers: `pyright-langserver`, `typescript-language-server`, `rust-analyzer`, `gopls`, etc., on your `PATH`.[file:1]
|
||||
- `git` and a Gitea instance if you use git / Gitea integration.[file:1]
|
||||
|
||||
Install Python dependencies (if any external libraries beyond the standard library are used, such as `requests`):[file:1]
|
||||
|
||||
```bash
|
||||
pip install requests
|
||||
```
|
||||
|
||||
---
|
||||
- **Rich UI:** Beautifully formatted output using `rich`.
|
||||
- **Powerful REPL:** Multi-line input, history, and completions via `prompt_toolkit`.
|
||||
- **Modular Tool System:** Easily extensible tool architecture (Filesystem, System, Web, etc.).
|
||||
- **Session Management:** Save and resume conversations with ease.
|
||||
- **Model Intelligence:** Automatically switches models based on task type (coding vs. general).
|
||||
- **Streaming Responses:** Real-time feedback as the model generates text.
|
||||
- **MCP Support:** Ready for Model Context Protocol integration.
|
||||
- **Local First:** Everything runs locally on your machine.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy `ollama-cli.py` somewhere on your `PATH`, for example:[file:1]
|
||||
### Prerequisites
|
||||
- Python 3.8+
|
||||
- [Ollama](https://ollama.ai/) installed and running.
|
||||
|
||||
### From Source
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
mkdir -p ~/.local/bin
|
||||
cp ollama-cli.py ~/.local/bin/ollama-cli
|
||||
chmod +x ~/.local/bin/ollama-cli
|
||||
git clone https://github.com/yourusername/ollama-cli.git
|
||||
cd ollama-cli
|
||||
```
|
||||
|
||||
2. Ensure `~/.local/bin` is in your `PATH`.[file:1]
|
||||
|
||||
3. Start your Ollama server, e.g.:[file:1]
|
||||
|
||||
2. Install in editable mode:
|
||||
```bash
|
||||
ollama serve
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
## Usage
|
||||
|
||||
Run the CLI:
|
||||
|
||||
```bash
|
||||
ollama-cli
|
||||
# or
|
||||
python3 ollama-cli.py
|
||||
```
|
||||
|
||||
On startup it will:[file:1]
|
||||
|
||||
- Load `~/.ollama-cli-config.json` (or create defaults in memory).[file:1]
|
||||
- Detect available models from the Ollama server.[file:1]
|
||||
- Show the current directory, home directory, and a list of supported commands.[file:1]
|
||||
|
||||
You then type messages at the `>` prompt and responses are streamed back to the terminal.[file:1]
|
||||
|
||||
Exit with:
|
||||
|
||||
```text
|
||||
/quit
|
||||
```
|
||||
|
||||
or `Ctrl+D` / `Ctrl+C`.[file:1]
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
At the `>` prompt you can use various slash-commands.[file:1]
|
||||
|
||||
### Core commands
|
||||
|
||||
- `/quit` – Exit the program.[file:1]
|
||||
- `/clear` – Clear conversation history while keeping the system context.[file:1]
|
||||
- `/retry` – Retry the last request to the model.[file:1]
|
||||
|
||||
### Sessions
|
||||
|
||||
- `/save` – Save current history to `chat_history.json` in the current directory.[file:1]
|
||||
- `/save [name]` – Save as a named session under `~/.ollama-cli-sessions/<name>.json`.[file:1]
|
||||
- `/load <name>` – Load a saved session by name.[file:1]
|
||||
- `/sessions` – List the most recent saved sessions.[file:1]
|
||||
|
||||
### Models
|
||||
|
||||
- `/models` – List available models from the Ollama server.[file:1]
|
||||
- `/model <name>` – Switch the default model for subsequent queries.[file:1]
|
||||
- `/exclude <name>` – Add a model to the excluded list in the config; takes effect after restart.[file:1]
|
||||
- `/list-excluded` – Show excluded models.[file:1]
|
||||
|
||||
### Configuration and server
|
||||
|
||||
- `/server` – Set the Ollama server URL (stored in config).[file:1]
|
||||
- `/config git` – Configure git credentials/remote in the config file.[file:1]
|
||||
- `/config gitea` – Configure Gitea server URL and token.[file:1]
|
||||
|
||||
### Files, context, and export
|
||||
|
||||
- `/run` – Execute a file (script helper, see implementation details in code).[file:1]
|
||||
- `/context` – Add a file to the context for the assistant.[file:1]
|
||||
- `/context list` – List context files.[file:1]
|
||||
- `/context clear` – Clear all context files.[file:1]
|
||||
- `/export` – Export the current conversation to Markdown.[file:1]
|
||||
|
||||
### Knowledge base
|
||||
|
||||
- `/kb add` – Add content to the internal knowledge base.[file:1]
|
||||
- `/kb search` – Search entries in the knowledge base.[file:1]
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
- `/mcp connect <name> <command> [args...]` – Start an MCP server over stdio and register its tools.[file:1]
|
||||
- `/mcp list` – List connected MCP servers and their tools.[file:1]
|
||||
- `/mcp disconnect <name>` – Disconnect a specific MCP server.[file:1]
|
||||
|
||||
When connected, MCP tools are exposed to the assistant and described in the system prompt.[file:1]
|
||||
|
||||
### LSP (Language Server Protocol)
|
||||
|
||||
- `/lsp start <language> [path]` – Start a language server for a given language, optionally with a project root path.[file:1]
|
||||
- Supported language keys include `python`, `javascript`, `typescript`, `rust`, `go` (see `start_lsp_server`).[file:1]
|
||||
|
||||
### Subagents
|
||||
|
||||
- `/agents` – Show status of running subagents.[file:1]
|
||||
|
||||
### Notifications (ntfy.sh)
|
||||
|
||||
- `/notify setup` – Configure ntfy settings in the config file (server, topic, enable flag).[file:1]
|
||||
- `/notify off` – Disable notifications in config.[file:1]
|
||||
- `/notify test` – Send a test notification.[file:1]
|
||||
|
||||
When notifications are enabled, the CLI starts an ntfy listener thread and:[file:1]
|
||||
|
||||
- Listens to `server/topic/json` for incoming notifications.[file:1]
|
||||
- Converts each notification into a user message.[file:1]
|
||||
- Sends back the assistant’s response as another notification (title `Response`).[file:1]
|
||||
|
||||
---
|
||||
### Slash Commands
|
||||
- `/quit` - Exit the CLI.
|
||||
- `/clear` - Clear conversation history.
|
||||
- `/models` - List available models.
|
||||
- `/model <name>` - Switch to a specific model.
|
||||
- `/sessions` - List recent sessions.
|
||||
- `/save [name]` - Save the current session.
|
||||
- `/load <name>` - Load a saved session.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is stored at:
|
||||
Config is stored at `~/.ollama-cli-config.json`. You can configure:
|
||||
- `ollama_url`: The URL of your Ollama server.
|
||||
- `default_model`: The model to use by default.
|
||||
- `excluded_models`: Models to hide from the list.
|
||||
|
||||
```text
|
||||
~/.ollama-cli-config.json
|
||||
```
|
||||
## Architecture
|
||||
|
||||
A typical structure includes:[file:1]
|
||||
|
||||
```json
|
||||
{
|
||||
"excluded_models": [],
|
||||
"git": {
|
||||
"remote_url": "",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"gitea": {
|
||||
"url": "",
|
||||
"token": ""
|
||||
},
|
||||
"auto_save_sessions": true,
|
||||
"mcp_servers": {},
|
||||
"ollama_url": "http://localhost:11434/api/chat",
|
||||
"ntfy": {
|
||||
"enabled": false,
|
||||
"topic": "",
|
||||
"server": "https://ntfy.sh"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can edit this by hand or through the `/config` and `/notify` commands.[file:1]
|
||||
|
||||
---
|
||||
|
||||
## Sessions Storage
|
||||
|
||||
Session files are stored under:
|
||||
|
||||
```text
|
||||
~/.ollama-cli-sessions
|
||||
```
|
||||
|
||||
Each session is a JSON document containing the messages and a timestamp, named like `YYYYMMDD_HHMMSS.json` (or your custom name).[[file:1]] You can back this folder up or sync it to move history between machines.[file:1]
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
- The script uses `requests` for HTTP calls, `subprocess` for MCP/LSP processes, `threading` for the notification listener, and standard modules such as `json`, `os`, `sys`, and `pathlib`.[file:1]
|
||||
- The main loop keeps a `messages` list of system, user, and assistant messages and trims it to avoid unbounded context size.[file:1]
|
||||
- The default model is `llama3.2`, but this can be changed either at runtime via `/model` or by editing the script/config.[file:1]
|
||||
|
||||
---
|
||||
- `src/ollama_cli/core`: Core logic (API, Config, Sessions).
|
||||
- `src/ollama_cli/tools`: Tool implementations and registry.
|
||||
- `src/ollama_cli/ui`: REPL and rich formatting.
|
||||
- `src/ollama_cli/integrations`: External integrations (MCP, LSP, Notify).
|
||||
|
||||
## License
|
||||
|
||||
Add your preferred license here (e.g. MIT, Apache-2.0).
|
||||
|
||||
---
|
||||
|
||||
## Roadmap / Ideas
|
||||
|
||||
Some potential future improvements:
|
||||
|
||||
- Automatic reconnection and health checks for MCP and LSP servers.[file:1]
|
||||
- Richer TUI (colors, panes for context, session browser).[file:1]
|
||||
- Plugin system for additional transports or notification backends.[file:1]
|
||||
```
|
||||
|
||||
Do you want this README tailored for publishing on GitHub (badges, screenshots, example configs), or kept minimal for local use?
|
||||
MIT
|
||||
|
||||
120
install.sh
Normal file
120
install.sh
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ollama CLI Installer
|
||||
# Installs Ollama CLI, Ollama, and ComfyUI
|
||||
|
||||
set -e
|
||||
|
||||
echo "==========================================="
|
||||
echo " Ollama CLI All-in-One Installer "
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
# 1. Check Prerequisites
|
||||
echo "[*] Checking system..."
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Error: Python 3 is required but not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "Error: git is required but not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Install Ollama CLI
|
||||
echo ""
|
||||
echo "[*] Installing Ollama CLI..."
|
||||
# Ensure we are in the directory containing pyproject.toml
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ -f "pyproject.toml" ]; then
|
||||
pip3 install .
|
||||
echo "✅ Ollama CLI installed."
|
||||
else
|
||||
echo "Error: Could not find pyproject.toml. Make sure you run this script from the ollama-cli directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Install Ollama (Optional)
|
||||
echo ""
|
||||
echo "-------------------------------------------"
|
||||
if command -v ollama &> /dev/null; then
|
||||
echo "✅ Ollama is already installed."
|
||||
else
|
||||
read -p "Do you want to install Ollama (LLM Server)? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "[*] Installing Ollama..."
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
echo "✅ Ollama installed."
|
||||
else
|
||||
echo "Skipping Ollama installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Install ComfyUI (Optional)
|
||||
echo ""
|
||||
echo "-------------------------------------------"
|
||||
read -p "Do you want to install ComfyUI (Image Generation)? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
INSTALL_DIR="$HOME/ComfyUI"
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "⚠️ ComfyUI directory already exists at $INSTALL_DIR"
|
||||
read -p "Update existing installation? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
cd "$INSTALL_DIR"
|
||||
git pull
|
||||
pip3 install -r requirements.txt
|
||||
echo "✅ ComfyUI updated."
|
||||
fi
|
||||
else
|
||||
echo "[*] Cloning ComfyUI to $INSTALL_DIR..."
|
||||
git clone https://github.com/comfyanonymous/ComfyUI.git "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
echo "[*] Installing ComfyUI dependencies..."
|
||||
pip3 install -r requirements.txt
|
||||
echo "✅ ComfyUI installed."
|
||||
|
||||
echo ""
|
||||
echo "[!] To use Image Generation, you must start ComfyUI:"
|
||||
echo " cd $INSTALL_DIR"
|
||||
echo " python3 main.py"
|
||||
fi
|
||||
|
||||
# Update config to point to ComfyUI
|
||||
# We use a python one-liner to update the json config safely
|
||||
python3 -c "
|
||||
import json
|
||||
import os
|
||||
config_path = os.path.expanduser('~/.ollama-cli-config.json')
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
else:
|
||||
config = {}
|
||||
config['comfy_url'] = 'http://127.0.0.1:8188'
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
print('✅ Configured ollama-cli to use ComfyUI at http://127.0.0.1:8188')
|
||||
"
|
||||
else
|
||||
echo "Skipping ComfyUI installation."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " Installation Complete! 🚀 "
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
echo "To start the CLI, run:"
|
||||
echo " ollama-cli"
|
||||
echo ""
|
||||
if command -v ollama &> /dev/null; then
|
||||
echo "Ensure Ollama is running:"
|
||||
echo " ollama serve"
|
||||
fi
|
||||
echo ""
|
||||
1808
ollama-cli.py.bak
Executable file
1808
ollama-cli.py.bak
Executable file
File diff suppressed because it is too large
Load Diff
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ollama-cli"
|
||||
version = "3.0.0"
|
||||
description = "A powerful, commercially-viable CLI for local Ollama LLMs"
|
||||
authors = [
|
||||
{ name = "Gemini CLI", email = "gemini@example.com" }
|
||||
]
|
||||
dependencies = [
|
||||
"requests",
|
||||
"rich",
|
||||
"prompt_toolkit",
|
||||
]
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[project.scripts]
|
||||
ollama-cli = "ollama_cli.main:main"
|
||||
9
src/ollama_cli.egg-info/PKG-INFO
Normal file
9
src/ollama_cli.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,9 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: ollama-cli
|
||||
Version: 3.0.0
|
||||
Summary: A powerful, commercially-viable CLI for local Ollama LLMs
|
||||
Author-email: Gemini CLI <gemini@example.com>
|
||||
Requires-Python: >=3.8
|
||||
Requires-Dist: requests
|
||||
Requires-Dist: rich
|
||||
Requires-Dist: prompt_toolkit
|
||||
30
src/ollama_cli.egg-info/SOURCES.txt
Normal file
30
src/ollama_cli.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/ollama_cli/__init__.py
|
||||
src/ollama_cli/main.py
|
||||
src/ollama_cli.egg-info/PKG-INFO
|
||||
src/ollama_cli.egg-info/SOURCES.txt
|
||||
src/ollama_cli.egg-info/dependency_links.txt
|
||||
src/ollama_cli.egg-info/entry_points.txt
|
||||
src/ollama_cli.egg-info/requires.txt
|
||||
src/ollama_cli.egg-info/top_level.txt
|
||||
src/ollama_cli/agents/__init__.py
|
||||
src/ollama_cli/core/__init__.py
|
||||
src/ollama_cli/core/config.py
|
||||
src/ollama_cli/core/ollama.py
|
||||
src/ollama_cli/core/sessions.py
|
||||
src/ollama_cli/integrations/__init__.py
|
||||
src/ollama_cli/integrations/lsp.py
|
||||
src/ollama_cli/integrations/mcp.py
|
||||
src/ollama_cli/integrations/notify.py
|
||||
src/ollama_cli/tools/__init__.py
|
||||
src/ollama_cli/tools/base.py
|
||||
src/ollama_cli/tools/code.py
|
||||
src/ollama_cli/tools/filesystem.py
|
||||
src/ollama_cli/tools/git.py
|
||||
src/ollama_cli/tools/knowledge.py
|
||||
src/ollama_cli/tools/system.py
|
||||
src/ollama_cli/tools/web.py
|
||||
src/ollama_cli/ui/__init__.py
|
||||
src/ollama_cli/ui/formatter.py
|
||||
src/ollama_cli/ui/repl.py
|
||||
1
src/ollama_cli.egg-info/dependency_links.txt
Normal file
1
src/ollama_cli.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2
src/ollama_cli.egg-info/entry_points.txt
Normal file
2
src/ollama_cli.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
ollama-cli = ollama_cli.main:main
|
||||
3
src/ollama_cli.egg-info/requires.txt
Normal file
3
src/ollama_cli.egg-info/requires.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests
|
||||
rich
|
||||
prompt_toolkit
|
||||
1
src/ollama_cli.egg-info/top_level.txt
Normal file
1
src/ollama_cli.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
ollama_cli
|
||||
0
src/ollama_cli/__init__.py
Normal file
0
src/ollama_cli/__init__.py
Normal file
BIN
src/ollama_cli/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/ollama_cli/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/__pycache__/main.cpython-312.pyc
Normal file
BIN
src/ollama_cli/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
0
src/ollama_cli/agents/__init__.py
Normal file
0
src/ollama_cli/agents/__init__.py
Normal file
0
src/ollama_cli/core/__init__.py
Normal file
0
src/ollama_cli/core/__init__.py
Normal file
BIN
src/ollama_cli/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/ollama_cli/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
src/ollama_cli/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/core/__pycache__/ollama.cpython-312.pyc
Normal file
BIN
src/ollama_cli/core/__pycache__/ollama.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/core/__pycache__/sessions.cpython-312.pyc
Normal file
BIN
src/ollama_cli/core/__pycache__/sessions.cpython-312.pyc
Normal file
Binary file not shown.
53
src/ollama_cli/core/config.py
Normal file
53
src/ollama_cli/core/config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
CONFIG_FILE = os.path.expanduser("~/.ollama-cli-config.json")
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"excluded_models": [],
|
||||
"git": {
|
||||
"remote_url": "",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"gitea": {
|
||||
"url": "",
|
||||
"token": ""
|
||||
},
|
||||
"auto_save_sessions": True,
|
||||
"mcp_servers": {},
|
||||
"ollama_url": "http://localhost:11434",
|
||||
"comfy_url": "http://localhost:9000",
|
||||
"ntfy": {
|
||||
"enabled": False,
|
||||
"topic": "",
|
||||
"server": "https://ntfy.sh"
|
||||
},
|
||||
"default_model": "llama3.2"
|
||||
}
|
||||
|
||||
def load_config():
|
||||
"""Load config file"""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r') as f:
|
||||
config = json.load(f)
|
||||
# Merge with defaults to ensure all keys exist
|
||||
for k, v in DEFAULT_CONFIG.items():
|
||||
if k not in config:
|
||||
config[k] = v
|
||||
return config
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_CONFIG.copy()
|
||||
|
||||
def save_config(config):
|
||||
"""Save config file"""
|
||||
with open(CONFIG_FILE, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
def update_config(key, value):
|
||||
"""Update a specific config key"""
|
||||
config = load_config()
|
||||
config[key] = value
|
||||
save_config(config)
|
||||
87
src/ollama_cli/core/ollama.py
Normal file
87
src/ollama_cli/core/ollama.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import requests
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List, Dict, Optional, Generator
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, base_url: str = "http://localhost:11434"):
|
||||
# Strip trailing slashes and common API paths if user provided them in config
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/api/chat"):
|
||||
self.base_url = self.base_url[:-9]
|
||||
elif self.base_url.endswith("/api"):
|
||||
self.base_url = self.base_url[:-4]
|
||||
|
||||
self.chat_url = f"{self.base_url}/api/chat"
|
||||
self.list_url = f"{self.base_url}/api/tags"
|
||||
|
||||
def get_available_models(self) -> List[str]:
|
||||
"""Get list of models available in Ollama"""
|
||||
try:
|
||||
# Try API first
|
||||
response = requests.get(self.list_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return [m['name'] for m in data.get('models', [])]
|
||||
|
||||
# Fallback to CLI
|
||||
result = subprocess.run("ollama list", shell=True, capture_output=True, text=True, timeout=5)
|
||||
models = []
|
||||
for line in result.stdout.split('\n')[1:]: # Skip header
|
||||
if line.strip():
|
||||
models.append(line.split()[0])
|
||||
return models
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def chat(self, messages: List[Dict], model: str, stream: bool = True) -> Generator[str, None, str]:
|
||||
"""Call Ollama API with streaming"""
|
||||
try:
|
||||
response = requests.post(self.chat_url, json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream
|
||||
}, stream=stream, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
full_response = ""
|
||||
if stream:
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
chunk = json.loads(line)
|
||||
if "message" in chunk and "content" in chunk["message"]:
|
||||
content = chunk["message"]["content"]
|
||||
full_response += content
|
||||
yield content
|
||||
return full_response
|
||||
else:
|
||||
data = response.json()
|
||||
return data.get("message", {}).get("content", "")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise ConnectionError(f"Cannot connect to Ollama at {self.base_url}. Make sure it's running.")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error calling Ollama: {str(e)}")
|
||||
|
||||
def select_best_model(self, user_input: str, available_models: List[str], default_model: str) -> str:
|
||||
"""Select best model based on task type"""
|
||||
if not available_models:
|
||||
return default_model
|
||||
|
||||
# Model preferences
|
||||
code_models = ["qwen2.5-coder", "mistral-nemo", "codellama", "deepseek-coder"]
|
||||
general_models = ["llama3.2", "llama3.1", "mistral"]
|
||||
|
||||
# Simple heuristic
|
||||
code_keywords = ['code', 'script', 'program', 'function', 'debug', 'python', 'javascript']
|
||||
is_code_task = any(k in user_input.lower() for k in code_keywords)
|
||||
|
||||
target_list = code_models if is_code_task else general_models
|
||||
|
||||
for target in target_list:
|
||||
for available in available_models:
|
||||
if target in available.lower():
|
||||
return available
|
||||
|
||||
return available_models[0] if available_models else default_model
|
||||
53
src/ollama_cli/core/sessions.py
Normal file
53
src/ollama_cli/core/sessions.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
SESSION_DIR = os.path.expanduser("~/.ollama-cli-sessions")
|
||||
|
||||
def save_session(messages: List[Dict], session_name: Optional[str] = None) -> str:
|
||||
"""Save conversation session"""
|
||||
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||
if not session_name:
|
||||
session_name = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# Ensure filename is safe
|
||||
safe_name = "".join([c for c in session_name if c.isalnum() or c in (' ', '.', '_', '-')]).strip()
|
||||
session_file = os.path.join(SESSION_DIR, f"{safe_name}.json")
|
||||
|
||||
with open(session_file, 'w') as f:
|
||||
json.dump({
|
||||
"messages": messages,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"name": session_name
|
||||
}, f, indent=2)
|
||||
return session_file
|
||||
|
||||
def load_session(session_name: str) -> Optional[List[Dict]]:
|
||||
"""Load conversation session"""
|
||||
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
|
||||
if not os.path.exists(session_file):
|
||||
# Try without extension if name doesn't have it
|
||||
if not session_name.endswith('.json'):
|
||||
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
|
||||
|
||||
if os.path.exists(session_file):
|
||||
with open(session_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
return data.get("messages", [])
|
||||
return None
|
||||
|
||||
def list_sessions() -> List[str]:
|
||||
"""List available sessions"""
|
||||
if not os.path.exists(SESSION_DIR):
|
||||
return []
|
||||
sessions = [f.replace('.json', '') for f in os.listdir(SESSION_DIR) if f.endswith('.json')]
|
||||
return sorted(sessions, reverse=True)
|
||||
|
||||
def delete_session(session_name: str) -> bool:
|
||||
"""Delete a session"""
|
||||
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
|
||||
if os.path.exists(session_file):
|
||||
os.remove(session_file)
|
||||
return True
|
||||
return False
|
||||
0
src/ollama_cli/integrations/__init__.py
Normal file
0
src/ollama_cli/integrations/__init__.py
Normal file
BIN
src/ollama_cli/integrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/ollama_cli/integrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/integrations/__pycache__/lsp.cpython-312.pyc
Normal file
BIN
src/ollama_cli/integrations/__pycache__/lsp.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/integrations/__pycache__/mcp.cpython-312.pyc
Normal file
BIN
src/ollama_cli/integrations/__pycache__/mcp.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/integrations/__pycache__/notify.cpython-312.pyc
Normal file
BIN
src/ollama_cli/integrations/__pycache__/notify.cpython-312.pyc
Normal file
Binary file not shown.
75
src/ollama_cli/integrations/lsp.py
Normal file
75
src/ollama_cli/integrations/lsp.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import subprocess
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
LSP_SERVERS = {}
|
||||
|
||||
def start_lsp_server(language: str, root_path: str = ".") -> str:
|
||||
"""Start a language server"""
|
||||
lsp_commands = {
|
||||
"python": ["pyright-langserver", "--stdio"],
|
||||
"javascript": ["typescript-language-server", "--stdio"],
|
||||
"typescript": ["typescript-language-server", "--stdio"],
|
||||
"rust": ["rust-analyzer"],
|
||||
"go": ["gopls"],
|
||||
}
|
||||
|
||||
if language not in lsp_commands:
|
||||
return f"No LSP server configured for {language}"
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
lsp_commands[language],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=os.path.expanduser(root_path)
|
||||
)
|
||||
|
||||
# Initialize
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"processId": os.getpid(),
|
||||
"rootUri": f"file://{os.path.abspath(os.path.expanduser(root_path))}",
|
||||
"capabilities": {}
|
||||
}
|
||||
}
|
||||
|
||||
msg = json.dumps(init_request)
|
||||
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
|
||||
process.stdin.flush()
|
||||
|
||||
LSP_SERVERS[language] = process
|
||||
return f"Started LSP server for {language}"
|
||||
except Exception as e:
|
||||
return f"Error starting LSP: {str(e)}"
|
||||
|
||||
def lsp_goto_definition(language: str, file_path: str, line: int, character: int) -> str:
|
||||
"""Get definition location"""
|
||||
if language not in LSP_SERVERS:
|
||||
return "LSP server not started"
|
||||
|
||||
try:
|
||||
process = LSP_SERVERS[language]
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "textDocument/definition",
|
||||
"params": {
|
||||
"textDocument": {"uri": f"file://{os.path.abspath(file_path)}"},
|
||||
"position": {"line": line, "character": character}
|
||||
}
|
||||
}
|
||||
|
||||
msg = json.dumps(request)
|
||||
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
|
||||
process.stdin.flush()
|
||||
|
||||
return "Definition lookup sent (response parsing not implemented)"
|
||||
except Exception as e:
|
||||
return f"LSP error: {str(e)}"
|
||||
103
src/ollama_cli/integrations/mcp.py
Normal file
103
src/ollama_cli/integrations/mcp.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
MCP_SERVERS = {}
|
||||
MCP_TOOLS = {}
|
||||
|
||||
def connect_mcp_server(name: str, command: str, args: Optional[List[str]] = None) -> str:
|
||||
"""Connect to an MCP server via stdio"""
|
||||
try:
|
||||
cmd = [command] + (args or [])
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# Send initialize request
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "ollama-cli", "version": "3.0"}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.write(json.dumps(init_request) + "\n")
|
||||
process.stdin.flush()
|
||||
|
||||
# Read response
|
||||
response = process.stdout.readline()
|
||||
if response:
|
||||
data = json.loads(response)
|
||||
if "result" in data:
|
||||
MCP_SERVERS[name] = process
|
||||
|
||||
# List tools
|
||||
tools_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
}
|
||||
process.stdin.write(json.dumps(tools_request) + "\n")
|
||||
process.stdin.flush()
|
||||
|
||||
tools_response = process.stdout.readline()
|
||||
if tools_response:
|
||||
tools_data = json.loads(tools_response)
|
||||
if "result" in tools_data and "tools" in tools_data["result"]:
|
||||
for tool in tools_data["result"]["tools"]:
|
||||
tool_name = f"mcp_{name}_{tool['name']}"
|
||||
MCP_TOOLS[tool_name] = {
|
||||
"server": name,
|
||||
"name": tool["name"],
|
||||
"description": tool.get("description", ""),
|
||||
"schema": tool.get("inputSchema", {})
|
||||
}
|
||||
|
||||
return f"Connected to MCP server '{name}' with {len([t for t in MCP_TOOLS if t.startswith(f'mcp_{name}_')])} tools"
|
||||
|
||||
return f"Failed to initialize MCP server '{name}'"
|
||||
except Exception as e:
|
||||
return f"Error connecting to MCP server: {str(e)}"
|
||||
|
||||
def call_mcp_tool(server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
|
||||
"""Call a tool on an MCP server"""
|
||||
try:
|
||||
if server_name not in MCP_SERVERS:
|
||||
return f"Error: MCP server '{server_name}' not connected"
|
||||
|
||||
process = MCP_SERVERS[server_name]
|
||||
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.write(json.dumps(request) + "\n")
|
||||
process.stdin.flush()
|
||||
|
||||
response = process.stdout.readline()
|
||||
if response:
|
||||
data = json.loads(response)
|
||||
if "result" in data:
|
||||
return json.dumps(data["result"], indent=2)
|
||||
elif "error" in data:
|
||||
return f"MCP Error: {data['error']}"
|
||||
|
||||
return "No response from MCP server"
|
||||
except Exception as e:
|
||||
return f"Error calling MCP tool: {str(e)}"
|
||||
64
src/ollama_cli/integrations/notify.py
Normal file
64
src/ollama_cli/integrations/notify.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import requests
|
||||
import json
|
||||
import threading
|
||||
from typing import Callable, List, Dict
|
||||
|
||||
def send_notification(config: Dict, title: str, message: str, priority: str = "default"):
|
||||
"""Send notification via ntfy.sh"""
|
||||
ntfy_config = config.get("ntfy", {})
|
||||
|
||||
if not ntfy_config.get("enabled") or not ntfy_config.get("topic"):
|
||||
return
|
||||
|
||||
try:
|
||||
server = ntfy_config.get("server", "https://ntfy.sh")
|
||||
topic = ntfy_config["topic"]
|
||||
url = f"{server}/{topic}"
|
||||
|
||||
requests.post(url,
|
||||
data=message.encode('utf-8'),
|
||||
headers={
|
||||
"Title": title,
|
||||
"Priority": priority
|
||||
},
|
||||
timeout=5
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
def ntfy_listener(config: Dict, on_message: Callable[[str], str]):
|
||||
"""Listen for ntfy notifications and process them"""
|
||||
ntfy_config = config.get("ntfy", {})
|
||||
|
||||
if not ntfy_config.get("enabled") or not ntfy_config.get("topic"):
|
||||
return
|
||||
|
||||
server = ntfy_config.get("server", "https://ntfy.sh")
|
||||
topic = ntfy_config["topic"]
|
||||
url = f"{server}/{topic}/json"
|
||||
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=None)
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
data = json.loads(line)
|
||||
if data.get("event") == "message":
|
||||
title = data.get("title", "")
|
||||
message = data.get("message", "")
|
||||
|
||||
# Skip messages sent by this script
|
||||
if title in ["Response", "Ollama CLI", "File Created", "Command Complete"]:
|
||||
continue
|
||||
|
||||
# Process message
|
||||
response_text = on_message(message)
|
||||
|
||||
# Send response back
|
||||
send_notification(config, "Response", response_text[:1000])
|
||||
except:
|
||||
pass
|
||||
|
||||
def start_notify_thread(config: Dict, on_message: Callable[[str], str]):
|
||||
thread = threading.Thread(target=ntfy_listener, args=(config, on_message), daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
335
src/ollama_cli/main.py
Normal file
335
src/ollama_cli/main.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Ollama CLI v3.0
|
||||
License: MIT
|
||||
Author: Gemini CLI
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.expanduser("~/.ollama-cli.log")),
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("ollama-cli")
|
||||
|
||||
# Add current directory to path
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from ollama_cli.core.config import load_config, save_config, update_config
|
||||
from ollama_cli.core.ollama import OllamaClient
|
||||
from ollama_cli.core.sessions import save_session, load_session, list_sessions
|
||||
from ollama_cli.ui.formatter import console, print_markdown, print_error, print_status, StreamingDisplay
|
||||
from ollama_cli.ui.repl import REPL
|
||||
from ollama_cli.tools.base import registry
|
||||
import ollama_cli.tools.filesystem
|
||||
import ollama_cli.tools.system
|
||||
import ollama_cli.tools.web
|
||||
import ollama_cli.tools.git
|
||||
import ollama_cli.tools.code
|
||||
import ollama_cli.tools.knowledge
|
||||
import ollama_cli.tools.memory
|
||||
import ollama_cli.tools.execution
|
||||
import ollama_cli.tools.media
|
||||
from ollama_cli.integrations.mcp import connect_mcp_server, call_mcp_tool, MCP_TOOLS
|
||||
from ollama_cli.integrations.lsp import start_lsp_server, lsp_goto_definition
|
||||
from ollama_cli.integrations.notify import start_notify_thread, send_notification
|
||||
|
||||
VERSION = "3.0.0"
|
||||
|
||||
class OllamaCLI:
|
||||
def __init__(self):
|
||||
self.config = load_config()
|
||||
self.client = OllamaClient(self.config.get("ollama_url"))
|
||||
self.repl = REPL()
|
||||
self.messages = []
|
||||
self.available_models = []
|
||||
self.current_model = self.config.get("default_model", "llama3.2")
|
||||
self.system_prompt = self._build_system_prompt()
|
||||
self.max_tool_iterations = 5
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
base_prompt = """You are a highly capable AI assistant powered by Ollama.
|
||||
You have access to a variety of tools to help you complete tasks.
|
||||
|
||||
When you need to use a tool, output a tool call in the following format:
|
||||
<tool_call>
|
||||
<tool_name>tool_name</tool_name>
|
||||
<parameters>{"param": "value"}</parameters>
|
||||
</tool_call>
|
||||
|
||||
You can call multiple tools in one response. Be precise and helpful."""
|
||||
|
||||
tool_desc = registry.generate_system_prompt_snippet()
|
||||
|
||||
# Add MCP tools if any
|
||||
if MCP_TOOLS:
|
||||
tool_desc += "\nMCP Tools:\n"
|
||||
for name, info in MCP_TOOLS.items():
|
||||
tool_desc += f"- {name}: {info['description']}\n"
|
||||
|
||||
return f"{base_prompt}\n\n{tool_desc}"
|
||||
|
||||
def reset_messages(self):
|
||||
self.messages = [{"role": "system", "content": self.system_prompt}]
|
||||
|
||||
def handle_command(self, user_input: str) -> bool:
|
||||
"""Handle slash commands."""
|
||||
if not user_input.startswith('/'):
|
||||
return False
|
||||
|
||||
parts = user_input.split()
|
||||
cmd = parts[0].lower()
|
||||
|
||||
try:
|
||||
if cmd == '/help':
|
||||
help_text = """
|
||||
[bold cyan]Ollama CLI Help[/bold cyan]
|
||||
|
||||
[bold]General Commands:[/bold]
|
||||
/help - Show this help message
|
||||
/quit - Exit the CLI
|
||||
/clear - Clear conversation history
|
||||
|
||||
[bold]Model Commands:[/bold]
|
||||
/models - List available models
|
||||
/model <name> - Switch current model
|
||||
|
||||
[bold]Session Commands:[/bold]
|
||||
/sessions - List saved sessions
|
||||
/save [name] - Save current session
|
||||
/load <name> - Load a saved session
|
||||
|
||||
[bold]Integrations:[/bold]
|
||||
/mcp connect <name> <cmd> [args] - Connect MCP server
|
||||
/lsp start <lang> [path] - Start LSP server
|
||||
/notify setup <topic> - Setup ntfy.sh remote control
|
||||
|
||||
[bold]Configuration:[/bold]
|
||||
/config ollama <url> - Set Ollama server URL
|
||||
/config comfy <url> - Set ComfyUI server URL
|
||||
"""
|
||||
console.print(help_text)
|
||||
elif cmd == '/config':
|
||||
if len(parts) > 2:
|
||||
subcmd = parts[1].lower()
|
||||
val = parts[2]
|
||||
if subcmd == 'ollama':
|
||||
update_config("ollama_url", val)
|
||||
print_status(f"Ollama URL updated to: {val}")
|
||||
self.client = OllamaClient(val)
|
||||
elif subcmd == 'comfy':
|
||||
update_config("comfy_url", val)
|
||||
print_status(f"ComfyUI URL updated to: {val}")
|
||||
else:
|
||||
print_error("Usage: /config <ollama|comfy> <url>")
|
||||
else:
|
||||
print_error("Usage: /config <ollama|comfy> <url>")
|
||||
elif cmd == '/quit':
|
||||
print_status("Goodbye!")
|
||||
sys.exit(0)
|
||||
elif cmd == '/clear':
|
||||
self.reset_messages()
|
||||
print_status("History cleared.")
|
||||
elif cmd == '/models':
|
||||
self.available_models = self.client.get_available_models()
|
||||
print_status(f"Available models: {', '.join(self.available_models)}")
|
||||
elif cmd == '/model':
|
||||
if len(parts) > 1:
|
||||
self.current_model = parts[1]
|
||||
update_config("default_model", self.current_model)
|
||||
print_status(f"Switched to model: {self.current_model}")
|
||||
else:
|
||||
print_status(f"Current model: {self.current_model}")
|
||||
elif cmd == '/sessions':
|
||||
sessions = list_sessions()
|
||||
print_status(f"Recent sessions: {', '.join(sessions[:10])}")
|
||||
elif cmd == '/save':
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
path = save_session(self.messages, name)
|
||||
print_status(f"Session saved to {path}")
|
||||
elif cmd == '/load':
|
||||
if len(parts) > 1:
|
||||
messages = load_session(parts[1])
|
||||
if messages:
|
||||
self.messages = messages
|
||||
print_status(f"Session '{parts[1]}' loaded.")
|
||||
else:
|
||||
print_error(f"Session '{parts[1]}' not found.")
|
||||
else:
|
||||
print_error("Usage: /load <session_name>")
|
||||
elif cmd == '/mcp':
|
||||
if len(parts) > 3 and parts[1] == 'connect':
|
||||
name = parts[2]
|
||||
command = parts[3]
|
||||
args = parts[4:] if len(parts) > 4 else None
|
||||
result = connect_mcp_server(name, command, args)
|
||||
print_status(result)
|
||||
# Refresh system prompt with new tools
|
||||
self.system_prompt = self._build_system_prompt()
|
||||
self.reset_messages()
|
||||
else:
|
||||
print_error("Usage: /mcp connect <name> <command> [args...]")
|
||||
elif cmd == '/lsp':
|
||||
if len(parts) > 2 and parts[1] == 'start':
|
||||
lang = parts[2]
|
||||
path = parts[3] if len(parts) > 3 else "."
|
||||
result = start_lsp_server(lang, path)
|
||||
print_status(result)
|
||||
else:
|
||||
print_error("Usage: /lsp start <language> [path]")
|
||||
elif cmd == '/notify':
|
||||
if len(parts) > 2 and parts[1] == 'setup':
|
||||
topic = parts[2]
|
||||
self.config["ntfy"]["enabled"] = True
|
||||
self.config["ntfy"]["topic"] = topic
|
||||
save_config(self.config)
|
||||
start_notify_thread(self.config, self.process_remote_message)
|
||||
print_status(f"Notifications enabled for topic: {topic}")
|
||||
else:
|
||||
print_error("Usage: /notify setup <topic>")
|
||||
else:
|
||||
print_error(f"Unknown command: {cmd}")
|
||||
except Exception as e:
|
||||
print_error(f"Command error: {e}")
|
||||
|
||||
return True
|
||||
|
||||
def process_remote_message(self, message: str) -> str:
|
||||
"""Handle messages coming from ntfy.sh"""
|
||||
temp_messages = self.messages + [{"role": "user", "content": message}]
|
||||
try:
|
||||
response = ""
|
||||
for chunk in self.client.chat(temp_messages, self.current_model, stream=False):
|
||||
response += chunk
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def run(self):
|
||||
console.print(f"[bold cyan]Ollama CLI v{VERSION}[/bold cyan]")
|
||||
|
||||
# Initial model detection
|
||||
try:
|
||||
self.available_models = self.client.get_available_models()
|
||||
if not self.available_models:
|
||||
print_error("No models found. Make sure Ollama is running.")
|
||||
else:
|
||||
if self.current_model not in self.available_models:
|
||||
self.current_model = self.available_models[0]
|
||||
print_status(f"Using model: [bold green]{self.current_model}[/bold green]")
|
||||
except Exception as e:
|
||||
print_error(f"Could not connect to Ollama: {e}")
|
||||
|
||||
self.reset_messages()
|
||||
|
||||
# Start notification listener if enabled
|
||||
if self.config.get("ntfy", {}).get("enabled"):
|
||||
start_notify_thread(self.config, self.process_remote_message)
|
||||
print_status(f"Notification listener active on topic: {self.config['ntfy']['topic']}")
|
||||
|
||||
while True:
|
||||
user_input = self.repl.get_input()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if self.handle_command(user_input):
|
||||
continue
|
||||
|
||||
self.messages.append({"role": "user", "content": user_input})
|
||||
self.process_chat_cycle()
|
||||
|
||||
def process_chat_cycle(self):
|
||||
"""The main loop for chat and tool execution."""
|
||||
for iteration in range(self.max_tool_iterations):
|
||||
full_response = ""
|
||||
try:
|
||||
print_status(f"Thinking ([dim]{self.current_model}[/dim])...")
|
||||
with StreamingDisplay() as display:
|
||||
for chunk in self.client.chat(self.messages, self.current_model):
|
||||
display.update(chunk)
|
||||
full_response += chunk
|
||||
|
||||
self.messages.append({"role": "assistant", "content": full_response})
|
||||
|
||||
# Check for tool calls
|
||||
tool_calls = self.parse_tool_calls(full_response)
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Execute tool calls
|
||||
results = []
|
||||
for name, params in tool_calls:
|
||||
result = self.execute_tool(name, params)
|
||||
results.append(f"Tool Result ({name}):\n{result}")
|
||||
|
||||
# Add all results to conversation
|
||||
self.messages.append({"role": "user", "content": "\n\n".join(results)})
|
||||
|
||||
# If we made it here, loop again to let the model process results
|
||||
|
||||
except Exception as e:
|
||||
print_error(str(e))
|
||||
logger.exception("Chat cycle error")
|
||||
break
|
||||
|
||||
def parse_tool_calls(self, text: str) -> List[tuple]:
|
||||
pattern = r'<tool_call>\s*<tool_name>(.*?)</tool_name>\s*<parameters>(.*?)</parameters>\s*</tool_call>'
|
||||
matches = re.findall(pattern, text, re.DOTALL)
|
||||
|
||||
calls = []
|
||||
for name, params_str in matches:
|
||||
name = name.strip()
|
||||
try:
|
||||
params = json.loads(params_str.strip())
|
||||
calls.append((name, params))
|
||||
except Exception as e:
|
||||
print_error(f"Failed to parse parameters for {name}: {e}")
|
||||
return calls
|
||||
|
||||
def execute_tool(self, name: str, params: Dict) -> str:
|
||||
"""Execute a tool (standard or MCP)."""
|
||||
# Try MCP tools first
|
||||
if name in MCP_TOOLS:
|
||||
mcp_tool = MCP_TOOLS[name]
|
||||
print_status(f"Executing MCP tool [bold blue]{mcp_tool['name']}[/bold blue]...")
|
||||
return call_mcp_tool(mcp_tool["server"], mcp_tool["name"], params)
|
||||
|
||||
# Try standard tools
|
||||
tool = registry.get_tool(name)
|
||||
if tool:
|
||||
print_status(f"Executing [bold blue]{name}[/bold blue]...")
|
||||
try:
|
||||
result = tool.execute(**params)
|
||||
# Notification for successful file/system ops
|
||||
if name == "write_file" and "Successfully" in str(result):
|
||||
send_notification(self.config, "File Created", f"Path: {params.get('path')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error executing tool: {e}"
|
||||
|
||||
return f"Unknown tool: {name}"
|
||||
|
||||
def main():
|
||||
try:
|
||||
cli = OllamaCLI()
|
||||
cli.run()
|
||||
except KeyboardInterrupt:
|
||||
print_status("Interrupted by user. Exiting.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print_error(f"Fatal error: {e}")
|
||||
logger.exception("Fatal error")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/ollama_cli/tools/__init__.py
Normal file
0
src/ollama_cli/tools/__init__.py
Normal file
BIN
src/ollama_cli/tools/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/base.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/code.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/code.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/git.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/git.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/knowledge.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/knowledge.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/system.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/system.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/web.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/web.cpython-312.pyc
Normal file
Binary file not shown.
55
src/ollama_cli/tools/base.py
Normal file
55
src/ollama_cli/tools/base.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import Dict, Any, Callable, List, Optional
|
||||
import json
|
||||
import inspect
|
||||
|
||||
class BaseTool:
|
||||
def __init__(self, name: str, description: str, parameters: Dict[str, Any]):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parameters = parameters
|
||||
|
||||
def execute(self, **kwargs) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, BaseTool] = {}
|
||||
|
||||
def register(self, tool: BaseTool):
|
||||
self.tools[tool.name] = tool
|
||||
|
||||
def get_tool(self, name: str) -> Optional[BaseTool]:
|
||||
return self.tools.get(name)
|
||||
|
||||
def list_tools(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.parameters
|
||||
}
|
||||
for t in self.tools.values()
|
||||
]
|
||||
|
||||
def generate_system_prompt_snippet(self) -> str:
|
||||
snippet = "Available tools:\n"
|
||||
for t in self.tools.values():
|
||||
snippet += f"- {t.name}: {t.description}\n"
|
||||
snippet += f" Parameters: {json.dumps(t.parameters)}\n"
|
||||
return snippet
|
||||
|
||||
# Singleton registry
|
||||
registry = ToolRegistry()
|
||||
|
||||
def tool(name: str, description: str, parameters: Dict[str, Any]):
|
||||
"""Decorator to register a function as a tool"""
|
||||
def decorator(func: Callable):
|
||||
class DecoratedTool(BaseTool):
|
||||
def __init__(self):
|
||||
super().__init__(name, description, parameters)
|
||||
def execute(self, **kwargs):
|
||||
return func(**kwargs)
|
||||
|
||||
registry.register(DecoratedTool())
|
||||
return func
|
||||
return decorator
|
||||
96
src/ollama_cli/tools/code.py
Normal file
96
src/ollama_cli/tools/code.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
import subprocess
|
||||
from .base import tool
|
||||
|
||||
@tool(
|
||||
name="code_analyze_file",
|
||||
description="Analyze code structure and suggest improvements",
|
||||
parameters={
|
||||
"file_path": {"type": "string", "description": "Path to the file to analyze"}
|
||||
}
|
||||
)
|
||||
def code_analyze_file(file_path: str) -> str:
|
||||
try:
|
||||
with open(os.path.expanduser(file_path), 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.split('\n')
|
||||
functions = [l for l in lines if 'def ' in l]
|
||||
classes = [l for l in lines if 'class ' in l]
|
||||
|
||||
issues = []
|
||||
if len(lines) > 500:
|
||||
issues.append(f"Large file ({len(lines)} lines) - consider splitting")
|
||||
if len(functions) > 30:
|
||||
issues.append(f"Many functions ({len(functions)}) - consider refactoring")
|
||||
|
||||
return f"""File: {file_path}
|
||||
Lines: {len(lines)} | Functions: {len(functions)} | Classes: {len(classes)}
|
||||
|
||||
Issues:
|
||||
{chr(10).join(f'- {i}' for i in issues) if issues else '- None detected'}"""
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="generate_readme",
|
||||
description="Generate a README.md for the current project",
|
||||
parameters={
|
||||
"path": {"type": "string", "description": "Project root path", "default": "."}
|
||||
}
|
||||
)
|
||||
def generate_readme(path: str = ".") -> str:
|
||||
try:
|
||||
project_name = os.path.basename(os.path.abspath(path))
|
||||
readme_content = f"# {project_name}\n\nGenerated by Ollama CLI."
|
||||
readme_path = os.path.join(path, "README.md")
|
||||
with open(readme_path, 'w') as f:
|
||||
f.write(readme_content)
|
||||
return f"Generated README.md at {readme_path}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="replace_text",
|
||||
description="Surgically replace text in a file",
|
||||
parameters={
|
||||
"path": {"type": "string", "description": "Path to the file"},
|
||||
"old_text": {"type": "string", "description": "The exact text to find"},
|
||||
"new_text": {"type": "string", "description": "The text to replace it with"}
|
||||
}
|
||||
)
|
||||
def replace_text(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
expanded_path = os.path.expanduser(path)
|
||||
with open(expanded_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
if old_text not in content:
|
||||
return f"Error: The text to replace was not found in {path}"
|
||||
|
||||
new_content = content.replace(old_text, new_text, 1) # Replace first occurrence
|
||||
with open(expanded_path, 'w') as f:
|
||||
f.write(new_content)
|
||||
return f"Successfully updated {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="run_linter",
|
||||
description="Run a linter on a file",
|
||||
parameters={
|
||||
"file_path": {"type": "string", "description": "Path to the file"},
|
||||
"linter": {"type": "string", "description": "Linter to use (pylint|eslint|auto)", "default": "auto"}
|
||||
}
|
||||
)
|
||||
def run_linter(file_path: str, linter: str = "auto") -> str:
|
||||
try:
|
||||
file_path = os.path.expanduser(file_path)
|
||||
if linter == "auto":
|
||||
ext = os.path.splitext(file_path)[1]
|
||||
linter = "pylint" if ext == ".py" else "eslint"
|
||||
|
||||
result = subprocess.run(f"{linter} {file_path}", shell=True, capture_output=True, text=True)
|
||||
return result.stdout + result.stderr
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
38
src/ollama_cli/tools/execution.py
Normal file
38
src/ollama_cli/tools/execution.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
from .base import tool
|
||||
|
||||
@tool(
|
||||
name="run_python",
|
||||
description="Execute a Python script or snippet",
|
||||
parameters={
|
||||
"code": {"type": "string", "description": "Python code to execute"},
|
||||
"args": {"type": "string", "description": "Optional arguments", "default": ""}
|
||||
}
|
||||
)
|
||||
def run_python(code: str, args: str = "") -> str:
|
||||
try:
|
||||
# Create a temp file
|
||||
temp_file = ".ollama-cli-exec.py"
|
||||
with open(temp_file, "w") as f:
|
||||
f.write(code)
|
||||
|
||||
cmd = f"{sys.executable} {temp_file} {args}"
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# Cleanup
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
|
||||
if result.returncode == 0:
|
||||
return f"Success:
|
||||
{output}" if output else "Success (no output)"
|
||||
else:
|
||||
return f"Error (Exit {result.returncode}):
|
||||
{output}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Execution Error: {str(e)}"
|
||||
83
src/ollama_cli/tools/filesystem.py
Normal file
83
src/ollama_cli/tools/filesystem.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import subprocess
|
||||
from .base import tool
|
||||
|
||||
@tool(
|
||||
name="read_file",
|
||||
description="Read the contents of a file",
|
||||
parameters={
|
||||
"path": {"type": "string", "description": "Path to the file"}
|
||||
}
|
||||
)
|
||||
def read_file(path: str) -> str:
|
||||
try:
|
||||
with open(os.path.expanduser(path), 'r') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
return f"Error reading file: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="write_file",
|
||||
description="Write content to a file",
|
||||
parameters={
|
||||
"path": {"type": "string", "description": "Path to the file"},
|
||||
"content": {"type": "string", "description": "Content to write"}
|
||||
}
|
||||
)
|
||||
def write_file(path: str, content: str) -> str:
|
||||
try:
|
||||
expanded_path = os.path.expanduser(path)
|
||||
os.makedirs(os.path.dirname(expanded_path), exist_ok=True)
|
||||
with open(expanded_path, 'w') as f:
|
||||
f.write(content)
|
||||
return f"Successfully written to {path}"
|
||||
except Exception as e:
|
||||
return f"Error writing file: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="list_directory",
|
||||
description="List contents of a directory",
|
||||
parameters={
|
||||
"path": {"type": "string", "description": "Path to the directory", "default": "."}
|
||||
}
|
||||
)
|
||||
def list_directory(path: str = ".") -> str:
|
||||
try:
|
||||
expanded = os.path.expanduser(path)
|
||||
files = os.listdir(expanded)
|
||||
return "\n".join(files) if files else "Directory is empty"
|
||||
except Exception as e:
|
||||
return f"Error listing directory: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="grep_search",
|
||||
description="Search for a regular expression pattern in file contents",
|
||||
parameters={
|
||||
"pattern": {"type": "string", "description": "The regex pattern to search for"},
|
||||
"path": {"type": "string", "description": "Directory to search in", "default": "."},
|
||||
"include": {"type": "string", "description": "Glob pattern for files to include", "default": "*"}
|
||||
}
|
||||
)
|
||||
def grep_search(pattern: str, path: str = ".", include: str = "*") -> str:
|
||||
try:
|
||||
cmd = f'grep -rE --include="{include}" "{pattern}" {path}'
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20)
|
||||
return result.stdout if result.stdout else "No matches found."
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="get_tree",
|
||||
description="Show the directory structure as a tree",
|
||||
parameters={
|
||||
"path": {"type": "string", "description": "Root path", "default": "."},
|
||||
"depth": {"type": "integer", "description": "Max depth", "default": 2}
|
||||
}
|
||||
)
|
||||
def get_tree(path: str = ".", depth: int = 2) -> str:
|
||||
try:
|
||||
cmd = f"find {path} -maxdepth {depth} -not -path '*/.*'"
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
return result.stdout
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
52
src/ollama_cli/tools/git.py
Normal file
52
src/ollama_cli/tools/git.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import subprocess
|
||||
import requests
|
||||
from typing import Optional
|
||||
from .base import tool
|
||||
from ..core.config import load_config
|
||||
|
||||
@tool(
|
||||
name="git_operation",
|
||||
description="Perform git operations (status, diff, commit, push, pull, list)",
|
||||
parameters={
|
||||
"operation": {"type": "string", "description": "Operation to perform (status|diff|commit|push|pull|list)"},
|
||||
"message": {"type": "string", "description": "Commit message", "default": "Auto commit"}
|
||||
}
|
||||
)
|
||||
def git_operation(operation: str, message: str = "Auto commit") -> str:
|
||||
config = load_config()
|
||||
git_config = config.get("git", {})
|
||||
gitea_config = config.get("gitea", {})
|
||||
|
||||
try:
|
||||
if operation == "status":
|
||||
result = subprocess.run("git status", shell=True, capture_output=True, text=True)
|
||||
elif operation == "diff":
|
||||
result = subprocess.run("git diff", shell=True, capture_output=True, text=True)
|
||||
elif operation == "commit":
|
||||
result = subprocess.run(f'git add -A && git commit -m "{message}"',
|
||||
shell=True, capture_output=True, text=True)
|
||||
elif operation == "push":
|
||||
result = subprocess.run("git push", shell=True, capture_output=True, text=True)
|
||||
elif operation == "pull":
|
||||
result = subprocess.run("git pull", shell=True, capture_output=True, text=True)
|
||||
elif operation == "list":
|
||||
if gitea_config.get("url") and gitea_config.get("token"):
|
||||
gitea_url = gitea_config["url"].rstrip("/")
|
||||
token = gitea_config["token"]
|
||||
response = requests.get(
|
||||
f"{gitea_url}/api/v1/user/repos",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
repos = response.json()
|
||||
return "\n".join([f"- {r['full_name']} ({r['clone_url']})" for r in repos])
|
||||
return f"Gitea API error: {response.status_code}"
|
||||
result = subprocess.run("find . -maxdepth 2 -name '.git' -type d | sed 's|/.git||'",
|
||||
shell=True, capture_output=True, text=True)
|
||||
else:
|
||||
return f"Unknown git operation: {operation}"
|
||||
|
||||
return result.stdout + result.stderr
|
||||
except Exception as e:
|
||||
return f"Git error: {str(e)}"
|
||||
41
src/ollama_cli/tools/knowledge.py
Normal file
41
src/ollama_cli/tools/knowledge.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import os
|
||||
from .base import tool
|
||||
|
||||
KB_FILE = os.path.expanduser("~/.ollama-cli-kb.json")
|
||||
|
||||
def load_kb():
|
||||
if os.path.exists(KB_FILE):
|
||||
with open(KB_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_kb(kb):
|
||||
with open(KB_FILE, 'w') as f:
|
||||
json.dump(kb, f, indent=2)
|
||||
|
||||
@tool(
|
||||
name="kb_add",
|
||||
description="Add content to the knowledge base",
|
||||
parameters={
|
||||
"key": {"type": "string", "description": "Search key"},
|
||||
"content": {"type": "string", "description": "Information content"}
|
||||
}
|
||||
)
|
||||
def kb_add(key: str, content: str) -> str:
|
||||
kb = load_kb()
|
||||
kb[key] = content
|
||||
save_kb(kb)
|
||||
return f"Added '{key}' to knowledge base"
|
||||
|
||||
@tool(
|
||||
name="kb_search",
|
||||
description="Search the knowledge base",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
}
|
||||
)
|
||||
def kb_search(query: str) -> str:
|
||||
kb = load_kb()
|
||||
results = [f"{k}: {v[:100]}..." for k, v in kb.items() if query.lower() in k.lower() or query.lower() in v.lower()]
|
||||
return "\n".join(results) if results else "No matches found"
|
||||
111
src/ollama_cli/tools/media.py
Normal file
111
src/ollama_cli/tools/media.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import json
|
||||
import random
|
||||
from .base import tool
|
||||
from ..core.config import load_config
|
||||
|
||||
@tool(
|
||||
name="generate_image",
|
||||
description="Generate an image using the local Stable Diffusion workflow (ComfyUI)",
|
||||
parameters={
|
||||
"prompt": {"type": "string", "description": "The image description"}
|
||||
}
|
||||
)
|
||||
def generate_image(prompt: str) -> str:
|
||||
"""Invokes the ComfyUI API directly with a predefined workflow"""
|
||||
config = load_config()
|
||||
comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/")
|
||||
|
||||
try:
|
||||
# Check if ComfyUI is running
|
||||
try:
|
||||
requests.get(comfy_url, timeout=2)
|
||||
except:
|
||||
return f"Error: ComfyUI server ({comfy_url}) appears to be down. Please start it first or configure the URL with '/config comfy <url>'."
|
||||
|
||||
# Define the workflow directly
|
||||
# Based on the user's previous generate_image.py, but using the configured URL
|
||||
workflow = {
|
||||
"1": {
|
||||
"inputs": {"ckpt_name": "dreamshaper_8.safetensors"},
|
||||
"class_type": "CheckpointLoaderSimple"
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"text": f"masterpiece, best quality, highly detailed, {prompt}",
|
||||
"clip": ["1", 1]
|
||||
},
|
||||
"class_type": "CLIPTextEncode"
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"text": "blurry, low quality, distorted, watermark, nsfw",
|
||||
"clip": ["1", 1]
|
||||
},
|
||||
"class_type": "CLIPTextEncode"
|
||||
},
|
||||
"4": {
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
"class_type": "EmptyLatentImage"
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"seed": random.randint(1, 1000000000), "steps": 20, "cfg": 7.0,
|
||||
"sampler_name": "dpmpp_2m", "scheduler": "karras",
|
||||
"denoise": 1.0, "model": ["1", 0],
|
||||
"positive": ["2", 0], "negative": ["3", 0],
|
||||
"latent_image": ["4", 0]
|
||||
},
|
||||
"class_type": "KSampler"
|
||||
},
|
||||
"6": {
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||
"class_type": "VAEDecode"
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ollama_cli_",
|
||||
"images": ["6", 0]
|
||||
},
|
||||
"class_type": "SaveImage"
|
||||
}
|
||||
}
|
||||
|
||||
payload = {"prompt": workflow}
|
||||
response = requests.post(f"{comfy_url}/prompt", json=payload, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
prompt_id = data.get('prompt_id', 'unknown')
|
||||
return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nCheck your ComfyUI output folder for 'ollama_cli_' prefixed files."
|
||||
else:
|
||||
return f"Error form ComfyUI: {response.status_code} - {response.text}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error generating image: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="speak_text",
|
||||
description="Convert text to speech using Piper TTS",
|
||||
parameters={
|
||||
"text": {"type": "string", "description": "Text to speak"},
|
||||
"output_file": {"type": "string", "description": "Output filename", "default": "output.wav"}
|
||||
}
|
||||
)
|
||||
def speak_text(text: str, output_file: str = "output.wav") -> str:
|
||||
"""Uses local Piper installation if available"""
|
||||
piper_bin = os.path.expanduser("~/piper/piper")
|
||||
model_path = os.path.expanduser("~/piper/models/en_US-lessac-medium.onnx") # Guessing model path based on common setup
|
||||
|
||||
# Fallback to simple 'say' command on macOS if piper is tricky to locate without scanning
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
subprocess.run(["say", "-o", output_file, text], check=True)
|
||||
return f"Audio saved to {output_file} (using macOS 'say')"
|
||||
except Exception as e:
|
||||
return f"Error using 'say': {e}"
|
||||
|
||||
return "TTS not configured for this platform yet."
|
||||
61
src/ollama_cli/tools/memory.py
Normal file
61
src/ollama_cli/tools/memory.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
import os
|
||||
from .base import tool
|
||||
|
||||
PROJECT_MEMORY_FILE = ".ollama-cli/memory.json"
|
||||
|
||||
def load_project_memory():
|
||||
if os.path.exists(PROJECT_MEMORY_FILE):
|
||||
try:
|
||||
with open(PROJECT_MEMORY_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_project_memory(memory):
|
||||
os.makedirs(os.path.dirname(PROJECT_MEMORY_FILE), exist_ok=True)
|
||||
with open(PROJECT_MEMORY_FILE, 'w') as f:
|
||||
json.dump(memory, f, indent=2)
|
||||
|
||||
@tool(
|
||||
name="remember_fact",
|
||||
description="Store a key piece of information about the project",
|
||||
parameters={
|
||||
"fact": {"type": "string", "description": "The fact to remember (e.g., 'API key is in .env', 'Main entry point is src/index.ts')"}
|
||||
}
|
||||
)
|
||||
def remember_fact(fact: str) -> str:
|
||||
memory = load_project_memory()
|
||||
facts = memory.get("facts", [])
|
||||
if fact not in facts:
|
||||
facts.append(fact)
|
||||
memory["facts"] = facts
|
||||
save_project_memory(memory)
|
||||
return f"Remembered: {fact}"
|
||||
return "Fact already known."
|
||||
|
||||
@tool(
|
||||
name="recall_facts",
|
||||
description="Recall all stored facts about the project",
|
||||
parameters={}
|
||||
)
|
||||
def recall_facts() -> str:
|
||||
memory = load_project_memory()
|
||||
facts = memory.get("facts", [])
|
||||
if not facts:
|
||||
return "No project facts stored."
|
||||
return "Project Facts:
|
||||
" + "
|
||||
".join(f"- {f}" for f in facts)
|
||||
|
||||
@tool(
|
||||
name="clear_memory",
|
||||
description="Clear project memory",
|
||||
parameters={}
|
||||
)
|
||||
def clear_memory() -> str:
|
||||
if os.path.exists(PROJECT_MEMORY_FILE):
|
||||
os.remove(PROJECT_MEMORY_FILE)
|
||||
return "Project memory cleared."
|
||||
return "Memory was already empty."
|
||||
46
src/ollama_cli/tools/system.py
Normal file
46
src/ollama_cli/tools/system.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import subprocess
|
||||
import os
|
||||
from .base import tool
|
||||
|
||||
@tool(
|
||||
name="execute_bash",
|
||||
description="Execute a bash command and return its output",
|
||||
parameters={
|
||||
"command": {"type": "string", "description": "The command to execute"}
|
||||
}
|
||||
)
|
||||
def execute_bash(command: str) -> str:
|
||||
# Basic safety check
|
||||
dangerous = ['rm -rf /', 'dd if=', 'mkfs', ':(){:|:&};:']
|
||||
if any(d in command for d in dangerous):
|
||||
return f"⚠️ DANGEROUS COMMAND BLOCKED: {command}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=os.getcwd(),
|
||||
timeout=60
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
return f"Exit code: {result.returncode}\n{output}" if output else f"Exit code: {result.returncode} (no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Command timed out after 60 seconds"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="get_env_info",
|
||||
description="Get information about the current system environment",
|
||||
parameters={}
|
||||
)
|
||||
def get_env_info() -> str:
|
||||
import sys
|
||||
import platform
|
||||
return f"""OS: {platform.system()} {platform.release()}
|
||||
Python: {sys.version}
|
||||
Shell: {os.environ.get('SHELL', 'unknown')}
|
||||
User: {os.environ.get('USER', 'unknown')}
|
||||
CWD: {os.getcwd()}"""
|
||||
67
src/ollama_cli/tools/web.py
Normal file
67
src/ollama_cli/tools/web.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import requests
|
||||
import urllib.parse
|
||||
from .base import tool
|
||||
|
||||
@tool(
|
||||
name="web_search",
|
||||
description="Search the web for information",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The search query"}
|
||||
}
|
||||
)
|
||||
def web_search(query: str) -> str:
|
||||
"""Enhanced web search with DuckDuckGo"""
|
||||
try:
|
||||
encoded = urllib.parse.quote(query)
|
||||
# Use DuckDuckGo Instant Answer API
|
||||
api_url = f"https://api.duckduckgo.com/?q={encoded}&format=json"
|
||||
response = requests.get(api_url, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
if data.get("AbstractText"):
|
||||
results.append(f"Summary: {data['AbstractText']}")
|
||||
|
||||
for topic in data.get("RelatedTopics", [])[:5]:
|
||||
if isinstance(topic, dict) and "Text" in topic:
|
||||
results.append(f"- {topic['Text']}")
|
||||
|
||||
return "\n".join(results) if results else "No direct results found. Try a different query."
|
||||
except Exception as e:
|
||||
return f"Search error: {str(e)}"
|
||||
|
||||
@tool(
|
||||
name="read_url",
|
||||
description="Read and extract text content from a URL",
|
||||
parameters={
|
||||
"url": {"type": "string", "description": "The URL to read"}
|
||||
}
|
||||
)
|
||||
def read_url(url: str) -> str:
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Remove scripts and styles
|
||||
for script in soup(["script", "style"]):
|
||||
script.decompose()
|
||||
|
||||
text = soup.get_text()
|
||||
|
||||
# Clean up whitespace
|
||||
lines = (line.strip() for line in text.splitlines())
|
||||
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
|
||||
text = '\n'.join(chunk for chunk in chunks if chunk)
|
||||
|
||||
return text[:10000] + ("\n... (truncated)" if len(text) > 10000 else "")
|
||||
|
||||
except ImportError:
|
||||
# Fallback if bs4 is missing (though we checked)
|
||||
return response.text[:10000]
|
||||
|
||||
except Exception as e:
|
||||
return f"Error reading URL: {str(e)}"
|
||||
0
src/ollama_cli/ui/__init__.py
Normal file
0
src/ollama_cli/ui/__init__.py
Normal file
BIN
src/ollama_cli/ui/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/ollama_cli/ui/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/ui/__pycache__/formatter.cpython-312.pyc
Normal file
BIN
src/ollama_cli/ui/__pycache__/formatter.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/ui/__pycache__/repl.cpython-312.pyc
Normal file
BIN
src/ollama_cli/ui/__pycache__/repl.cpython-312.pyc
Normal file
Binary file not shown.
40
src/ollama_cli/ui/formatter.py
Normal file
40
src/ollama_cli/ui/formatter.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
from rich.syntax import Syntax
|
||||
from typing import Optional
|
||||
|
||||
console = Console()
|
||||
|
||||
def print_markdown(text: str):
|
||||
console.print(Markdown(text))
|
||||
|
||||
def print_panel(text: str, title: Optional[str] = None, style: str = "blue"):
|
||||
console.print(Panel(text, title=title, border_style=style))
|
||||
|
||||
def print_error(text: str):
|
||||
console.print(f"[bold red]Error:[/bold red] {text}")
|
||||
|
||||
def print_status(text: str):
|
||||
console.print(f"[bold blue]>>>[/bold blue] {text}")
|
||||
|
||||
class StreamingDisplay:
|
||||
def __init__(self, title: Optional[str] = None):
|
||||
self.text = ""
|
||||
self.live = Live(Text(self.text), refresh_per_second=10)
|
||||
self.title = title
|
||||
|
||||
def __enter__(self):
|
||||
self.live.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.live.stop()
|
||||
|
||||
def update(self, chunk: str):
|
||||
self.text += chunk
|
||||
# Try to parse markdown if it looks complete-ish, otherwise just show text
|
||||
# For performance, we might just show raw text during streaming
|
||||
self.live.update(Markdown(self.text) if "```" in self.text else Text(self.text))
|
||||
25
src/ollama_cli/ui/repl.py
Normal file
25
src/ollama_cli/ui/repl.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.styles import Style
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
import os
|
||||
|
||||
class REPL:
|
||||
def __init__(self):
|
||||
history_file = os.path.expanduser("~/.ollama-cli-history")
|
||||
self.session = PromptSession(history=FileHistory(history_file))
|
||||
self.style = Style.from_dict({
|
||||
'prompt': 'ansicyan bold',
|
||||
'command': 'ansigreen',
|
||||
})
|
||||
|
||||
def get_input(self) -> str:
|
||||
try:
|
||||
return self.session.prompt(
|
||||
HTML('<prompt><b>> </b></prompt>'),
|
||||
style=self.style
|
||||
).strip()
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
except EOFError:
|
||||
return "/quit"
|
||||
Reference in New Issue
Block a user