diff --git a/ollama-cli.py b/ollama-cli.py
new file mode 100755
index 0000000..ea8203c
--- /dev/null
+++ b/ollama-cli.py
@@ -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()