Clover/tools/dependency_tools.py
Jarian Cottingham 392bcfa2ef feat: Implement AI agent with multi-turn conversation and tool calling
Major enhancements to Clover CLI:

 New Features:
- AI agent with multi-turn conversation capabilities
- Tool calling system with 11+ tools for file operations, Git, linting, etc.
- Step-by-step AI assistance with play-by-play commentary
- Enhanced interactive mode with better UX

🔧 Core Components Added:
- ai_agent.py: Main AI agent with conversation management
- models/: API client and model management system
- Comprehensive tool system for development tasks

🛠️ Tools Available:
- File operations (create, read, update, delete)
- Command execution with safety checks
- Git operations (status, diff, commit, push)
- Code linting and formatting
- Project structure analysis
- Security scanning and dependency management

💡 User Experience:
- Real-time tool execution summaries
- File creation with full path visibility
- Error handling and retry mechanisms
- Clean conversation flow until task completion

🧹 Repository Cleanup:
- Added comprehensive .gitignore
- Removed __pycache__ directories and build artifacts
- Organized project structure

The AI can now actually create files, run commands, and work through complex
development tasks step-by-step with full transparency.
2026-01-15 01:23:40 -06:00

883 lines
30 KiB
Python

"""
Dependency management tools for Clover - A terminal assistant for AI-powered project management
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Add the current directory to Python path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config.settings import load_config
from models.api_client import APIClient
from tools.file_tools import create_file, read_file, update_file
class DependencyManager:
"""Handle dependency management operations across different package managers"""
def __init__(self):
self.config = load_config()
self.api_client = APIClient()
self.package_managers = {
"python": {
"files": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"],
"install_cmd": ["pip", "install"],
"uninstall_cmd": ["pip", "uninstall", "-y"],
"list_cmd": ["pip", "list", "--format=json"],
"outdated_cmd": ["pip", "list", "--outdated", "--format=json"],
},
"javascript": {
"files": ["package.json", "package-lock.json", "yarn.lock"],
"install_cmd": ["npm", "install"],
"uninstall_cmd": ["npm", "uninstall"],
"list_cmd": ["npm", "list", "--json"],
"outdated_cmd": ["npm", "outdated", "--json"],
},
"rust": {
"files": ["Cargo.toml", "Cargo.lock"],
"install_cmd": ["cargo", "add"],
"uninstall_cmd": ["cargo", "remove"],
"list_cmd": ["cargo", "tree"],
"outdated_cmd": ["cargo", "outdated"],
},
"go": {
"files": ["go.mod", "go.sum"],
"install_cmd": ["go", "get"],
"uninstall_cmd": ["go", "mod", "edit", "-droprequire"],
"list_cmd": ["go", "list", "-m", "all"],
"outdated_cmd": ["go", "list", "-u", "-m", "all"],
},
"ruby": {
"files": ["Gemfile", "Gemfile.lock"],
"install_cmd": ["gem", "install"],
"uninstall_cmd": ["gem", "uninstall"],
"list_cmd": ["gem", "list"],
"outdated_cmd": ["gem", "outdated"],
},
}
def _detect_project_type(self, project_path: str = ".") -> List[str]:
"""
Detect project type(s) based on dependency files
Args:
project_path (str): Path to project directory
Returns:
List of detected project types
"""
detected_types = []
for project_type, config in self.package_managers.items():
for dep_file in config["files"]:
if os.path.exists(os.path.join(project_path, dep_file)):
detected_types.append(project_type)
break
return detected_types or ["unknown"]
def _run_command(self, cmd: List[str], cwd: str = ".") -> Dict[str, Any]:
"""
Execute a command and return structured result
Args:
cmd (List[str]): Command to execute
cwd (str): Working directory
Returns:
Dict containing command result
"""
try:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
return {
"success": result.returncode == 0,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
"return_code": result.returncode,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"error": f"Command timed out: {' '.join(cmd)}",
"return_code": -1,
}
except Exception as e:
return {
"success": False,
"error": f"Error executing command: {str(e)}",
"return_code": -1,
}
def _parse_requirements_txt(self, filepath: str) -> List[Dict[str, Any]]:
"""
Parse requirements.txt file
Args:
filepath (str): Path to requirements.txt
Returns:
List of dependency dictionaries
"""
try:
content = read_file(filepath)
dependencies = []
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Parse dependency line
# Handle various formats: package==1.0.0, package>=1.0.0, package, etc.
match = re.match(r"^([a-zA-Z0-9\-_.]+)([><=!~]*)([\d\w\-.*]*)", line)
if match:
name, operator, version = match.groups()
dependencies.append(
{
"name": name,
"version": version if version else None,
"operator": operator if operator else None,
"raw": line,
}
)
return dependencies
except Exception as e:
return [{"error": f"Error parsing requirements.txt: {str(e)}"}]
def _parse_package_json(self, filepath: str) -> Dict[str, List[Dict[str, Any]]]:
"""
Parse package.json file
Args:
filepath (str): Path to package.json
Returns:
Dict containing dependencies and devDependencies
"""
try:
content = read_file(filepath)
data = json.loads(content)
result = {"dependencies": [], "devDependencies": []}
# Parse regular dependencies
if "dependencies" in data:
for name, version in data["dependencies"].items():
result["dependencies"].append(
{"name": name, "version": version, "type": "production"}
)
# Parse dev dependencies
if "devDependencies" in data:
for name, version in data["devDependencies"].items():
result["devDependencies"].append(
{"name": name, "version": version, "type": "development"}
)
return result
except Exception as e:
return {
"error": f"Error parsing package.json: {str(e)}",
"dependencies": [],
"devDependencies": [],
}
def _parse_pyproject_toml(self, filepath: str) -> List[Dict[str, Any]]:
"""
Parse pyproject.toml file
Args:
filepath (str): Path to pyproject.toml
Returns:
List of dependency dictionaries
"""
try:
content = read_file(filepath)
dependencies = []
# Simple TOML parsing for dependencies
# This is a basic implementation - for production use, consider using a TOML library
in_dependencies = False
for line in content.splitlines():
line = line.strip()
if (
line == "[tool.poetry.dependencies]"
or line == "[project.dependencies]"
):
in_dependencies = True
continue
elif line.startswith("[") and in_dependencies:
in_dependencies = False
continue
if in_dependencies and "=" in line:
parts = line.split("=", 1)
if len(parts) == 2:
name = parts[0].strip().strip('"')
version = parts[1].strip().strip('"')
dependencies.append(
{"name": name, "version": version, "raw": line}
)
return dependencies
except Exception as e:
return [{"error": f"Error parsing pyproject.toml: {str(e)}"}]
def scan_dependencies(project_path: str = ".") -> Dict[str, Any]:
"""
Parse requirements.txt, pyproject.toml, package.json etc.
Args:
project_path (str): Path to project directory
Returns:
Dict containing dependency analysis
"""
try:
manager = DependencyManager()
project_types = manager._detect_project_type(project_path)
results = {
"project_path": project_path,
"project_types": project_types,
"dependency_files": {},
"total_dependencies": 0,
}
# Scan each detected project type
for project_type in project_types:
if project_type == "unknown":
continue
config = manager.package_managers.get(project_type, {})
dep_files = config.get("files", [])
for dep_file in dep_files:
file_path = os.path.join(project_path, dep_file)
if os.path.exists(file_path):
if dep_file == "requirements.txt":
deps = manager._parse_requirements_txt(file_path)
results["dependency_files"][dep_file] = {
"type": "python",
"dependencies": deps,
"count": len([d for d in deps if "error" not in d]),
}
elif dep_file == "package.json":
deps = manager._parse_package_json(file_path)
total_deps = len(deps.get("dependencies", [])) + len(
deps.get("devDependencies", [])
)
results["dependency_files"][dep_file] = {
"type": "javascript",
"dependencies": deps,
"count": total_deps,
}
elif dep_file == "pyproject.toml":
deps = manager._parse_pyproject_toml(file_path)
results["dependency_files"][dep_file] = {
"type": "python",
"dependencies": deps,
"count": len([d for d in deps if "error" not in d]),
}
else:
# For other files, just note their presence
results["dependency_files"][dep_file] = {
"type": project_type,
"found": True,
"count": 0,
}
# Calculate total dependencies
for file_info in results["dependency_files"].values():
results["total_dependencies"] += file_info.get("count", 0)
return results
except Exception as e:
return {"error": f"Error scanning dependencies: {str(e)}"}
def add_dependency(
package_name: str, version: str = None, project_path: str = ".", dev: bool = False
) -> Dict[str, Any]:
"""
Add a package to project dependencies
Args:
package_name (str): Name of package to add
version (str): Version specification (optional)
project_path (str): Path to project directory
dev (bool): Whether this is a development dependency
Returns:
Dict containing operation result
"""
try:
manager = DependencyManager()
project_types = manager._detect_project_type(project_path)
if "python" in project_types:
return _add_python_dependency(package_name, version, project_path, dev)
elif "javascript" in project_types:
return _add_javascript_dependency(package_name, version, project_path, dev)
else:
return {"error": f"Unsupported project type: {project_types}"}
except Exception as e:
return {"error": f"Error adding dependency: {str(e)}"}
def _add_python_dependency(
package_name: str, version: str = None, project_path: str = ".", dev: bool = False
) -> Dict[str, Any]:
"""Add Python dependency"""
try:
manager = DependencyManager()
# Try pip install first
install_cmd = manager.package_managers["python"]["install_cmd"].copy()
if version:
package_spec = f"{package_name}=={version}"
else:
package_spec = package_name
install_cmd.append(package_spec)
result = manager._run_command(install_cmd, project_path)
if not result["success"]:
return {
"error": f"Failed to install {package_name}: {result.get('stderr', 'Unknown error')}",
"package": package_name,
}
# Update requirements.txt if it exists
req_file = os.path.join(project_path, "requirements.txt")
if os.path.exists(req_file):
try:
content = read_file(req_file)
# Check if package already exists
lines = content.splitlines()
updated = False
for i, line in enumerate(lines):
if line.strip().startswith(package_name):
# Update existing entry
lines[i] = package_spec
updated = True
break
if not updated:
# Add new entry
lines.append(package_spec)
# Write back to file
update_file(req_file, "\n".join(lines))
except Exception as e:
# Installation succeeded but file update failed
return {
"warning": f"Package installed but failed to update requirements.txt: {str(e)}",
"package": package_name,
"version": version,
}
return {
"success": True,
"package": package_name,
"version": version,
"message": f"Successfully added {package_spec}",
}
except Exception as e:
return {"error": f"Error adding Python dependency: {str(e)}"}
def _add_javascript_dependency(
package_name: str, version: str = None, project_path: str = ".", dev: bool = False
) -> Dict[str, Any]:
"""Add JavaScript dependency"""
try:
manager = DependencyManager()
# Use npm install
install_cmd = ["npm", "install"]
if dev:
install_cmd.append("--save-dev")
if version:
package_spec = f"{package_name}@{version}"
else:
package_spec = package_name
install_cmd.append(package_spec)
result = manager._run_command(install_cmd, project_path)
if not result["success"]:
return {
"error": f"Failed to install {package_name}: {result.get('stderr', 'Unknown error')}",
"package": package_name,
}
return {
"success": True,
"package": package_name,
"version": version,
"dev": dev,
"message": f"Successfully added {package_spec}",
}
except Exception as e:
return {"error": f"Error adding JavaScript dependency: {str(e)}"}
def remove_dependency(package_name: str, project_path: str = ".") -> Dict[str, Any]:
"""
Remove a package from project dependencies
Args:
package_name (str): Name of package to remove
project_path (str): Path to project directory
Returns:
Dict containing operation result
"""
try:
manager = DependencyManager()
project_types = manager._detect_project_type(project_path)
if "python" in project_types:
return _remove_python_dependency(package_name, project_path)
elif "javascript" in project_types:
return _remove_javascript_dependency(package_name, project_path)
else:
return {"error": f"Unsupported project type: {project_types}"}
except Exception as e:
return {"error": f"Error removing dependency: {str(e)}"}
def _remove_python_dependency(
package_name: str, project_path: str = "."
) -> Dict[str, Any]:
"""Remove Python dependency"""
try:
manager = DependencyManager()
# Try pip uninstall
uninstall_cmd = manager.package_managers["python"]["uninstall_cmd"].copy()
uninstall_cmd.append(package_name)
result = manager._run_command(uninstall_cmd, project_path)
# Update requirements.txt if it exists
req_file = os.path.join(project_path, "requirements.txt")
if os.path.exists(req_file):
try:
content = read_file(req_file)
lines = content.splitlines()
# Remove lines that start with the package name
filtered_lines = [
line for line in lines if not line.strip().startswith(package_name)
]
update_file(req_file, "\n".join(filtered_lines))
except Exception as e:
return {
"warning": f"Package uninstalled but failed to update requirements.txt: {str(e)}",
"package": package_name,
}
return {
"success": result["success"],
"package": package_name,
"message": f"Removed {package_name}"
if result["success"]
else f"Failed to remove {package_name}",
"details": result.get("stderr") if not result["success"] else None,
}
except Exception as e:
return {"error": f"Error removing Python dependency: {str(e)}"}
def _remove_javascript_dependency(
package_name: str, project_path: str = "."
) -> Dict[str, Any]:
"""Remove JavaScript dependency"""
try:
manager = DependencyManager()
# Use npm uninstall
uninstall_cmd = manager.package_managers["javascript"]["uninstall_cmd"].copy()
uninstall_cmd.append(package_name)
result = manager._run_command(uninstall_cmd, project_path)
return {
"success": result["success"],
"package": package_name,
"message": f"Removed {package_name}"
if result["success"]
else f"Failed to remove {package_name}",
"details": result.get("stderr") if not result["success"] else None,
}
except Exception as e:
return {"error": f"Error removing JavaScript dependency: {str(e)}"}
def dependency_report(project_path: str = ".") -> Dict[str, Any]:
"""
Generate structured dependency analysis
Args:
project_path (str): Path to project directory
Returns:
Dict containing comprehensive dependency report
"""
try:
manager = DependencyManager()
# Scan current dependencies
scan_result = scan_dependencies(project_path)
if "error" in scan_result:
return scan_result
# Get installed packages info
project_types = scan_result.get("project_types", [])
installed_packages = {}
for project_type in project_types:
if project_type == "python":
result = manager._run_command(
manager.package_managers["python"]["list_cmd"], project_path
)
if result["success"]:
try:
packages = json.loads(result["stdout"])
installed_packages["python"] = packages
except json.JSONDecodeError:
installed_packages["python"] = {
"error": "Failed to parse pip list output"
}
elif project_type == "javascript":
result = manager._run_command(
manager.package_managers["javascript"]["list_cmd"], project_path
)
if result["success"]:
try:
packages = json.loads(result["stdout"])
installed_packages["javascript"] = packages
except json.JSONDecodeError:
installed_packages["javascript"] = {
"error": "Failed to parse npm list output"
}
# Check for outdated packages
outdated_packages = {}
for project_type in project_types:
if project_type == "python":
result = manager._run_command(
manager.package_managers["python"]["outdated_cmd"], project_path
)
if result["success"]:
try:
packages = json.loads(result["stdout"])
outdated_packages["python"] = packages
except json.JSONDecodeError:
outdated_packages["python"] = []
# Analyze security vulnerabilities (basic check)
security_issues = _check_security_issues(scan_result, project_path)
# Compile comprehensive report
report = {
"project_path": project_path,
"project_types": project_types,
"dependency_scan": scan_result,
"installed_packages": installed_packages,
"outdated_packages": outdated_packages,
"security_issues": security_issues,
"summary": {
"total_dependencies": scan_result.get("total_dependencies", 0),
"dependency_files": len(scan_result.get("dependency_files", {})),
"outdated_count": sum(
len(v) for v in outdated_packages.values() if isinstance(v, list)
),
"security_issues_count": len(security_issues.get("issues", [])),
},
}
return report
except Exception as e:
return {"error": f"Error generating dependency report: {str(e)}"}
def _check_security_issues(
scan_result: Dict[str, Any], project_path: str
) -> Dict[str, Any]:
"""
Basic security vulnerability check
Args:
scan_result (Dict): Result from dependency scan
project_path (str): Path to project directory
Returns:
Dict containing security analysis
"""
try:
issues = []
# Check for common vulnerable packages (basic list)
vulnerable_patterns = {
"python": [
{
"name": "pillow",
"versions": ["<8.1.1"],
"issue": "PIL vulnerability",
},
{
"name": "urllib3",
"versions": ["<1.26.5"],
"issue": "SSL verification bypass",
},
{
"name": "requests",
"versions": ["<2.25.1"],
"issue": "Various security issues",
},
],
"javascript": [
{
"name": "lodash",
"versions": ["<4.17.21"],
"issue": "Prototype pollution",
},
{
"name": "axios",
"versions": ["<0.21.1"],
"issue": "SSRF vulnerability",
},
{
"name": "express",
"versions": ["<4.17.1"],
"issue": "Various security issues",
},
],
}
# Analyze dependencies for known vulnerabilities
for file_name, file_info in scan_result.get("dependency_files", {}).items():
project_type = file_info.get("type")
dependencies = file_info.get("dependencies", [])
if project_type in vulnerable_patterns:
for dep in dependencies:
if isinstance(dep, dict) and "name" in dep:
dep_name = dep["name"]
dep_version = dep.get("version", "")
for vuln in vulnerable_patterns[project_type]:
if dep_name == vuln["name"]:
# Simple version check (this is basic - real security scanners are much more sophisticated)
if dep_version and any(
pattern in dep_version
for pattern in vuln["versions"]
):
issues.append(
{
"package": dep_name,
"version": dep_version,
"issue": vuln["issue"],
"severity": "medium", # Default severity
"file": file_name,
}
)
return {
"issues": issues,
"total_issues": len(issues),
"note": "This is a basic security check. Use specialized tools like 'safety' (Python) or 'npm audit' (JavaScript) for comprehensive security analysis.",
}
except Exception as e:
return {"error": f"Error checking security issues: {str(e)}", "issues": []}
def update_all_dependencies(
project_path: str = ".", dry_run: bool = True
) -> Dict[str, Any]:
"""
Update all dependencies to latest versions
Args:
project_path (str): Path to project directory
dry_run (bool): If True, only show what would be updated
Returns:
Dict containing update results
"""
try:
manager = DependencyManager()
project_types = manager._detect_project_type(project_path)
results = {
"project_path": project_path,
"dry_run": dry_run,
"updates": {},
}
for project_type in project_types:
if project_type == "python":
# Get outdated packages
result = manager._run_command(
manager.package_managers["python"]["outdated_cmd"], project_path
)
if result["success"]:
try:
outdated = json.loads(result["stdout"])
updates = []
for package in outdated:
package_name = package.get("name")
current_version = package.get("version")
latest_version = package.get("latest_version")
update_info = {
"package": package_name,
"current_version": current_version,
"latest_version": latest_version,
"updated": False,
}
if not dry_run:
# Actually update the package
install_result = manager._run_command(
["pip", "install", "--upgrade", package_name],
project_path,
)
update_info["updated"] = install_result["success"]
if not install_result["success"]:
update_info["error"] = install_result.get("stderr")
updates.append(update_info)
results["updates"]["python"] = updates
except json.JSONDecodeError:
results["updates"]["python"] = {
"error": "Failed to parse outdated packages"
}
elif project_type == "javascript":
if not dry_run:
# Run npm update
result = manager._run_command(["npm", "update"], project_path)
results["updates"]["javascript"] = {
"success": result["success"],
"message": "Ran npm update",
"details": result.get("stdout") or result.get("stderr"),
}
else:
results["updates"]["javascript"] = {
"message": "Would run npm update"
}
return results
except Exception as e:
return {"error": f"Error updating dependencies: {str(e)}"}
def create_lock_file(project_path: str = ".") -> Dict[str, Any]:
"""
Create or update lock files for dependency pinning
Args:
project_path (str): Path to project directory
Returns:
Dict containing lock file creation results
"""
try:
manager = DependencyManager()
project_types = manager._detect_project_type(project_path)
results = {"project_path": project_path, "lock_files": {}}
for project_type in project_types:
if project_type == "python":
# Generate requirements-lock.txt with exact versions
result = manager._run_command(["pip", "freeze"], project_path)
if result["success"]:
lock_file = os.path.join(project_path, "requirements-lock.txt")
success = create_file(lock_file, result["stdout"])
results["lock_files"]["requirements-lock.txt"] = {
"created": success,
"path": lock_file,
}
elif project_type == "javascript":
# package-lock.json is created automatically by npm
lock_file = os.path.join(project_path, "package-lock.json")
results["lock_files"]["package-lock.json"] = {
"exists": os.path.exists(lock_file),
"path": lock_file,
}
return results
except Exception as e:
return {"error": f"Error creating lock files: {str(e)}"}
# Example usage
if __name__ == "__main__":
# Scan project dependencies
scan_result = scan_dependencies(".")
print(f"Dependencies found: {scan_result}")
# Generate comprehensive report
report = dependency_report(".")
print(f"Dependency report: {report}")