Implement secure configuration management with encryption and validation (Phase 1 improvements)
This commit is contained in:
commit
d4d6aafea9
12 files changed
+1656
No files matched your search
@@ -0,0 +1,118 @@
|
||||
# MaxClaw Agent
|
||||
|
||||
A configurable agent harness using DeepAgents that supports multiple LLM providers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Provider Agnostic**: Works with OpenAI, Ollama, OpenRouter, and any other provider supported by LangChain's `init_chat_model`
|
||||
- **Built on DeepAgents**: Includes planning, filesystem access, subagents, and context management out of the box
|
||||
- **Easy Configuration**: Set environment variables to choose your provider and model
|
||||
- **Persistent Configuration**: Store and manage configurations via CLI or TUI
|
||||
- **Extensible**: Add custom tools, modify prompts, and configure subagents as needed
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone this repository
|
||||
2. Install the required packages:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure MaxClaw Agent in several ways:
|
||||
|
||||
### 1. Environment Variables (Quick Start)
|
||||
|
||||
Set the following environment variables to configure your LLM provider:
|
||||
|
||||
- `MODEL_PROVIDER`: The provider to use (e.g., "openai", "ollama", "openrouter")
|
||||
- `MODEL_NAME`: The specific model name (e.g., "gpt-4o", "gemma2:9b")
|
||||
- Provider-specific variables:
|
||||
- For OpenAI: `OPENAI_API_KEY`
|
||||
- For Ollama: `OLLAMA_BASE_URL` (defaults to http://localhost:11434)
|
||||
- For OpenRouter: `OPENROUTER_API_KEY` and optionally `OPENROUTER_BASE_URL` (defaults to https://openrouter.ai/api/v1)
|
||||
|
||||
### Example Configurations
|
||||
|
||||
#### OpenAI
|
||||
```bash
|
||||
export MODEL_PROVIDER="openai"
|
||||
export MODEL_NAME="gpt-4o"
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
```
|
||||
|
||||
#### Ollama (local)
|
||||
```bash
|
||||
export MODEL_PROVIDER="ollama"
|
||||
export MODEL_NAME="gemma2:9b"
|
||||
export OLLAMA_BASE_URL="http://localhost:11434"
|
||||
```
|
||||
|
||||
#### OpenRouter
|
||||
```bash
|
||||
export MODEL_PROVIDER="openrouter"
|
||||
export MODEL_NAME="anthropic/claude-3.5-sonnet"
|
||||
export OPENROUTER_API_KEY="your-openrouter-api-key"
|
||||
```
|
||||
|
||||
### 2. Persistent Configuration (Recommended)
|
||||
|
||||
Use the CLI or TUI to manage configurations that persist across sessions.
|
||||
|
||||
#### CLI Usage
|
||||
```bash
|
||||
# List configurations
|
||||
python config_cli.py list
|
||||
|
||||
# Add a new configuration interactively
|
||||
python config_cli.py add
|
||||
|
||||
# Add a new configuration non-interactively
|
||||
python config_cli.py add --name "my-ollama" --provider "ollama" --model "gemma2:9b" --base-url "https://ollama.solarpi.net" --set-active
|
||||
|
||||
# Remove a configuration
|
||||
python config_cli.py remove "my-ollama"
|
||||
|
||||
# Set active configuration
|
||||
python config_cli.py set-active "my-ollama"
|
||||
|
||||
# Show active configuration
|
||||
python config_cli.py active
|
||||
```
|
||||
|
||||
#### TUI Usage
|
||||
```bash
|
||||
# Launch the Text User Interface
|
||||
python config_tui.py
|
||||
```
|
||||
|
||||
Navigate using arrow keys, press Enter to select, and 'q' to quit.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the agent:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
You can then interact with the agent in the terminal. Type 'exit' to quit.
|
||||
|
||||
## Customization
|
||||
|
||||
To customize the agent, edit `main.py`:
|
||||
|
||||
- Change the default system prompt in `create_maxclaw_agent`
|
||||
- Add custom tools to the `create_deep_agent` call
|
||||
- Configure subagents or other DeepAgents features
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test script:
|
||||
```bash
|
||||
python test_agent.py
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
# Example environment variables for OpenClaw Agent
|
||||
# Copy this file to .env and fill in your actual values
|
||||
|
||||
# Model Provider: openai, ollama, openrouter
|
||||
MODEL_PROVIDER=openai
|
||||
MODEL_NAME=gpt-4o-mini
|
||||
|
||||
# OpenAI (if MODEL_PROVIDER=openai)
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
|
||||
# Ollama (if MODEL_PROVIDER=ollama)
|
||||
OLLAMA_BASE_URL=https://ollama.solarpi.net
|
||||
|
||||
# OpenRouter (if MODEL_PROVIDER=openrouter)
|
||||
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Command-line interface for managing MaxClaw Agent configurations
|
||||
"""
|
||||
import sys
|
||||
import argparse
|
||||
from typing import Optional
|
||||
from config_manager import config_manager
|
||||
|
||||
|
||||
def print_configurations():
|
||||
"""Print all configurations in a formatted way"""
|
||||
configs = config_manager.get_configurations()
|
||||
active = config_manager.get_active_configuration()
|
||||
|
||||
if not configs:
|
||||
print("No configurations found.")
|
||||
return
|
||||
|
||||
print("\nMaxClaw Agent Configurations:")
|
||||
print("=" * 50)
|
||||
|
||||
for name, config in configs.items():
|
||||
marker = " * " if name == active else " "
|
||||
provider = config.get("provider", "unknown")
|
||||
model = config.get("model_name", "unknown")
|
||||
print(f"{marker}{name}:")
|
||||
print(f" Provider: {provider}")
|
||||
print(f" Model: {model}")
|
||||
|
||||
# Show additional info based on provider
|
||||
if provider == "openai":
|
||||
print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}")
|
||||
elif provider == "ollama":
|
||||
base_url = config.get("base_url", "http://localhost:11434")
|
||||
print(f" Base URL: {base_url}")
|
||||
elif provider == "openrouter":
|
||||
print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}")
|
||||
base_url = config.get("base_url", "https://openrouter.ai/api/v1")
|
||||
print(f" Base URL: {base_url}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def add_configuration_interactive():
|
||||
"""Interactively add a new configuration"""
|
||||
print("\nAdd New Configuration")
|
||||
print("=" * 30)
|
||||
|
||||
name = input("Configuration name: ").strip()
|
||||
if not name:
|
||||
print("Error: Configuration name cannot be empty")
|
||||
return
|
||||
|
||||
if config_manager.configuration_exists(name):
|
||||
overwrite = input(f"Configuration '{name}' already exists. Overwrite? (y/N): ").strip().lower()
|
||||
if overwrite != 'y':
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
print("\nSelect provider:")
|
||||
print("1. OpenAI")
|
||||
print("2. Ollama")
|
||||
print("3. OpenRouter")
|
||||
|
||||
provider_choice = input("Enter choice (1-3): ").strip()
|
||||
provider_map = {
|
||||
"1": "openai",
|
||||
"2": "ollama",
|
||||
"3": "openrouter"
|
||||
}
|
||||
|
||||
if provider_choice not in provider_map:
|
||||
print("Invalid choice.")
|
||||
return
|
||||
|
||||
provider = provider_map[provider_choice]
|
||||
config = {"provider": provider}
|
||||
|
||||
# Get model name
|
||||
model_name = input(f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ").strip()
|
||||
if not model_name:
|
||||
print("Error: Model name cannot be empty")
|
||||
return
|
||||
config["model_name"] = model_name
|
||||
|
||||
# Get provider-specific settings
|
||||
if provider == "openai":
|
||||
api_key = input("OpenAI API key: ").strip()
|
||||
if not api_key:
|
||||
print("Warning: API key is required for OpenAI")
|
||||
config["api_key"] = api_key
|
||||
|
||||
elif provider == "ollama":
|
||||
base_url = input(f"Ollama base URL (default: http://localhost:11434): ").strip()
|
||||
if not base_url:
|
||||
base_url = "http://localhost:11434"
|
||||
config["base_url"] = base_url
|
||||
|
||||
elif provider == "openrouter":
|
||||
api_key = input("OpenRouter API key: ").strip()
|
||||
if not api_key:
|
||||
print("Warning: API key is required for OpenRouter")
|
||||
config["api_key"] = api_key
|
||||
|
||||
base_url = input(f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ").strip()
|
||||
if not base_url:
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
config["base_url"] = base_url
|
||||
|
||||
# Ask if this should be the active configuration
|
||||
set_active = input("Set as active configuration? (y/N): ").strip().lower()
|
||||
|
||||
# Save the configuration
|
||||
try:
|
||||
config_manager.add_configuration(name, config)
|
||||
print(f"\nConfiguration '{name}' saved successfully.")
|
||||
|
||||
if set_active == 'y':
|
||||
config_manager.set_active_configuration(name)
|
||||
print(f"Configuration '{name}' set as active.")
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def remove_configuration_interactive():
|
||||
"""Interactively remove a configuration"""
|
||||
configs = config_manager.get_configurations()
|
||||
if not configs:
|
||||
print("No configurations to remove.")
|
||||
return
|
||||
|
||||
print("\nRemove Configuration")
|
||||
print("=" * 30)
|
||||
print_configurations()
|
||||
|
||||
name = input("Enter configuration name to remove: ").strip()
|
||||
if not name:
|
||||
print("Error: Configuration name cannot be empty")
|
||||
return
|
||||
|
||||
if not config_manager.configuration_exists(name):
|
||||
print(f"Error: Configuration '{name}' not found.")
|
||||
return
|
||||
|
||||
active = config_manager.get_active_configuration()
|
||||
if name == active:
|
||||
confirm = input(f"'{name}' is the active configuration. Remove it anyway? (y/N): ").strip().lower()
|
||||
if confirm != 'y':
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
confirm = input(f"Are you sure you want to remove configuration '{name}'? (y/N): ").strip().lower()
|
||||
if confirm == 'y':
|
||||
if config_manager.remove_configuration(name):
|
||||
print(f"Configuration '{name}' removed successfully.")
|
||||
if name == active:
|
||||
print("Active configuration has been unset.")
|
||||
else:
|
||||
print(f"Error: Failed to remove configuration '{name}'.")
|
||||
else:
|
||||
print("Cancelled.")
|
||||
|
||||
|
||||
def set_active_configuration_interactive():
|
||||
"""Interactively set the active configuration"""
|
||||
configs = config_manager.get_configurations()
|
||||
if not configs:
|
||||
print("No configurations available.")
|
||||
return
|
||||
|
||||
print("\nSet Active Configuration")
|
||||
print("=" * 30)
|
||||
print_configurations()
|
||||
|
||||
name = input("Enter configuration name to set as active: ").strip()
|
||||
if not name:
|
||||
print("Error: Configuration name cannot be empty")
|
||||
return
|
||||
|
||||
if not config_manager.configuration_exists(name):
|
||||
print(f"Error: Configuration '{name}' not found.")
|
||||
return
|
||||
|
||||
if config_manager.set_active_configuration(name):
|
||||
print(f"Configuration '{name}' set as active.")
|
||||
else:
|
||||
print(f"Error: Failed to set configuration '{name}' as active.")
|
||||
|
||||
|
||||
def show_active_configuration():
|
||||
"""Show the currently active configuration"""
|
||||
active_name = config_manager.get_active_configuration()
|
||||
if not active_name:
|
||||
print("No active configuration set.")
|
||||
return
|
||||
|
||||
config = config_manager.get_configuration(active_name)
|
||||
if not config:
|
||||
print(f"Error: Active configuration '{active_name}' not found.")
|
||||
return
|
||||
|
||||
print("\nActive Configuration")
|
||||
print("=" * 30)
|
||||
print(f"Name: {active_name}")
|
||||
print(f"Provider: {config.get('provider', 'unknown')}")
|
||||
print(f"Model: {config.get('model_name', 'unknown')}")
|
||||
|
||||
# Show additional info based on provider
|
||||
provider = config.get('provider')
|
||||
if provider == "openai":
|
||||
print(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
|
||||
elif provider == "ollama":
|
||||
base_url = config.get('base_url', 'http://localhost:11434')
|
||||
print(f"Base URL: {base_url}")
|
||||
elif provider == "openrouter":
|
||||
print(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
|
||||
base_url = config.get('base_url', 'https://openrouter.ai/api/v1')
|
||||
print(f"Base URL: {base_url}")
|
||||
|
||||
|
||||
def test_configuration():
|
||||
"""Test connection to a configuration"""
|
||||
configs = config_manager.get_configurations()
|
||||
if not configs:
|
||||
print("No configurations to test.")
|
||||
return
|
||||
|
||||
print("\nTest Configuration Connection")
|
||||
print("=" * 30)
|
||||
print_configurations()
|
||||
|
||||
name = input("Enter configuration name to test: ").strip()
|
||||
if not name:
|
||||
print("Error: Configuration name cannot be empty")
|
||||
return
|
||||
|
||||
if not config_manager.configuration_exists(name):
|
||||
print(f"Error: Configuration '{name}' not found.")
|
||||
return
|
||||
|
||||
print(f"\nTesting connection to '{name}'...")
|
||||
success, message = config_manager.test_configuration_connection(name)
|
||||
|
||||
if success:
|
||||
print(f"✅ {message}")
|
||||
else:
|
||||
print(f"❌ {message}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point"""
|
||||
parser = argparse.ArgumentParser(description="MaxClaw Agent Configuration Manager")
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List all configurations')
|
||||
|
||||
# Add command
|
||||
add_parser = subparsers.add_parser('add', help='Add a new configuration')
|
||||
add_parser.add_argument('--non-interactive', action='store_true',
|
||||
help='Non-interactive mode (requires all parameters)')
|
||||
add_parser.add_argument('--name', help='Configuration name')
|
||||
add_parser.add_argument('--provider', choices=['openai', 'ollama', 'openrouter'], help='Provider')
|
||||
add_parser.add_argument('--model', help='Model name')
|
||||
add_parser.add_argument('--api-key', help='API key (for OpenAI/OpenRouter)')
|
||||
add_parser.add_argument('--base-url', help='Base URL (for Ollama/OpenRouter)')
|
||||
add_parser.add_argument('--set-active', action='store_true', help='Set as active configuration')
|
||||
|
||||
# Remove command
|
||||
remove_parser = subparsers.add_parser('remove', help='Remove a configuration')
|
||||
remove_parser.add_argument('name', nargs='?', help='Configuration name to remove')
|
||||
|
||||
# Set active command
|
||||
active_parser = subparsers.add_parser('set-active', help='Set active configuration')
|
||||
active_parser.add_argument('name', nargs='?', help='Configuration name to set as active')
|
||||
|
||||
# Show active command
|
||||
subparsers.add_parser('active', help='Show active configuration')
|
||||
|
||||
# Test command
|
||||
test_parser = subparsers.add_parser('test', help='Test connection to a configuration')
|
||||
test_parser.add_argument('name', nargs='?', help='Configuration name to test')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'list':
|
||||
print_configurations()
|
||||
|
||||
elif args.command == 'add':
|
||||
if args.non_interactive:
|
||||
# Non-interactive mode
|
||||
if not all([args.name, args.provider, args.model]):
|
||||
print("Error: --name, --provider, and --model are required for non-interactive mode")
|
||||
return 1
|
||||
|
||||
config = {
|
||||
"provider": args.provider,
|
||||
"model_name": args.model
|
||||
}
|
||||
|
||||
if args.provider in ['openai', 'openrouter']:
|
||||
if not args.api_key:
|
||||
print("Error: --api-key is required for OpenAI/OpenRouter providers")
|
||||
return 1
|
||||
config["api_key"] = args.api_key
|
||||
|
||||
if args.provider in ['ollama', 'openrouter']:
|
||||
config["base_url"] = args.base_url or (
|
||||
"http://localhost:11434" if args.provider == "ollama"
|
||||
else "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
try:
|
||||
config_manager.add_configuration(args.name, config)
|
||||
print(f"Configuration '{args.name}' added successfully.")
|
||||
|
||||
if args.set_active:
|
||||
config_manager.set_active_configuration(args.name)
|
||||
print(f"Configuration '{args.name}' set as active.")
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
else:
|
||||
# Interactive mode
|
||||
add_configuration_interactive()
|
||||
|
||||
elif args.command == 'remove':
|
||||
if args.name:
|
||||
# Non-interactive removal
|
||||
if config_manager.remove_configuration(args.name):
|
||||
print(f"Configuration '{args.name}' removed successfully.")
|
||||
active = config_manager.get_active_configuration()
|
||||
if args.name == active:
|
||||
print("Active configuration has been unset.")
|
||||
else:
|
||||
print(f"Error: Configuration '{args.name}' not found.")
|
||||
return 1
|
||||
else:
|
||||
# Interactive removal
|
||||
remove_configuration_interactive()
|
||||
|
||||
elif args.command == 'set-active':
|
||||
if args.name:
|
||||
# Non-interactive setting
|
||||
if config_manager.set_active_configuration(args.name):
|
||||
print(f"Configuration '{args.name}' set as active.")
|
||||
else:
|
||||
print(f"Error: Configuration '{args.name}' not found.")
|
||||
return 1
|
||||
else:
|
||||
# Interactive setting
|
||||
set_active_configuration_interactive()
|
||||
|
||||
elif args.command == 'active':
|
||||
show_active_configuration()
|
||||
|
||||
elif args.command == 'test':
|
||||
if args.name:
|
||||
# Non-interactive test
|
||||
print(f"Testing connection to '{args.name}'...")
|
||||
success, message = config_manager.test_configuration_connection(args.name)
|
||||
if success:
|
||||
print(f"✅ {message}")
|
||||
else:
|
||||
print(f"❌ {message}")
|
||||
return 1
|
||||
else:
|
||||
# Interactive test
|
||||
test_configuration()
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Configuration manager for MaxClaw Agent
|
||||
Handles persistent storage of provider configurations with encryption and validation
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import copy
|
||||
from cryptography.fernet import Fernet
|
||||
import base64
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages MaxClaw Agent configuration with encryption and validation"""
|
||||
|
||||
def __init__(self, config_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the config manager
|
||||
|
||||
Args:
|
||||
config_dir: Directory to store config files. If None, uses ~/.maxclaw
|
||||
"""
|
||||
if config_dir is None:
|
||||
self.config_dir = Path.home() / ".maxclaw"
|
||||
else:
|
||||
self.config_dir = Path(config_dir)
|
||||
|
||||
self.config_file = self.config_dir / "config.json"
|
||||
self.key_file = self.config_dir / ".key"
|
||||
|
||||
# Ensure config directory exists with secure permissions
|
||||
self._ensure_config_dir()
|
||||
|
||||
# Load or create encryption key
|
||||
self._encryption_key = self._load_or_create_key()
|
||||
self._cipher = Fernet(self._encryption_key)
|
||||
|
||||
# Load configuration
|
||||
self.config = self._load_config()
|
||||
|
||||
def _ensure_config_dir(self):
|
||||
"""Ensure the configuration directory exists with secure permissions"""
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Set directory permissions to owner-only (700)
|
||||
os.chmod(self.config_dir, 0o700)
|
||||
|
||||
def _load_or_create_key(self) -> bytes:
|
||||
"""Load existing encryption key or create a new one"""
|
||||
if self.key_file.exists():
|
||||
try:
|
||||
key = self.key_file.read_bytes()
|
||||
# Validate key format
|
||||
Fernet(key) # This will raise an exception if invalid
|
||||
# Set key file permissions to owner-only (600)
|
||||
os.chmod(self.key_file, 0o600)
|
||||
return key
|
||||
except Exception:
|
||||
# If key is invalid or corrupted, create a new one
|
||||
pass
|
||||
|
||||
# Generate new key
|
||||
key = Fernet.generate_key()
|
||||
self.key_file.write_bytes(key)
|
||||
# Set key file permissions to owner-only (600)
|
||||
os.chmod(self.key_file, 0o600)
|
||||
return key
|
||||
|
||||
def _encrypt_data(self, data: str) -> str:
|
||||
"""Encrypt sensitive data"""
|
||||
if not data:
|
||||
return data
|
||||
encrypted = self._cipher.encrypt(data.encode())
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
|
||||
def _decrypt_data(self, encrypted_data: str) -> str:
|
||||
"""Decrypt sensitive data"""
|
||||
if not encrypted_data:
|
||||
return encrypted_data
|
||||
try:
|
||||
decoded = base64.b64decode(encrypted_data.encode())
|
||||
decrypted = self._cipher.decrypt(decoded)
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception:
|
||||
# If decryption fails, return as-is (might be unencrypted legacy data)
|
||||
return encrypted_data
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from file"""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Decrypt any encrypted fields in existing config
|
||||
return self._decrypt_config(config)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
# Return default config if file is corrupted
|
||||
print(f"Warning: Could not load config file ({e}), using defaults")
|
||||
return self._get_default_config()
|
||||
else:
|
||||
# Return default config if file doesn't exist
|
||||
return self._get_default_config()
|
||||
|
||||
def _decrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Decrypt sensitive fields in configuration"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
|
||||
# Create a deep copy to avoid modifying original
|
||||
result = copy.deepcopy(config)
|
||||
|
||||
# Decrypt configurations
|
||||
if "configurations" in result and isinstance(result["configurations"], dict):
|
||||
for name, config_data in result["configurations"].items():
|
||||
if isinstance(config_data, dict):
|
||||
# Decrypt API keys for relevant providers
|
||||
provider = config_data.get("provider", "").lower()
|
||||
if provider in ["openai", "openrouter"] and "api_key" in config_data:
|
||||
config_data["api_key"] = self._decrypt_data(config_data["api_key"])
|
||||
|
||||
return result
|
||||
|
||||
def _encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Encrypt sensitive fields in configuration before storage"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
|
||||
# Create a deep copy to avoid modifying original
|
||||
result = copy.deepcopy(config)
|
||||
|
||||
# Encrypt configurations
|
||||
if "configurations" in result and isinstance(result["configurations"], dict):
|
||||
for name, config_data in result["configurations"].items():
|
||||
if isinstance(config_data, dict):
|
||||
# Encrypt API keys for relevant providers
|
||||
provider = config_data.get("provider", "").lower()
|
||||
if provider in ["openai", "openrouter"] and "api_key" in config_data:
|
||||
api_key = config_data["api_key"]
|
||||
# Only encrypt if not already encrypted (avoid double encryption)
|
||||
if api_key and not self._is_likely_encrypted(api_key):
|
||||
config_data["api_key"] = self._encrypt_data(api_key)
|
||||
|
||||
return result
|
||||
|
||||
def _is_likely_encrypted(self, data: str) -> bool:
|
||||
"""Heuristic to check if data appears to be encrypted"""
|
||||
if not data or len(data) < 10:
|
||||
return False
|
||||
# Encrypted data with Fernet is base64 encoded and typically longer
|
||||
try:
|
||||
# Try to decode as base64
|
||||
base64.b64decode(data.encode(), validate=True)
|
||||
# If it decodes successfully and is reasonably long, likely encrypted
|
||||
return len(data) >= 20
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_default_config(self) -> Dict[str, Any]:
|
||||
"""Get default configuration structure"""
|
||||
return {
|
||||
"configurations": {},
|
||||
"active_configuration": None,
|
||||
"version": "1.0"
|
||||
}
|
||||
|
||||
def _save_config(self):
|
||||
"""Save configuration to file with encryption of sensitive data"""
|
||||
try:
|
||||
# Encrypt sensitive data before saving
|
||||
config_to_save = self._encrypt_config(self.config)
|
||||
|
||||
# Create a backup of the existing config
|
||||
if self.config_file.exists():
|
||||
backup_file = self.config_file.with_suffix('.json.bak')
|
||||
backup_file.write_text(self.config_file.read_text())
|
||||
# Set backup file permissions to owner-only (600)
|
||||
os.chmod(backup_file, 0o600)
|
||||
|
||||
# Write the encrypted config
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(config_to_save, f, indent=2, sort_keys=True)
|
||||
|
||||
# Set config file permissions to owner-only (600)
|
||||
os.chmod(self.config_file, 0o600)
|
||||
except IOError as e:
|
||||
raise Exception(f"Failed to save configuration: {e}")
|
||||
|
||||
def validate_configuration(self, config: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate a configuration dictionary
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check if config is a dictionary
|
||||
if not isinstance(config, dict):
|
||||
errors.append("Configuration must be a dictionary")
|
||||
return False, errors
|
||||
|
||||
# Validate provider
|
||||
provider = config.get("provider", "").lower()
|
||||
valid_providers = ["openai", "ollama", "openrouter"]
|
||||
|
||||
if not provider:
|
||||
errors.append("Provider is required")
|
||||
elif provider not in valid_providers:
|
||||
errors.append(f"Unsupported provider: {provider}. Valid options: {', '.join(valid_providers)}")
|
||||
|
||||
# Validate model name
|
||||
model_name = config.get("model_name", "").strip()
|
||||
if not model_name:
|
||||
errors.append("Model name is required and cannot be empty")
|
||||
elif len(model_name) > 100:
|
||||
errors.append("Model name is too long (maximum 100 characters)")
|
||||
|
||||
# Provider-specific validation
|
||||
if provider == "openai":
|
||||
api_key = config.get("api_key", "").strip()
|
||||
if not api_key:
|
||||
errors.append("API key is required for OpenAI provider")
|
||||
elif not api_key.startswith("sk-"):
|
||||
errors.append("OpenAI API key should start with 'sk-'")
|
||||
elif len(api_key) < 20:
|
||||
errors.append("OpenAI API key appears too short")
|
||||
|
||||
elif provider == "ollama":
|
||||
base_url = config.get("base_url", "").strip()
|
||||
if not base_url:
|
||||
errors.append("Base URL is required for Ollama provider")
|
||||
else:
|
||||
# Normalize URL for validation
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
errors.append("Ollama base URL must start with http:// or https://")
|
||||
else:
|
||||
# Remove trailing slash for consistency
|
||||
if base_url.endswith("/"):
|
||||
config["base_url"] = base_url.rstrip("/")
|
||||
|
||||
elif provider == "openrouter":
|
||||
api_key = config.get("api_key", "").strip()
|
||||
base_url = config.get("base_url", "").strip()
|
||||
|
||||
if not api_key:
|
||||
errors.append("API key is required for OpenRouter provider")
|
||||
elif len(api_key) < 10:
|
||||
errors.append("OpenRouter API key appears too short")
|
||||
|
||||
if not base_url:
|
||||
errors.append("Base URL is required for OpenRouter provider")
|
||||
elif not base_url.startswith(("http://", "https://")):
|
||||
errors.append("OpenRouter base URL must start with http:// or https://")
|
||||
else:
|
||||
# Remove trailing slash for consistency
|
||||
if base_url.endswith("/"):
|
||||
config["base_url"] = base_url.rstrip("/")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def get_configurations(self) -> Dict[str, Any]:
|
||||
"""Get all configurations"""
|
||||
return self.config.get("configurations", {})
|
||||
|
||||
def get_active_configuration(self) -> Optional[str]:
|
||||
"""Get the name of the active configuration"""
|
||||
return self.config.get("active_configuration")
|
||||
|
||||
def get_configuration(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific configuration by name"""
|
||||
configurations = self.get_configurations()
|
||||
config = configurations.get(name)
|
||||
# Return a copy to prevent accidental modification of internal data
|
||||
return copy.deepcopy(config) if config else None
|
||||
|
||||
def add_configuration(self, name: str, config: Dict[str, Any]):
|
||||
"""
|
||||
Add or update a configuration with validation
|
||||
|
||||
Args:
|
||||
name: Configuration name
|
||||
config: Configuration dictionary with provider, model, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Configuration name must be a non-empty string")
|
||||
|
||||
# Validate the configuration
|
||||
is_valid, errors = self.validate_configuration(config)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid configuration: {', '.join(errors)}")
|
||||
|
||||
if "configurations" not in self.config:
|
||||
self.config["configurations"] = {}
|
||||
|
||||
# Deep copy to avoid reference issues
|
||||
self.config["configurations"][name] = copy.deepcopy(config)
|
||||
self._save_config()
|
||||
|
||||
def remove_configuration(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a configuration
|
||||
|
||||
Args:
|
||||
name: Configuration name to remove
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
if "configurations" in self.config and name in self.config["configurations"]:
|
||||
del self.config["configurations"][name]
|
||||
|
||||
# If this was the active configuration, unset it
|
||||
if self.config.get("active_configuration") == name:
|
||||
self.config["active_configuration"] = None
|
||||
|
||||
self._save_config()
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_active_configuration(self, name: str) -> bool:
|
||||
"""
|
||||
Set the active configuration
|
||||
|
||||
Args:
|
||||
name: Configuration name to set as active
|
||||
|
||||
Returns:
|
||||
True if successful, False if configuration not found
|
||||
"""
|
||||
if name in self.get_configurations():
|
||||
self.config["active_configuration"] = name
|
||||
self._save_config()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_config_details(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get the details of the active configuration"""
|
||||
active_name = self.get_active_configuration()
|
||||
if active_name:
|
||||
return self.get_configuration(active_name)
|
||||
return None
|
||||
|
||||
def list_configuration_names(self) -> List[str]:
|
||||
"""Get a list of all configuration names"""
|
||||
return list(self.get_configurations().keys())
|
||||
|
||||
def configuration_exists(self, name: str) -> bool:
|
||||
"""Check if a configuration exists"""
|
||||
return name in self.get_configurations()
|
||||
|
||||
def test_configuration_connection(self, name: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test connection to a configured provider
|
||||
|
||||
Args:
|
||||
name: Configuration name to test
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
config = self.get_configuration(name)
|
||||
if not config:
|
||||
return False, f"Configuration '{name}' not found"
|
||||
|
||||
provider = config.get("provider", "").lower()
|
||||
|
||||
# Check if requests is available
|
||||
if requests is None:
|
||||
return False, "The 'requests' library is not available. Please install it to test connections."
|
||||
|
||||
try:
|
||||
if provider == "ollama":
|
||||
base_url = config.get("base_url", "http://localhost:11434")
|
||||
# Ensure no trailing slash
|
||||
if base_url.endswith("/"):
|
||||
base_url = base_url.rstrip("/")
|
||||
response = requests.get(f"{base_url}/api/tags", timeout=5)
|
||||
if response.status_code == 200:
|
||||
models = response.json().get("models", [])
|
||||
model_names = [m.get("name", "unknown") for m in models]
|
||||
return True, f"Connected successfully. Available models: {', '.join(model_names[:5])}{'...' if len(model_names) > 5 else ''}"
|
||||
else:
|
||||
return False, f"Ollama server returned status {response.status_code}"
|
||||
|
||||
elif provider == "openai":
|
||||
# For OpenAI, we can't easily test without making an actual API call
|
||||
# which might incur costs. Just validate the key format.
|
||||
api_key = config.get("api_key", "")
|
||||
if api_key and api_key.startswith("sk-") and len(api_key) > 20:
|
||||
return True, "API key format appears valid (no live test performed to avoid costs)"
|
||||
else:
|
||||
return False, "API key format appears invalid"
|
||||
|
||||
elif provider == "openrouter":
|
||||
# Similar to OpenAI, just validate format to avoid costs
|
||||
api_key = config.get("api_key", "")
|
||||
base_url = config.get("base_url", "https://openrouter.ai/api/v1")
|
||||
if api_key and len(api_key) > 10:
|
||||
return True, "API key format appears valid (no live test performed to avoid costs)"
|
||||
else:
|
||||
return False, "API key format appears invalid"
|
||||
|
||||
else:
|
||||
return False, f"Connection testing not implemented for provider: {provider}"
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return False, "Connection timeout - please check the URL and network connectivity"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, "Connection failed - please check the URL and ensure the service is running"
|
||||
except Exception as e:
|
||||
return False, f"Connection test failed: {str(e)}"
|
||||
|
||||
|
||||
# Global config manager instance
|
||||
config_manager = ConfigManager()
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
Text User Interface for MaxClaw Agent configuration management
|
||||
"""
|
||||
import curses
|
||||
import sys
|
||||
from typing import Optional, List, Dict, Any
|
||||
from config_manager import config_manager
|
||||
|
||||
|
||||
def draw_menu(stdscr, selected_row_idx, options):
|
||||
"""Draw a menu with options"""
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Title
|
||||
title = "MaxClaw Agent Configuration TUI"
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
# Instructions
|
||||
instructions = "Use arrow keys to navigate, Enter to select, 'q' to quit"
|
||||
stdscr.addstr(2, (w - len(instructions)) // 2, instructions)
|
||||
|
||||
# Menu options
|
||||
for idx, option in enumerate(options):
|
||||
x = w // 2 - len(option) // 2
|
||||
y = h // 2 - len(options) // 2 + idx
|
||||
if idx == selected_row_idx:
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
stdscr.addstr(y, x, option)
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(y, x, option)
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
def get_string_input(stdscr, prompt, y, x, max_length=50):
|
||||
"""Get string input from user"""
|
||||
curses.echo()
|
||||
stdscr.addstr(y, x, prompt)
|
||||
stdscr.refresh()
|
||||
input_win = curses.newwin(1, max_length, y, x + len(prompt))
|
||||
input_win.box()
|
||||
input_win.refresh()
|
||||
curses.curs_set(1)
|
||||
result = input_win.getstr(0, 0, max_length).decode('utf-8')
|
||||
curses.curs_set(0)
|
||||
curses.noecho()
|
||||
return result
|
||||
|
||||
|
||||
def get_password_input(stdscr, prompt, y, x, max_length=50):
|
||||
"""Get password input from user (hidden)"""
|
||||
curses.noecho()
|
||||
stdscr.addstr(y, x, prompt)
|
||||
stdscr.refresh()
|
||||
input_win = curses.newwin(1, max_length, y, x + len(prompt))
|
||||
input_win.box()
|
||||
input_win.refresh()
|
||||
curses.curs_set(1)
|
||||
result = ""
|
||||
while True:
|
||||
ch = input_win.getch()
|
||||
if ch in [curses.KEY_ENTER, 10, 13]: # Enter key
|
||||
break
|
||||
elif ch in [curses.KEY_BACKSPACE, 127, 8]: # Backspace
|
||||
if len(result) > 0:
|
||||
result = result[:-1]
|
||||
input_win.addstr(0, len(result), " ")
|
||||
input_win.move(0, len(result))
|
||||
elif ch >= 32 and ch <= 126 and len(result) < max_length: # Printable characters
|
||||
result += chr(ch)
|
||||
input_win.addstr(0, len(result)-1, "*")
|
||||
input_win.refresh()
|
||||
curses.curs_set(0)
|
||||
return result
|
||||
|
||||
|
||||
def show_message(stdscr, message, wait_for_key=True):
|
||||
"""Show a message to the user"""
|
||||
h, w = stdscr.getmaxyx()
|
||||
stdscr.addstr(h//2, (w - len(message)) // 2, message)
|
||||
stdscr.refresh()
|
||||
if wait_for_key:
|
||||
stdscr.getch()
|
||||
|
||||
|
||||
def main_tui(stdscr):
|
||||
"""Main TUI function"""
|
||||
# Initialize colors if available
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||
|
||||
# Main menu options
|
||||
main_options = [
|
||||
"View Configurations",
|
||||
"Add Configuration",
|
||||
"Remove Configuration",
|
||||
"Set Active Configuration",
|
||||
"View Active Configuration",
|
||||
"Exit"
|
||||
]
|
||||
|
||||
current_selection = 0
|
||||
|
||||
while True:
|
||||
draw_menu(stdscr, current_selection, main_options)
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
if key == curses.KEY_UP and current_selection > 0:
|
||||
current_selection -= 1
|
||||
elif key == curses.KEY_DOWN and current_selection < len(main_options) - 1:
|
||||
current_selection += 1
|
||||
elif key == curses.KEY_ENTER or key in [10, 13]:
|
||||
# Enter key pressed
|
||||
if current_selection == 0: # View Configurations
|
||||
view_configurations_tui(stdscr)
|
||||
elif current_selection == 1: # Add Configuration
|
||||
add_configuration_tui(stdscr)
|
||||
elif current_selection == 2: # Remove Configuration
|
||||
remove_configuration_tui(stdscr)
|
||||
elif current_selection == 3: # Set Active Configuration
|
||||
set_active_configuration_tui(stdscr)
|
||||
elif current_selection == 4: # View Active Configuration
|
||||
view_active_configuration_tui(stdscr)
|
||||
elif current_selection == 5: # Exit
|
||||
break
|
||||
elif key == ord('q') or key == ord('Q'):
|
||||
break
|
||||
|
||||
|
||||
def view_configurations_tui(stdscr):
|
||||
"""View all configurations in TUI"""
|
||||
configs = config_manager.get_configurations()
|
||||
active = config_manager.get_active_configuration()
|
||||
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Title
|
||||
title = "Configurations"
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
if not configs:
|
||||
stdscr.addstr(2, 0, "No configurations found.")
|
||||
else:
|
||||
# Header
|
||||
header = f"{'Name':<20} {'Provider':<12} {'Model':<20} {'Active'}"
|
||||
stdscr.addstr(2, 0, header)
|
||||
stdscr.addstr(3, 0, "-" * len(header))
|
||||
|
||||
# Configurations
|
||||
for idx, (name, config) in enumerate(configs.items()):
|
||||
y = 4 + idx
|
||||
if y >= h - 2: # Prevent overflow
|
||||
break
|
||||
|
||||
marker = "*" if name == active else " "
|
||||
provider = config.get("provider", "unknown")[:11]
|
||||
model = config.get("model_name", "unknown")[:19]
|
||||
line = f"{marker} {name:<19} {provider:<12} {model:<20}"
|
||||
stdscr.addstr(y, 0, line)
|
||||
|
||||
# Instructions
|
||||
instructions = "Press any key to return to main menu"
|
||||
stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions)
|
||||
stdscr.refresh()
|
||||
stdscr.getch()
|
||||
|
||||
|
||||
def add_configuration_tui(stdscr):
|
||||
"""Add a new configuration via TUI"""
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Title
|
||||
title = "Add New Configuration"
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
# Get configuration name
|
||||
name = get_string_input(stdscr, "Configuration name: ", 2, 0)
|
||||
if not name:
|
||||
show_message(stdscr, "Error: Configuration name cannot be empty")
|
||||
return
|
||||
|
||||
if config_manager.configuration_exists(name):
|
||||
overwrite_prompt = f"Configuration '{name}' already exists. Overwrite? (y/n): "
|
||||
overwrite = get_string_input(stdscr, overwrite_prompt, 3, 0, 1)
|
||||
if overwrite.lower() != 'y':
|
||||
show_message(stdscr, "Cancelled.")
|
||||
return
|
||||
|
||||
# Select provider
|
||||
providers = ["OpenAI", "Ollama", "OpenRouter"]
|
||||
provider_selection = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
stdscr.addstr(2, 0, f"Name: {name}")
|
||||
stdscr.addstr(3, 0, "Select provider:")
|
||||
|
||||
for idx, provider in enumerate(providers):
|
||||
y = 4 + idx
|
||||
if idx == provider_selection:
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
stdscr.addstr(y, 2, f"> {provider}")
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(y, 2, f" {provider}")
|
||||
|
||||
stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm")
|
||||
stdscr.refresh()
|
||||
|
||||
key = stdscr.getch()
|
||||
if key == curses.KEY_UP and provider_selection > 0:
|
||||
provider_selection -= 1
|
||||
elif key == curses.KEY_DOWN and provider_selection < len(providers) - 1:
|
||||
provider_selection += 1
|
||||
elif key == curses.KEY_ENTER or key in [10, 13]:
|
||||
break
|
||||
|
||||
provider_map = {"OpenAI": "openai", "Ollama": "ollama", "OpenRouter": "openrouter"}
|
||||
provider = provider_map[providers[provider_selection]]
|
||||
config = {"provider": provider}
|
||||
|
||||
# Get model name
|
||||
model_name = get_string_input(stdscr, f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ", 5, 0)
|
||||
if not model_name:
|
||||
show_message(stdscr, "Error: Model name cannot be empty")
|
||||
return
|
||||
config["model_name"] = model_name
|
||||
|
||||
# Get provider-specific settings
|
||||
y_pos = 6
|
||||
if provider == "openai":
|
||||
api_key = get_password_input(stdscr, "OpenAI API key: ", y_pos, 0)
|
||||
config["api_key"] = api_key
|
||||
y_pos += 1
|
||||
elif provider == "ollama":
|
||||
base_url = get_string_input(stdscr, f"Ollama base URL (default: http://localhost:11434): ", y_pos, 0)
|
||||
if not base_url:
|
||||
base_url = "http://localhost:11434"
|
||||
config["base_url"] = base_url
|
||||
y_pos += 1
|
||||
elif provider == "openrouter":
|
||||
api_key = get_password_input(stdscr, "OpenRouter API key: ", y_pos, 0)
|
||||
config["api_key"] = api_key
|
||||
y_pos += 1
|
||||
|
||||
base_url = get_string_input(stdscr, f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ", y_pos, 0)
|
||||
if not base_url:
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
config["base_url"] = base_url
|
||||
y_pos += 1
|
||||
|
||||
# Ask if this should be the active configuration
|
||||
set_active_prompt = "Set as active configuration? (y/n): "
|
||||
set_active = get_string_input(stdscr, set_active_prompt, y_pos, 0, 1)
|
||||
|
||||
# Save the configuration
|
||||
try:
|
||||
config_manager.add_configuration(name, config)
|
||||
message = f"Configuration '{name}' saved successfully."
|
||||
if set_active.lower() == 'y':
|
||||
config_manager.set_active_configuration(name)
|
||||
message += f" Configuration '{name}' set as active."
|
||||
show_message(stdscr, message)
|
||||
except Exception as e:
|
||||
show_message(stdscr, f"Error saving configuration: {e}")
|
||||
|
||||
|
||||
def remove_configuration_tui(stdscr):
|
||||
"""Remove a configuration via TUI"""
|
||||
configs = config_manager.get_configurations()
|
||||
if not configs:
|
||||
show_message(stdscr, "No configurations to remove.")
|
||||
return
|
||||
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Title
|
||||
title = "Remove Configuration"
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
# List configurations
|
||||
config_names = list(configs.keys())
|
||||
selection = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
stdscr.addstr(2, 0, "Select configuration to remove:")
|
||||
|
||||
for idx, name in enumerate(config_names):
|
||||
y = 3 + idx
|
||||
if y >= h - 4: # Prevent overflow
|
||||
break
|
||||
|
||||
active_marker = " *"
|
||||
if name == config_manager.get_active_configuration():
|
||||
active_marker = " (active)"
|
||||
|
||||
if idx == selection:
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
stdscr.addstr(y, 2, f"> {name}{active_marker}")
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(y, 2, f" {name}{active_marker}")
|
||||
|
||||
stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel")
|
||||
stdscr.refresh()
|
||||
|
||||
key = stdscr.getch()
|
||||
if key == curses.KEY_UP and selection > 0:
|
||||
selection -= 1
|
||||
elif key == curses.KEY_DOWN and selection < len(config_names) - 1:
|
||||
selection += 1
|
||||
elif key == curses.KEY_ENTER or key in [10, 13]:
|
||||
# Confirm removal
|
||||
name_to_remove = config_names[selection]
|
||||
active = config_manager.get_active_configuration()
|
||||
|
||||
if name_to_remove == active:
|
||||
confirm_prompt = f"'{name_to_remove}' is the active configuration. Remove it anyway? (y/n): "
|
||||
confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1)
|
||||
if confirm.lower() != 'y':
|
||||
show_message(stdscr, "Cancelled.")
|
||||
return
|
||||
|
||||
confirm_prompt = f"Are you sure you want to remove configuration '{name_to_remove}'? (y/n): "
|
||||
confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1)
|
||||
if confirm.lower() == 'y':
|
||||
if config_manager.remove_configuration(name_to_remove):
|
||||
show_message(stdscr, f"Configuration '{name_to_remove}' removed successfully.")
|
||||
if name_to_remove == active:
|
||||
show_message(stdscr, "Active configuration has been unset.")
|
||||
else:
|
||||
show_message(stdscr, f"Error: Failed to remove configuration '{name_to_remove}'.")
|
||||
else:
|
||||
show_message(stdscr, "Cancelled.")
|
||||
return
|
||||
elif key == ord('c') or key == ord('C'):
|
||||
show_message(stdscr, "Cancelled.")
|
||||
return
|
||||
|
||||
|
||||
def set_active_configuration_tui(stdscr):
|
||||
"""Set active configuration via TUI"""
|
||||
configs = config_manager.get_configurations()
|
||||
if not configs:
|
||||
show_message(stdscr, "No configurations available.")
|
||||
return
|
||||
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Title
|
||||
title = "Set Active Configuration"
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
# List configurations
|
||||
config_names = list(configs.keys())
|
||||
selection = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
stdscr.addstr(2, 0, "Select configuration to set as active:")
|
||||
|
||||
for idx, name in enumerate(config_names):
|
||||
y = 3 + idx
|
||||
if y >= h - 4: # Prevent overflow
|
||||
break
|
||||
|
||||
current_active = config_manager.get_active_configuration()
|
||||
active_marker = " (current)" if name == current_active else ""
|
||||
|
||||
if idx == selection:
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
stdscr.addstr(y, 2, f"> {name}{active_marker}")
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(y, 2, f" {name}{active_marker}")
|
||||
|
||||
stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel")
|
||||
stdscr.refresh()
|
||||
|
||||
key = stdscr.getch()
|
||||
if key == curses.KEY_UP and selection > 0:
|
||||
selection -= 1
|
||||
elif key == curses.KEY_DOWN and selection < len(config_names) - 1:
|
||||
selection += 1
|
||||
elif key == curses.KEY_ENTER or key in [10, 13]:
|
||||
# Set as active
|
||||
name_to_set = config_names[selection]
|
||||
if config_manager.set_active_configuration(name_to_set):
|
||||
show_message(stdscr, f"Configuration '{name_to_set}' set as active.")
|
||||
else:
|
||||
show_message(stdscr, f"Error: Failed to set configuration '{name_to_set}' as active.")
|
||||
return
|
||||
elif key == ord('c') or key == ord('C'):
|
||||
show_message(stdscr, "Cancelled.")
|
||||
return
|
||||
|
||||
|
||||
def view_active_configuration_tui(stdscr):
|
||||
"""View active configuration via TUI"""
|
||||
active_name = config_manager.get_active_configuration()
|
||||
if not active_name:
|
||||
show_message(stdscr, "No active configuration set.")
|
||||
return
|
||||
|
||||
config = config_manager.get_configuration(active_name)
|
||||
if not config:
|
||||
show_message(stdscr, f"Error: Active configuration '{active_name}' not found.")
|
||||
return
|
||||
|
||||
stdscr.clear()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Title
|
||||
title = "Active Configuration"
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, (w - len(title)) // 2, title)
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
# Display configuration details
|
||||
details = [
|
||||
f"Name: {active_name}",
|
||||
f"Provider: {config.get('provider', 'unknown')}",
|
||||
f"Model: {config.get('model_name', 'unknown')}"
|
||||
]
|
||||
|
||||
# Add provider-specific details
|
||||
provider = config.get('provider')
|
||||
if provider == "openai":
|
||||
details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
|
||||
elif provider == "ollama":
|
||||
base_url = config.get('base_url', 'http://localhost:11434')
|
||||
details.append(f"Base URL: {base_url}")
|
||||
elif provider == "openrouter":
|
||||
details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
|
||||
base_url = config.get('base_url', 'https://openrouter.ai/api/v1')
|
||||
details.append(f"Base URL: {base_url}")
|
||||
|
||||
# Display details
|
||||
for idx, detail in enumerate(details):
|
||||
y = 2 + idx
|
||||
if y >= h - 2: # Prevent overflow
|
||||
break
|
||||
stdscr.addstr(y, 0, detail)
|
||||
|
||||
# Instructions
|
||||
instructions = "Press any key to return to main menu"
|
||||
stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions)
|
||||
stdscr.refresh()
|
||||
stdscr.getch()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the TUI"""
|
||||
curses.wrapper(main_tui)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example usage of OpenClaw Agent with different LLM providers
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from main import create_maxclaw_agent
|
||||
|
||||
def example_with_openai():
|
||||
"""Example using OpenAI"""
|
||||
print("=== Example: OpenAI ===")
|
||||
os.environ["MODEL_PROVIDER"] = "openai"
|
||||
os.environ["MODEL_NAME"] = "gpt-4o-mini"
|
||||
# You would need to set OPENAI_API_KEY in your environment
|
||||
# os.environ["OPENAI_API_KEY"] = "your-api-key"
|
||||
|
||||
try:
|
||||
agent = create_maxclaw_agent()
|
||||
print("✅ OpenAI agent created")
|
||||
# Uncomment to test (requires API key)
|
||||
# result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]})
|
||||
# print(f"Response: {result['messages'][-1].content}")
|
||||
except Exception as e:
|
||||
print(f"❌ OpenAI agent failed: {e}")
|
||||
print("💡 Set OPENAI_API_KEY environment variable to test")
|
||||
|
||||
def example_with_ollama():
|
||||
"""Example using Ollama"""
|
||||
print("\n=== Example: Ollama ===")
|
||||
os.environ["MODEL_PROVIDER"] = "ollama"
|
||||
os.environ["MODEL_NAME"] = "gemma2:9b"
|
||||
os.environ["OLLAMA_BASE_URL"] = "https://ollama.solarpi.net" # From user's profile
|
||||
|
||||
try:
|
||||
agent = create_maxclaw_agent()
|
||||
print("✅ Ollama agent created")
|
||||
# Uncomment to test (requires Ollama server)
|
||||
# result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]})
|
||||
# print(f"Response: {result['messages'][-1].content}")
|
||||
except Exception as e:
|
||||
print(f"❌ Ollama agent failed: {e}")
|
||||
print("💡 Make sure Ollama is running at https://ollama.solarpi.net")
|
||||
|
||||
def example_with_openrouter():
|
||||
"""Example using OpenRouter"""
|
||||
print("\n=== Example: OpenRouter ===")
|
||||
os.environ["MODEL_PROVIDER"] = "openrouter"
|
||||
os.environ["MODEL_NAME"] = "anthropic/claude-3.5-sonnet"
|
||||
# You would need to set OPENROUTER_API_KEY in your environment
|
||||
# os.environ["OPENROUTER_API_KEY"] = "your-api-key"
|
||||
|
||||
try:
|
||||
agent = create_maxclaw_agent()
|
||||
print("✅ OpenRouter agent created")
|
||||
# Uncomment to test (requires API key)
|
||||
# result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]})
|
||||
# print(f"Response: {result['messages'][-1].content}")
|
||||
except Exception as e:
|
||||
print(f"❌ OpenRouter agent failed: {e}")
|
||||
print("💡 Set OPENROUTER_API_KEY environment variable to test")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("OpenClaw Agent - Provider Configuration Examples\n")
|
||||
example_with_openai()
|
||||
example_with_ollama()
|
||||
example_with_openrouter()
|
||||
print("\n💡 To run the agent interactively, use: python main.py")
|
||||
print(" Remember to set the appropriate environment variables for your chosen provider.")
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MaxClaw Agent - A configurable agent harness using DeepAgents
|
||||
Supports multiple LLM providers via LangChain's init_chat_model
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
|
||||
def load_model_from_config():
|
||||
"""
|
||||
Load the LLM model based on configuration.
|
||||
Configuration can come from environment variables or a config file.
|
||||
For simplicity, we'll use environment variables.
|
||||
|
||||
Expected environment variables:
|
||||
- OPENAI_API_KEY: For OpenAI models
|
||||
- OLLAMA_BASE_URL: For Ollama (e.g., https://ollama.solarpi.net)
|
||||
- MODEL_PROVIDER: One of "openai", "ollama", "openrouter", etc.
|
||||
- MODEL_NAME: The specific model name (e.g., "gpt-4o", "gemma2:9b")
|
||||
"""
|
||||
provider = os.getenv("MODEL_PROVIDER", "openai").lower()
|
||||
model_name = os.getenv("MODEL_NAME", "gpt-4o")
|
||||
|
||||
# Configure based on provider
|
||||
if provider == "openai":
|
||||
# Uses OPENAI_API_KEY from environment
|
||||
return init_chat_model(f"openai:{model_name}")
|
||||
elif provider == "ollama":
|
||||
# For Ollama, we need to specify the base URL
|
||||
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
return init_chat_model(f"ollama:{model_name}", base_url=base_url)
|
||||
elif provider == "openrouter":
|
||||
# For OpenRouter, we need OPENROUTER_API_KEY and optionally base URL
|
||||
# Note: LangChain's init_chat_model for openrouter might require specific setup
|
||||
# As of now, we assume it's supported via openai-compatible API
|
||||
base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
||||
api_key = os.getenv("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY environment variable is required for OpenRouter")
|
||||
# Using openai-compatible API
|
||||
return init_chat_model(
|
||||
f"openai:{model_name}",
|
||||
base_url=base_url,
|
||||
api_key=api_key
|
||||
)
|
||||
else:
|
||||
# Fallback to openai for unknown providers (or add more as needed)
|
||||
return init_chat_model(f"openai:{model_name}")
|
||||
|
||||
|
||||
def create_maxclaw_agent(system_prompt: Optional[str] = None):
|
||||
"""
|
||||
Create and return a DeepAgent instance with configurable model.
|
||||
"""
|
||||
if system_prompt is None:
|
||||
system_prompt = """You are MaxClaw, a helpful AI agent that can plan, use tools,
|
||||
manage context, and delegate to subagents. You are designed to be configurable
|
||||
to work with various LLM providers."""
|
||||
|
||||
model = load_model_from_config()
|
||||
agent = create_deep_agent(
|
||||
model=model,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the MaxClaw agent.
|
||||
"""
|
||||
print("🚀 Initializing MaxClaw Agent...")
|
||||
|
||||
try:
|
||||
agent = create_maxclaw_agent()
|
||||
print("✅ Agent initialized successfully!")
|
||||
|
||||
# Simple test interaction
|
||||
print("\n💬 You can now interact with the agent. Type 'exit' to quit.")
|
||||
while True:
|
||||
user_input = input("\n👤 You: ").strip()
|
||||
if user_input.lower() in ['exit', 'quit']:
|
||||
print("👋 Goodbye!")
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Invoke the agent
|
||||
try:
|
||||
result = agent.invoke({"messages": [HumanMessage(content=user_input)]})
|
||||
# Extract the AI's response
|
||||
ai_message = result["messages"][-1]
|
||||
print(f"\n🤖 MaxClaw: {ai_message.content}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to initialize agent: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
deepagents>=0.0.34
|
||||
langchain>=0.3.0
|
||||
langchain-community>=0.3.0
|
||||
langchain-openai>=0.1.0
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for OpenClaw Agent
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from main import create_maxclaw_agent
|
||||
|
||||
def test_agent_creation():
|
||||
"""Test that the agent can be created successfully."""
|
||||
print("Testing agent creation...")
|
||||
try:
|
||||
agent = create_maxclaw_agent()
|
||||
print("✅ Agent created successfully!")
|
||||
return agent
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to create agent: {e}")
|
||||
print("💡 Make sure to set the required environment variables for your chosen model provider.")
|
||||
print(" See README.md for configuration examples.")
|
||||
return None
|
||||
|
||||
def test_simple_query(agent):
|
||||
"""Test a simple query to the agent."""
|
||||
print("\nTesting simple query...")
|
||||
try:
|
||||
result = agent.invoke({"messages": [{"role": "user", "content": "Hello, how are you?"}]})
|
||||
ai_message = result["messages"][-1]
|
||||
print(f"✅ Agent responded: {ai_message.content}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to get response: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set some default environment variables for testing
|
||||
# In a real scenario, these would be set externally
|
||||
if not os.getenv("MODEL_PROVIDER"):
|
||||
os.environ["MODEL_PROVIDER"] = "openai"
|
||||
if not os.getenv("MODEL_NAME"):
|
||||
os.environ["MODEL_NAME"] = "gpt-4o-mini" # Using a cheaper model for testing
|
||||
|
||||
agent = test_agent_creation()
|
||||
if agent:
|
||||
test_simple_query(agent)
|
||||
Reference in new issue
Block a user