mnemo_cards/tools/agent/cursor_cli_wrapper.py

426 lines
15 KiB
Python
Raw Permalink Normal View History

2025-11-20 21:28:55 +00:00
"""
Cursor CLI wrapper with stream-json parsing and progress tracking.
"""
import json
2025-11-20 22:04:17 +00:00
import os
import shutil
2025-11-20 21:28:55 +00:00
import subprocess
import sys
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
from dataclasses import dataclass
from enum import Enum
class CursorResultStatus(Enum):
"""Status of cursor CLI execution."""
SUCCESS = "success"
FAILURE = "failure"
TIMEOUT = "timeout"
ERROR = "error"
@dataclass
class CursorResult:
"""Result of cursor CLI execution."""
status: CursorResultStatus
output: str
error: Optional[str]
duration_ms: Optional[int]
files_modified: List[str]
files_created: List[str]
files_read: List[str]
tool_calls: int
class CursorCLI:
"""Wrapper for Cursor CLI with stream-json parsing."""
def __init__(self,
project_root: Path,
api_key: str,
2025-11-21 11:50:00 +00:00
model: str = "auto",
2025-11-20 21:28:55 +00:00
verbose: bool = True):
self.project_root = project_root
self.api_key = api_key
self.model = model
self.verbose = verbose
2025-11-20 22:04:17 +00:00
self.cursor_agent_path = self._find_cursor_agent()
def _find_cursor_agent(self) -> str:
"""Find cursor-agent executable."""
# Try to find in PATH first
cursor_agent = shutil.which("cursor-agent")
if cursor_agent:
return cursor_agent
2025-11-20 22:12:32 +00:00
# Try common installation locations (new default location first)
2025-11-20 22:04:17 +00:00
home = os.path.expanduser("~")
possible_paths = [
2025-11-20 22:12:32 +00:00
os.path.join(home, ".local", "bin", "cursor-agent"), # New default location
os.path.join(home, ".cursor", "bin", "cursor-agent"), # Old location
2025-11-20 22:04:17 +00:00
"/usr/local/bin/cursor-agent",
"/usr/bin/cursor-agent",
]
for path in possible_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
if self.verbose:
print(f"✅ Found cursor-agent at: {path}")
return path
# If not found, return "cursor-agent" and let it fail with a clear error
if self.verbose:
print("⚠️ cursor-agent not found in PATH or common locations")
print(f" PATH: {os.environ.get('PATH', 'not set')}")
print(f" Home: {home}")
2025-11-20 22:12:32 +00:00
print(f" Checked locations: {possible_paths}")
2025-11-20 22:04:17 +00:00
print(" Trying 'cursor-agent' anyway...")
return "cursor-agent"
2025-11-20 21:28:55 +00:00
def run_agent(self,
task_description: str,
force: bool = True,
max_iterations: int = 5,
progress_callback: Optional[Callable[[str, Any], None]] = None) -> CursorResult:
"""
Run cursor agent on a task.
Args:
task_description: Description of the task for the agent
force: Allow file modifications
max_iterations: Maximum number of agent iterations
progress_callback: Callback function(event_type, data) for progress updates
Returns:
CursorResult with execution details
"""
2025-11-20 22:04:17 +00:00
# Build command using found path
2025-11-20 21:28:55 +00:00
cmd = [
2025-11-20 22:04:17 +00:00
self.cursor_agent_path,
2025-11-20 21:28:55 +00:00
"-p", # Print mode (non-interactive)
"--output-format", "stream-json",
"--stream-partial-output",
]
if force:
cmd.append("--force")
# Add task description
cmd.append(task_description)
2025-11-20 22:22:34 +00:00
# Set environment with PATH including bash location
current_path = os.environ.get("PATH", "")
# Ensure common bash locations are in PATH
bash_paths = [
"/usr/bin",
"/bin",
os.path.expanduser("~/.local/bin"),
os.path.expanduser("~/.cursor/bin"),
]
# Add bash paths if they exist
additional_paths = ":".join([p for p in bash_paths if os.path.exists(p)])
new_path = ":".join([additional_paths, current_path]) if additional_paths else current_path
2025-11-21 11:50:00 +00:00
# Prepare environment variables
2025-11-20 21:28:55 +00:00
env = {
"CURSOR_API_KEY": self.api_key,
2025-11-20 22:22:34 +00:00
"PATH": new_path,
2025-11-20 21:28:55 +00:00
}
2025-11-21 11:50:00 +00:00
# Prepare command
cmd = [self.cursor_agent_path]
# Add model flag if provided
if self.model:
cmd.extend(["--model", self.model])
# Add prompt
cmd.append(task_description)
# Add options
cmd.extend(["--output-format", "stream-json"])
cmd.append("--stream-partial-output")
if force:
cmd.append("--force")
# Add print mode (non-interactive)
cmd.append("-p")
2025-11-20 21:28:55 +00:00
if self.verbose:
print(f"🚀 Running cursor-agent in {self.project_root}")
print(f"📝 Task: {task_description[:100]}...")
2025-11-20 22:04:17 +00:00
print(f"🔧 Using cursor-agent at: {self.cursor_agent_path}")
# Verify cursor-agent exists
if self.cursor_agent_path == "cursor-agent":
# Try one more time to find it
cursor_agent = shutil.which("cursor-agent")
if not cursor_agent:
error_msg = (
f"❌ cursor-agent not found!\n"
f" Please ensure Cursor CLI is installed.\n"
f" Installation: curl https://cursor.com/install -fsS | bash\n"
2025-11-20 22:12:32 +00:00
f" Expected locations:\n"
f" - $HOME/.local/bin/cursor-agent (new default)\n"
f" - $HOME/.cursor/bin/cursor-agent (old location)\n"
2025-11-20 22:04:17 +00:00
f" Current PATH: {os.environ.get('PATH', 'not set')}\n"
f" Current HOME: {os.environ.get('HOME', 'not set')}"
)
return CursorResult(
status=CursorResultStatus.ERROR,
output="",
error=error_msg,
duration_ms=None,
files_modified=[],
files_created=[],
files_read=[],
tool_calls=0
)
self.cursor_agent_path = cursor_agent
if self.verbose:
print(f"✅ Found cursor-agent at: {self.cursor_agent_path}")
2025-11-20 21:28:55 +00:00
# Track execution metrics
accumulated_text = ""
tool_count = 0
files_modified = []
files_created = []
files_read = []
duration_ms = None
error_message = None
try:
# Run cursor-agent with streaming output
process = subprocess.Popen(
cmd,
cwd=self.project_root,
env={**subprocess.os.environ, **env},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
# Process streaming output line by line
for line in process.stdout:
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
event_type = event.get("type")
subtype = event.get("subtype")
# Call progress callback
if progress_callback:
progress_callback(event_type, event)
# Process different event types
if event_type == "system":
self._handle_system_event(event, subtype)
elif event_type == "assistant":
accumulated_text += self._handle_assistant_event(event)
elif event_type == "tool_call":
tool_result = self._handle_tool_call_event(
event, subtype, files_modified, files_created, files_read
)
if tool_result:
tool_count += 1
elif event_type == "result":
duration_ms = event.get("duration_ms")
if self.verbose:
self._print_summary(
duration_ms, tool_count,
len(accumulated_text),
files_created, files_modified
)
except json.JSONDecodeError:
# Not JSON, might be error message
if self.verbose:
print(f"⚠️ {line}")
error_message = line
# Wait for process to complete
return_code = process.wait()
# Check stderr
stderr = process.stderr.read()
if stderr:
error_message = stderr
if self.verbose:
print(f"❌ Error: {stderr}")
# Determine status
if return_code == 0:
status = CursorResultStatus.SUCCESS
else:
status = CursorResultStatus.FAILURE
return CursorResult(
status=status,
output=accumulated_text,
error=error_message,
duration_ms=duration_ms,
files_modified=files_modified,
files_created=files_created,
files_read=files_read,
tool_calls=tool_count
)
except subprocess.TimeoutExpired:
return CursorResult(
status=CursorResultStatus.TIMEOUT,
output=accumulated_text,
error="Cursor CLI execution timeout",
duration_ms=None,
files_modified=files_modified,
files_created=files_created,
files_read=files_read,
tool_calls=tool_count
)
except Exception as e:
return CursorResult(
status=CursorResultStatus.ERROR,
output=accumulated_text,
error=str(e),
duration_ms=None,
files_modified=files_modified,
files_created=files_created,
files_read=files_read,
tool_calls=tool_count
)
def _handle_system_event(self, event: Dict[str, Any], subtype: Optional[str]) -> None:
"""Handle system event."""
if subtype == "init" and self.verbose:
model = event.get("model", "unknown")
print(f"🤖 Using model: {model}")
def _handle_assistant_event(self, event: Dict[str, Any]) -> str:
"""Handle assistant event and return text delta."""
content = event.get("message", {}).get("content", [])
if content:
text = content[0].get("text", "")
if text and self.verbose:
# Show progress indicator
sys.stdout.write(".")
sys.stdout.flush()
return text
return ""
def _handle_tool_call_event(self,
event: Dict[str, Any],
subtype: Optional[str],
files_modified: List[str],
files_created: List[str],
files_read: List[str]) -> bool:
"""Handle tool call event. Returns True if tool completed."""
tool_call = event.get("tool_call", {})
if subtype == "started":
# Tool started
if "writeToolCall" in tool_call:
path = tool_call["writeToolCall"].get("args", {}).get("path", "unknown")
if self.verbose:
print(f"\n🔧 Writing: {path}")
elif "readToolCall" in tool_call:
path = tool_call["readToolCall"].get("args", {}).get("path", "unknown")
files_read.append(path)
if self.verbose:
print(f"\n📖 Reading: {path}")
return False
elif subtype == "completed":
# Tool completed
if "writeToolCall" in tool_call:
result = tool_call["writeToolCall"].get("result", {})
if "success" in result:
success = result["success"]
path = tool_call["writeToolCall"].get("args", {}).get("path", "unknown")
2025-11-20 23:56:30 +00:00
lines_created = success.get("linesCreated", 0)
lines_modified = success.get("linesModified", 0)
2025-11-20 21:28:55 +00:00
2025-11-20 23:56:30 +00:00
if lines_created > 0:
2025-11-20 21:28:55 +00:00
files_created.append(path)
if self.verbose:
2025-11-20 23:56:30 +00:00
print(f" ✅ Created {lines_created} lines")
elif lines_modified > 0:
2025-11-20 21:28:55 +00:00
files_modified.append(path)
if self.verbose:
print(f" ✅ Modified {lines_modified} lines")
2025-11-20 23:56:30 +00:00
else:
# File was written but not actually changed
if self.verbose:
print(f" ⚠️ Written but no changes detected (file unchanged)")
2025-11-20 21:28:55 +00:00
elif "readToolCall" in tool_call:
result = tool_call["readToolCall"].get("result", {})
if "success" in result and self.verbose:
lines = result["success"].get("totalLines", 0)
print(f" ✅ Read {lines} lines")
return True
return False
def _print_summary(self,
duration_ms: Optional[int],
tool_count: int,
chars_generated: int,
files_created: List[str],
files_modified: List[str]) -> None:
"""Print execution summary."""
print(f"\n\n🎯 Completed in {duration_ms}ms")
print(f"📊 Stats: {tool_count} tool calls, {chars_generated} chars generated")
if files_created:
print(f"📝 Created {len(files_created)} file(s)")
if files_modified:
print(f"✏️ Modified {len(files_modified)} file(s)")
def test_cursor_cli():
"""Test cursor CLI wrapper."""
import os
from pathlib import Path
api_key = os.getenv("CURSOR_API_KEY")
if not api_key:
print("❌ CURSOR_API_KEY not set")
return
project_root = Path.cwd()
cli = CursorCLI(project_root, api_key)
task = "List all Python files in tools/agent directory"
def progress_callback(event_type: str, event: Dict[str, Any]):
"""Example progress callback."""
if event_type == "tool_call" and event.get("subtype") == "started":
print(f"🔄 Tool started...")
result = cli.run_agent(
task_description=task,
force=False,
progress_callback=progress_callback
)
print(f"\n📋 Result: {result.status}")
print(f"📄 Output: {result.output[:200]}...")
if __name__ == "__main__":
test_cursor_cli()