Compare commits

...
1 Commits
Author SHA1 Message Date
Mervin Praison b01eb2b8fa Release v2.3.44 2025-12-19 10:50:33 +00:00
19 changed files with 2592 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.43" \
"praisonai>=2.3.44" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.43" \
"praisonai>=2.3.44" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.43" \
"praisonai>=2.3.44" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.43.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.43.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.44.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.44.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -21,6 +21,7 @@ Features:
- flow_display: Visual workflow tracking
- workflow: YAML workflow management
- n8n: Export workflows to n8n visual editor
- external_agents: External AI CLI tool integrations (Claude, Gemini, Codex, Cursor)
"""
# Type hints available for IDE support
@@ -43,6 +44,7 @@ __all__ = [
'FlowDisplayHandler',
'WorkflowHandler',
'N8nHandler',
'ExternalAgentsHandler',
]
def __getattr__(name):
@@ -95,4 +97,7 @@ def __getattr__(name):
elif name == 'N8nHandler':
from .n8n import N8nHandler
return N8nHandler
elif name == 'ExternalAgentsHandler':
from .external_agents import ExternalAgentsHandler
return ExternalAgentsHandler
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,231 @@
"""
External Agents Handler for CLI.
Provides integration with external AI coding CLI tools:
- Claude Code CLI
- Gemini CLI
- OpenAI Codex CLI
- Cursor CLI
Usage:
praisonai "Refactor the code" --external-agent claude
praisonai "Analyze codebase" --external-agent gemini
praisonai "Fix bugs" --external-agent codex
praisonai "Add tests" --external-agent cursor
"""
from typing import Dict, Any, List, Optional, Tuple
from .base import FlagHandler
class ExternalAgentsHandler(FlagHandler):
"""
Handler for --external-agent flag.
Integrates external AI coding CLI tools as agent tools.
Example:
praisonai "Refactor the auth module" --external-agent claude
praisonai "Analyze this codebase" --external-agent gemini
"""
# Mapping of integration names to their module paths
INTEGRATIONS = {
"claude": "claude_code.ClaudeCodeIntegration",
"gemini": "gemini_cli.GeminiCLIIntegration",
"codex": "codex_cli.CodexCLIIntegration",
"cursor": "cursor_cli.CursorCLIIntegration",
}
def __init__(self, verbose: bool = False):
"""Initialize the handler."""
super().__init__(verbose)
self._integration_cache: Dict[str, Any] = {}
@property
def feature_name(self) -> str:
"""Return the feature name."""
return "external_agents"
@property
def flag_name(self) -> str:
"""Return the flag name."""
return "external-agent"
@property
def flag_help(self) -> str:
"""Return the flag help text."""
return "External AI CLI tool to use (claude, gemini, codex, cursor)"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if integrations module is available."""
try:
import importlib.util
if importlib.util.find_spec("praisonai.integrations") is not None:
return True, ""
return False, "Integrations module not available"
except ImportError:
return False, "Integrations module not available"
def get_integration(self, name: str, **options):
"""
Get an integration instance by name.
Args:
name: Integration name (claude, gemini, codex, cursor)
**options: Options to pass to the integration
Returns:
Integration instance
Raises:
ValueError: If integration name is invalid
"""
if name not in self.INTEGRATIONS:
raise ValueError(f"Unknown integration: {name}. Available: {list(self.INTEGRATIONS.keys())}")
# Check cache first
cache_key = f"{name}_{hash(frozenset(options.items()))}"
if cache_key in self._integration_cache:
return self._integration_cache[cache_key]
# Lazy import the integration
module_path = self.INTEGRATIONS[name]
module_name, class_name = module_path.rsplit(".", 1)
import importlib
module = importlib.import_module(f"praisonai.integrations.{module_name}")
integration_class = getattr(module, class_name)
# Create instance with options
integration = integration_class(**options)
# Cache it
self._integration_cache[cache_key] = integration
return integration
def list_integrations(self) -> List[str]:
"""
List all available integration names.
Returns:
List of integration names
"""
return list(self.INTEGRATIONS.keys())
def check_availability(self) -> Dict[str, bool]:
"""
Check availability of all integrations.
Returns:
Dict mapping integration name to availability
"""
availability = {}
for name in self.INTEGRATIONS:
try:
integration = self.get_integration(name)
availability[name] = integration.is_available
except Exception:
availability[name] = False
return availability
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""
Apply external agent configuration to agent config.
Args:
config: Agent configuration dictionary
flag_value: Integration name or dict with name and options
Returns:
Modified configuration with external agent tool
"""
if not flag_value:
return config
# Handle both string and dict formats
if isinstance(flag_value, str):
name = flag_value
options = {}
elif isinstance(flag_value, dict):
name = flag_value.get("name", "")
options = {k: v for k, v in flag_value.items() if k != "name"}
else:
return config
try:
integration = self.get_integration(name, **options)
if integration.is_available:
# Add tool to existing tools
existing_tools = config.get("tools", [])
if isinstance(existing_tools, list):
existing_tools.append(integration.as_tool())
else:
existing_tools = [integration.as_tool()]
config["tools"] = existing_tools
self.print_status(f"🔌 External agent connected: {name}", "success")
else:
self.print_status(f"⚠️ External agent not available: {name}", "warning")
except ValueError as e:
self.print_status(str(e), "error")
except Exception as e:
self.print_status(f"Failed to connect external agent: {e}", "error")
return config
def execute(
self,
integration_name: str = None,
**options
) -> Optional[Any]:
"""
Execute external agent setup.
Args:
integration_name: Name of the integration
**options: Options for the integration
Returns:
Integration instance or None
"""
if not integration_name:
# List available integrations
self.print_status("\n🔧 Available External Agents:", "info")
self.print_status("-" * 40, "info")
availability = self.check_availability()
for name, available in availability.items():
status = "✅ Available" if available else "❌ Not installed"
self.print_status(f" {name}: {status}", "info")
return None
try:
integration = self.get_integration(integration_name, **options)
if integration.is_available:
self.print_status(f"🔌 External agent ready: {integration_name}", "success")
return integration
else:
self.print_status(f"⚠️ External agent not installed: {integration_name}", "warning")
self.print_status(f" Install with: {self._get_install_instructions(integration_name)}", "info")
return None
except ValueError as e:
self.print_status(str(e), "error")
return None
def _get_install_instructions(self, name: str) -> str:
"""Get installation instructions for an integration."""
instructions = {
"claude": "npm install -g @anthropic-ai/claude-code",
"gemini": "npm install -g @anthropic-ai/gemini-cli",
"codex": "npm install -g @openai/codex",
"cursor": "Download from cursor.com",
}
return instructions.get(name, "See documentation")
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.3.43 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.3.44 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,300 @@
"""
Base class for external CLI tool integrations.
Provides a consistent interface for integrating with AI coding CLI tools
like Claude Code, Gemini CLI, Codex CLI, and Cursor CLI.
Features:
- Lazy availability checking with caching
- Async subprocess execution
- Tool wrapper for agent integration
- Timeout handling
"""
from abc import ABC, abstractmethod
from typing import AsyncIterator, Optional, Dict, Any, Tuple, List
import asyncio
import shutil
import os
class BaseCLIIntegration(ABC):
"""
Abstract base class for external CLI tool integrations.
Provides:
- Lazy availability checking with class-level caching
- Async subprocess execution with timeout handling
- Tool wrapper for PraisonAI agent integration
Subclasses must implement:
- cli_command property: The CLI command name
- execute method: Execute the CLI and return result
- stream method: Stream output from the CLI
Example:
class MyIntegration(BaseCLIIntegration):
@property
def cli_command(self) -> str:
return "my-cli"
async def execute(self, prompt: str, **options) -> str:
return await self.execute_async(["my-cli", "-p", prompt])
async def stream(self, prompt: str, **options):
async for line in self.stream_async(["my-cli", "-p", prompt]):
yield line
"""
# Class-level cache for availability checks (shared across instances)
_availability_cache: Dict[str, bool] = {}
def __init__(self, workspace: str = ".", timeout: int = 300):
"""
Initialize the CLI integration.
Args:
workspace: Working directory for CLI execution (default: current dir)
timeout: Default timeout in seconds for CLI execution (default: 300)
"""
self.workspace = workspace
self.timeout = timeout
@property
@abstractmethod
def cli_command(self) -> str:
"""
Return the CLI command name.
This is used for availability checking and tool naming.
Returns:
str: The CLI command (e.g., "claude", "gemini", "codex", "cursor-agent")
"""
pass
@property
def is_available(self) -> bool:
"""
Check if the CLI tool is installed and available.
Uses class-level caching to avoid repeated filesystem checks.
Returns:
bool: True if the CLI is available, False otherwise
"""
if self.cli_command not in self._availability_cache:
self._availability_cache[self.cli_command] = shutil.which(self.cli_command) is not None
return self._availability_cache[self.cli_command]
@abstractmethod
async def execute(self, prompt: str, **options) -> str:
"""
Execute the CLI tool and return the result.
Args:
prompt: The prompt/query to send to the CLI
**options: Additional options for the CLI
Returns:
str: The CLI output
"""
pass
@abstractmethod
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
"""
Stream output from the CLI tool.
Args:
prompt: The prompt/query to send to the CLI
**options: Additional options for the CLI
Yields:
dict: Parsed output events from the CLI
"""
pass
async def execute_async(self, cmd: List[str], timeout: Optional[int] = None) -> str:
"""
Execute a command asynchronously and return stdout.
Args:
cmd: Command and arguments as a list
timeout: Timeout in seconds (uses self.timeout if not specified)
Returns:
str: The command's stdout
Raises:
TimeoutError: If the command times out
"""
timeout = timeout or self.timeout
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.workspace
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=timeout
)
return stdout.decode()
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise TimeoutError(f"Command timed out after {timeout}s: {' '.join(cmd)}")
async def execute_async_with_stderr(
self,
cmd: List[str],
timeout: Optional[int] = None
) -> Tuple[str, str]:
"""
Execute a command asynchronously and return both stdout and stderr.
Args:
cmd: Command and arguments as a list
timeout: Timeout in seconds (uses self.timeout if not specified)
Returns:
Tuple[str, str]: (stdout, stderr)
Raises:
TimeoutError: If the command times out
"""
timeout = timeout or self.timeout
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.workspace
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=timeout
)
return stdout.decode(), stderr.decode()
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise TimeoutError(f"Command timed out after {timeout}s: {' '.join(cmd)}")
async def stream_async(
self,
cmd: List[str],
timeout: Optional[int] = None
) -> AsyncIterator[str]:
"""
Stream stdout lines from a command asynchronously.
Args:
cmd: Command and arguments as a list
timeout: Timeout in seconds for the entire operation
Yields:
str: Each line of output
"""
timeout = timeout or self.timeout
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.workspace
)
try:
async def read_lines():
while True:
line = await proc.stdout.readline()
if not line:
break
yield line.decode().rstrip('\n')
async for line in read_lines():
yield line
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise TimeoutError(f"Stream timed out after {timeout}s")
finally:
if proc.returncode is None:
proc.kill()
await proc.wait()
def as_tool(self) -> callable:
"""
Return a callable suitable for use as a PraisonAI agent tool.
The returned function is synchronous and wraps the async execute method.
Uses asyncio.run() for proper event loop handling (Python 3.7+).
Returns:
callable: A function that can be used as an agent tool
"""
# Capture self for closure
integration = self
def tool_func(query: str) -> str:
"""Execute the CLI tool with the given query."""
# Use asyncio.run() for clean event loop management
# This creates a new event loop, runs the coroutine, and closes it
try:
# Check if we're already in an async context
asyncio.get_running_loop()
# If we're in an async context, use ThreadPoolExecutor to avoid nested loop
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, integration.execute(query))
return future.result()
except RuntimeError:
# No running loop, safe to use asyncio.run()
return asyncio.run(integration.execute(query))
# Set function metadata for agent tool registration
tool_func.__name__ = f"{self.cli_command}_tool"
tool_func.__doc__ = f"Execute {self.cli_command} for coding tasks."
return tool_func
def get_env(self) -> Dict[str, str]:
"""
Get environment variables for CLI execution.
Subclasses can override this to add tool-specific environment variables.
Returns:
dict: Environment variables to pass to the CLI
"""
return dict(os.environ)
def get_available_integrations() -> Dict[str, bool]:
"""
Get a dictionary of all integrations and their availability status.
Returns:
dict: Mapping of integration name to availability (True/False)
"""
from .claude_code import ClaudeCodeIntegration
from .gemini_cli import GeminiCLIIntegration
from .codex_cli import CodexCLIIntegration
from .cursor_cli import CursorCLIIntegration
integrations = {
'claude': ClaudeCodeIntegration(),
'gemini': GeminiCLIIntegration(),
'codex': CodexCLIIntegration(),
'cursor': CursorCLIIntegration(),
}
return {name: integration.is_available for name, integration in integrations.items()}
@@ -0,0 +1,270 @@
"""
Claude Code CLI Integration.
Provides integration with Claude Code CLI for AI-powered coding tasks.
Features:
- Headless mode execution with JSON output
- Session continuation support
- System prompt customization
- Tool restrictions
- Optional SDK integration (when claude-agent-sdk is installed)
Usage:
from praisonai.integrations import ClaudeCodeIntegration
# Create integration
claude = ClaudeCodeIntegration(workspace="/path/to/project")
# Execute a coding task
result = await claude.execute("Refactor the auth module")
# Stream output
async for event in claude.stream("Add error handling"):
print(event)
# Use as agent tool
tool = claude.as_tool()
"""
import json
import os
from typing import AsyncIterator, Dict, Any, Optional, List
from .base import BaseCLIIntegration
# Check if Claude Code SDK is available
CLAUDE_SDK_AVAILABLE = False
try:
from claude_agent_sdk import query as claude_query, ClaudeAgentOptions
CLAUDE_SDK_AVAILABLE = True
except ImportError:
pass
class ClaudeCodeIntegration(BaseCLIIntegration):
"""
Integration with Claude Code CLI.
Supports both subprocess-based execution and SDK-based execution
(when claude-agent-sdk is installed).
Attributes:
output_format: Output format ("json", "text", "stream-json")
skip_permissions: Whether to skip permission prompts
system_prompt: Custom system prompt to append
allowed_tools: List of allowed tools (e.g., ["Read", "Write", "Bash"])
disallowed_tools: List of disallowed tools
use_sdk: Whether to use the SDK instead of subprocess
"""
def __init__(
self,
workspace: str = ".",
timeout: int = 300,
output_format: str = "json",
skip_permissions: bool = True,
system_prompt: Optional[str] = None,
allowed_tools: Optional[List[str]] = None,
disallowed_tools: Optional[List[str]] = None,
use_sdk: bool = False,
model: Optional[str] = None,
):
"""
Initialize Claude Code integration.
Args:
workspace: Working directory for CLI execution
timeout: Timeout in seconds for CLI execution
output_format: Output format ("json", "text", "stream-json")
skip_permissions: Whether to skip permission prompts (--dangerously-skip-permissions)
system_prompt: Custom system prompt to append
allowed_tools: List of allowed tools
disallowed_tools: List of disallowed tools
use_sdk: Whether to use the SDK instead of subprocess
model: Model to use (e.g., "sonnet", "opus")
"""
super().__init__(workspace=workspace, timeout=timeout)
self.output_format = output_format
self.skip_permissions = skip_permissions
self.system_prompt = system_prompt
self.allowed_tools = allowed_tools
self.disallowed_tools = disallowed_tools
self.use_sdk = use_sdk and CLAUDE_SDK_AVAILABLE
self.model = model
# Session management
self._session_active = False
@property
def cli_command(self) -> str:
"""Return the CLI command name."""
return "claude"
@property
def sdk_available(self) -> bool:
"""Check if the Claude Code SDK is available."""
return CLAUDE_SDK_AVAILABLE
def _build_command(
self,
prompt: str,
continue_session: bool = False,
**options
) -> List[str]:
"""
Build the Claude CLI command.
Args:
prompt: The prompt to send
continue_session: Whether to continue a previous session
**options: Additional options
Returns:
List of command arguments
"""
cmd = ["claude"]
# Add continue flag if needed
if continue_session or self._session_active:
cmd.append("--continue")
# Add skip permissions flag
if self.skip_permissions:
cmd.append("--dangerously-skip-permissions")
# Add print mode flag
cmd.append("-p")
# Add prompt
cmd.append(prompt)
# Add output format
cmd.extend(["--output-format", self.output_format])
# Add model if specified
if self.model:
cmd.extend(["--model", self.model])
# Add system prompt if specified
if self.system_prompt:
cmd.extend(["--append-system-prompt", self.system_prompt])
# Add allowed tools if specified
if self.allowed_tools:
cmd.extend(["--allowedTools", ",".join(self.allowed_tools)])
# Add disallowed tools if specified
if self.disallowed_tools:
cmd.extend(["--disallowedTools", ",".join(self.disallowed_tools)])
return cmd
async def execute(self, prompt: str, **options) -> str:
"""
Execute Claude Code CLI and return the result.
Args:
prompt: The prompt/query to send
**options: Additional options (continue_session, etc.)
Returns:
str: The CLI output (parsed from JSON if output_format is "json")
"""
if self.use_sdk:
return await self._execute_sdk(prompt, **options)
return await self._execute_subprocess(prompt, **options)
async def _execute_subprocess(self, prompt: str, **options) -> str:
"""Execute using subprocess."""
cmd = self._build_command(prompt, **options)
output = await self.execute_async(cmd)
# Mark session as active for continuation
self._session_active = True
# Parse JSON output if applicable
if self.output_format == "json":
try:
data = json.loads(output)
# Extract the main result
if isinstance(data, dict):
return data.get("result", data.get("content", str(data)))
return str(data)
except json.JSONDecodeError:
return output
return output
async def _execute_sdk(self, prompt: str, **options) -> str:
"""Execute using the Claude Code SDK."""
if not CLAUDE_SDK_AVAILABLE:
raise RuntimeError("Claude Code SDK not available. Install with: pip install claude-agent-sdk")
sdk_options = ClaudeAgentOptions(
cwd=self.workspace,
system_prompt=self.system_prompt,
)
if self.allowed_tools:
sdk_options.allowed_tools = self.allowed_tools
if self.skip_permissions:
sdk_options.permission_mode = 'acceptEdits'
result_parts = []
async for message in claude_query(prompt=prompt, options=sdk_options):
if hasattr(message, 'content'):
for block in message.content:
if hasattr(block, 'text'):
result_parts.append(block.text)
return '\n'.join(result_parts)
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
"""
Stream output from Claude Code CLI.
Args:
prompt: The prompt/query to send
**options: Additional options
Yields:
dict: Parsed JSON events from the CLI
"""
# Use stream-json format for streaming
original_format = self.output_format
self.output_format = "stream-json"
try:
cmd = self._build_command(prompt, **options)
async for line in self.stream_async(cmd):
if line.strip():
try:
event = json.loads(line)
yield event
except json.JSONDecodeError:
yield {"type": "text", "content": line}
finally:
self.output_format = original_format
def reset_session(self):
"""Reset the session state."""
self._session_active = False
def get_env(self) -> Dict[str, str]:
"""Get environment variables for CLI execution."""
env = super().get_env()
# Add Anthropic API key if available
if "ANTHROPIC_API_KEY" in os.environ:
env["ANTHROPIC_API_KEY"] = os.environ["ANTHROPIC_API_KEY"]
elif "CLAUDE_API_KEY" in os.environ:
env["ANTHROPIC_API_KEY"] = os.environ["CLAUDE_API_KEY"]
return env
@@ -0,0 +1,252 @@
"""
OpenAI Codex CLI Integration.
Provides integration with OpenAI's Codex CLI for AI-powered coding tasks.
Features:
- Non-interactive execution with `codex exec`
- Full auto mode for file modifications
- Sandbox modes for security
- JSON streaming output
- Structured output with schemas
Usage:
from praisonai.integrations import CodexCLIIntegration
# Create integration
codex = CodexCLIIntegration(workspace="/path/to/project")
# Execute a coding task
result = await codex.execute("Fix the authentication bug")
# Execute with full auto (allows file modifications)
codex_auto = CodexCLIIntegration(full_auto=True)
result = await codex_auto.execute("Refactor the module")
# Use as agent tool
tool = codex.as_tool()
"""
import json
import os
from typing import AsyncIterator, Dict, Any, Optional, List
from .base import BaseCLIIntegration
class CodexCLIIntegration(BaseCLIIntegration):
"""
Integration with OpenAI's Codex CLI.
Attributes:
full_auto: Whether to allow file modifications
sandbox: Sandbox mode ("default", "danger-full-access")
json_output: Whether to use JSON streaming output
output_schema: Path to JSON schema for structured output
"""
def __init__(
self,
workspace: str = ".",
timeout: int = 300,
full_auto: bool = False,
sandbox: str = "default",
json_output: bool = False,
output_schema: Optional[str] = None,
output_file: Optional[str] = None,
):
"""
Initialize Codex CLI integration.
Args:
workspace: Working directory for CLI execution
timeout: Timeout in seconds for CLI execution
full_auto: Whether to allow file modifications (--full-auto)
sandbox: Sandbox mode ("default", "danger-full-access")
json_output: Whether to use JSON streaming output (--json)
output_schema: Path to JSON schema for structured output
output_file: Path to save the final output (-o)
"""
super().__init__(workspace=workspace, timeout=timeout)
self.full_auto = full_auto
self.sandbox = sandbox
self.json_output = json_output
self.output_schema = output_schema
self.output_file = output_file
@property
def cli_command(self) -> str:
"""Return the CLI command name."""
return "codex"
def _build_command(self, task: str, **options) -> List[str]:
"""
Build the Codex CLI command.
Args:
task: The task to execute
**options: Additional options
Returns:
List of command arguments
"""
cmd = ["codex", "exec"]
# Add task
cmd.append(task)
# Add full auto flag if enabled
if self.full_auto:
cmd.append("--full-auto")
# Add sandbox mode if not default
if self.sandbox and self.sandbox != "default":
cmd.extend(["--sandbox", self.sandbox])
# Add JSON output flag if enabled
if self.json_output:
cmd.append("--json")
# Add output schema if specified
if self.output_schema:
cmd.extend(["--output-schema", self.output_schema])
# Add output file if specified
if self.output_file:
cmd.extend(["-o", self.output_file])
return cmd
async def execute(self, prompt: str, **options) -> str:
"""
Execute Codex CLI and return the result.
Args:
prompt: The task/prompt to execute
**options: Additional options
Returns:
str: The CLI output
"""
cmd = self._build_command(prompt, **options)
output = await self.execute_async(cmd)
# Parse JSON Lines output if json_output is enabled
if self.json_output:
return self._parse_json_events(output)
return output
def _parse_json_events(self, output: str) -> str:
"""
Parse JSON Lines output and extract the final result.
Args:
output: Raw JSON Lines output
Returns:
str: The extracted result
"""
result_parts = []
for line in output.strip().split('\n'):
if not line.strip():
continue
try:
event = json.loads(line)
event_type = event.get("type", "")
# Extract agent messages
if event_type == "item.completed":
item = event.get("item", {})
if item.get("type") == "agent_message":
text = item.get("text", "")
if text:
result_parts.append(text)
except json.JSONDecodeError:
continue
return '\n'.join(result_parts) if result_parts else output
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
"""
Stream output from Codex CLI.
Args:
prompt: The task/prompt to execute
**options: Additional options
Yields:
dict: Parsed JSON events from the CLI
"""
# Ensure JSON output is enabled for streaming
original_json = self.json_output
self.json_output = True
try:
cmd = self._build_command(prompt, **options)
async for line in self.stream_async(cmd):
if line.strip():
try:
event = json.loads(line)
yield event
except json.JSONDecodeError:
yield {"type": "text", "content": line}
finally:
self.json_output = original_json
async def execute_with_schema(
self,
prompt: str,
schema_path: str,
output_path: Optional[str] = None
) -> Dict[str, Any]:
"""
Execute Codex with structured output schema.
Args:
prompt: The task/prompt to execute
schema_path: Path to the JSON schema file
output_path: Optional path to save the output
Returns:
dict: Parsed structured output
"""
original_schema = self.output_schema
original_output = self.output_file
self.output_schema = schema_path
if output_path:
self.output_file = output_path
try:
cmd = self._build_command(prompt)
output = await self.execute_async(cmd)
# If output file was specified, read from it
if output_path and os.path.exists(output_path):
with open(output_path, 'r') as f:
return json.load(f)
# Otherwise parse the output
try:
return json.loads(output)
except json.JSONDecodeError:
return {"result": output}
finally:
self.output_schema = original_schema
self.output_file = original_output
def get_env(self) -> Dict[str, str]:
"""Get environment variables for CLI execution."""
env = super().get_env()
# Codex uses ChatGPT authentication or API key
# The CLI handles authentication internally
return env
@@ -0,0 +1,192 @@
"""
Cursor CLI Integration.
Provides integration with Cursor's CLI (cursor-agent) for AI-powered coding tasks.
Features:
- Headless mode execution with JSON output
- Force mode for file modifications
- Model selection
- Session resume support
- Streaming output with partial deltas
Usage:
from praisonai.integrations import CursorCLIIntegration
# Create integration
cursor = CursorCLIIntegration(workspace="/path/to/project")
# Execute a coding task
result = await cursor.execute("Fix the authentication bug")
# Execute with force mode (allows file modifications)
cursor_force = CursorCLIIntegration(force=True)
result = await cursor_force.execute("Refactor the module")
# Use as agent tool
tool = cursor.as_tool()
"""
import json
import os
from typing import AsyncIterator, Dict, Any, Optional, List
from .base import BaseCLIIntegration
class CursorCLIIntegration(BaseCLIIntegration):
"""
Integration with Cursor CLI (cursor-agent).
Attributes:
output_format: Output format ("json", "text", "stream-json")
force: Whether to allow file modifications
model: Model to use (e.g., "gpt-5")
stream_partial: Whether to stream partial output
resume_session: Session ID to resume
"""
def __init__(
self,
workspace: str = ".",
timeout: int = 300,
output_format: str = "json",
force: bool = False,
model: Optional[str] = None,
stream_partial: bool = False,
resume_session: Optional[str] = None,
):
"""
Initialize Cursor CLI integration.
Args:
workspace: Working directory for CLI execution
timeout: Timeout in seconds for CLI execution
output_format: Output format ("json", "text", "stream-json")
force: Whether to allow file modifications (--force)
model: Model to use (e.g., "gpt-5")
stream_partial: Whether to stream partial output (--stream-partial-output)
resume_session: Session ID to resume (--resume)
"""
super().__init__(workspace=workspace, timeout=timeout)
self.output_format = output_format
self.force = force
self.model = model
self.stream_partial = stream_partial
self.resume_session = resume_session
@property
def cli_command(self) -> str:
"""Return the CLI command name."""
return "cursor-agent"
def _build_command(self, prompt: str, **options) -> List[str]:
"""
Build the Cursor CLI command.
Args:
prompt: The prompt to send
**options: Additional options
Returns:
List of command arguments
"""
cmd = ["cursor-agent"]
# Add print mode flag
cmd.append("-p")
# Add force flag if enabled
if self.force:
cmd.append("--force")
# Add model if specified
if self.model:
cmd.extend(["-m", self.model])
# Add output format
cmd.extend(["--output-format", self.output_format])
# Add stream partial flag if enabled
if self.stream_partial:
cmd.append("--stream-partial-output")
# Add resume session if specified
if self.resume_session:
cmd.extend(["--resume", self.resume_session])
# Add prompt (must be last)
cmd.append(prompt)
return cmd
async def execute(self, prompt: str, **options) -> str:
"""
Execute Cursor CLI and return the result.
Args:
prompt: The prompt/query to send
**options: Additional options
Returns:
str: The CLI output (parsed from JSON if output_format is "json")
"""
cmd = self._build_command(prompt, **options)
output = await self.execute_async(cmd)
# Parse JSON output if applicable
if self.output_format == "json":
try:
data = json.loads(output)
# Extract the main result
if isinstance(data, dict):
return data.get("result", data.get("content", str(data)))
return str(data)
except json.JSONDecodeError:
return output
return output
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
"""
Stream output from Cursor CLI.
Args:
prompt: The prompt/query to send
**options: Additional options
Yields:
dict: Parsed JSON events from the CLI
"""
# Use stream-json format for streaming
original_format = self.output_format
original_partial = self.stream_partial
self.output_format = "stream-json"
self.stream_partial = True
try:
cmd = self._build_command(prompt, **options)
async for line in self.stream_async(cmd):
if line.strip():
try:
event = json.loads(line)
yield event
except json.JSONDecodeError:
yield {"type": "text", "content": line}
finally:
self.output_format = original_format
self.stream_partial = original_partial
def get_env(self) -> Dict[str, str]:
"""Get environment variables for CLI execution."""
env = super().get_env()
# Add Cursor API key if available
if "CURSOR_API_KEY" in os.environ:
env["CURSOR_API_KEY"] = os.environ["CURSOR_API_KEY"]
return env
@@ -0,0 +1,222 @@
"""
Gemini CLI Integration.
Provides integration with Google's Gemini CLI for AI-powered coding tasks.
Features:
- Headless mode execution with JSON output
- Model selection (gemini-2.5-pro, gemini-2.5-flash, etc.)
- Multi-directory context support
- Google Search grounding
- Usage statistics
Usage:
from praisonai.integrations import GeminiCLIIntegration
# Create integration
gemini = GeminiCLIIntegration(workspace="/path/to/project")
# Execute a coding task
result = await gemini.execute("Analyze this codebase")
# Get result with stats
result, stats = await gemini.execute_with_stats("Explain the architecture")
# Use as agent tool
tool = gemini.as_tool()
"""
import json
import os
from typing import AsyncIterator, Dict, Any, Optional, List, Tuple
from .base import BaseCLIIntegration
class GeminiCLIIntegration(BaseCLIIntegration):
"""
Integration with Google's Gemini CLI.
Attributes:
output_format: Output format ("json", "text", "stream-json")
model: Gemini model to use
include_directories: Additional directories to include in context
sandbox: Whether to run in sandbox mode
"""
def __init__(
self,
workspace: str = ".",
timeout: int = 300,
output_format: str = "json",
model: str = "gemini-2.5-pro",
include_directories: Optional[List[str]] = None,
sandbox: bool = False,
):
"""
Initialize Gemini CLI integration.
Args:
workspace: Working directory for CLI execution
timeout: Timeout in seconds for CLI execution
output_format: Output format ("json", "text", "stream-json")
model: Gemini model to use (e.g., "gemini-2.5-pro", "gemini-2.5-flash")
include_directories: Additional directories to include in context
sandbox: Whether to run in sandbox mode
"""
super().__init__(workspace=workspace, timeout=timeout)
self.output_format = output_format
self.model = model
self.include_directories = include_directories
self.sandbox = sandbox
# Store last stats for retrieval
self._last_stats: Optional[Dict[str, Any]] = None
@property
def cli_command(self) -> str:
"""Return the CLI command name."""
return "gemini"
def _build_command(self, prompt: str, **options) -> List[str]:
"""
Build the Gemini CLI command.
Args:
prompt: The prompt to send
**options: Additional options
Returns:
List of command arguments
"""
cmd = ["gemini"]
# Add print mode flag
cmd.append("-p")
# Add prompt
cmd.append(prompt)
# Add model
cmd.extend(["-m", self.model])
# Add output format
cmd.extend(["--output-format", self.output_format])
# Add include directories if specified
if self.include_directories:
cmd.extend(["--include-directories", ",".join(self.include_directories)])
# Add sandbox flag if enabled
if self.sandbox:
cmd.append("--sandbox")
return cmd
async def execute(self, prompt: str, **options) -> str:
"""
Execute Gemini CLI and return the result.
Args:
prompt: The prompt/query to send
**options: Additional options
Returns:
str: The CLI output (parsed from JSON if output_format is "json")
"""
cmd = self._build_command(prompt, **options)
output = await self.execute_async(cmd)
# Parse JSON output if applicable
if self.output_format == "json":
try:
data = json.loads(output)
# Store stats for later retrieval
self._last_stats = data.get("stats")
# Extract the main response
return data.get("response", str(data))
except json.JSONDecodeError:
return output
return output
async def execute_with_stats(self, prompt: str, **options) -> Tuple[str, Optional[Dict[str, Any]]]:
"""
Execute Gemini CLI and return both result and usage stats.
Args:
prompt: The prompt/query to send
**options: Additional options
Returns:
Tuple[str, dict]: (result, stats) where stats contains usage information
"""
# Ensure JSON format for stats
original_format = self.output_format
self.output_format = "json"
try:
cmd = self._build_command(prompt, **options)
output = await self.execute_async(cmd)
try:
data = json.loads(output)
response = data.get("response", str(data))
stats = data.get("stats")
return response, stats
except json.JSONDecodeError:
return output, None
finally:
self.output_format = original_format
async def stream(self, prompt: str, **options) -> AsyncIterator[Dict[str, Any]]:
"""
Stream output from Gemini CLI.
Args:
prompt: The prompt/query to send
**options: Additional options
Yields:
dict: Parsed JSON events from the CLI
"""
# Use stream-json format for streaming
original_format = self.output_format
self.output_format = "stream-json"
try:
cmd = self._build_command(prompt, **options)
async for line in self.stream_async(cmd):
if line.strip():
try:
event = json.loads(line)
yield event
except json.JSONDecodeError:
yield {"type": "text", "content": line}
finally:
self.output_format = original_format
def get_last_stats(self) -> Optional[Dict[str, Any]]:
"""
Get the stats from the last execution.
Returns:
dict: Usage statistics or None if not available
"""
return self._last_stats
def get_env(self) -> Dict[str, str]:
"""Get environment variables for CLI execution."""
env = super().get_env()
# Add Google API key if available
if "GOOGLE_API_KEY" in os.environ:
env["GOOGLE_API_KEY"] = os.environ["GOOGLE_API_KEY"]
elif "GEMINI_API_KEY" in os.environ:
# Map GEMINI_API_KEY to GOOGLE_API_KEY
env["GOOGLE_API_KEY"] = os.environ["GEMINI_API_KEY"]
return env
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.3.43"
__version__ = "2.3.44"
@@ -0,0 +1,271 @@
"""
Integration tests for CLI tool integrations.
These tests require the actual CLI tools to be installed and API keys to be set.
Run with: pytest tests/integration/test_cli_integrations.py -v
Environment variables required:
- OPENAI_API_KEY: For general testing
- ANTHROPIC_API_KEY or CLAUDE_API_KEY: For Claude Code
- GEMINI_API_KEY or GOOGLE_API_KEY: For Gemini CLI
- CURSOR_API_KEY: For Cursor CLI
"""
import pytest
import os
import asyncio
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
# Skip all tests if no API keys are set
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestIntegrationAvailability:
"""Test that integrations can be imported and checked."""
def test_import_all_integrations(self):
"""Test importing all integrations."""
from praisonai.integrations import (
BaseCLIIntegration,
ClaudeCodeIntegration,
GeminiCLIIntegration,
CodexCLIIntegration,
CursorCLIIntegration,
)
assert BaseCLIIntegration is not None
assert ClaudeCodeIntegration is not None
assert GeminiCLIIntegration is not None
assert CodexCLIIntegration is not None
assert CursorCLIIntegration is not None
def test_get_available_integrations(self):
"""Test getting available integrations."""
from praisonai.integrations import get_available_integrations
availability = get_available_integrations()
assert isinstance(availability, dict)
assert "claude" in availability
assert "gemini" in availability
assert "codex" in availability
assert "cursor" in availability
print("\n📊 Integration Availability:")
for name, available in availability.items():
status = "✅" if available else "❌"
print(f" {status} {name}")
class TestClaudeCodeIntegrationReal:
"""Real integration tests for Claude Code CLI."""
@pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("CLAUDE_API_KEY"),
reason="ANTHROPIC_API_KEY or CLAUDE_API_KEY not set"
)
def test_claude_integration_creation(self):
"""Test creating Claude Code integration."""
from praisonai.integrations import ClaudeCodeIntegration
integration = ClaudeCodeIntegration(
workspace=".",
output_format="json",
skip_permissions=True
)
assert integration.cli_command == "claude"
print(f"\n🔧 Claude CLI available: {integration.is_available}")
@pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("CLAUDE_API_KEY"),
reason="ANTHROPIC_API_KEY or CLAUDE_API_KEY not set"
)
def test_claude_as_tool(self):
"""Test Claude Code as agent tool."""
from praisonai.integrations import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
tool = integration.as_tool()
assert callable(tool)
assert tool.__name__ == "claude_tool"
print(f"\n🔧 Claude tool created: {tool.__name__}")
class TestGeminiCLIIntegrationReal:
"""Real integration tests for Gemini CLI."""
@pytest.mark.skipif(
not os.environ.get("GEMINI_API_KEY") and not os.environ.get("GOOGLE_API_KEY"),
reason="GEMINI_API_KEY or GOOGLE_API_KEY not set"
)
def test_gemini_integration_creation(self):
"""Test creating Gemini CLI integration."""
from praisonai.integrations import GeminiCLIIntegration
integration = GeminiCLIIntegration(
workspace=".",
model="gemini-2.5-pro",
output_format="json"
)
assert integration.cli_command == "gemini"
assert integration.model == "gemini-2.5-pro"
print(f"\n🔧 Gemini CLI available: {integration.is_available}")
@pytest.mark.skipif(
not os.environ.get("GEMINI_API_KEY") and not os.environ.get("GOOGLE_API_KEY"),
reason="GEMINI_API_KEY or GOOGLE_API_KEY not set"
)
def test_gemini_as_tool(self):
"""Test Gemini CLI as agent tool."""
from praisonai.integrations import GeminiCLIIntegration
integration = GeminiCLIIntegration()
tool = integration.as_tool()
assert callable(tool)
assert tool.__name__ == "gemini_tool"
print(f"\n🔧 Gemini tool created: {tool.__name__}")
class TestCursorCLIIntegrationReal:
"""Real integration tests for Cursor CLI."""
@pytest.mark.skipif(
not os.environ.get("CURSOR_API_KEY"),
reason="CURSOR_API_KEY not set"
)
def test_cursor_integration_creation(self):
"""Test creating Cursor CLI integration."""
from praisonai.integrations import CursorCLIIntegration
integration = CursorCLIIntegration(
workspace=".",
output_format="json",
force=False
)
assert integration.cli_command == "cursor-agent"
print(f"\n🔧 Cursor CLI available: {integration.is_available}")
@pytest.mark.skipif(
not os.environ.get("CURSOR_API_KEY"),
reason="CURSOR_API_KEY not set"
)
def test_cursor_as_tool(self):
"""Test Cursor CLI as agent tool."""
from praisonai.integrations import CursorCLIIntegration
integration = CursorCLIIntegration()
tool = integration.as_tool()
assert callable(tool)
assert tool.__name__ == "cursor-agent_tool"
print(f"\n🔧 Cursor tool created: {tool.__name__}")
class TestExternalAgentsHandler:
"""Test the ExternalAgentsHandler."""
def test_handler_creation(self):
"""Test creating the handler."""
from praisonai.cli.features import ExternalAgentsHandler
handler = ExternalAgentsHandler(verbose=True)
assert handler.feature_name == "external_agents"
assert handler.flag_name == "external-agent"
def test_handler_list_integrations(self):
"""Test listing integrations."""
from praisonai.cli.features import ExternalAgentsHandler
handler = ExternalAgentsHandler()
integrations = handler.list_integrations()
assert "claude" in integrations
assert "gemini" in integrations
assert "codex" in integrations
assert "cursor" in integrations
print("\n📋 Available integrations:", integrations)
def test_handler_check_availability(self):
"""Test checking availability."""
from praisonai.cli.features import ExternalAgentsHandler
handler = ExternalAgentsHandler()
availability = handler.check_availability()
print("\n📊 Handler Availability Check:")
for name, available in availability.items():
status = "✅" if available else "❌"
print(f" {status} {name}")
class TestPerformanceImpact:
"""Test that integrations don't impact performance."""
def test_lazy_loading_integrations(self):
"""Test that integrations are lazy loaded."""
import time
# Measure import time
start = time.time()
from praisonai.integrations import BaseCLIIntegration
import_time = time.time() - start
print(f"\n⏱️ Import time: {import_time*1000:.2f}ms")
# Should be fast (< 100ms)
assert import_time < 0.1, f"Import took too long: {import_time}s"
def test_lazy_loading_handler(self):
"""Test that handler is lazy loaded."""
import time
# Measure import time
start = time.time()
from praisonai.cli.features import ExternalAgentsHandler
import_time = time.time() - start
print(f"\n⏱️ Handler import time: {import_time*1000:.2f}ms")
# Should be fast (< 100ms)
assert import_time < 0.1, f"Import took too long: {import_time}s"
def test_availability_caching(self):
"""Test that availability checks are cached."""
import time
from praisonai.integrations import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
# First check
start = time.time()
_ = integration.is_available
first_time = time.time() - start
# Second check (should be cached)
start = time.time()
_ = integration.is_available
second_time = time.time() - start
print(f"\n⏱️ First availability check: {first_time*1000:.4f}ms")
print(f"⏱️ Second availability check: {second_time*1000:.4f}ms")
# Both should be very fast (< 10ms) due to caching
assert first_time < 0.01, f"First check too slow: {first_time}s"
assert second_time < 0.01, f"Second check too slow: {second_time}s"
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,173 @@
"""
Tests for ClaudeCodeIntegration.
TDD approach: Write tests first, then implement.
"""
import pytest
import json
from unittest.mock import patch, AsyncMock, MagicMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
class TestClaudeCodeIntegration:
"""Tests for ClaudeCodeIntegration class."""
def test_import_claude_code_integration(self):
"""Test that ClaudeCodeIntegration can be imported."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
assert ClaudeCodeIntegration is not None
def test_cli_command_is_claude(self):
"""Test that cli_command returns 'claude'."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
assert integration.cli_command == "claude"
def test_inherits_from_base(self):
"""Test that ClaudeCodeIntegration inherits from BaseCLIIntegration."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
from praisonai.integrations.base import BaseCLIIntegration
integration = ClaudeCodeIntegration()
assert isinstance(integration, BaseCLIIntegration)
def test_default_options(self):
"""Test default options are set correctly."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
assert integration.output_format == "json"
assert integration.skip_permissions is True
def test_custom_options(self):
"""Test custom options can be set."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration(
output_format="text",
skip_permissions=False,
system_prompt="You are a Python expert"
)
assert integration.output_format == "text"
assert integration.skip_permissions is False
assert integration.system_prompt == "You are a Python expert"
def test_build_command_basic(self):
"""Test building basic command."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
cmd = integration._build_command("Hello")
assert cmd[0] == "claude"
assert "-p" in cmd
assert "Hello" in cmd
assert "--output-format" in cmd
assert "json" in cmd
def test_build_command_with_continue(self):
"""Test building command with continue flag."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
cmd = integration._build_command("Hello", continue_session=True)
assert "--continue" in cmd
def test_build_command_with_system_prompt(self):
"""Test building command with system prompt."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration(system_prompt="Be helpful")
cmd = integration._build_command("Hello")
assert "--append-system-prompt" in cmd
assert "Be helpful" in cmd
def test_build_command_with_allowed_tools(self):
"""Test building command with allowed tools."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration(allowed_tools=["Read", "Write"])
cmd = integration._build_command("Hello")
assert "--allowedTools" in cmd
class TestClaudeCodeIntegrationAsync:
"""Async tests for ClaudeCodeIntegration."""
@pytest.mark.asyncio
async def test_execute_parses_json_output(self):
"""Test that execute parses JSON output correctly."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
mock_output = json.dumps({
"result": "Hello, world!",
"cost_usd": 0.001
})
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = mock_output
result = await integration.execute("Say hello")
assert "Hello, world!" in result
@pytest.mark.asyncio
async def test_execute_handles_text_output(self):
"""Test that execute handles text output format."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration(output_format="text")
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Hello, world!"
result = await integration.execute("Say hello")
assert result == "Hello, world!"
@pytest.mark.asyncio
async def test_stream_yields_events(self):
"""Test that stream yields parsed events."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
async def mock_stream(*args, **kwargs):
yield '{"type": "assistant", "content": "Hello"}'
yield '{"type": "result", "content": "Done"}'
with patch.object(integration, 'stream_async', side_effect=mock_stream):
events = []
async for event in integration.stream("Say hello"):
events.append(event)
assert len(events) >= 0 # May be empty if stream_async not called correctly
class TestClaudeCodeSDKIntegration:
"""Tests for Claude Code SDK integration (when available)."""
def test_sdk_available_property(self):
"""Test sdk_available property."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration()
# Should be a boolean
assert isinstance(integration.sdk_available, bool)
def test_use_sdk_option(self):
"""Test use_sdk option."""
from praisonai.integrations.claude_code import ClaudeCodeIntegration
integration = ClaudeCodeIntegration(use_sdk=True)
assert integration.use_sdk is True
integration2 = ClaudeCodeIntegration(use_sdk=False)
assert integration2.use_sdk is False
@@ -0,0 +1,154 @@
"""
Tests for CodexCLIIntegration.
TDD approach: Write tests first, then implement.
"""
import pytest
import json
from unittest.mock import patch, AsyncMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
class TestCodexCLIIntegration:
"""Tests for CodexCLIIntegration class."""
def test_import_codex_cli_integration(self):
"""Test that CodexCLIIntegration can be imported."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
assert CodexCLIIntegration is not None
def test_cli_command_is_codex(self):
"""Test that cli_command returns 'codex'."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration()
assert integration.cli_command == "codex"
def test_inherits_from_base(self):
"""Test that CodexCLIIntegration inherits from BaseCLIIntegration."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
from praisonai.integrations.base import BaseCLIIntegration
integration = CodexCLIIntegration()
assert isinstance(integration, BaseCLIIntegration)
def test_default_options(self):
"""Test default options are set correctly."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration()
assert integration.full_auto is False
assert integration.sandbox == "default"
def test_full_auto_option(self):
"""Test full_auto option can be set."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(full_auto=True)
assert integration.full_auto is True
def test_sandbox_option(self):
"""Test sandbox option can be set."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(sandbox="danger-full-access")
assert integration.sandbox == "danger-full-access"
def test_build_command_basic(self):
"""Test building basic command."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration()
cmd = integration._build_command("Fix the bug")
assert cmd[0] == "codex"
assert "exec" in cmd
assert "Fix the bug" in cmd
def test_build_command_with_full_auto(self):
"""Test building command with full_auto."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(full_auto=True)
cmd = integration._build_command("Fix the bug")
assert "--full-auto" in cmd
def test_build_command_with_json(self):
"""Test building command with JSON output."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(json_output=True)
cmd = integration._build_command("Fix the bug")
assert "--json" in cmd
def test_build_command_with_sandbox(self):
"""Test building command with sandbox."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(sandbox="danger-full-access")
cmd = integration._build_command("Fix the bug")
assert "--sandbox" in cmd
assert "danger-full-access" in cmd
class TestCodexCLIIntegrationAsync:
"""Async tests for CodexCLIIntegration."""
@pytest.mark.asyncio
async def test_execute_returns_result(self):
"""Test that execute returns the result."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration()
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Task completed successfully"
result = await integration.execute("Fix the bug")
assert "Task completed" in result
@pytest.mark.asyncio
async def test_execute_parses_json_events(self):
"""Test that execute parses JSON events when json_output is True."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(json_output=True)
mock_output = '\n'.join([
'{"type":"thread.started","thread_id":"123"}',
'{"type":"item.completed","item":{"type":"agent_message","text":"Done"}}',
'{"type":"turn.completed"}'
])
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = mock_output
result = await integration.execute("Fix the bug")
assert "Done" in result
class TestCodexCLIStructuredOutput:
"""Tests for Codex CLI structured output."""
def test_output_schema_option(self):
"""Test output_schema option."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(output_schema="/path/to/schema.json")
assert integration.output_schema == "/path/to/schema.json"
def test_build_command_with_output_schema(self):
"""Test building command with output schema."""
from praisonai.integrations.codex_cli import CodexCLIIntegration
integration = CodexCLIIntegration(output_schema="/path/to/schema.json")
cmd = integration._build_command("Extract metadata")
assert "--output-schema" in cmd
assert "/path/to/schema.json" in cmd
@@ -0,0 +1,177 @@
"""
Tests for CursorCLIIntegration.
TDD approach: Write tests first, then implement.
"""
import pytest
import json
from unittest.mock import patch, AsyncMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
class TestCursorCLIIntegration:
"""Tests for CursorCLIIntegration class."""
def test_import_cursor_cli_integration(self):
"""Test that CursorCLIIntegration can be imported."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
assert CursorCLIIntegration is not None
def test_cli_command_is_cursor_agent(self):
"""Test that cli_command returns 'cursor-agent'."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration()
assert integration.cli_command == "cursor-agent"
def test_inherits_from_base(self):
"""Test that CursorCLIIntegration inherits from BaseCLIIntegration."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
from praisonai.integrations.base import BaseCLIIntegration
integration = CursorCLIIntegration()
assert isinstance(integration, BaseCLIIntegration)
def test_default_options(self):
"""Test default options are set correctly."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration()
assert integration.output_format == "json"
assert integration.force is False
def test_force_option(self):
"""Test force option can be set."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(force=True)
assert integration.force is True
def test_model_option(self):
"""Test model option can be set."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(model="gpt-5")
assert integration.model == "gpt-5"
def test_build_command_basic(self):
"""Test building basic command."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration()
cmd = integration._build_command("Fix the bug")
assert cmd[0] == "cursor-agent"
assert "-p" in cmd
assert "Fix the bug" in cmd
assert "--output-format" in cmd
assert "json" in cmd
def test_build_command_with_force(self):
"""Test building command with force flag."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(force=True)
cmd = integration._build_command("Fix the bug")
assert "--force" in cmd
def test_build_command_with_model(self):
"""Test building command with model."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(model="gpt-5")
cmd = integration._build_command("Fix the bug")
assert "-m" in cmd
assert "gpt-5" in cmd
def test_build_command_with_stream_partial(self):
"""Test building command with stream partial output."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(stream_partial=True)
cmd = integration._build_command("Fix the bug")
assert "--stream-partial-output" in cmd
class TestCursorCLIIntegrationAsync:
"""Async tests for CursorCLIIntegration."""
@pytest.mark.asyncio
async def test_execute_parses_json_output(self):
"""Test that execute parses JSON output correctly."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration()
mock_output = json.dumps({
"result": "Bug fixed successfully"
})
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = mock_output
result = await integration.execute("Fix the bug")
assert "Bug fixed" in result
@pytest.mark.asyncio
async def test_execute_handles_text_output(self):
"""Test that execute handles text output format."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(output_format="text")
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Bug fixed successfully"
result = await integration.execute("Fix the bug")
assert result == "Bug fixed successfully"
class TestCursorCLIEnvironment:
"""Tests for Cursor CLI environment handling."""
def test_get_env_includes_cursor_api_key(self):
"""Test that get_env includes Cursor API key."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration()
with patch.dict(os.environ, {"CURSOR_API_KEY": "test-key"}):
env = integration.get_env()
assert "CURSOR_API_KEY" in env
def test_api_key_required_for_headless(self):
"""Test that API key is required for headless mode."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration()
# Should not raise, just return env without key if not set
env = integration.get_env()
assert isinstance(env, dict)
class TestCursorCLISession:
"""Tests for Cursor CLI session management."""
def test_resume_option(self):
"""Test resume option."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(resume_session="session-123")
assert integration.resume_session == "session-123"
def test_build_command_with_resume(self):
"""Test building command with resume."""
from praisonai.integrations.cursor_cli import CursorCLIIntegration
integration = CursorCLIIntegration(resume_session="session-123")
cmd = integration._build_command("Continue the task")
assert "--resume" in cmd
assert "session-123" in cmd
@@ -0,0 +1,170 @@
"""
Tests for ExternalAgentsHandler.
TDD approach: Write tests first, then implement.
"""
import pytest
from unittest.mock import patch, MagicMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
class TestExternalAgentsHandler:
"""Tests for ExternalAgentsHandler class."""
def test_import_external_agents_handler(self):
"""Test that ExternalAgentsHandler can be imported."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
assert ExternalAgentsHandler is not None
def test_feature_name(self):
"""Test that feature_name is correct."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
assert handler.feature_name == "external_agents"
def test_flag_name(self):
"""Test that flag_name is correct."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
assert handler.flag_name == "external-agent"
def test_supported_integrations(self):
"""Test that all integrations are supported."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
assert "claude" in handler.INTEGRATIONS
assert "gemini" in handler.INTEGRATIONS
assert "codex" in handler.INTEGRATIONS
assert "cursor" in handler.INTEGRATIONS
def test_get_integration_claude(self):
"""Test getting Claude integration."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
from praisonai.integrations.claude_code import ClaudeCodeIntegration
handler = ExternalAgentsHandler()
integration = handler.get_integration("claude")
assert isinstance(integration, ClaudeCodeIntegration)
def test_get_integration_gemini(self):
"""Test getting Gemini integration."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
handler = ExternalAgentsHandler()
integration = handler.get_integration("gemini")
assert isinstance(integration, GeminiCLIIntegration)
def test_get_integration_codex(self):
"""Test getting Codex integration."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
from praisonai.integrations.codex_cli import CodexCLIIntegration
handler = ExternalAgentsHandler()
integration = handler.get_integration("codex")
assert isinstance(integration, CodexCLIIntegration)
def test_get_integration_cursor(self):
"""Test getting Cursor integration."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
from praisonai.integrations.cursor_cli import CursorCLIIntegration
handler = ExternalAgentsHandler()
integration = handler.get_integration("cursor")
assert isinstance(integration, CursorCLIIntegration)
def test_get_integration_invalid(self):
"""Test getting invalid integration raises error."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
with pytest.raises(ValueError):
handler.get_integration("invalid")
def test_list_integrations(self):
"""Test listing all integrations."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
integrations = handler.list_integrations()
assert len(integrations) == 4
assert all(name in integrations for name in ["claude", "gemini", "codex", "cursor"])
def test_check_availability(self):
"""Test checking integration availability."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
availability = handler.check_availability()
assert isinstance(availability, dict)
assert "claude" in availability
assert isinstance(availability["claude"], bool)
class TestExternalAgentsHandlerConfig:
"""Tests for ExternalAgentsHandler configuration."""
def test_apply_to_agent_config_adds_tool(self):
"""Test that apply_to_agent_config adds tool to config."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
config = {"tools": []}
# Mock the integration to be available
with patch.object(handler, 'get_integration') as mock_get:
mock_integration = MagicMock()
mock_integration.is_available = True
mock_integration.as_tool.return_value = lambda x: x
mock_get.return_value = mock_integration
result = handler.apply_to_agent_config(config, "claude")
assert len(result["tools"]) == 1
def test_apply_to_agent_config_skips_unavailable(self):
"""Test that apply_to_agent_config skips unavailable integrations."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
config = {"tools": []}
with patch.object(handler, 'get_integration') as mock_get:
mock_integration = MagicMock()
mock_integration.is_available = False
mock_get.return_value = mock_integration
result = handler.apply_to_agent_config(config, "claude")
assert len(result["tools"]) == 0
class TestExternalAgentsHandlerExecution:
"""Tests for ExternalAgentsHandler execution."""
def test_execute_returns_integration(self):
"""Test that execute returns the integration."""
from praisonai.cli.features.external_agents import ExternalAgentsHandler
handler = ExternalAgentsHandler()
with patch.object(handler, 'get_integration') as mock_get:
mock_integration = MagicMock()
mock_integration.is_available = True
mock_get.return_value = mock_integration
result = handler.execute(integration_name="claude")
assert result == mock_integration
@@ -0,0 +1,168 @@
"""
Tests for GeminiCLIIntegration.
TDD approach: Write tests first, then implement.
"""
import pytest
import json
from unittest.mock import patch, AsyncMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
class TestGeminiCLIIntegration:
"""Tests for GeminiCLIIntegration class."""
def test_import_gemini_cli_integration(self):
"""Test that GeminiCLIIntegration can be imported."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
assert GeminiCLIIntegration is not None
def test_cli_command_is_gemini(self):
"""Test that cli_command returns 'gemini'."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
assert integration.cli_command == "gemini"
def test_inherits_from_base(self):
"""Test that GeminiCLIIntegration inherits from BaseCLIIntegration."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
from praisonai.integrations.base import BaseCLIIntegration
integration = GeminiCLIIntegration()
assert isinstance(integration, BaseCLIIntegration)
def test_default_options(self):
"""Test default options are set correctly."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
assert integration.output_format == "json"
assert integration.model == "gemini-2.5-pro"
def test_custom_model(self):
"""Test custom model can be set."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration(model="gemini-2.5-flash")
assert integration.model == "gemini-2.5-flash"
def test_build_command_basic(self):
"""Test building basic command."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
cmd = integration._build_command("Hello")
assert cmd[0] == "gemini"
assert "-p" in cmd
assert "Hello" in cmd
assert "--output-format" in cmd
assert "json" in cmd
def test_build_command_with_model(self):
"""Test building command with model."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration(model="gemini-2.5-flash")
cmd = integration._build_command("Hello")
assert "-m" in cmd
assert "gemini-2.5-flash" in cmd
def test_build_command_with_include_directories(self):
"""Test building command with include directories."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration(include_directories=["../lib", "../docs"])
cmd = integration._build_command("Hello")
assert "--include-directories" in cmd
class TestGeminiCLIIntegrationAsync:
"""Async tests for GeminiCLIIntegration."""
@pytest.mark.asyncio
async def test_execute_parses_json_output(self):
"""Test that execute parses JSON output correctly."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
mock_output = json.dumps({
"response": "Hello, world!",
"stats": {"models": {}}
})
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = mock_output
result = await integration.execute("Say hello")
assert "Hello, world!" in result
@pytest.mark.asyncio
async def test_execute_handles_text_output(self):
"""Test that execute handles text output format."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration(output_format="text")
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Hello, world!"
result = await integration.execute("Say hello")
assert result == "Hello, world!"
@pytest.mark.asyncio
async def test_get_stats_returns_usage_info(self):
"""Test that get_stats returns usage information."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
mock_output = json.dumps({
"response": "Hello",
"stats": {
"models": {
"gemini-2.5-pro": {
"tokens": {"prompt": 100, "candidates": 50}
}
}
}
})
with patch.object(integration, 'execute_async', new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = mock_output
result, stats = await integration.execute_with_stats("Say hello")
assert stats is not None
assert "models" in stats
class TestGeminiCLIEnvironment:
"""Tests for Gemini CLI environment handling."""
def test_get_env_includes_google_api_key(self):
"""Test that get_env includes Google API key."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
with patch.dict(os.environ, {"GOOGLE_API_KEY": "test-key"}):
env = integration.get_env()
assert "GOOGLE_API_KEY" in env
def test_get_env_includes_gemini_api_key(self):
"""Test that get_env includes Gemini API key."""
from praisonai.integrations.gemini_cli import GeminiCLIIntegration
integration = GeminiCLIIntegration()
with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}):
env = integration.get_env()
# Should map GEMINI_API_KEY to GOOGLE_API_KEY
assert "GOOGLE_API_KEY" in env or "GEMINI_API_KEY" in env