Refactor to v3.0.0: Modular architecture, advanced tools (Filesystem, Web, Python, Media), and professional REPL

This commit is contained in:
2026-02-22 23:55:37 +00:00
parent bdbd90709c
commit e49dcbd075
57 changed files with 3517 additions and 207 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

245
README.md
View File

@@ -1,234 +1,65 @@
```markdown # Ollama CLI v3.0
# ollama-cli
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 ## Features
- **Interactive chat** with streaming responses against your local Ollama server (`/api/chat`).[file:1] - **Rich UI:** Beautifully formatted output using `rich`.
- **Model management**: list models, switch default model, and exclude models you do not want to use.[file:1] - **Powerful REPL:** Multi-line input, history, and completions via `prompt_toolkit`.
- **Session management**: save, load, and list past chat sessions stored under `~/.ollama-cli-sessions`.[file:1] - **Modular Tool System:** Easily extensible tool architecture (Filesystem, System, Web, etc.).
- **Config file support** at `~/.ollama-cli-config.json` for excluded models, Ollama URL, MCP servers, git/Gitea, and ntfy settings.[file:1] - **Session Management:** Save and resume conversations with ease.
- **Context files**: add local files as context for the assistant to use.[file:1] - **Model Intelligence:** Automatically switches models based on task type (coding vs. general).
- **Knowledge base**: simple built-in knowledge storage and search.[file:1] - **Streaming Responses:** Real-time feedback as the model generates text.
- **MCP integration**: connect to MCP servers over stdio and expose their tools to the assistant.[file:1] - **MCP Support:** Ready for Model Context Protocol integration.
- **LSP integration**: start language servers (Python, TypeScript/JavaScript, Rust, Go, etc.) via built-in commands.[file:1] - **Local First:** Everything runs locally on your machine.
- **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
```
---
## Installation ## 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 ```bash
mkdir -p ~/.local/bin git clone https://github.com/yourusername/ollama-cli.git
cp ollama-cli.py ~/.local/bin/ollama-cli cd ollama-cli
chmod +x ~/.local/bin/ollama-cli
``` ```
2. Ensure `~/.local/bin` is in your `PATH`.[file:1] 2. Install in editable mode:
3. Start your Ollama server, e.g.:[file:1]
```bash ```bash
ollama serve pip install -e .
``` ```
--- ## Usage
## Basic Usage
Run the CLI: Run the CLI:
```bash ```bash
ollama-cli ollama-cli
# or
python3 ollama-cli.py
``` ```
On startup it will:[file:1] ### Slash Commands
- `/quit` - Exit the CLI.
- Load `~/.ollama-cli-config.json` (or create defaults in memory).[file:1] - `/clear` - Clear conversation history.
- Detect available models from the Ollama server.[file:1] - `/models` - List available models.
- Show the current directory, home directory, and a list of supported commands.[file:1] - `/model <name>` - Switch to a specific model.
- `/sessions` - List recent sessions.
You then type messages at the `>` prompt and responses are streamed back to the terminal.[file:1] - `/save [name]` - Save the current session.
- `/load <name>` - Load a saved session.
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]
---
## Configuration ## 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 ## Architecture
~/.ollama-cli-config.json
```
A typical structure includes:[file:1] - `src/ollama_cli/core`: Core logic (API, Config, Sessions).
- `src/ollama_cli/tools`: Tool implementations and registry.
```json - `src/ollama_cli/ui`: REPL and rich formatting.
{ - `src/ollama_cli/integrations`: External integrations (MCP, LSP, Notify).
"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]
---
## License ## License
MIT
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?

120
install.sh Normal file
View 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

File diff suppressed because it is too large Load Diff

20
pyproject.toml Normal file
View 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"

View 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

View 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
[console_scripts]
ollama-cli = ollama_cli.main:main

View File

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

View File

@@ -0,0 +1 @@
ollama_cli

View File

Binary file not shown.

Binary file not shown.

View File

View File

Binary file not shown.

Binary file not shown.

View 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)

View 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

View 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

View File

View 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)}"

View 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)}"

View 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
View 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()

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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)}"

View 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)}"

View 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)}"

View 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)}"

View 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"

View 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."

View 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."

View 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()}"""

View 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)}"

View File

Binary file not shown.

Binary file not shown.

View 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
View 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>&gt; </b></prompt>'),
style=self.style
).strip()
except KeyboardInterrupt:
return ""
except EOFError:
return "/quit"