From e49dcbd0753e97e2cdda685bfa97cd07dc2eee0b Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 22 Feb 2026 23:55:37 +0000 Subject: [PATCH] Refactor to v3.0.0: Modular architecture, advanced tools (Filesystem, Web, Python, Media), and professional REPL --- .DS_Store | Bin 0 -> 6148 bytes README.md | 245 +-- install.sh | 120 ++ ollama-cli.py.bak | 1808 +++++++++++++++++ pyproject.toml | 20 + src/ollama_cli.egg-info/PKG-INFO | 9 + src/ollama_cli.egg-info/SOURCES.txt | 30 + src/ollama_cli.egg-info/dependency_links.txt | 1 + src/ollama_cli.egg-info/entry_points.txt | 2 + src/ollama_cli.egg-info/requires.txt | 3 + src/ollama_cli.egg-info/top_level.txt | 1 + src/ollama_cli/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 146 bytes .../__pycache__/main.cpython-312.pyc | Bin 0 -> 15856 bytes src/ollama_cli/agents/__init__.py | 0 src/ollama_cli/core/__init__.py | 0 .../core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 151 bytes .../core/__pycache__/config.cpython-312.pyc | Bin 0 -> 2118 bytes .../core/__pycache__/ollama.cpython-312.pyc | Bin 0 -> 5017 bytes .../core/__pycache__/sessions.cpython-312.pyc | Bin 0 -> 3599 bytes src/ollama_cli/core/config.py | 53 + src/ollama_cli/core/ollama.py | 87 + src/ollama_cli/core/sessions.py | 53 + src/ollama_cli/integrations/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 159 bytes .../__pycache__/lsp.cpython-312.pyc | Bin 0 -> 3249 bytes .../__pycache__/mcp.cpython-312.pyc | Bin 0 -> 4257 bytes .../__pycache__/notify.cpython-312.pyc | Bin 0 -> 2740 bytes src/ollama_cli/integrations/lsp.py | 75 + src/ollama_cli/integrations/mcp.py | 103 + src/ollama_cli/integrations/notify.py | 64 + src/ollama_cli/main.py | 335 +++ src/ollama_cli/tools/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 152 bytes .../tools/__pycache__/base.cpython-312.pyc | Bin 0 -> 4067 bytes .../tools/__pycache__/code.cpython-312.pyc | Bin 0 -> 4451 bytes .../__pycache__/filesystem.cpython-312.pyc | Bin 0 -> 3608 bytes .../tools/__pycache__/git.cpython-312.pyc | Bin 0 -> 2997 bytes .../__pycache__/knowledge.cpython-312.pyc | Bin 0 -> 2411 bytes .../tools/__pycache__/system.cpython-312.pyc | Bin 0 -> 1726 bytes .../tools/__pycache__/web.cpython-312.pyc | Bin 0 -> 1718 bytes src/ollama_cli/tools/base.py | 55 + src/ollama_cli/tools/code.py | 96 + src/ollama_cli/tools/execution.py | 38 + src/ollama_cli/tools/filesystem.py | 83 + src/ollama_cli/tools/git.py | 52 + src/ollama_cli/tools/knowledge.py | 41 + src/ollama_cli/tools/media.py | 111 + src/ollama_cli/tools/memory.py | 61 + src/ollama_cli/tools/system.py | 46 + src/ollama_cli/tools/web.py | 67 + src/ollama_cli/ui/__init__.py | 0 .../ui/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 149 bytes .../ui/__pycache__/formatter.cpython-312.pyc | Bin 0 -> 2894 bytes .../ui/__pycache__/repl.cpython-312.pyc | Bin 0 -> 1585 bytes src/ollama_cli/ui/formatter.py | 40 + src/ollama_cli/ui/repl.py | 25 + 57 files changed, 3517 insertions(+), 207 deletions(-) create mode 100644 .DS_Store create mode 100644 install.sh create mode 100755 ollama-cli.py.bak create mode 100644 pyproject.toml create mode 100644 src/ollama_cli.egg-info/PKG-INFO create mode 100644 src/ollama_cli.egg-info/SOURCES.txt create mode 100644 src/ollama_cli.egg-info/dependency_links.txt create mode 100644 src/ollama_cli.egg-info/entry_points.txt create mode 100644 src/ollama_cli.egg-info/requires.txt create mode 100644 src/ollama_cli.egg-info/top_level.txt create mode 100644 src/ollama_cli/__init__.py create mode 100644 src/ollama_cli/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ollama_cli/__pycache__/main.cpython-312.pyc create mode 100644 src/ollama_cli/agents/__init__.py create mode 100644 src/ollama_cli/core/__init__.py create mode 100644 src/ollama_cli/core/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ollama_cli/core/__pycache__/config.cpython-312.pyc create mode 100644 src/ollama_cli/core/__pycache__/ollama.cpython-312.pyc create mode 100644 src/ollama_cli/core/__pycache__/sessions.cpython-312.pyc create mode 100644 src/ollama_cli/core/config.py create mode 100644 src/ollama_cli/core/ollama.py create mode 100644 src/ollama_cli/core/sessions.py create mode 100644 src/ollama_cli/integrations/__init__.py create mode 100644 src/ollama_cli/integrations/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ollama_cli/integrations/__pycache__/lsp.cpython-312.pyc create mode 100644 src/ollama_cli/integrations/__pycache__/mcp.cpython-312.pyc create mode 100644 src/ollama_cli/integrations/__pycache__/notify.cpython-312.pyc create mode 100644 src/ollama_cli/integrations/lsp.py create mode 100644 src/ollama_cli/integrations/mcp.py create mode 100644 src/ollama_cli/integrations/notify.py create mode 100644 src/ollama_cli/main.py create mode 100644 src/ollama_cli/tools/__init__.py create mode 100644 src/ollama_cli/tools/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/base.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/code.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/git.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/knowledge.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/system.cpython-312.pyc create mode 100644 src/ollama_cli/tools/__pycache__/web.cpython-312.pyc create mode 100644 src/ollama_cli/tools/base.py create mode 100644 src/ollama_cli/tools/code.py create mode 100644 src/ollama_cli/tools/execution.py create mode 100644 src/ollama_cli/tools/filesystem.py create mode 100644 src/ollama_cli/tools/git.py create mode 100644 src/ollama_cli/tools/knowledge.py create mode 100644 src/ollama_cli/tools/media.py create mode 100644 src/ollama_cli/tools/memory.py create mode 100644 src/ollama_cli/tools/system.py create mode 100644 src/ollama_cli/tools/web.py create mode 100644 src/ollama_cli/ui/__init__.py create mode 100644 src/ollama_cli/ui/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ollama_cli/ui/__pycache__/formatter.cpython-312.pyc create mode 100644 src/ollama_cli/ui/__pycache__/repl.cpython-312.pyc create mode 100644 src/ollama_cli/ui/formatter.py create mode 100644 src/ollama_cli/ui/repl.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0` 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/.json`.[file:1] -- `/load ` – 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 ` – Switch the default model for subsequent queries.[file:1] -- `/exclude ` – 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 [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 ` – 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 [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 ` - Switch to a specific model. +- `/sessions` - List recent sessions. +- `/save [name]` - Save the current session. +- `/load ` - 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 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..bcb3c7a --- /dev/null +++ b/install.sh @@ -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 "" diff --git a/ollama-cli.py.bak b/ollama-cli.py.bak new file mode 100755 index 0000000..ea8203c --- /dev/null +++ b/ollama-cli.py.bak @@ -0,0 +1,1808 @@ +#!/usr/bin/env python3 +import requests +import json +import subprocess +import os +import re +import sys +import threading +import queue +from pathlib import Path + +OLLAMA_URL = "http://localhost:11434/api/chat" +MODEL = "llama3.2" # Default model +AVAILABLE_MODELS = [] # Cache available models +CONFIG_FILE = os.path.expanduser("~/.ollama-cli-config.json") +SESSION_DIR = os.path.expanduser("~/.ollama-cli-sessions") +CURRENT_SESSION = None +SESSION_CONTEXT = { + "created_files": [], + "last_output": "", + "context_files": [] +} +MCP_SERVERS = {} # Connected MCP servers +MCP_TOOLS = {} # Tools from MCP servers +LSP_SERVERS = {} # Language servers +KNOWLEDGE_BASE = {} # Simple knowledge store +SUBAGENTS = [] # Running subagents +NTFY_TOPIC = None # ntfy.sh topic for notifications + +def load_config(): + """Load config file""" + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + except: + pass + return { + "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" + } + } + +def send_notification(title, message, priority="default"): + """Send notification via ntfy.sh""" + config = load_config() + 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 # Silently fail + +def ntfy_listener(messages_list): + """Listen for ntfy notifications and process them""" + config = load_config() + 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 to avoid feedback loop + if title in ["Response", "Ollama CLI", "File Created", "Command Complete", "Test"]: + continue + + # Process notification as a user request + print(f"\n[📱 Notification: {title}]") + print(f"> {message}\n") + + messages_list.append({"role": "user", "content": message}) + + # Get response + selected_model = select_best_model(message, "initial") + response_text = call_ollama(messages_list, selected_model) + messages_list.append({"role": "assistant", "content": response_text}) + + # Extract clean output - remove tool calls and metadata + clean_output = re.sub(r'.*?', '', response_text, flags=re.DOTALL) + clean_output = '\n'.join([ + line for line in clean_output.split('\n') + if line.strip() and not any(line.strip().startswith(p) for p in + ['Result:', 'Output:', 'Exit code:', '[Executing', '[Result:', '[Using']) + ]) + + # Send response back as notification + send_notification("Response", clean_output.strip()[:1000] if clean_output.strip() else response_text[:1000]) + except: + pass + +def save_config(config): + """Save config file""" + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + +def save_session(messages, session_name=None): + """Save conversation session""" + os.makedirs(SESSION_DIR, exist_ok=True) + if not session_name: + from datetime import datetime + session_name = datetime.now().strftime("%Y%m%d_%H%M%S") + + session_file = os.path.join(SESSION_DIR, f"{session_name}.json") + with open(session_file, 'w') as f: + json.dump({"messages": messages, "timestamp": session_name}, f, indent=2) + return session_file + +def load_session(session_name): + """Load conversation session""" + 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 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 connect_mcp_server(name, command, args=None): + """Connect to an MCP server via stdio""" + try: + import subprocess + 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": "2.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, tool_name, arguments): + """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)}" + +# === LSP SUPPORT === +def start_lsp_server(language, root_path="."): + """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, file_path, line, character): + """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() + + # Read response (simplified) + return "Definition lookup sent (response parsing not implemented)" + except Exception as e: + return f"LSP error: {str(e)}" + +def lsp_find_references(language, file_path, line, character): + """Find all references to symbol""" + if language not in LSP_SERVERS: + return "LSP server not started" + + try: + process = LSP_SERVERS[language] + request = { + "jsonrpc": "2.0", + "id": 3, + "method": "textDocument/references", + "params": { + "textDocument": {"uri": f"file://{os.path.abspath(file_path)}"}, + "position": {"line": line, "character": character}, + "context": {"includeDeclaration": True} + } + } + + msg = json.dumps(request) + process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}") + process.stdin.flush() + + return "References lookup sent" + except Exception as e: + return f"LSP error: {str(e)}" + +# === WEB SEARCH (Enhanced) === +def web_search_enhanced(query): + """Enhanced web search with DuckDuckGo""" + try: + import urllib.parse + 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 results found" + except Exception as e: + return f"Search error: {str(e)}" + +# === KNOWLEDGE BASE === +def kb_add(key, content): + """Add to knowledge base""" + KNOWLEDGE_BASE[key] = { + "content": content, + "timestamp": subprocess.run("date", capture_output=True, text=True).stdout.strip() + } + return f"Added '{key}' to knowledge base" + +def kb_search(query): + """Search knowledge base (simple keyword match)""" + results = [] + query_lower = query.lower() + for key, data in KNOWLEDGE_BASE.items(): + if query_lower in key.lower() or query_lower in data["content"].lower(): + results.append(f"{key}: {data['content'][:200]}...") + return "\n".join(results) if results else "No matches found" + +def kb_get(key): + """Get from knowledge base""" + if key in KNOWLEDGE_BASE: + return KNOWLEDGE_BASE[key]["content"] + return f"Key '{key}' not found" + +# === SUBAGENTS === +def spawn_subagent(task, model=None): + """Spawn a subagent to handle a task""" + try: + agent_id = len(SUBAGENTS) + 1 + + # Create a simple prompt for the subagent + messages = [ + {"role": "system", "content": "You are a helpful assistant. Complete the task concisely."}, + {"role": "user", "content": task} + ] + + # Run in thread + result_queue = queue.Queue() + + def run_agent(): + try: + response = call_ollama(messages, model) + result_queue.put({"success": True, "result": response}) + except Exception as e: + result_queue.put({"success": False, "error": str(e)}) + + thread = threading.Thread(target=run_agent, daemon=True) + thread.start() + + agent = { + "id": agent_id, + "task": task, + "thread": thread, + "queue": result_queue, + "status": "running" + } + + SUBAGENTS.append(agent) + return f"Spawned subagent #{agent_id} for task: {task[:50]}..." + except Exception as e: + return f"Error spawning subagent: {str(e)}" + +def check_subagents(): + """Check status of subagents""" + results = [] + for agent in SUBAGENTS: + if not agent["queue"].empty(): + result = agent["queue"].get() + agent["status"] = "completed" + agent["result"] = result + results.append(f"Agent #{agent['id']}: {result.get('result', result.get('error'))[:200]}") + elif agent["thread"].is_alive(): + results.append(f"Agent #{agent['id']}: Still running...") + else: + results.append(f"Agent #{agent['id']}: Completed") + + return "\n".join(results) if results else "No active subagents" + +# === ADVANCED CODE TOOLS === +def code_search_symbol(symbol_name, path="."): + """Search for symbol definitions""" + try: + # Simple grep-based search + result = subprocess.run( + f'grep -rn "def {symbol_name}\\|class {symbol_name}\\|function {symbol_name}" {path}', + shell=True, capture_output=True, text=True + ) + return result.stdout if result.stdout else f"Symbol '{symbol_name}' not found" + except Exception as e: + return f"Error: {str(e)}" + +def code_analyze_file(file_path): + """Analyze code structure and suggest improvements""" + 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] + + # Analyze issues + issues = [] + if len(lines) > 500: + issues.append(f"Large file ({len(lines)} lines) - consider splitting into modules") + if len(functions) > 30: + issues.append(f"Many functions ({len(functions)}) - consider organizing into classes") + + bare_excepts = sum(1 for l in lines if l.strip() == 'except:') + if bare_excepts > 0: + issues.append(f"{bare_excepts} bare except clauses - should catch specific exceptions") + + todos = sum(1 for l in lines if 'TODO' in l or 'FIXME' in l) + if todos > 0: + issues.append(f"{todos} TODO/FIXME comments") + + long_funcs = [] + in_func = False + func_lines = 0 + func_name = "" + for line in lines: + if line.strip().startswith('def '): + if in_func and func_lines > 50: + long_funcs.append(f"{func_name} ({func_lines} lines)") + in_func = True + func_lines = 0 + func_name = line.strip().split('(')[0].replace('def ', '') + elif in_func: + func_lines += 1 + + if long_funcs: + issues.append(f"Long functions: {', '.join(long_funcs[:3])}") + + return f"""File: {file_path} +Lines: {len(lines)} | Functions: {len(functions)} | Classes: {len(classes)} + +Issues found: +{chr(10).join(f'- {i}' for i in issues) if issues else '- No major issues detected'} + +Suggestions: +- Add type hints for better code clarity +- Add docstrings to undocumented functions +- Consider using logging instead of print statements +- Run a linter (pylint/flake8) for detailed analysis""" + except Exception as e: + return f"Error: {str(e)}" + +# === DOCUMENTATION GENERATION === +def generate_readme(path="."): + """Generate README.md from project structure""" + try: + project_name = os.path.basename(os.path.abspath(path)) + + # Analyze project + files = [] + for root, dirs, filenames in os.walk(path): + # Skip common ignore dirs + dirs[:] = [d for d in dirs if d not in ['.git', 'node_modules', '__pycache__', 'venv']] + for f in filenames: + if f.endswith(('.py', '.js', '.ts', '.go', '.rs')): + files.append(os.path.join(root, f)) + + # Detect language + extensions = [os.path.splitext(f)[1] for f in files] + main_lang = max(set(extensions), key=extensions.count) if extensions else '.txt' + lang_map = {'.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.go': 'Go', '.rs': 'Rust'} + language = lang_map.get(main_lang, 'Unknown') + + readme = f"""# {project_name} + +## Description +A {language} project with {len(files)} source files. + +## Project Structure +``` +{chr(10).join([f'- {os.path.relpath(f, path)}' for f in files[:10]])} +{'...' if len(files) > 10 else ''} +``` + +## Installation +```bash +# Add installation instructions here +``` + +## Usage +```bash +# Add usage examples here +``` + +## License +MIT +""" + + readme_path = os.path.join(path, "README.md") + with open(readme_path, 'w') as f: + f.write(readme) + + return f"Generated README.md at {readme_path}" + except Exception as e: + return f"Error generating README: {str(e)}" + +def generate_docstrings(file_path): + """Generate docstrings for functions in a file""" + try: + with open(os.path.expanduser(file_path), 'r') as f: + lines = f.readlines() + + new_lines = [] + i = 0 + while i < len(lines): + line = lines[i] + # Detect function definition + if line.strip().startswith('def ') and ':' in line: + # Check if next line is already a docstring + if i + 1 < len(lines) and '"""' not in lines[i + 1]: + # Extract function name + func_name = line.strip().split('(')[0].replace('def ', '') + indent = len(line) - len(line.lstrip()) + docstring = f'{" " * (indent + 4)}"""TODO: Document {func_name}"""\n' + new_lines.append(line) + new_lines.append(docstring) + i += 1 + continue + new_lines.append(line) + i += 1 + + # Write back + with open(os.path.expanduser(file_path), 'w') as f: + f.writelines(new_lines) + + return f"Added docstring placeholders to {file_path}" + except Exception as e: + return f"Error: {str(e)}" + +# === CODE QUALITY TOOLS === +def run_linter(file_path, linter="auto"): + """Run linter on file""" + try: + file_path = os.path.expanduser(file_path) + ext = os.path.splitext(file_path)[1] + + # Auto-detect linter + if linter == "auto": + linter_map = { + '.py': 'pylint', + '.js': 'eslint', + '.ts': 'eslint', + '.go': 'golint', + '.rs': 'cargo clippy' + } + linter = linter_map.get(ext, 'pylint') + + # Run linter + result = subprocess.run( + f"{linter} {file_path}", + shell=True, capture_output=True, text=True, timeout=30 + ) + + return result.stdout + result.stderr + except Exception as e: + return f"Error running linter: {str(e)}" + +def format_code(file_path, formatter="auto"): + """Format code file""" + try: + file_path = os.path.expanduser(file_path) + ext = os.path.splitext(file_path)[1] + + # Auto-detect formatter + if formatter == "auto": + formatter_map = { + '.py': 'black', + '.js': 'prettier --write', + '.ts': 'prettier --write', + '.go': 'gofmt -w', + '.rs': 'rustfmt' + } + formatter = formatter_map.get(ext, 'cat') + + # Run formatter + result = subprocess.run( + f"{formatter} {file_path}", + shell=True, capture_output=True, text=True, timeout=30 + ) + + return f"Formatted {file_path}\n{result.stdout + result.stderr}" + except Exception as e: + return f"Error formatting: {str(e)}" + +def check_security(path="."): + """Run security checks""" + try: + # Try bandit for Python + result = subprocess.run( + f"bandit -r {path} 2>&1 || echo 'Install: pip install bandit'", + shell=True, capture_output=True, text=True, timeout=60 + ) + return result.stdout + except Exception as e: + return f"Error: {str(e)}" + +# === PROJECT TEMPLATES === +def create_project(name, template="python"): + """Create new project from template""" + try: + project_path = os.path.expanduser(f"./{name}") + os.makedirs(project_path, exist_ok=True) + + templates = { + "python": { + "main.py": "#!/usr/bin/env python3\n\ndef main():\n print('Hello, World!')\n\nif __name__ == '__main__':\n main()\n", + "requirements.txt": "# Add dependencies here\n", + "README.md": f"# {name}\n\nA Python project.\n", + ".gitignore": "__pycache__/\n*.pyc\nvenv/\n.env\n" + }, + "javascript": { + "index.js": "console.log('Hello, World!');\n", + "package.json": f'{{"name": "{name}", "version": "1.0.0", "main": "index.js"}}', + "README.md": f"# {name}\n\nA JavaScript project.\n", + ".gitignore": "node_modules/\n.env\n" + }, + "flask": { + "app.py": "from flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/')\ndef hello():\n return 'Hello, World!'\n\nif __name__ == '__main__':\n app.run(debug=True)\n", + "requirements.txt": "flask\n", + "README.md": f"# {name}\n\nA Flask web application.\n", + ".gitignore": "__pycache__/\nvenv/\n*.pyc\n" + }, + "express": { + "server.js": "const express = require('express');\nconst app = express();\n\napp.get('/', (req, res) => {\n res.send('Hello, World!');\n});\n\napp.listen(3000, () => console.log('Server running on port 3000'));\n", + "package.json": f'{{"name": "{name}", "version": "1.0.0", "main": "server.js", "dependencies": {{"express": "^4.18.0"}}}}', + "README.md": f"# {name}\n\nAn Express.js application.\n", + ".gitignore": "node_modules/\n.env\n" + } + } + + if template not in templates: + return f"Unknown template: {template}. Available: {', '.join(templates.keys())}" + + # Create files + for filename, content in templates[template].items(): + filepath = os.path.join(project_path, filename) + with open(filepath, 'w') as f: + f.write(content) + + # Initialize git + subprocess.run("git init", shell=True, cwd=project_path, capture_output=True) + + return f"Created {template} project at {project_path}\nFiles: {', '.join(templates[template].keys())}" + except Exception as e: + return f"Error creating project: {str(e)}" + +# Model selection based on task type +MODEL_PREFERENCES = { + "code": ["qwen3-coder:30b", "mistral:latest", "llama3:latest", "deepseek-coder-v2:16b", "granite-code:latest"], + "general": ["mistral:latest", "llama3:latest", "llama3.2:latest"], + "fast": ["llama3.2:3b", "llama3.2:latest"], +} + +def get_available_models(): + """Get list of models available in Ollama""" + config = load_config() + excluded = config.get("excluded_models", []) + + try: + 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(): + model_name = line.split()[0] + # Skip base models, tiny models, and excluded models + if ('base' not in model_name.lower() and + ':1b' not in model_name.lower() and + model_name not in excluded): + models.append(model_name) + return models + except: + return [] + +def select_best_model(user_input, task_type="initial"): + """Select best model based on user input and task type""" + if not AVAILABLE_MODELS: + return MODEL + + # For tool execution, prefer fast general models + if task_type == "tool_response": + for model in MODEL_PREFERENCES["general"]: + if any(model in m for m in AVAILABLE_MODELS): + return next(m for m in AVAILABLE_MODELS if model in m) + + # Detect task type from user input + code_keywords = ['code', 'script', 'program', 'function', 'debug', 'python', 'javascript', 'write a', 'create a'] + + if any(keyword in user_input.lower() for keyword in code_keywords): + # Prefer code models + for model in MODEL_PREFERENCES["code"]: + if any(model in m for m in AVAILABLE_MODELS): + return next(m for m in AVAILABLE_MODELS if model in m) + + # Default to general models + for model in MODEL_PREFERENCES["general"]: + if any(model in m for m in AVAILABLE_MODELS): + return next(m for m in AVAILABLE_MODELS if model in m) + + # Fallback to first available + return AVAILABLE_MODELS[0] if AVAILABLE_MODELS else MODEL + +def execute_bash(command): + """Execute bash command and return output""" + # Check for dangerous commands + dangerous = ['rm -rf', 'dd if=', 'mkfs', '> /dev/', 'format', 'sudo rm', ':(){:|:&};:'] + if any(d in command.lower() for d in dangerous): + return f"⚠️ DANGEROUS COMMAND BLOCKED: {command}\nThis command could cause data loss. If you really need to run it, do so manually." + + try: + result = subprocess.run(command, shell=True, capture_output=True, text=True, + cwd=os.getcwd(), timeout=30) + 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 30 seconds" + except Exception as e: + return f"Error: {str(e)}" + +def read_file(path): + """Read file contents""" + try: + with open(os.path.expanduser(path), 'r') as f: + return f.read() + except Exception as e: + return f"Error reading file: {str(e)}" + +def write_file(path, content): + """Write content to file""" + try: + expanded_path = os.path.expanduser(path) + + # For Python files, try to validate and fix basic issues + if path.endswith('.py'): + # Try to compile to check for syntax errors + try: + compile(content, expanded_path, 'exec') + except SyntaxError as e: + return f"Error: Generated Python code has syntax error at line {e.lineno}: {e.msg}" + + os.makedirs(os.path.dirname(expanded_path), exist_ok=True) + with open(expanded_path, 'w') as f: + f.write(content) + + # Track created file + SESSION_CONTEXT["created_files"].append(expanded_path) + + return f"Successfully written to {path}" + except Exception as e: + return f"Error writing file: {str(e)}" + +def edit_file(path, old_str, new_str): + """Edit file by replacing old_str with new_str""" + try: + expanded_path = os.path.expanduser(path) + with open(expanded_path, 'r') as f: + content = f.read() + + if old_str not in content: + return f"Error: Could not find the specified text in {path}" + + new_content = content.replace(old_str, new_str, 1) + + # Validate Python syntax if applicable + if path.endswith('.py'): + try: + compile(new_content, expanded_path, 'exec') + except SyntaxError as e: + return f"Error: Edited code has syntax error at line {e.lineno}: {e.msg}" + + with open(expanded_path, 'w') as f: + f.write(new_content) + return f"Successfully edited {path}" + except Exception as e: + return f"Error editing file: {str(e)}" + +def append_file(path, content): + """Append content to file""" + try: + expanded_path = os.path.expanduser(path) + with open(expanded_path, 'a') as f: + f.write(content) + return f"Successfully appended to {path}" + except Exception as e: + return f"Error appending to file: {str(e)}" + +def search_files(pattern, path="."): + """Search for pattern in files""" + try: + result = subprocess.run( + f'grep -r "{pattern}" {path}', + shell=True, capture_output=True, text=True, timeout=10 + ) + return result.stdout if result.stdout else "No matches found" + except Exception as e: + return f"Error searching: {str(e)}" + +def git_operation(operation, message=None, **kwargs): + """Perform git operations""" + 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": + msg = message or kwargs.get("message", "Auto commit") + result = subprocess.run(f'git add -A && git commit -m "{msg}"', + shell=True, capture_output=True, text=True) + elif operation == "push": + remote = git_config.get("remote_url", "origin") + username = git_config.get("username", "") + password = git_config.get("password", "") + + if username and password: + # Inject credentials into URL + result = subprocess.run(f"git push {remote}", + shell=True, capture_output=True, text=True) + else: + 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": + # Check if Gitea is configured + if gitea_config.get("url") and gitea_config.get("token"): + # Query Gitea API + gitea_url = gitea_config["url"].rstrip("/") + token = gitea_config["token"] + + try: + response = requests.get( + f"{gitea_url}/api/v1/user/repos", + headers={"Authorization": f"token {token}"}, + timeout=10, + verify=False # Skip SSL verification for localhost + ) + + if response.status_code == 200: + repos = response.json() + result_text = "Repositories on Gitea server:\n" + for repo in repos: + result_text += f"- {repo['full_name']} ({repo['clone_url']})\n" + return result_text + else: + return f"Gitea API error: {response.status_code}" + except Exception as e: + return f"Error querying Gitea: {str(e)}" + else: + # Fallback to local search + 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)}" + +def install_package(package, manager="pip"): + """Install package using pip or npm""" + try: + if manager == "pip": + result = subprocess.run(f"pip install {package}", + shell=True, capture_output=True, text=True, timeout=60) + elif manager == "npm": + result = subprocess.run(f"npm install {package}", + shell=True, capture_output=True, text=True, timeout=60) + else: + return f"Unknown package manager: {manager}" + + return result.stdout + result.stderr + except Exception as e: + return f"Error installing package: {str(e)}" + +def web_search(query): + """Simple web search using curl""" + try: + import urllib.parse + encoded_query = urllib.parse.quote(query) + result = subprocess.run( + f'curl -s "https://html.duckduckgo.com/html/?q={encoded_query}"', + shell=True, capture_output=True, text=True, timeout=10 + ) + # Extract snippets (very basic) + content = result.stdout[:3000] + return f"Search results for '{query}':\n{content}" + except Exception as e: + return f"Error searching: {str(e)}" + +def list_directory(path="."): + """List directory contents""" + 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)}" + +def get_file_info(path): + """Get file metadata""" + try: + p = os.path.expanduser(path) + stat = os.stat(p) + import time + return f"Path: {p}\nSize: {stat.st_size} bytes\nModified: {time.ctime(stat.st_mtime)}\nPermissions: {oct(stat.st_mode)}" + except Exception as e: + return f"Error getting file info: {str(e)}" + +TOOLS = { + "execute_bash": execute_bash, + "read_file": read_file, + "write_file": write_file, + "edit_file": edit_file, + "append_file": append_file, + "web_search": web_search, + "web_search_enhanced": web_search_enhanced, + "list_directory": list_directory, + "get_file_info": get_file_info, + "search_files": search_files, + "git_operation": git_operation, + "install_package": install_package, + "lsp_goto_definition": lsp_goto_definition, + "lsp_find_references": lsp_find_references, + "kb_add": kb_add, + "kb_search": kb_search, + "kb_get": kb_get, + "spawn_subagent": spawn_subagent, + "check_subagents": check_subagents, + "code_search_symbol": code_search_symbol, + "code_analyze_file": code_analyze_file, + "generate_readme": generate_readme, + "generate_docstrings": generate_docstrings, + "run_linter": run_linter, + "format_code": format_code, + "check_security": check_security, + "create_project": create_project, +} + +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +WHEN TO USE TOOLS: +- User says "create a FILE/SCRIPT" → use write_file +- User says "edit/modify FILE" → use edit_file +- User says "add to FILE" → use append_file +- User asks to READ a file → use read_file +- User asks to RUN/EXECUTE a command → use execute_bash +- User asks to LIST files/directories → use list_directory +- User asks to LIST GIT PROJECTS/REPOS → use git_operation with operation="list" +- User asks to SEARCH in files → use search_files +- User asks about GIT (status, commit, push, pull) → use git_operation +- User asks to INSTALL package → use install_package +- User asks to SEARCH the web → use web_search_enhanced +- User asks about code definitions/references → use lsp_goto_definition, lsp_find_references +- User wants to save/retrieve knowledge → use kb_add, kb_search, kb_get +- User has complex multi-step task → use spawn_subagent +- User wants code analysis → use code_search_symbol, code_analyze_file +- User wants documentation → use generate_readme, generate_docstrings +- User wants code quality checks → use run_linter, format_code, check_security +- User wants to create new project → use create_project + +WHEN NOT TO USE TOOLS (just answer normally): +- "how do I make FOOD" → answer with recipe +- "how to do X" → answer with instructions +- "what is X" → answer with explanation +- General questions about cooking, advice, information + +Available tools: +- write_file, edit_file, append_file, read_file +- execute_bash, list_directory, get_file_info, search_files +- git_operation, install_package +- web_search_enhanced: Better web search with API +- lsp_goto_definition: Find symbol definition. Params: {"language": "python", "file_path": "file.py", "line": 0, "character": 0} +- lsp_find_references: Find all references. Params: {"language": "python", "file_path": "file.py", "line": 0, "character": 0} +- kb_add: Add to knowledge base. Params: {"key": "string", "content": "string"} +- kb_search: Search knowledge base. Params: {"query": "string"} +- kb_get: Get from knowledge base. Params: {"key": "string"} +- spawn_subagent: Spawn parallel task. Params: {"task": "string", "model": "optional"} +- check_subagents: Check subagent status +- code_search_symbol: Find symbol in code. Params: {"symbol_name": "string", "path": "."} +- code_analyze_file: Analyze code structure. Params: {"file_path": "string"} +- generate_readme: Generate README.md. Params: {"path": "."} +- generate_docstrings: Add docstrings to file. Params: {"file_path": "string"} +- run_linter: Run linter. Params: {"file_path": "string", "linter": "auto|pylint|eslint"} +- format_code: Format code. Params: {"file_path": "string", "formatter": "auto|black|prettier"} +- check_security: Security scan. Params: {"path": "."} +- create_project: Create project from template. Params: {"name": "string", "template": "python|javascript|flask|express"} + +MCP Tools: {mcp_tools_list} + +Tool call format: + +write_file +{"path": "~/file.py", "content": "code here"} + + +Output ONLY the tool call when using a tool. Use \\n for newlines, proper indentation (4 spaces for Python).""" + +def extract_code_blocks(text): + """Extract code blocks from markdown""" + pattern = r'```(\w+)?\n(.*?)```' + matches = re.findall(pattern, text, re.DOTALL) + return [(lang, code.strip()) for lang, code in matches] + +def auto_save_code(response, user_input): + """Automatically save code blocks if user asked to create a file""" + create_keywords = ['create', 'write', 'make', 'generate', 'build'] + file_keywords = ['script', 'file', 'program', '.py', '.js', '.html', '.sh'] + + should_save = any(k in user_input.lower() for k in create_keywords) and \ + any(k in user_input.lower() for k in file_keywords) + + if not should_save: + return None + + code_blocks = extract_code_blocks(response) + if not code_blocks: + return None + + # Get the largest code block (likely the main code) + lang, code = max(code_blocks, key=lambda x: len(x[1])) + + if not code: + return None + + # Determine filename from language or user input + extensions = { + 'python': '.py', + 'javascript': '.js', + 'html': '.html', + 'bash': '.sh', + 'sh': '.sh', + } + + # Try to extract filename from user input + words = user_input.lower().split() + filename = None + for i, word in enumerate(words): + if any(ext in word for ext in ['.py', '.js', '.html', '.sh']): + filename = word + break + if word in ['called', 'named'] and i + 1 < len(words): + filename = words[i + 1] + break + + if not filename: + # Generate filename based on language + ext = extensions.get(lang.lower(), '.txt') + if 'calculator' in user_input.lower(): + filename = f'calculator{ext}' + elif 'script' in user_input.lower(): + filename = f'script{ext}' + else: + filename = f'output{ext}' + + if not filename: + return None + + return filename, code + +def parse_tool_calls(text): + """Extract tool calls from model response""" + calls = [] + + # Try standard format first + pattern = r'\s*(.*?)\s*(.*?)\s*' + matches = re.findall(pattern, text, re.DOTALL) + + # Try DeepSeek format: <|tool▁call▁begin|>function<|tool▁sep|>TOOL_NAME\n```json\n{...}\n``` + if not matches: + ds_pattern = r'<|tool▁call▁begin|>function<|tool▁sep|>(\w+)\s*```json\s*(\{.*?\})\s*```' + ds_matches = re.findall(ds_pattern, text, re.DOTALL) + if ds_matches: + matches = ds_matches + + for name, params in matches: + params_str = params.strip() + name = name.strip() + + # Try JSON first + try: + calls.append((name, json.loads(params_str))) + continue + except json.JSONDecodeError: + pass + + # Try ast.literal_eval (handles Python literals) + try: + import ast + calls.append((name, ast.literal_eval(params_str))) + continue + except: + pass + + # Try fixing common issues: literal newlines in JSON strings + try: + # Replace literal newlines with \n escape sequence + fixed = params_str.replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') + calls.append((name, json.loads(fixed))) + continue + except: + pass + + print(f"Warning: Failed to parse parameters for {name}") + print(f"Raw params: {params_str[:100]}...") + + return calls + +def call_ollama(messages, model=None): + """Call Ollama API with streaming""" + config = load_config() + ollama_url = config.get("ollama_url", OLLAMA_URL) + selected_model = model or MODEL + try: + response = requests.post(ollama_url, json={ + "model": selected_model, + "messages": messages, + "stream": True + }, stream=True, timeout=60) + response.raise_for_status() + + full_response = "" + 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"] + print(content, end="", flush=True) + full_response += content + print() # Newline after streaming + return full_response + except requests.exceptions.ConnectionError: + print("\nError: Cannot connect to Ollama. Make sure it's running with 'ollama serve'") + sys.exit(1) + except Exception as e: + return f"Error calling Ollama: {str(e)}" + +def trim_messages(messages, max_messages=20): + """Keep only recent messages to avoid context overflow""" + if len(messages) > max_messages: + # Keep system prompt + last (max_messages - 1) messages + return [messages[0]] + messages[-(max_messages-1):] + return messages + +def main(): + global AVAILABLE_MODELS, CURRENT_SESSION, MODEL + AVAILABLE_MODELS = get_available_models() + + print(f"Ollama CLI v2.0 - Default model: {MODEL}") + + config = load_config() + ollama_url = config.get("ollama_url", OLLAMA_URL) + print(f"Server: {ollama_url}") + + if AVAILABLE_MODELS: + print(f"Available models: {', '.join(AVAILABLE_MODELS)}") + else: + print("Warning: Could not detect available models, using default") + + config = load_config() + if config.get("excluded_models"): + print(f"Excluded models: {', '.join(config['excluded_models'])}") + + print(f"Current directory: {os.getcwd()}") + print(f"Home directory: {os.path.expanduser('~')}") + print("\nCommands:") + print(" /quit - Exit") + print(" /clear - Clear history") + print(" /save [name] - Save session") + print(" /load - Load session") + print(" /sessions - List sessions") + print(" /model - Switch model") + print(" /models - List models") + print(" /exclude - Exclude model") + print(" /retry - Retry last request") + print(" /run - Execute file") + print(" /config git - Configure git") + print(" /config gitea - Configure Gitea server") + print(" /export - Export to markdown") + print(" /mcp connect [args] - Connect MCP server") + print(" /mcp list - List MCP servers and tools") + print(" /mcp disconnect - Disconnect MCP server") + print(" /context - Add file to context") + print(" /context list - List context files") + print(" /context clear - Clear context files") + print(" /lsp start [path] - Start LSP server") + print(" /kb add - Add to knowledge base") + print(" /kb search - Search knowledge base") + print(" /agents - Check subagent status") + print(" /server - Set Ollama server URL") + print(" /notify setup - Enable ntfy.sh notifications") + print(" /notify off - Disable notifications") + print(" /notify test - Send test notification") + print("\nWhen notifications are enabled, send messages to your topic to interact remotely!\n") + + # Add system context to initial prompt + system_context = f"""Operating System: {sys.platform} +Current Directory: {os.getcwd()} +Home Directory: {os.path.expanduser('~')} +User: {os.environ.get('USER', 'unknown')} + +{SYSTEM_PROMPT}""" + + # Add MCP tools to system prompt + if MCP_TOOLS: + mcp_tools_desc = "\n".join([f"- {name}: {tool['description']}" for name, tool in MCP_TOOLS.items()]) + system_context = system_context.replace("{mcp_tools_list}", mcp_tools_desc) + else: + system_context = system_context.replace("{mcp_tools_list}", "None connected") + + messages = [{"role": "system", "content": system_context}] + + # Auto-connect MCP servers from config + config = load_config() + for name, server_config in config.get("mcp_servers", {}).items(): + result = connect_mcp_server(name, server_config["command"], server_config.get("args")) + print(f"[{result}]") + + # Start ntfy listener if enabled + if config.get("ntfy", {}).get("enabled"): + topic = config["ntfy"]["topic"] + print(f"[Starting ntfy listener for topic: {topic}]") + listener_thread = threading.Thread(target=ntfy_listener, args=(messages,), daemon=True) + listener_thread.start() + + while True: + try: + user_input = input("\n> ").strip() + except (EOFError, KeyboardInterrupt): + print("\nGoodbye!") + break + + if not user_input: + continue + + if user_input.lower() in ['/quit', 'exit']: + print("Goodbye!") + break + + if user_input.lower() == '/clear': + system_context = f"""Operating System: {sys.platform} +Current Directory: {os.getcwd()} +Home Directory: {os.path.expanduser('~')} +User: {os.environ.get('USER', 'unknown')} + +{SYSTEM_PROMPT}""" + messages = [{"role": "system", "content": system_context}] + print("History cleared.") + continue + + if user_input.lower() == '/save': + try: + with open('chat_history.json', 'w') as f: + json.dump(messages, f, indent=2) + print("History saved to chat_history.json") + except Exception as e: + print(f"Error saving history: {e}") + continue + + if user_input.lower().startswith('/exclude '): + model_to_exclude = user_input[9:].strip() + config = load_config() + if model_to_exclude not in config["excluded_models"]: + config["excluded_models"].append(model_to_exclude) + save_config(config) + print(f"Excluded {model_to_exclude}. Restart to apply changes.") + else: + print(f"{model_to_exclude} is already excluded.") + continue + + if user_input.lower() == '/list-excluded': + config = load_config() + excluded = config.get("excluded_models", []) + if excluded: + print(f"Excluded models: {', '.join(excluded)}") + else: + print("No models excluded.") + continue + + if user_input.lower().startswith('/save'): + parts = user_input.split(maxsplit=1) + session_name = parts[1] if len(parts) > 1 else None + session_file = save_session(messages, session_name) + print(f"Session saved to {session_file}") + continue + + if user_input.lower().startswith('/load '): + session_name = user_input[6:].strip() + loaded = load_session(session_name) + if loaded: + messages = loaded + print(f"Session '{session_name}' loaded.") + else: + print(f"Session '{session_name}' not found.") + continue + + if user_input.lower() == '/sessions': + sessions = list_sessions() + if sessions: + print("Available sessions:") + for s in sessions[:10]: # Show last 10 + print(f" {s}") + else: + print("No saved sessions.") + continue + + if user_input.lower().startswith('/model '): + new_model = user_input[7:].strip() + if new_model in AVAILABLE_MODELS: + MODEL = new_model + print(f"Switched to model: {MODEL}") + else: + print(f"Model '{new_model}' not available.") + continue + + if user_input.lower() == '/models': + print("Available models:") + for m in AVAILABLE_MODELS: + print(f" {m}") + continue + + if user_input.lower() == '/retry': + if len(messages) > 1: + # Remove last assistant response and retry + if messages[-1]["role"] == "assistant": + messages.pop() + user_input = messages[-1]["content"] if messages[-1]["role"] == "user" else "" + if not user_input: + print("Nothing to retry.") + continue + messages.pop() # Remove user message, will be re-added + else: + print("Nothing to retry.") + continue + + if user_input.lower().startswith('/run '): + filepath = user_input[5:].strip() + result = execute_bash(f"python3 {filepath}" if filepath.endswith('.py') else filepath) + print(result) + continue + + if user_input.lower().startswith('/config git '): + parts = user_input[12:].split() + if len(parts) >= 3: + config = load_config() + config["git"] = { + "remote_url": parts[0], + "username": parts[1], + "password": parts[2] + } + save_config(config) + print("Git configuration saved.") + else: + print("Usage: /config git ") + continue + + if user_input.lower().startswith('/config gitea '): + parts = user_input[14:].split() + if len(parts) >= 2: + config = load_config() + config["gitea"] = { + "url": parts[0], + "token": parts[1] + } + save_config(config) + print("Gitea configuration saved.") + else: + print("Usage: /config gitea ") + continue + + if user_input.lower().startswith('/export '): + filename = user_input[8:].strip() + try: + with open(filename, 'w') as f: + f.write("# Ollama CLI Conversation\n\n") + for msg in messages[1:]: # Skip system prompt + role = msg["role"].upper() + content = msg["content"] + f.write(f"## {role}\n\n{content}\n\n") + print(f"Conversation exported to {filename}") + except Exception as e: + print(f"Error exporting: {e}") + continue + + if user_input.lower().startswith('/mcp connect '): + parts = user_input[13:].split() + if len(parts) >= 2: + name = parts[0] + command = parts[1] + args = parts[2:] if len(parts) > 2 else None + result = connect_mcp_server(name, command, args) + print(result) + # Save to config + config = load_config() + config["mcp_servers"][name] = {"command": command, "args": args} + save_config(config) + else: + print("Usage: /mcp connect [args...]") + continue + + if user_input.lower() == '/mcp list': + if MCP_SERVERS: + print("Connected MCP servers:") + for server in MCP_SERVERS: + tools = [t for t in MCP_TOOLS if t.startswith(f"mcp_{server}_")] + print(f" {server}: {len(tools)} tools") + for tool_key in tools[:5]: # Show first 5 + tool = MCP_TOOLS[tool_key] + print(f" - {tool['name']}: {tool['description'][:60]}") + else: + print("No MCP servers connected.") + continue + + if user_input.lower().startswith('/mcp disconnect '): + name = user_input[16:].strip() + if name in MCP_SERVERS: + MCP_SERVERS[name].terminate() + del MCP_SERVERS[name] + # Remove tools + MCP_TOOLS.clear() + for key in list(MCP_TOOLS.keys()): + if key.startswith(f"mcp_{name}_"): + del MCP_TOOLS[key] + print(f"Disconnected from MCP server '{name}'") + else: + print(f"MCP server '{name}' not connected.") + continue + + if user_input.lower().startswith('/context '): + arg = user_input[9:].strip() + + if arg == 'list': + if SESSION_CONTEXT["context_files"]: + print("Context files:") + for cf in SESSION_CONTEXT["context_files"]: + print(f" {cf['path']} ({len(cf['content'])} chars)") + else: + print("No context files loaded.") + continue + + if arg == 'clear': + SESSION_CONTEXT["context_files"].clear() + print("Context files cleared.") + continue + + # Load file into context + filepath = os.path.expanduser(arg) + try: + with open(filepath, 'r') as f: + content = f.read() + SESSION_CONTEXT["context_files"].append({ + "path": filepath, + "content": content + }) + print(f"Added {filepath} to context ({len(content)} chars)") + except Exception as e: + print(f"Error loading context file: {e}") + continue + + if user_input.lower().startswith('/lsp start '): + parts = user_input[11:].split() + if len(parts) >= 1: + language = parts[0] + path = parts[1] if len(parts) > 1 else "." + result = start_lsp_server(language, path) + print(result) + else: + print("Usage: /lsp start [path]") + continue + + if user_input.lower().startswith('/kb add '): + parts = user_input[8:].split(maxsplit=1) + if len(parts) == 2: + key, content = parts + result = kb_add(key, content) + print(result) + else: + print("Usage: /kb add ") + continue + + if user_input.lower().startswith('/kb search '): + query = user_input[11:].strip() + result = kb_search(query) + print(result) + continue + + if user_input.lower() == '/agents': + result = check_subagents() + print(result) + continue + + if user_input.lower().startswith('/server '): + url = user_input[8:].strip() + if not url.startswith('http'): + url = f"http://{url}" + if '/api/chat' not in url: + url = f"{url}/api/chat" + + config = load_config() + config["ollama_url"] = url + save_config(config) + print(f"Ollama server set to: {url}") + print("Restart to apply changes.") + continue + + if user_input.lower().startswith('/notify '): + parts = user_input[8:].split(maxsplit=1) + if len(parts) >= 1: + cmd = parts[0] + if cmd == 'setup' and len(parts) == 2: + topic = parts[1].strip() + config = load_config() + config["ntfy"] = { + "enabled": True, + "topic": topic, + "server": "https://ntfy.sh" + } + save_config(config) + print(f"Notifications enabled for topic: {topic}") + print(f"Subscribe at: https://ntfy.sh/{topic}") + print("Starting listener...") + listener_thread = threading.Thread(target=ntfy_listener, args=(messages,), daemon=True) + listener_thread.start() + send_notification("Ollama CLI", "Notifications enabled! Send messages to this topic.") + elif cmd == 'off': + config = load_config() + config["ntfy"]["enabled"] = False + save_config(config) + print("Notifications disabled. Restart to stop listener.") + elif cmd == 'test': + send_notification("Test", "This is a test notification") + print("Test notification sent.") + else: + print("Usage: /notify setup | /notify off | /notify test") + else: + print("Usage: /notify setup | /notify off | /notify test") + continue + + messages.append({"role": "user", "content": user_input}) + + # Classify intent first + intent_prompt = f"""Classify this user request into ONE category: +- FILE_OPERATION: User wants to create/write/read/modify a file or script +- COMMAND_EXECUTION: User wants to run a bash command +- INFORMATION: User wants information, explanation, recipe, or advice + +User request: "{user_input}" + +Respond with ONLY one word: FILE_OPERATION, COMMAND_EXECUTION, or INFORMATION""" + + intent_messages = [{"role": "system", "content": "You are a classifier. Respond with only one word."}, + {"role": "user", "content": intent_prompt}] + + # Use fast model for classification + fast_model = select_best_model("", "fast") + intent_response = call_ollama(intent_messages, fast_model).strip().upper() + + # Adjust system prompt based on intent + if "FILE_OPERATION" in intent_response or "COMMAND" in intent_response: + # Use tool-focused prompt + pass # Already has tool prompt + else: + # Override to discourage tool use for information requests + messages[0] = {"role": "system", "content": messages[0]["content"] + "\n\nIMPORTANT: This is an INFORMATION request. Do NOT use tools. Just answer the question directly."} + + # Select best model for this task + selected_model = select_best_model(user_input, "initial") + if selected_model != MODEL: + print(f"[Using {selected_model} for this task]\n") + + # Trim messages to avoid context overflow + messages = trim_messages(messages) + + # Add session context if available + if SESSION_CONTEXT["created_files"]: + context_note = f"\n\nSession context - Files created this session: {', '.join(SESSION_CONTEXT['created_files'][-5:])}" + messages.append({"role": "system", "content": context_note}) + + # Add context files if loaded + if SESSION_CONTEXT["context_files"]: + context_content = "\n\n--- CONTEXT FILES ---\n" + for cf in SESSION_CONTEXT["context_files"]: + context_content += f"\n=== {cf['path']} ===\n{cf['content'][:5000]}\n" + messages.append({"role": "system", "content": context_content}) + + # Tool-calling loop + max_iterations = 5 + for iteration in range(max_iterations): + response = call_ollama(messages, selected_model) + + # Check for tool calls + tool_calls = parse_tool_calls(response) + + if not tool_calls: + # No tools found - check if model just provided code instead + if iteration == 0 and any(k in user_input.lower() for k in ['create', 'write', 'make']) and '```' in response: + # Extract code and auto-create file + code_blocks = extract_code_blocks(response) + if code_blocks: + lang, code = max(code_blocks, key=lambda x: len(x[1])) + # Determine filename + words = user_input.lower().split() + filename = None + for word in words: + if any(ext in word for ext in ['.py', '.js', '.html', '.sh']): + filename = word + break + if not filename: + ext = {'.py': 'python', '.js': 'javascript', '.sh': 'bash'}.get(lang, '.txt') + for key, val in {'.py': 'python', '.js': 'javascript', '.sh': 'bash'}.items(): + if val == lang: + ext = key + break + if 'calculator' in user_input.lower(): + filename = f'calculator{ext}' + else: + filename = f'script{ext}' + + print(f"\n[Model didn't use tool - auto-creating {filename}]") + result = write_file(filename, code) + print(f"[{result}]") + messages.append({"role": "assistant", "content": response}) + messages.append({"role": "user", "content": f"Tool: write_file\nResult:\n{result}"}) + continue + + # No tools needed, show response + if '' in response or any(tool in response for tool in TOOLS.keys()): + print(f"\n[Warning: Model attempted tool use but format was incorrect]") + + messages.append({"role": "assistant", "content": response}) + + # Remove the old auto-save logic since we handle it above now + break + + # Execute tools + print() # Newline before tool execution + tool_results = [] + for tool_name, params in tool_calls: + # Check if it's an MCP tool + if tool_name.startswith("mcp_") and tool_name in MCP_TOOLS: + mcp_tool = MCP_TOOLS[tool_name] + server_name = mcp_tool["server"] + actual_tool_name = mcp_tool["name"] + print(f"[Executing MCP tool: {actual_tool_name} on {server_name}]") + result = call_mcp_tool(server_name, actual_tool_name, params) + print(f"[Result: {result[:200]}{'...' if len(result) > 200 else ''}]") + tool_results.append(f"Tool: {tool_name}\nResult:\n{result}") + elif tool_name in TOOLS: + print(f"[Executing: {tool_name}({json.dumps(params)[:100]}...)]") + result = TOOLS[tool_name](**params) + # Show more for git list operations + display_limit = 1000 if tool_name == "git_operation" else 200 + print(f"[Result: {result[:display_limit]}{'...' if len(result) > display_limit else ''}]") + tool_results.append(f"Tool: {tool_name}\nResult:\n{result}") + + # Send notifications for key events + if tool_name == "write_file" and "Successfully" in result: + send_notification("File Created", f"Created: {params.get('path', 'file')}") + elif tool_name == "execute_bash" and "Exit code: 0" in result: + send_notification("Command Complete", f"Executed: {params.get('command', 'command')[:50]}") + else: + tool_results.append(f"Error: Unknown tool '{tool_name}'") + + # Add assistant message and tool results + messages.append({"role": "assistant", "content": response}) + + # Check if there was a syntax error + has_syntax_error = any('syntax error' in r.lower() for r in tool_results) + + # For successful file creation, confirm and stop + if any('write_file' in r and 'Successfully' in r for r in tool_results): + print("\nFile created successfully.") + + # Check if it's an executable script and offer to run it + created_files = [params.get('path') for name, params in tool_calls if name == 'write_file'] + for filepath in created_files: + if filepath and (filepath.endswith('.py') or filepath.endswith('.sh')): + try: + response = input(f"\nRun {filepath}? (y/n): ").strip().lower() + if response == 'y': + print(f"\n[Running {filepath}...]") + result = execute_bash(f"python3 {filepath}" if filepath.endswith('.py') else f"bash {filepath}") + print(result) + except: + pass + + messages.append({"role": "user", "content": "\n\n".join(tool_results)}) + break + + # For other successful tool executions, show result and stop (don't loop) + if not has_syntax_error and iteration == 0: + print() # Newline after tool results + messages.append({"role": "user", "content": "\n\n".join(tool_results)}) + break + + # If syntax error, let model see it and try to fix + if has_syntax_error: + print("\n[Syntax error detected - asking model to fix]") + messages.append({"role": "user", "content": "\n\n".join(tool_results) + "\n\nPlease fix the syntax error and try again."}) + else: + messages.append({"role": "user", "content": "\n\n".join(tool_results)}) + + # Switch to a general model for interpreting tool results + response_model = select_best_model("", "tool_response") + if response_model != selected_model: + print(f"[Switching to {response_model} for response]\n") + selected_model = response_model + + # If last iteration, get final response + if iteration == max_iterations - 1: + final_response = call_ollama(messages, selected_model) + messages.append({"role": "assistant", "content": final_response}) + + # Auto-save session after each interaction + config = load_config() + if config.get("auto_save_sessions", True): + save_session(messages, "autosave") + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cb27c9c --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/ollama_cli.egg-info/PKG-INFO b/src/ollama_cli.egg-info/PKG-INFO new file mode 100644 index 0000000..fe1f24a --- /dev/null +++ b/src/ollama_cli.egg-info/PKG-INFO @@ -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 +Requires-Python: >=3.8 +Requires-Dist: requests +Requires-Dist: rich +Requires-Dist: prompt_toolkit diff --git a/src/ollama_cli.egg-info/SOURCES.txt b/src/ollama_cli.egg-info/SOURCES.txt new file mode 100644 index 0000000..c19923b --- /dev/null +++ b/src/ollama_cli.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/src/ollama_cli.egg-info/dependency_links.txt b/src/ollama_cli.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/ollama_cli.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/ollama_cli.egg-info/entry_points.txt b/src/ollama_cli.egg-info/entry_points.txt new file mode 100644 index 0000000..ce90b47 --- /dev/null +++ b/src/ollama_cli.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +ollama-cli = ollama_cli.main:main diff --git a/src/ollama_cli.egg-info/requires.txt b/src/ollama_cli.egg-info/requires.txt new file mode 100644 index 0000000..6ce68bf --- /dev/null +++ b/src/ollama_cli.egg-info/requires.txt @@ -0,0 +1,3 @@ +requests +rich +prompt_toolkit diff --git a/src/ollama_cli.egg-info/top_level.txt b/src/ollama_cli.egg-info/top_level.txt new file mode 100644 index 0000000..d8bb51b --- /dev/null +++ b/src/ollama_cli.egg-info/top_level.txt @@ -0,0 +1 @@ +ollama_cli diff --git a/src/ollama_cli/__init__.py b/src/ollama_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ollama_cli/__pycache__/__init__.cpython-312.pyc b/src/ollama_cli/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a95f43cfbc35dfee5b9b488e07d898f2295a018 GIT binary patch literal 146 zcmX@j%ge<81jqYkXM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdWvCxooLW?@pOK%N zs-K^elbD;Ro1BxWUtE+7<;H`!@$s2?nI-Y@dIgogIBatBQ%ZAE?TT1|CNKhVF^KVz MnURsPh#ANN04^~ikN^Mx literal 0 HcmV?d00001 diff --git a/src/ollama_cli/__pycache__/main.cpython-312.pyc b/src/ollama_cli/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5aa5b45ed4707b63e8b86992cb867bf2d416ffc5 GIT binary patch literal 15856 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 qrx!Jd>RRAg;;CyAZ4*X*qG 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 literal 0 HcmV?d00001 diff --git a/src/ollama_cli/core/__pycache__/sessions.cpython-312.pyc b/src/ollama_cli/core/__pycache__/sessions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b8015cf85114cec5c807f6be833f157355cf7f3 GIT binary patch literal 3599 zcmd57&-6`t9hCAs_&snWKnN{MOMvP@e)QQAcQk)66?8>>$3Miw3AKxzSMzBRciX zl1tKoP!}jrbcmgK^XAR$?3?d>Z}=yV$BCeXe{n1JmK~wL(~ec3Znoe01$sJr0hk4O0auSia zMo@@0$tF2X$u2pgHqr4WH|zqfQ?$cK*@})wiO9c6hTWhir5RC1bb`iHwypyU7xcZ; zIOO@9zHBgr4TmV`k{(M)A=V%RF-xQgwP(5W9^!x zX)vG}js!@;n4}r*vTigfBqYP8O8SJFj4<>SXqS;*{#JzIwN_MTiQEf1%$Na7w#Uu{!3j24&(Gln9}2)H!O zq?ow3I&MkUTQn}kh}N95tx}~toQ78&eq&Ct6R&V_}408Mc8eBRllUz zTY!vTReX~s`d({liimd0XD^^Mr#Dz5@qi^&+Z6f_J=#>AV@cMxq_F6CmrZdZzv{e0 zPZ+jqE*xTYixJQZS9 z3LAr`IM;L`F>VkF)z2xkU`1OjDN0FwFjQwS3I(?Ch9t&R&2SBUXK1MJmBG<)-w#y! zWd@s6rVNLst5Ld^LE=g*Y1m_$ASWjh1_uK%SbSX2FB?P|my%#X_dvu6cqt##l&GR6 z1U+OkccfAb8#rR{nh=#r;Zq5wXiSz29Mx=K6Ew@ti=7uVa7pK7B_VZIW3p3IBjxTW zglK0(QKe2RPCLe@Awa>Qqh*a6f(7Y|H3O9rS9dPrJi5A*7%j5-D}CUe=F}lJ2&u<_sq+g-aH>H zB904W&lVi+1^zm}6$lm@58Q6P*?jBS&Bm_v#;$zhH@5Z!AK8iDS#%(`XYtj4@`MjO zW?@>ze{Rzai5j7G0|gzDVXz7mODp!zyI+A35KiBH6fdu8_C zg+m(=dVp&ow_!Ab#_tSupcaV>KEIxY~FLIh#0*QzQ=Y;+R@yw;SZ0t_BOM>Yvy`e>=Ec! zN+sI^vHzFxREY%0%0spHhF!9MH43d+kk29CnE4!zC5@gMP58!Mu&JQ9@!Tq zg>5icLZMxTMIbAw?VzH@+qJhex5Hp_xyX#yUzDd<)3@`6YuCs*GFA{KKX}}xgU-^=)bgxm-!_= z)0%z$f$wn6J34m`s)DP2@klnj;R@wQ=*uryZ3ErjLWb*F& zApAe^uDY-0hHCyhfDP7^Dnny5%}lc)GUE$NvZPBv!8%>@aL<1iGW{-`qSAAvH|2yf zDZ$xMG@H!v#IeQsi(lI6CUj)sQx`&d^PGFPMTd8+cq5gx4ix>mv0-EfV##R<5Dlu*gSjmNxDsFcf0WEtdj zMUl 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 diff --git a/src/ollama_cli/core/sessions.py b/src/ollama_cli/core/sessions.py new file mode 100644 index 0000000..bc136c7 --- /dev/null +++ b/src/ollama_cli/core/sessions.py @@ -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 diff --git a/src/ollama_cli/integrations/__init__.py b/src/ollama_cli/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ollama_cli/integrations/__pycache__/__init__.cpython-312.pyc b/src/ollama_cli/integrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b81423f409a99ac6a4408566dcf738b5b2bcda7 GIT binary patch literal 159 zcmX@j%ge<81jqYkXM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdWv3rnoLW?@pOK%N zs-K^elbD;Ro1BxWUtE+7<;H`!nRz9t=|zbpnfZCe`tk9Zd6^~g@p=W7zc_4i^HWN5 ZQtgUZfyOZcaWRPTk(rT^v4|PS0swuQC*uGB literal 0 HcmV?d00001 diff --git a/src/ollama_cli/integrations/__pycache__/lsp.cpython-312.pyc b/src/ollama_cli/integrations/__pycache__/lsp.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dcf8622477151355f0c04e99e0644344c67c394d GIT binary patch literal 3249 zcmbtWO>7&-6`tk(k|M<)$)Zw=>58@}Gp)qLs@uv9VpOqeyKo&tj3Y}$A<&!^wJgcq z&Mqy9r2?bK!9fco1`0@p6x2ZjM1ga#fdKZYFbo(7+6xIKFfkTT0qw;%srI2zFMYG* zifrpPKs&&`_vX#ao40RvzHk2+2>1}R=^N|%x)-58vyR)G5$HUf2eFDU!dwB(HZ?cP zIepKp2ehZa7lm1&D9(ySX;$Kpj%H=tg?UZE0z9Io%u85$N1XLKJ{f$9=EGii*q9Id z-VtWImbs+=3ETZblBdFHJ!@0``=w>7ykXk9QOXpkQpl8Y+(3E7wc0x0)wW%$%H3*Z{i*j>YxN3`eQ*|j zTXtvV{jSy4w^}9+e8sN8_8QmviWUDgPd8sybsgdE4IT$KgcZRKyV3Iz*J|sk;I3N{ zt9-tpvVsB5(~4%zI;oK_Xx21S;n(qeRRs_2`&NMGf)?ZDvTOXzhny zH%hivvPaHqrJTKRQr#Ba)sMTG>$X zqG4(!DqK8wagurriwX`X`Lrc7Q^IAaF_m(fZ9+nF=8EME3M@@3;&RcnNQk|&xJYzc zqkI8EiSvcBwLrUIySen_)LWBNY3iL^%4!Y@Qr@yjQYP$n6LzJkzhIejS)*8l{VXcG ziTPO>=ZN;RvIfV*7p)xSG>ffJn{9tQbqUh5QVT{=OBsbirkEMY7W9-wvaRkM>*`Ro z9LX>Uwomb^HNuCsLy=YahWscLuZQ9{$G1Ww&jNCf@4CF(hx%iUNKd0L z{+HO-e>?M=ne`txVn;uk`gr=o>5u0=ocnC*?)07MM<>qIPn`MOyLDn}Ybaei`eyC$ zrG^^clZ9yap4Z#&yY7G1hk`xt<=@S(4QzEMuJa8g_+H@Mz$4{wT{&EPar9H`_Sh{* z|DJNc=e}4Q_`w&-nSYw|g%aCQhkw=e{@J<`YlM$&_HX)X!Lgb$_P6b>-oGMFOf|wo zA7wWOe>Z>gaxFOer84^WT^`)~7Q>JA(?gRSddPVvh%mMkqlxAzv+=_r1eF|8_l6^pj z&!fEj0nNA(5Ngws1M?h^Cy(U~4_4su0^Ex>{CqT9-u>-x9iZD_y&_eG=h0XGIkbd* zZ4&8(Slm^W{sr!zAG4<;g_hZd`(;0X`T_Acpe_S>9?(uz{x?wXUXkCH0n{Dc0qVg@ z^o(Y!Seti{&nT${BkSC^N?!*il?=OyMH(iDAfG4f94b);p{I>(xd_mfYR8~_ndqb+ zrX)S&d!PuTq@;)XI~P#JG%P?*KutzogmDsygQ$$3?o7w{Y1ssLFWKrbqFE*&fu;hi zS_~FhUV70iXeKl!rS%l zv$f#Rm&%ZX3yCH!{Bj~Z5kn7R-ig=v2Z_MMIR9XbWA?ZRGRf_8qi_sI;ANY~z=}^L zpmI1q!jOP4cpxGZ&SA0AJBUtsK!2xRpP;AUL{3%rl~;=fE*G@d$T{#a*0z2E0-%cH z9;5JM Q-c+?qm!Gjgau#;@2fZH(B>(^b literal 0 HcmV?d00001 diff --git a/src/ollama_cli/integrations/__pycache__/mcp.cpython-312.pyc b/src/ollama_cli/integrations/__pycache__/mcp.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97affda100db797457de16b5bebbc482b5efb9de GIT binary patch literal 4257 zcmc&1TWlN0agTRL9^aH`TW^W9WXV)yQI^!irDHpZsKho>+p&_M5ugYR_aq%d@|eBT zi#ZxWkPjCy5DN$(wUJR7C=ePp5Eku+@@FIkVxT`{q}0qsKn1iv{*&W;r0!2=kGxP8 zlomxlx&UWpXJ%(-XZL1?e|Eba2*x{0A4_%*LVu$ZbJ(lIqYr>sMi^lxiN-6K8E1^S zW!wVTl4MidIG5tb`IL3snzD`C7$l)_0e*Ju!mQ}P9Q=6ED%wOx6WF6aY!w|-0=9j? zk2|pday$GSB4~>Ilx3HO^8;?&waA3r52#BQLY&S;rMRlI&!-o4;bKOWW`BISlK)N7b#}2TCthqFo!Le$5shp8y2vA)q)+XEOxGP zIbP!uRlVIFB+MJ!#Z;Zrb*+}L#B>~o&^#j=D}X?Px$}e zM@TUD{y#;?uGxw7AL3&-eatx^K8`s=F5x;2X!=dT?3_vCI?9QniPP}bow!~@A0zD7 zocI9ce~fN$IhTgax#mnz_MU+c;zPHq-^Nd9G)TzQz>TY%+H9K8T=brme^N1aFwm{z+GY0P9iPE1F9T|r31ehxfD=DGAEM2oD>TvDwgC|AJ7jj#OpkrrgbYIc~&*B zNQlm<;)1H%-kP0Kq^$Uu9qb>_9a36SrC1UeS;yeO;PA2I$BzyCEbEYy$yh3OEDjQO zsDB`2AvQ3s+h9ipQe=qLy%{2_a$HWnUSa93cq|ipOG<)4QPCZ+R7|Vq(o^y+PPZi# zIZZNg-6CP#niAC+8E_^>Vkt#u95ksavq|*RM+D{eWlsKkk6WN#hmPAQ$4Rmb8pVk!n1-dc&2p-O1@fn&(BP1UH1n8tNi zP_{;EORSSy8M7?TIyynVxK5ln|K&;+5Ny($h~Gjsz}*>UD=Q z`xZnmE-H%7Uy?IoTIVjEyEIB@9ntxD_$o!6qao9IJe$gZ)>LvMj-v5HDU z-b!2qFimI<)7f|m>o(Ir7d`AG5#N~=p(4WGN=8mA;2w^tG2Ly%=0~x|oK}@8H#upj za#W_O*sL-?@cyc`cdH6NBG9^4Cr^ImWC59CeuTCf7b%mw0Do&FaE!j#K z-a~(8QLnE?ev-bA>b<`RThNm!&%pC~5AAR#`IBYup{vr$@NXh(gC$S*Rd%bs^?v=~ zQvKnw%M0{!cqzQ)Z(BaIbf${RbqC7_4u0>p`5jknJBLtXTe;p}K6H4yGh7ZFF82)G z77N&C(A`@s{X;E9c3GFI0^^sloR0r-a`iD?Pt5$ zdmi`MAbYQcp>&W3s5>jK(Rlmh1+b>up`V;hL6TISXeMd@wC0(DqodtJ=g^#?Yb%%6h$d9;2+=uva~3ax|M6BWQXmW!;yvU9o{h z1Me#9b=lUDSTY%i86_qlr=KkCS-(+CB2N^VthXnSmQ}ODh?r1c9?%D47S1@>tQKr? z7Wkx*0@@9cb_!_66Jm6$l*aJ*bRL=mnPh1dsZ_5>Alq_54w$JvFh%6lE+4Xu)fxxe z&;m!-(X38uo0AOE9cUdReH0v{pr3*;1+;*X0Sb;&@C*ef0E7hc5~b;PQSmtfly0lm z9YVi8a*Bc|0EJ$nSu@BeOmsIrw2}0wE93(3=+Tu&0BFi@oT2i;qxqJ+ zyD(Ywj}~2{f3I&_dAn5K3t8P-%j%86a$EO9o~7AbFjD@3{j&M|Mr*jxRcd{DQ+Q@? zHsAI)@5$xg?3>F=-j1z?mgVeHcCBk|;b#AO-Dbndq7PD0<4Sa?qukWB_G-T8=ET~? zV#9FJH@xlb`(ev@fTpXVa$V?7XFhhjI}hn<@M~evNLNFTl@Of!efNk1U4Nku;OD2D z0Kah9N1kH8@ViI4*)IYNrMr26A?8BJP3RKizI2NWe{-n)8~wN@wHz*qT-z^Ob0WxqU<0* b`3j<%_xyLyes;y+7B9c{J(ZHnu<^eDberjJ literal 0 HcmV?d00001 diff --git a/src/ollama_cli/integrations/__pycache__/notify.cpython-312.pyc b/src/ollama_cli/integrations/__pycache__/notify.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f304681f87703cb440c991e6ad7e0961966dd72 GIT binary patch literal 2740 zcmb7FO>7&-6`uWH{;WirvZU0K%sR4VI+d8Xb!!_+9f$raYAZqF#4&?}pgBWnRg%lj zj+~HK0cv5OGGM^eNerAIJoKPI2C7?mOw)^jUL?>(n6-fzXb;Jaj)Ef4sc)8~D!UfY z4zTadym>S8_Ragg`I}+*5j6Go9n;hi`Uf9$o3GQ^{5^D*5k^?Zp=4VNNx>V7NfES| zlk##>&MQeJuO?Li;Q*G1ie;i<1wNH%SsiOPl%($2bpkWf0$9c8VDGGqeK+K!Zuzv< zv$U(tkTfRLb~R?^i8aS!vMWs!=IWIEc8=K0cIBc4x2}>x#=<0~Qx(=z{@WC47*Srh zZh)VhYL^7<;bw!Ir8%0jxmUE)wkG3mLC|)5#>$hp1$C8Ak7sgb+@_h%FvW*vfstwI zCBlw-X(>$a$e+%Kh=PO}(cj_b5g)JhZ%jX@6~S{e{U!_~k{l8QNDpzY!W;RGQ&| zie43}2de|?JwINQ{;7v<8#j$grWqbw9$6ZxX^rsVN6(EkhejVO(%znyj`sHd%g}uO zA6g+nF}~7+TXqnF`txbus{;B$5MNa!*KZY49jIhFr4B9#@g#Ww`R;!OhzP~^ED-Gk z$g@430LU)8?1YEph#h#yj^x2Z2C07{&nrH(10vp!JSiLn`>=f1zuEy308E|b1^853 z?TpW$_lf{R&CzV(mddulHOuq!RCBa0%;t4Re`YQMT@M`m&0(k!;ns`0cpA88n+|-t z_yl+O_#Geay^g4G>#B$V=Kk(U^8ps<2I?RjaspV!;UA%H1_X4A%KuFPd&(1>TuC9C zwWx>tC)mJ_rhy-d)XD(OM%fI>Q+^8N_2ofrs*So{J`RB>EAd&|DqIEvP;Ev@I#2t- z>?-6sDX=zjd3Id{fY;w3cF`)>r0jpqgJd*u@mJ;GIWtG130TVrE{76UKF@;yUCa?i z=qM~Vzk!R5sdNDPbdc}oH(@H3GYbTmgNNWMIV+8A52&$Vn?3e(_kCP!a|IpZtATae zk+?G7g7$HrZLV>R?sSTg51EIqF0*h=hxs0c$3;0Wn}pIAz{c&i0%-Yv2;z}3csq47 zRefiD?0CaCv2d;#9H?p=!RW%-rV&}ZP&r*8l`GY=>;3}^iIyBujE9kZ%aco!HEHEc zee{0h)S}+(8~U4%`dk@vJE3@_Sd&<4O^^;S7?|bd>etz%|t!S5(_*3_n{_(_7 z^o1Pba^kcE>aRz|#LuNCXo*ABqor%mSw?J|aAtRs7tHa-Ixif8_uUb)A{Oq7-LjwO zbhQoVwm+9$;0wqR--Ksr>M}e{Doa!T2FajoSH)?Pw+c|FJ~ji@h|R*Zt6uiH-tD@I z4GUOI`cHAe7T&zggWs-6K+!wElCiB2dMV}3Oqoe zuhG~B8hb476^trwAy6wn^;A9Lse95UH?}55z+tq7K&@O@%dEXsPpl<3e)7sDx3pdn eBwhwF*@vCp5cpWHZl?t5 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)}" diff --git a/src/ollama_cli/integrations/mcp.py b/src/ollama_cli/integrations/mcp.py new file mode 100644 index 0000000..f2fa29f --- /dev/null +++ b/src/ollama_cli/integrations/mcp.py @@ -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)}" diff --git a/src/ollama_cli/integrations/notify.py b/src/ollama_cli/integrations/notify.py new file mode 100644 index 0000000..c7a2da5 --- /dev/null +++ b/src/ollama_cli/integrations/notify.py @@ -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 diff --git a/src/ollama_cli/main.py b/src/ollama_cli/main.py new file mode 100644 index 0000000..23bbb5f --- /dev/null +++ b/src/ollama_cli/main.py @@ -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_name +{"param": "value"} + + +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 - Switch current model + +[bold]Session Commands:[/bold] + /sessions - List saved sessions + /save [name] - Save current session + /load - Load a saved session + +[bold]Integrations:[/bold] + /mcp connect [args] - Connect MCP server + /lsp start [path] - Start LSP server + /notify setup - Setup ntfy.sh remote control + +[bold]Configuration:[/bold] + /config ollama - Set Ollama server URL + /config comfy - 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 ") + else: + print_error("Usage: /config ") + 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 ") + 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 [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 [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 ") + 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'\s*(.*?)\s*(.*?)\s*' + 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() diff --git a/src/ollama_cli/tools/__init__.py b/src/ollama_cli/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ollama_cli/tools/__pycache__/__init__.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3acd1da08ba3ab24977f8f16003768d9987834f3 GIT binary patch literal 152 zcmX@j%ge<81jqYkXM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdWv(AuoLW?@pOK%N zs-K^elbD;Ro1BxWUtE+7<;H`!CHeU|#rpB_nR%Hd@$q^EmA^P_a`RJ4b5iY!Sb^p+ S0&y{j@sXL4k+Fyw$N~T}wj}ld literal 0 HcmV?d00001 diff --git a/src/ollama_cli/tools/__pycache__/base.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..977358e80a1c841412def9ccfca4309e87585776 GIT binary patch literal 4067 zcmb6cTWnLwb>`l=_O)Y&JR1^NFd<|yEXnRt@ls03Zg$zCDqWRULRME7ukR!{^{aDl zc8QyysIr8uvZeVzNdzmrQl)~Z)a+OLN&hPKCn>B*?^fzZzxrn|Y6@RHXRfbfyJ=NM z@|l@4bI+XjIsQ{P93(LQzH~cz!v`~NyrI_?)dDP*iAhYFB54|W=XH! z#M9hqGr5%zN%^7^%*6#DZ7!??Yj_(qpu(>@BA=e8^Tenh&nvK_-gaJvT}@+z%#r(a z^1dq2M4}Br2tbrM%;Kr37PN&=Mqs==KKeroco@BsP4m$#C?_31mPjQ>Es>~g4cyuw zhBf*z2sDxdH5x`Tle7&Z-|5LX;-SDXh_xHQBH8TOU1T=`n|p?f?6JOkb6}{*KHF7= zGvfFJw`WBrQI`ggxJJ&a?EsdE4bUAwqXJP;>U2+L?eC;>DW2vTo15P6>l1M~!BF@jR^2Dso^Br|YYZ!dULPIZ-zqTJ(*AIwp*i53`3EWpU2tcEM7Dx;|lz|_r;D?&Y zIQpfZGZ`FAdZ!MU01t8>Z*x6Zo++ju0|(B0@U;%>@LV7obVBIC-{;d1E@H09Ty6b~8G`i{>@l2*k--%LbBnxfq+D0lZ-`hyO?nJxbmW5)26CHU2ye23$;V`6?sQWxz zKpV(-aYGg3Wd=~KW1h}5epNdjgV|$L5eHx=FDTykR z0{tthNsBwaPvWUrZnb1cFR4(wNpryz#DP?umG5hE-R%R+1J(rqi=@&{zA>;ITZ%pD z9p302e$;#TpJ9JTuo5DjU6laoAGqsV^=JL&y4lxH~ELHBUNKG*VL z*Jh?>W6a7igHG2nfCkp2mf<6~rUzQ`YKGXMX+EHg<4s6#+?4Hx~JoxRHgO9M|j z2RAwgA9W5rBTC>sS{&aDb+2@-jNg5K_5GDo<+sc6U#GuFmyNY|*85IwgiaREos6~%%#lDTbZ&nH5Dm|Lkx28TPFu9M( zchH@-+nPjUD#T17v(I5zTqoD*bp>N#yM35ainK_I$`sVbLTEvqSHZK?{E-Qs$bx~h z0vs#i1x~NV8xTRi)_`{~PsK85Vkxem^7IahD z=Hfw(=LKrlOVNy1$$^G96!3g6Hoe%Oq(qSC<~9`t$5F-B;ub>|yjB&MpxbQ3R1hA* zyP(HdkzA!Q4T-%RLD5FWQrSd2Wt|-{J#}oOY8v7u`g)W2$hZ_Am41sgc55X_LZ88T zNi8WQ=7~+WE3KnhvpFuL8$uSA&0D~LJ~M7vhT&RS+8eWlOns3>7_Qf$@8(aw8t3)C z0u?0-^uM6%>*!tbZ*)Y!1%JAB;j4~s|7Tr8Au(X8Sg>zVv~UUj1wf}me?@Zd`Sc~HTYWbwbIF^O{iC1L2)~vpb)Uap)-+$@bvvh4`YU$c~$05mb)VE`N z<9klHrq%>cr3?doQnPcE(`5xZb$-tK*o`Ib}-4FA9|4my!#lpPwVi7oDf#IhF zz?~1Cv1WE9iKgyOJ=4UQYXIUrc;?+M_}vtlNl?= z6Sn*BK<2h@;kCU8uuin3*_Zm5`yb@27>85r_GGTDsFc#bkrQ8$6JL|IuSxqB^V7kn x1i)5TKka);5S${R-9JCOs8?7U?JdIp4VcQ^a#|Z&vwr{X+SM;do+DOD@oypKNHqWe literal 0 HcmV?d00001 diff --git a/src/ollama_cli/tools/__pycache__/code.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/code.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8312266efb05ff586ad33b9a45fcd19fa378f2e2 GIT binary patch literal 4451 zcmbUkZERE5_1^d8=dbu9&ZpD#5vL?p^U<^fTFN33l4i8PD1{1;Mr3?n61$G=z4zP@ z%Vvxr%~g>|x@qwQY*9C9?!r`lBCS70)b7vzI1MY-E0dVEALGZQ@FBIO`>}K1^K(p# zsI+V4oO92;=iHlnKHp#6ZU=(lU$_|iiyfhV(up--ZjAe%0a`)?5u8BBO^lCY!VosW z%(BN>j2I-dgd;o=h>eKEeu;_UD~=V`R6qooBTku{;)v@Uw_-BUao&(}lLq1eDKGI6 z|0VVjxenr~Nj9G2Ym$N?86Zs{*Gx|X+(KHZJZZbc)Sdj4advxEi$H>PT5R(;+K*5?zv1P02*Hj3P^sB$3q2^t7yM zQfxM@q~4Zi<)o(Sb}BZRj%YKw0NZ29>1^;I%p@%(X*04!mnmI6CCtFt^J!Uk5?PHZ zv9uOTCBv-FB_p%4LM@>?(h&tFnyje0pvZ81GD@#)4L1<}P;=RlR$JA8V~#jy zQJXk!+oUZjhgPc61g})3hfyBqaNKeWYb#*OFnI=?1b&tTMn7ia&bp0Nk}#I`Jey<3 z&;>5aOrt3CL(rY)2%F>HMk+op=D0=XypuzDKF>X7ANQ?&LY|vOd7DPx!WcQ$I*IUa zjUl9T=4^3~wYrIk@Hm-^h}bUIG+

hp*`spQzXVI2q9EsMkxJIRrdc-QL=%z<1a2 zYv%&Lp^h(D_W`~qXH!C&&-(9(*IJ7=TD(n6eCJTkHdeEtwf7>r?05qazllR=2rM$k zU~~*&_?`dHDfSqe!%^TwnLIZK(4s;}3t0NQkiXGIfUo2Xw6H*pcOkJSAm?XdtEIl}ScvXsJ?fw)K(iiaZsGYAGX9g;yXtcS>2&v5%?+ zszJ(0uUN7$cfouQy^gW!a5(m=Y6fV>9hy%ciAx%jFTmgZtpHwww_8FvWOzCzD2L&F znP1{13~vgaK9B#%&fzeVW%{K9A0S8z%)oOxj;S;TCF$V{gGW?I0E08BS$Qy(NJM5M z{n12hP*tMU=_H-fG^P&HTs4rMe`wz|EhptuX=Nna{jJnCuq%;@MiT1CKyBC4aF*H( z;NR%WGw4%yPjP60Sr}Rfou65A_k5+&9CFsgDJTv1ZWPATlt zxl~$C>P$-2IU@@?-~x~>GjM&KQ{)KIdBZ|Fn}B^nBn{h%&PHcoIi89ob^G|KsBGjx zorMeM7XTAcd%Okv^+ z7C0LM^0h9x7u`j&?hO_AiuBB9f#Kp%srl;AAA;|_UJeWw>;NjAq0juoMc?wNt3&VS z-y16XhYL0Uw|$KT;g++Zka>4!k^RðoFa_P?{gxbI41>BvfZ$yMHVu-yLo4c8&M zYyRDl;^?Q&&g!mmN8gnby=KXSwM=IzF|Vz?ND3qK>?5}k$dcO7SP z3nRY^EysR4P~I|hwd14cpL_n4xOQR#vHLK-E1!rrx z>ld4gV%f9hZ{n7(H&|dlR8PRepAC{Au1OB!KG*iS0m^2}#*@hr=>IkxtG_ zKQQvAv}fPmfvmme4LY97wruXJ@ui3+DXnlH$VBac4DZmzlMz*>O`2|toK&fIC|z{1 z4S>$iDKY5RLX^=BAS{?duBwAg8iEa`Z%$fS%~_or%ATRJeE?MIP-Y4<6Ief8p@6Da ze-D5rFcA`1OWV@i;@r)ap0$>qE6j(s4{RTLKJZ-Q)_cFV-ZD~fRf10y6U7tdXD7;S z-!C-WYHnScUYuS^EGCw>m4fTd!v!ZK!G&jw>bg5b1G{_qM-`X*!o)ih3kPnvLNx7l z-}d#Ef+cs^v%M^C|AGgOvGeN(!gmJ#cDU==-2G-t#s;JSEq98g{YMG6Zb z%R&pI3#Q3*!L*r5K+nQt~Oy3{}Tws45+xO&5)( z1J-qinmGw^7?o93XTh!LcNzf=29_12E^K+jD-3VYSt<+PUn(SgGcal9HUiU(%NOa* zDHw)7_16F(F8y|h%Ruwe#Nx!wz}B_E*5z)-s7+vBsN{HTalMf+%&UG=#~yV=!FOzCbO zU>GX{un7<~OIS1?bq~-wNAsUToyN#;W?N;XCgUBb_3y(UGT!E)xN9~=G6{J^c@20F zLP)&{02%^}KS%b@k?-%Q`5&0C*gGoT-M7WY;!JtR;kBkC8!Y1$9@qqi*=T6OUCWId z2vDi<9!2*(7XR)5VuXslz2Y6YEjAQe%g>LO4;@+azPTxXx519PijdX-mDqa}Rpo)Y z+ib`;RlL2oJGYj1>|JYmwGs@Kw;e6ZZ>{+gcX^gx!^SXoy$(|ipk;Lf0lEg-0NwXl K%gU?pzx@yU$P*&~ literal 0 HcmV?d00001 diff --git a/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a2a4595784aedf24b8a19b74c07965df14fb0eb GIT binary patch literal 3608 zcmbtWT}&I<6~1?VJhrjT*yhJ1kPHM$O!!FyOR`l|$z&02waZo|?Kbp*_4*FMtp9dq zT(D)LD%uAkRZ3`Af@)LM>aJ8O@{q@>?ZY~+eKDda?kufRwJ+%tgh;8#OV7RIu?9p39 zu4B6^LVCXR%{tL)Dav`Jk)!=NUMZxCk6M)w83Jl!e|FO#sPHTwO3#?gD#-2BONbs7~Mh`-W*!yZ=of8 z0T~RC)J!kZLMt5M46kjpz}fqMihjw(xLaU#g}cMeRL3Q}RJ9Z4r%kNoTP8Ivtts?0 zAiyl<-%!GmDWo%M%H-0zM&o0;)#XeyMY6i8nF3WdU6ac(m4(~n;dH{hrHJQEtm-tL zilQ97Ir4p2p^q%2lj=x1k%%UvL$O4BMAu@~=^UL>MCc>*JoTJzsLA0>P7}cdBb3qK z2l5lNFAL(xp4b0L&wBq_|I!F)VUxSzh#zgq5t09c3%HUf zMtoAq%~RgTsNWU2!k5l?BSU)dG8(TFPaTyjTpitu)bfePsLfBceF}!_ zh$HwBS4XhXWSc5L8MNh)3w(iJtl`zxL8yRjb-{EdqaUgy4)sR@$|_Scm5U4kT99hD011oMA_@W1Yh|2D z=4b`fTB%YS5S^Yc9i|_2TT>0J2UFQU()q|m8aL3z6^QddnU-5YFPu-h(F!g6o zCrzcUXa4YJ?`B)M`EuEL`IQI~ruq7%19@7%I$#6M~EPF&=l^kB+g6oHz)1U(6en;J9HS}x!DHq7nJs&~gy^+-tB%;novkBu^AE0q%ec*4v{NH(ozdFzAXkXz9oaQH|_J?|I0a6+IPt<;% z@C~(}KLzFd9iE{a#s-uNQ2X8az*}}ymAI~`$&8W9cUb*^2Ax8>7DCz-PU2dkTA*PH zi|Kf(0mp(>(;$mz(@=>ivf6Ipmv+)Re7otPP|^oKJqm=Dc~KnO3%0IbUAy`;*tZ?* zE0UeyrB&yi99Va+xi_xu$i1uLUoKo+HSXu`p>ik&6Kk#UD z^P{ct&5Pyc(Ou^#tMg+=dN1s9H{ysip^^(z+T@B1@}(AUq@OQ!W6Jl7KuwRXMzz=i ztLb{aYlh8~dFYo>1+o&Z6e%U1Vog)eQ|K`XR0eAu)y%imgIxa*Pr=1Ss+zEp{t`HK ztnSm>1;gVPz#PAqoK~9&rZtjq1q?M#Ab<>bg-7C6wI5KNopy`SJg3V6H4a*Djarvr zb(JTQQR@PQ{~dA8>AdT5OP3g&VJ<84J^coWuOi^D@6A9B7i)P?%rp~3aMkZ^( zwN70&Ov#8RRgmQ6>9mrJ8nFdchp=W-B=5J7YK0oG3J4WVx1RkVqorxNHhC?ZqBlnZ z>yLqxhT1|X+y`~21E&FVfGSZk20#5a5UZ{Z?)lHIpIbXu@wIPA4{}AWI9B{x*%Ph= z+KR^G-Y>Y9A|G(CI`*BYyZ<-dUwc=7e1GNMO4&JC9-Rbg!*j6W@A-VR_|a$MMS$m} zUFRhR&%q=8JnVJ%>UH#cd>#GK73tzjqBj!aO94#zkO(x4wI0w4S6j^$;7#F!DBQJZ zcClt+U7%QZU^zzr%5TvjP{bO?k@I>oO|l8~n;O0MVEE}hK;bTg@t4T;CG!6jwf!B7 z6<13|9(v~VZG_5K-rANY_jyisyp$Xqx9=12$b%dE2!@RtFX(XCY|9UPh;vk2tra=^ ztfSjnTIuX9zcIBP{MLcUQyUW4key&-W*@;&9A(4i=nFbK^xK~gT9~PhiacEP#iL59C&OaxQ}2c>M!U}bp^NsnXPZH$Rp3F2XAi&->FcazEgg0c3b{Y-J1jO bW^m&Ibz`u|oEm)0ycuNPu+Q2xh}C}p#b7hJ literal 0 HcmV?d00001 diff --git a/src/ollama_cli/tools/__pycache__/git.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/git.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29a692c6bdcbf91a804e8c296176358b2ac2d215 GIT binary patch literal 2997 zcma(TOLP<0_03OT%d#wgvA~0|Wy?P$kP<#F)D75eN=#CdG?-#V(Hq+`BaQNA6hIy2 zu*inA=fs>WY>wgJMHb323oqRrn-rhZMMs9#>JZMMrz`Iq@FI0Ly>Fy3PGU-)j_!N+ zzWaOc-goc()#vjd7>{P=lPNbs|E7pN0Jp}oPhgrw7-2?6W969{V*qDmHpPu`48k1d zWj-a039uHhLw2N`V@?LKND{CUyRaL3u=f@Va~1aC>RTN4-(v69EOKQNxE=>27p{eW zP;yN;aNVcEVtEUVxvw&jhINXKL>SX`Hlrn#bX;C%fC2%w*Lm3T3%h zl#{9!F&(N#lIh9Zk+Zf=QBy=%TUsk>bgD%!E77SA0J5xG8X_!sdo?4OUM#5zlC(~e ztA4|>D5fM;jZaEj-oY%WEo+Eyga(Ls0hmrKO~kXZ25Q{f{+rH3@QBjV1jzo!>PsfyGJ; z$(JwSsNLrHLg#Uy8LoKRe(ukMfN;>}%R7Ltmh$DD8cjxPOz+I23Qc#wz17`j;Yl~> z&Utj3O)|LgZj=4g^Df;rT~%S;G2v$GOarh>Q}qX9I~Uz}&vf7in}F)>Q|NO~f}KPO z_9E=(J-9{p;1Kl~{MifZeU)|VZQ%y{ryviUd5?X*QG~_a{h}urMK6i+{$1iE;5Hp> zQ)yk8uHD6FVY=?e)EtmT!8gY8?=m`ov<9229g(KI7a!2*QLLeR?GsQ-3HCgc=e+k5 zFPz7kFEf|X74AH`%tYEpOb#laDFBe-w3yMVm-EnSSYDrl5+bNmk}R9miFgJog%ne= zS|+PO*^;gRq_r*Qq7rmvI?6>Vx0h1l7>j*_VmNHo*Yb|omlE5ym^PEAm9Q*HR}`0J z8wYzlt&b*`C9)}K$|Whi#YA(p(Rd~q{rJ^rR+UJUNEt;neNY^y6q3x9Yr4Z?7T0MJ zgR0zB|;Q(Ow1jfNT#va4~K|f!BR$>5)X=fR9x)rhZA>-eYBX1kU63%VX;%yqm-`e zow>$$)0fi9<@EP^4e0cO8QVa_N1W6>Zc@@rkDC23LzDzG0h1%yG}LO^ZZf8eNFQY- zRZ~rGIZV*ruw?Sns*(n%B!N}cFdWvJ!Fkht`bq-)g{Eh6pd?bYvjtR|BaOS=D63#C z#cd3lgbH28beE}cKV(036C6=9N?Mgnp&Sc_n2e;-w=C`$TL+`>LcG-Il#-I7iY&)d z@xFwdjH)D2L1Pr9ZCj0kjsDD4;sPZWpckTYP*<*@&0195{Kt#ehDw3P**!P*%$;61 z_#hCz_GT&AJbUcMvAM|w@*voK?aT%*3gMEsZqBvp6~C!(ogKe1zHnxlUpZ&I|NDpa z7e5;+`5NZJtG?~cH<$3rYsPzH59=?KH@jASZ52r){KmcEdq-CWjrNgJSMTz<6@HUr z8b+AUhMv4w&Gr9h9Q>p}rW3u7wU4 zLx-2t2chFL?ow0x!taVreKW#0EeGxl+#D!1iZhpt{fCUY*GhYPmmEvkrAedzgb_Mf z3hmo;@}b%p*M6WEj2olp z3T)xk!iBF+7~RK~+m_#1{==Wgje#>OgR9|@)sFE8Eit1mR%-4p99io*S?oD!bpOI= z95iYM?YnA^eea5{4{bP5Lto*1;WtL`h*5RqiNF5y{Pq0Y$9M8K^F@EJ;p+X*Q$GsF znC~`gP*wd?#6ai*p3`?h88Fr(u#fD+t_4Q!~ zUH`lq;9sitQP|T2@IEh4=ynfxbN6d~!yVlHR)(%S1b`8S&>W3;NFU9zR{)p}c)9Q? zN9bpb$$uD!gr(mjrZ=GwslWV0tj9@Sr!q7>ze3LdA~FbI%QcWva8{O1kYPY+yH!5` z0Dd#fBNTXq{Etz~W7O~%b^MDFO75mo%{x!_g^kxgD%KN-hw-!1#&2WA;0NCdT#aMX h>15e&YdVC^RJmv(#E^3CL1f)0D8;J4yC;HghN0 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 literal 0 HcmV?d00001 diff --git a/src/ollama_cli/tools/__pycache__/web.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/web.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e741fb1bef97e5f2f04e9dbc85b3acf9fe33c0b GIT binary patch literal 1718 zcmZ`)O>7%Q6rTO}Z=5&|NvLSIN(nJd;*kDOm9$l9Q{aFPBt>l`RBd)9$r^jtogI_d zS_48H98oFFDV9nlBYFs=ha5SRBNta~g*6RQ5U1SSh65@WW*l!oMICA1``&vqZ{B-)RGtP zYho+UPbDQP+eQxIAiI`^@&;xCLD;1#Mx=mklgHyPl>wf~UXy9pKoAh$0y5$OJOCNx z03HAjY_vU=1t@sJ9RS6(e0rjtuuxOuJ=8QkyLa~apMclpqIrwX~8fs1E#8vO`m4T9jblebJay~}%x%B@}Eh)#u2DV26~ zXkla$9V54pB`%whf(=LYXy?igOf`pYRwpxSVqYJ z=F>wpWSb5|kZP`#~#Fw z)vi8_AFpUTiSf<1Ht-Uj6YxqqBFdW3{6euT8EGe)nPRLjA~z$DJpe z3fuiO00u`YWTm)VtoA;P9jyn<`nbvHU)$ltGax9*M%R&hQtjgRYIUg|8+sfV`nxGH z^>cTOCH>XAhc6|-&qrlW2ahwl6&E;71TUSEwqA=~8k4r(6gVDJ7)@%pj~%0t{k9lw zbp`I>DtXpmZ-M+vx2 F_74awwLt&? literal 0 HcmV?d00001 diff --git a/src/ollama_cli/tools/base.py b/src/ollama_cli/tools/base.py new file mode 100644 index 0000000..207594d --- /dev/null +++ b/src/ollama_cli/tools/base.py @@ -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 diff --git a/src/ollama_cli/tools/code.py b/src/ollama_cli/tools/code.py new file mode 100644 index 0000000..e8a8798 --- /dev/null +++ b/src/ollama_cli/tools/code.py @@ -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)}" diff --git a/src/ollama_cli/tools/execution.py b/src/ollama_cli/tools/execution.py new file mode 100644 index 0000000..4ee6525 --- /dev/null +++ b/src/ollama_cli/tools/execution.py @@ -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)}" \ No newline at end of file diff --git a/src/ollama_cli/tools/filesystem.py b/src/ollama_cli/tools/filesystem.py new file mode 100644 index 0000000..ebb02a9 --- /dev/null +++ b/src/ollama_cli/tools/filesystem.py @@ -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)}" diff --git a/src/ollama_cli/tools/git.py b/src/ollama_cli/tools/git.py new file mode 100644 index 0000000..6b2a081 --- /dev/null +++ b/src/ollama_cli/tools/git.py @@ -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)}" diff --git a/src/ollama_cli/tools/knowledge.py b/src/ollama_cli/tools/knowledge.py new file mode 100644 index 0000000..9dc28af --- /dev/null +++ b/src/ollama_cli/tools/knowledge.py @@ -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" diff --git a/src/ollama_cli/tools/media.py b/src/ollama_cli/tools/media.py new file mode 100644 index 0000000..6f1563d --- /dev/null +++ b/src/ollama_cli/tools/media.py @@ -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 '." + + # 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." diff --git a/src/ollama_cli/tools/memory.py b/src/ollama_cli/tools/memory.py new file mode 100644 index 0000000..4ec450f --- /dev/null +++ b/src/ollama_cli/tools/memory.py @@ -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." \ No newline at end of file diff --git a/src/ollama_cli/tools/system.py b/src/ollama_cli/tools/system.py new file mode 100644 index 0000000..329fcc9 --- /dev/null +++ b/src/ollama_cli/tools/system.py @@ -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()}""" diff --git a/src/ollama_cli/tools/web.py b/src/ollama_cli/tools/web.py new file mode 100644 index 0000000..bef085e --- /dev/null +++ b/src/ollama_cli/tools/web.py @@ -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)}" diff --git a/src/ollama_cli/ui/__init__.py b/src/ollama_cli/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ollama_cli/ui/__pycache__/__init__.cpython-312.pyc b/src/ollama_cli/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45e6b1910f161e1dc7a340f8319f3319b50a4174 GIT binary patch literal 149 zcmX@j%ge<81jqYkXM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdWuhNioLW?@pOK%N zs-K^elbD;Ro1BxWUtE+7<;H`!rJ4Hi@tJvW@?-kk}<;*zVlgg_+&;&TQ>A z{lKJ&pdvMzv;>UhOGx?XXZ6Ql4JJaQkxb}^@r%TdLQPEg@|-&}ODQEr6K{6UJ?GqW z&)oC8_eLreCon$$>{96unULR+2uCzPmcIjJikQTt7O`obQp6=ovLpG3z$2DyD|toW zvZdP5d{p3yrP;B3OyH^&w-fn)TLQTzkP}j{Ck=R;MBX4~^8_(lW+h)~K2wkDZTFDSsTVy|DLWO5 z0jeD?@Y81HgEHXC+l4Z-0FmD;jo_kul#RMDt4Hc(w=fEr_Eyy`Rmug+lU-OVQeVA* z3cv{bmwN$B5f`98P9b;Ia8X)}aWWfd31>>`UT`N7HOZ~dU+d!X}9ISAYaYKApkMI;Qc@qNkA~4V)LX>9!b5L70o8`?gdkNpv zD#n&?01j?+5P4+h_Gx*hZ(h0G+Or@ZI-)!2!io}S?Ng3NL(O}_fzN#FI3TX1U*-vlBeJgZKgo0G4O+-%*uAn)@} z4swlwuE(+54Pc5elBXugOQvQ@EW%{`EL432x`ATK6C|&i3c#qTG8y<$=muJy>M^hR zh|5{QE|uSVt>jd#LcL$)OEKsV_zBV|;8Q?ApMyXeVgZ30q$pr3N*5(pk1+72bUhOG zAJEj;4=GQK&@L!-UGDeDJxpO}Fs+;oapnvgRm^t=^>6BmpWiA-a4a-WwxB0RMcJIFiF12{wOWY(XRKaJhZv|r9ln_rhMmM%Rz z|IY19FLLj!>pUx;k3X4L_(4$^2LPEO<76#=tkwR)UQc1q5yF$O9g6)qn6SGPICNdU z4XBl65Hk!`hW-U6b*^Dju=Wh@S_c4Z(vlZq=VBMq=hC;ejyvhLvkBjXhVwzJVS=Av z|HXv*;|9o%TdC@@IKQW{(W2qjtIUfcHUc+?Dp-RKo&vg5(54_>Z1kz|@|U)tZP?Tv z+17NfX+dijW_e0+xK=*Bc1nA&3j;Jt*9y@%9fO`)lpdu!_+e#fz-}C;8maHo2(e7o z(m6tSryD*Uo{}12sf07$d~Ph%y|&&^Hk@$}+!bSylP6Dxr6LkZtyRqez19>ly{u$f_aal^1HX3au8 zX&CR<3YNdZ(HmaGaXH@(7~g@Q3jrSb8u9gEPVmhkz`M+8HEFBONof;R}qLibXF)p$3cZ@swS z`4=oEh!+zK(WJ0x1vEaG{Qi{?>yCI-aSbI@C=uw*!dm|(+yiX!ED?9Mxc0;i;+we_ zSMi<}H{?U@72kV3{2=h~W#l*jmLinWMUq`4TNlZuMbasLn-|H(-{LKw=x3r!GNoIm z+m;DT_f<-J>11&Uh+aCgAJ>p#$^*K`0LO EH%*^Fi~s-t literal 0 HcmV?d00001 diff --git a/src/ollama_cli/ui/__pycache__/repl.cpython-312.pyc b/src/ollama_cli/ui/__pycache__/repl.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e17e017a56e91dfacd1e24e1847d296ea3b2778 GIT binary patch literal 1585 zcmZux%WoS+82|QVd!0CS@na#O;6g}Qq}WKn2@@%xq(zhvHE^hVSUKKxvS!!o&W@GX z3aCOk1o4n^G7|g&p!9$^aqXE)s~|NUoD(N*CaF}E6W{E5onkSEZ+_onemnC$_P10j z3Aldz_Io=o0sP68Xvsrxx{9C&CYXW)u22yKjzvdwrHT|_$&p>9q6AoWVs5+=53u4S zDhUy;f*IQdGkze3j1{$`=!w6$q6;jwN<6nk*DT++y(StamTkwnX8Y759aNPy+Houd z^0kjYyuMNkIYK!kgwq#L?|}ss!Gwxv3cFB|Owp3-qABgl6{REV3X^XvtzNGQPZ21H z@!4?tb8w!HT#bl%WC3XK=&@xA2SdKk1R9T=RE^Ni$bVOiB6q+TG0{{M*_evZ`EE+4 zY$`uIeKHYwJD|r_y1CCwMbB}nZgsxq*z=pCti^}0o2oW_yVj{T3+tX^GL@?v#Il;Y z$YQM^^(ZnC7c-9lU2w+P`mBD^11+z;zS_!F2~M*_pNYm_N| zP$rSdzU9=}P{qa=Hd|xVHs?_XL%0Sn0}W=C8)kvvq?V)CH2k^9;I5>2}+udYoKDop4gZ?;5(Xw#06+ ziyv5>b+1ay_nQ=(qTQk_vGmdM5+NQzrYy$$d>RTQkCLey7RBnd+O#_r6mO)bdE_k= zcVHkU)S07<)+_IqA7eq*3O_6im&JM|<67qVYkUu_KH zQY!z?;{>EHpMap|A7yg=3mS(rk22E(3HLK!L;f$`Sp>KT>SA8HH<92nC!k!>W4yDP zRo5~MmNX34GusZwlZJ7-U3Eea;S@~5$CDQk{RHG$+=9mu-hhGNL+|q>?!t)}Qziy! zRhjwjjmLmn7|%oXB%2CzZcxv2KDB8uQ!M{qV{te&;jHDxoWX?o;pAN!^VL1#Rw=bi zgIe1(tR4TOgcqIVXmt6rL^*u$0@2aIOXl+*C?-x2j^Wi~C?3NVlz)KwM1Dq?IRO;G G`F{ZzHEECl literal 0 HcmV?d00001 diff --git a/src/ollama_cli/ui/formatter.py b/src/ollama_cli/ui/formatter.py new file mode 100644 index 0000000..b9cd1f8 --- /dev/null +++ b/src/ollama_cli/ui/formatter.py @@ -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)) diff --git a/src/ollama_cli/ui/repl.py b/src/ollama_cli/ui/repl.py new file mode 100644 index 0000000..9534678 --- /dev/null +++ b/src/ollama_cli/ui/repl.py @@ -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('> '), + style=self.style + ).strip() + except KeyboardInterrupt: + return "" + except EOFError: + return "/quit"