diff --git a/README.md b/README.md index a631f4fe..28c4bc08 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ Join Discord Community + +OPACTOR Website + + +Follow Aaron +

## What is Claudable? @@ -224,4 +230,19 @@ If you encounter the error: `Error output dangerously skip permissions cannot be ## License -MIT License. \ No newline at end of file +MIT License. + +## Upcoming Features +These features are in development and will be opened soon. +- **New CLI Agents** - Trust us, you're going to LOVE this! +- **Checkpoints for Chat** - Save and restore conversation/codebase states +- **Advanced MCP Integration** - Native integration with MCP +- **Enhanced Agent System** - Subagents, AGENTS.md integration +- **Website Cloning** - You can start a project from a reference URL. +- Various bug fixes and community PR merges + +We're working hard to deliver the features you've been asking for. Stay tuned! + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=opactorai/Claudable&type=Date)](https://www.star-history.com/#opactorai/Claudable&Date) diff --git a/apps/api/app/api/assets.py b/apps/api/app/api/assets.py index ebf14305..a4c07005 100644 --- a/apps/api/app/api/assets.py +++ b/apps/api/app/api/assets.py @@ -28,6 +28,27 @@ async def upload_logo(project_id: str, body: LogoRequest, db: Session = Depends( return {"path": f"assets/logo.png"} +@router.get("/{project_id}/{filename}") +async def get_image(project_id: str, filename: str, db: Session = Depends(get_db)): + """Get an image file from project assets directory""" + from fastapi.responses import FileResponse + + # Verify project exists + row = db.get(ProjectModel, project_id) + if not row: + raise HTTPException(status_code=404, detail="Project not found") + + # Build file path + file_path = os.path.join(settings.projects_root, project_id, "assets", filename) + + # Check if file exists + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Image not found") + + # Return the image file + return FileResponse(file_path) + + @router.post("/{project_id}/upload") async def upload_image(project_id: str, file: UploadFile = File(...), db: Session = Depends(get_db)): """Upload an image file to project assets directory""" diff --git a/apps/api/app/api/chat/act.py b/apps/api/app/api/chat/act.py index 7ea61cb9..3ea151ee 100644 --- a/apps/api/app/api/chat/act.py +++ b/apps/api/app/api/chat/act.py @@ -16,7 +16,8 @@ from app.models.sessions import Session as ChatSession from app.models.commits import Commit from app.models.user_requests import UserRequest -from app.services.cli.unified_manager import UnifiedCLIManager, CLIType +from app.services.cli.unified_manager import UnifiedCLIManager +from app.services.cli.base import CLIType from app.services.git_ops import commit_all from app.core.websocket.manager import manager from app.core.terminal_ui import ui @@ -27,7 +28,9 @@ class ImageAttachment(BaseModel): name: str - base64_data: str + # Either base64_data or path must be provided + base64_data: Optional[str] = None + path: Optional[str] = None # Absolute path to image file mime_type: str = "image/jpeg" @@ -156,11 +159,14 @@ async def execute_chat_task( db=db ) + # Qwen Coder does not support images yet; drop them to prevent errors + safe_images = [] if cli_preference == CLIType.QWEN else images + result = await cli_manager.execute_instruction( instruction=instruction, cli_type=cli_preference, fallback_enabled=project_fallback_enabled, - images=images, + images=safe_images, model=project_selected_model, is_initial_prompt=is_initial_prompt ) @@ -318,11 +324,14 @@ async def execute_act_task( db=db ) + # Qwen Coder does not support images yet; drop them to prevent errors + safe_images = [] if cli_preference == CLIType.QWEN else images + result = await cli_manager.execute_instruction( instruction=instruction, cli_type=cli_preference, fallback_enabled=project_fallback_enabled, - images=images, + images=safe_images, model=project_selected_model, is_initial_prompt=is_initial_prompt ) @@ -511,23 +520,94 @@ async def run_act( ui.error(f"Project {project_id} not found", "ACT API") raise HTTPException(status_code=404, detail="Project not found") + # Check if project is still initializing + if project.status == "initializing": + ui.error(f"Project {project_id} is still initializing", "ACT API") + raise HTTPException(status_code=400, detail="Project is still initializing. Please wait for initialization to complete.") + + # Check if project has a valid repo_path + if not project.repo_path: + ui.error(f"Project {project_id} repository path not available", "ACT API") + raise HTTPException(status_code=400, detail="Project repository is not ready. Please wait for initialization to complete.") + # Determine CLI preference cli_preference = CLIType(body.cli_preference or project.preferred_cli) fallback_enabled = body.fallback_enabled if body.fallback_enabled is not None else project.fallback_enabled conversation_id = body.conversation_id or str(uuid.uuid4()) - # Save user instruction as message + # πŸ” DEBUG: Log incoming request data + print(f"πŸ“₯ ACT Request - Project: {project_id}") + print(f"πŸ“₯ Instruction: {body.instruction[:100]}...") + print(f"πŸ“₯ Images count: {len(body.images)}") + print(f"πŸ“₯ Images data: {body.images}") + for i, img in enumerate(body.images): + print(f"πŸ“₯ Image {i+1}: {img}") + if hasattr(img, '__dict__'): + print(f"πŸ“₯ Image {i+1} dict: {img.__dict__}") + + # Extract image paths and build attachments for metadata/WS + image_paths = [] + attachments = [] + import os as _os + + print(f"πŸ” Processing {len(body.images)} images...") + for i, img in enumerate(body.images): + print(f"πŸ” Processing image {i+1}: {img}") + + img_dict = img if isinstance(img, dict) else img.__dict__ if hasattr(img, '__dict__') else {} + print(f"πŸ” Image {i+1} converted to dict: {img_dict}") + + p = img_dict.get('path') + n = img_dict.get('name') + print(f"πŸ” Image {i+1} - path: {p}, name: {n}") + + if p: + print(f"πŸ” Adding path to image_paths: {p}") + image_paths.append(p) + try: + fname = _os.path.basename(p) + print(f"πŸ” Processing path: {p}") + print(f"πŸ” Extracted filename: {fname}") + if fname and fname.strip(): + attachment = { + "name": n or fname, + "url": f"/api/assets/{project_id}/{fname}" + } + print(f"πŸ” Created attachment: {attachment}") + attachments.append(attachment) + else: + print(f"❌ Failed to extract filename from: {p}") + except Exception as e: + print(f"❌ Exception processing path {p}: {e}") + pass + elif n: + print(f"πŸ” Adding name to image_paths: {n}") + image_paths.append(n) + else: + print(f"❌ Image {i+1} has neither path nor name!") + + print(f"πŸ” Final image_paths: {image_paths}") + print(f"πŸ” Final attachments: {attachments}") + + # Save user instruction as message (with image paths in content for display) + message_content = body.instruction + if image_paths: + image_refs = [f"Image #{i+1} path: {path}" for i, path in enumerate(image_paths)] + message_content = f"{body.instruction}\n\n{chr(10).join(image_refs)}" + user_message = Message( id=str(uuid.uuid4()), project_id=project_id, role="user", message_type="chat", - content=body.instruction, + content=message_content, metadata_json={ "type": "act_instruction", "cli_preference": cli_preference.value, "fallback_enabled": fallback_enabled, - "has_images": len(body.images) > 0 + "has_images": len(body.images) > 0, + "image_paths": image_paths, + "attachments": attachments }, conversation_id=conversation_id, created_at=datetime.utcnow() @@ -572,7 +652,7 @@ async def run_act( "id": user_message.id, "role": "user", "message_type": "chat", - "content": body.instruction, + "content": message_content, "metadata_json": user_message.metadata_json, "parent_message_id": None, "session_id": session.id, @@ -636,18 +716,54 @@ async def run_chat( fallback_enabled = body.fallback_enabled if body.fallback_enabled is not None else project.fallback_enabled conversation_id = body.conversation_id or str(uuid.uuid4()) - # Save user instruction as message + # Extract image paths and build attachments for metadata/WS + image_paths = [] + attachments = [] + import os as _os2 + for img in body.images: + img_dict = img if isinstance(img, dict) else img.__dict__ if hasattr(img, '__dict__') else {} + p = img_dict.get('path') + n = img_dict.get('name') + if p: + image_paths.append(p) + try: + fname = _os2.path.basename(p) + print(f"πŸ” [CHAT] Processing path: {p}") + print(f"πŸ” [CHAT] Extracted filename: {fname}") + if fname and fname.strip(): + attachment = { + "name": n or fname, + "url": f"/api/assets/{project_id}/{fname}" + } + print(f"πŸ” [CHAT] Created attachment: {attachment}") + attachments.append(attachment) + else: + print(f"❌ [CHAT] Failed to extract filename from: {p}") + except Exception as e: + print(f"❌ [CHAT] Exception processing path {p}: {e}") + pass + elif n: + image_paths.append(n) + + # Save user instruction as message (with image paths in content for display) + message_content = body.instruction + if image_paths: + image_refs = [f"Image #{i+1} path: {path}" for i, path in enumerate(image_paths)] + message_content = f"{body.instruction}\n\n{chr(10).join(image_refs)}" + user_message = Message( id=str(uuid.uuid4()), project_id=project_id, role="user", message_type="chat", - content=body.instruction, + content=message_content, metadata_json={ "type": "chat_instruction", "cli_preference": cli_preference.value, "fallback_enabled": fallback_enabled, - "has_images": len(body.images) > 0 + "has_images": len(body.images) > 0, + "image_paths": image_paths, + "attachments": attachments }, conversation_id=conversation_id, created_at=datetime.utcnow() @@ -679,7 +795,7 @@ async def run_chat( "id": user_message.id, "role": "user", "message_type": "chat", - "content": body.instruction, + "content": message_content, "metadata_json": user_message.metadata_json, "parent_message_id": None, "session_id": session.id, @@ -719,4 +835,4 @@ async def run_chat( conversation_id=conversation_id, status="running", message="Chat execution started" - ) \ No newline at end of file + ) diff --git a/apps/api/app/api/chat/cli_preferences.py b/apps/api/app/api/chat/cli_preferences.py index 2d160d32..6a3ff4b5 100644 --- a/apps/api/app/api/chat/cli_preferences.py +++ b/apps/api/app/api/chat/cli_preferences.py @@ -9,7 +9,8 @@ from app.api.deps import get_db from app.models.projects import Project -from app.services.cli import UnifiedCLIManager, CLIType +from app.services.cli import UnifiedCLIManager +from app.services.cli.base import CLIType router = APIRouter() @@ -36,6 +37,9 @@ class CLIStatusResponse(BaseModel): class AllCLIStatusResponse(BaseModel): claude: CLIStatusResponse cursor: CLIStatusResponse + codex: CLIStatusResponse + qwen: CLIStatusResponse + gemini: CLIStatusResponse preferred_cli: str @@ -164,28 +168,37 @@ async def get_all_cli_status(project_id: str, db: Session = Depends(get_db)): if not project: raise HTTPException(status_code=404, detail="Project not found") - # For now, return mock status data to avoid CLI manager issues preferred_cli = getattr(project, 'preferred_cli', 'claude') - - # Create mock status responses - claude_status = CLIStatusResponse( - cli_type="claude", - available=True, - configured=True, - error=None, - models=["claude-3.5-sonnet", "claude-3-opus"] - ) - - cursor_status = CLIStatusResponse( - cli_type="cursor", - available=False, - configured=False, - error="Not configured", - models=[] + + # Build real status for each CLI using UnifiedCLIManager + manager = UnifiedCLIManager( + project_id=project.id, + project_path=project.repo_path, + session_id="status_check", + conversation_id="status_check", + db=db, ) - + + def to_resp(cli_key: str, status: Dict[str, Any]) -> CLIStatusResponse: + return CLIStatusResponse( + cli_type=cli_key, + available=status.get("available", False), + configured=status.get("configured", False), + error=status.get("error"), + models=status.get("models"), + ) + + claude_status = await manager.check_cli_status(CLIType.CLAUDE) + cursor_status = await manager.check_cli_status(CLIType.CURSOR) + codex_status = await manager.check_cli_status(CLIType.CODEX) + qwen_status = await manager.check_cli_status(CLIType.QWEN) + gemini_status = await manager.check_cli_status(CLIType.GEMINI) + return AllCLIStatusResponse( - claude=claude_status, - cursor=cursor_status, - preferred_cli=preferred_cli - ) \ No newline at end of file + claude=to_resp("claude", claude_status), + cursor=to_resp("cursor", cursor_status), + codex=to_resp("codex", codex_status), + qwen=to_resp("qwen", qwen_status), + gemini=to_resp("gemini", gemini_status), + preferred_cli=preferred_cli, + ) diff --git a/apps/api/app/api/projects/crud.py b/apps/api/app/api/projects/crud.py index 78e70708..213c2788 100644 --- a/apps/api/app/api/projects/crud.py +++ b/apps/api/app/api/projects/crud.py @@ -303,7 +303,9 @@ async def get_project(project_id: str, db: Session = Depends(get_db)) -> Project features=ai_info.get('features'), tech_stack=ai_info.get('tech_stack'), ai_generated=ai_info.get('ai_generated', False), - initial_prompt=project.initial_prompt + initial_prompt=project.initial_prompt, + preferred_cli=project.preferred_cli, + selected_model=project.selected_model ) except HTTPException: raise @@ -484,4 +486,4 @@ async def delete_project(project_id: str, db: Session = Depends(get_db)): print(f"❌ Error cleaning up project files for {project_id}: {e}") # Don't fail the whole operation if file cleanup fails - return {"message": f"Project {project_id} deleted successfully"} \ No newline at end of file + return {"message": f"Project {project_id} deleted successfully"} diff --git a/apps/api/app/api/settings.py b/apps/api/app/api/settings.py index 248b0eed..25d8e1fd 100644 --- a/apps/api/app/api/settings.py +++ b/apps/api/app/api/settings.py @@ -4,7 +4,8 @@ from typing import Dict, Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from app.services.cli.unified_manager import CLIType, CursorAgentCLI +from app.services.cli.unified_manager import CursorAgentCLI +from app.services.cli.base import CLIType router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -83,17 +84,23 @@ async def get_cli_status() -> Dict[str, Any]: results = {} # μƒˆλ‘œμš΄ UnifiedCLIManager의 CLI μΈμŠ€ν„΄μŠ€ μ‚¬μš© - from app.services.cli.unified_manager import ClaudeCodeCLI, CursorAgentCLI + from app.services.cli.unified_manager import ClaudeCodeCLI, CursorAgentCLI, CodexCLI, QwenCLI, GeminiCLI cli_instances = { "claude": ClaudeCodeCLI(), - "cursor": CursorAgentCLI() + "cursor": CursorAgentCLI(), + "codex": CodexCLI(), + "qwen": QwenCLI(), + "gemini": GeminiCLI() } # λͺ¨λ“  CLIλ₯Ό λ³‘λ ¬λ‘œ 확인 tasks = [] for cli_id, cli_instance in cli_instances.items(): + print(f"[DEBUG] Setting up check for CLI: {cli_id}") async def check_cli(cli_id, cli_instance): + print(f"[DEBUG] Checking CLI: {cli_id}") status = await cli_instance.check_availability() + print(f"[DEBUG] CLI {cli_id} status: {status}") return cli_id, status tasks.append(check_cli(cli_id, cli_instance)) @@ -143,4 +150,4 @@ async def update_global_settings(settings: GlobalSettingsModel) -> Dict[str, Any "cli_settings": settings.cli_settings }) - return {"success": True, "settings": GLOBAL_SETTINGS} \ No newline at end of file + return {"success": True, "settings": GLOBAL_SETTINGS} diff --git a/apps/api/app/db/migrations.py b/apps/api/app/db/migrations.py new file mode 100644 index 00000000..cfe1574e --- /dev/null +++ b/apps/api/app/db/migrations.py @@ -0,0 +1,24 @@ +"""Database migrations module for SQLite.""" + +import logging +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +def run_sqlite_migrations(db_path: Optional[str] = None) -> None: + """ + Run SQLite database migrations. + + Args: + db_path: Path to the SQLite database file + """ + if db_path: + logger.info(f"Running migrations for SQLite database at: {db_path}") + else: + logger.info("Running migrations for in-memory SQLite database") + + # Add migration logic here as needed + # For now, this is a placeholder that ensures the module exists + pass \ No newline at end of file diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 4f7d22fe..3c182a7a 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -18,6 +18,7 @@ from app.db.base import Base import app.models # noqa: F401 ensures models are imported for metadata from app.db.session import engine +from app.db.migrations import run_sqlite_migrations import os configure_logging() @@ -79,6 +80,8 @@ def on_startup() -> None: inspector = inspect(engine) Base.metadata.create_all(bind=engine) ui.success("Database initialization complete") + # Run lightweight SQLite migrations for additive changes + run_sqlite_migrations(engine) # Show available endpoints ui.info("API server ready") diff --git a/apps/api/app/prompt/system-prompt.md b/apps/api/app/prompt/system-prompt.md index 4469bb9e..6916158f 100644 --- a/apps/api/app/prompt/system-prompt.md +++ b/apps/api/app/prompt/system-prompt.md @@ -1,4 +1,4 @@ -You are CLovable, an advanced AI coding assistant specialized in building modern fullstack web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes. +You are Claudable, an advanced AI coding assistant specialized in building modern fullstack web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes. ## Core Identity @@ -16,6 +16,8 @@ Not every interaction requires code changes - you're happy to discuss architectu - Implement only the specific functionality the user explicitly requests - Avoid adding extra features, optimizations, or enhancements unless specifically asked - Keep implementations simple and focused on the core requirement +- Avoid unnecessary abstraction - write code in the same file when it makes sense +- Don't over-componentize - larger single-file components are often more maintainable ## Technical Stack Guidelines @@ -62,10 +64,10 @@ Not every interaction requires code changes - you're happy to discuss architectu ### File Structure & Organization - Follow Next.js 15 App Router conventions -- Organize components in logical directories (ui/, forms/, layout/, etc.) -- Create reusable utility functions in lib/ directory -- Store types and schemas in separate files for reusability -- Use proper barrel exports for clean imports +- Keep code simple and avoid over-engineering file structures +- Only separate components when there's clear reusability benefit +- Inline helper functions and types when they're only used once +- Prioritize readability and maintainability over strict separation ### Component Patterns - Write complete, immediately runnable components @@ -73,7 +75,7 @@ Not every interaction requires code changes - you're happy to discuss architectu - Implement proper error handling with error boundaries - Follow accessibility best practices (ARIA labels, semantic HTML) - Create responsive designs with Tailwind CSS -- Keep components focused and under 200 lines when possible +- Prefer practical solutions over strict component separation - inline code when it makes sense ### Data Management - Use server actions for form submissions and mutations @@ -140,6 +142,7 @@ Not every interaction requires code changes - you're happy to discuss architectu - **Always** use the specified tech stack (Next.js 15, Supabase, Vercel, Zod) ## Rules +- Always work from the project root directory "/" - all file paths and operations should be relative to the root - Always run "npm run build" after completing code changes to verify the build works correctly - Never run "npm run dev" or start servers; the user will handle server processes - Never run "npm install". The node_modules are already installed. diff --git a/apps/api/app/services/cli/adapters/__init__.py b/apps/api/app/services/cli/adapters/__init__.py new file mode 100644 index 00000000..83063788 --- /dev/null +++ b/apps/api/app/services/cli/adapters/__init__.py @@ -0,0 +1,13 @@ +from .claude_code import ClaudeCodeCLI +from .cursor_agent import CursorAgentCLI +from .codex_cli import CodexCLI +from .qwen_cli import QwenCLI +from .gemini_cli import GeminiCLI + +__all__ = [ + "ClaudeCodeCLI", + "CursorAgentCLI", + "CodexCLI", + "QwenCLI", + "GeminiCLI", +] diff --git a/apps/api/app/services/cli/adapters/claude_code.py b/apps/api/app/services/cli/adapters/claude_code.py new file mode 100644 index 00000000..d231eb61 --- /dev/null +++ b/apps/api/app/services/cli/adapters/claude_code.py @@ -0,0 +1,499 @@ +"""Claude Code provider implementation. + +Moved from unified_manager.py to a dedicated adapter module. +""" +from __future__ import annotations + +import asyncio +import os +import uuid +from datetime import datetime +from typing import Any, AsyncGenerator, Callable, Dict, List, Optional + +from app.core.terminal_ui import ui +from app.models.messages import Message +from claude_code_sdk import ClaudeSDKClient, ClaudeCodeOptions + +from ..base import BaseCLI, CLIType + + +class ClaudeCodeCLI(BaseCLI): + """Claude Code Python SDK implementation""" + + def __init__(self): + super().__init__(CLIType.CLAUDE) + self.session_mapping: Dict[str, str] = {} + + async def check_availability(self) -> Dict[str, Any]: + """Check if Claude Code CLI is available""" + try: + # First try to check if claude CLI is installed and working + result = await asyncio.create_subprocess_shell( + "claude -h", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await result.communicate() + + if result.returncode != 0: + return { + "available": False, + "configured": False, + "error": ( + "Claude Code CLI not installed or not working.\n\nTo install:\n" + "1. Install Claude Code: npm install -g @anthropic-ai/claude-code\n" + "2. Login to Claude: claude login\n3. Try running your prompt again" + ), + } + + # Check if help output contains expected content + help_output = stdout.decode() + stderr.decode() + if "claude" not in help_output.lower(): + return { + "available": False, + "configured": False, + "error": ( + "Claude Code CLI not responding correctly.\n\nPlease try:\n" + "1. Reinstall: npm install -g @anthropic-ai/claude-code\n" + "2. Login: claude login\n3. Check installation: claude -h" + ), + } + + return { + "available": True, + "configured": True, + "mode": "CLI", + "models": self.get_supported_models(), + "default_models": [ + "claude-sonnet-4-20250514", + "claude-opus-4-1-20250805", + ], + } + except Exception as e: + return { + "available": False, + "configured": False, + "error": ( + f"Failed to check Claude Code CLI: {str(e)}\n\nTo install:\n" + "1. Install Claude Code: npm install -g @anthropic-ai/claude-code\n" + "2. Login to Claude: claude login" + ), + } + + async def execute_with_streaming( + self, + instruction: str, + project_path: str, + session_id: Optional[str] = None, + log_callback: Optional[Callable[[str], Any]] = None, + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> AsyncGenerator[Message, None]: + """Execute instruction using Claude Code Python SDK""" + + ui.info("Starting Claude SDK execution", "Claude SDK") + ui.debug(f"Instruction: {instruction[:100]}...", "Claude SDK") + ui.debug(f"Project path: {project_path}", "Claude SDK") + ui.debug(f"Session ID: {session_id}", "Claude SDK") + + # Validate project_path + if not project_path: + error_msg = "Project path is not available. The project may still be initializing." + ui.error(error_msg, "Claude SDK") + yield Message( + id=str(uuid.uuid4()), + project_id="unknown", + role="assistant", + message_type="error", + content=error_msg, + metadata_json={"type": "project_path_error"}, + created_at=datetime.utcnow() + ) + return + + if not os.path.exists(project_path): + error_msg = f"Project directory does not exist: {project_path}. The project may have failed to initialize." + ui.error(error_msg, "Claude SDK") + yield Message( + id=str(uuid.uuid4()), + project_id="unknown", + role="assistant", + message_type="error", + content=error_msg, + metadata_json={"type": "project_path_error"}, + created_at=datetime.utcnow() + ) + return + + if log_callback: + await log_callback("Starting execution...") + + # Load system prompt + try: + from app.services.claude_act import get_system_prompt + + system_prompt = get_system_prompt() + ui.debug(f"System prompt loaded: {len(system_prompt)} chars", "Claude SDK") + except Exception as e: + ui.error(f"Failed to load system prompt: {e}", "Claude SDK") + system_prompt = ( + "You are Claude Code, an AI coding assistant specialized in building modern web applications." + ) + + # Get CLI-specific model name + cli_model = self._get_cli_model_name(model) or "claude-sonnet-4-20250514" + + # Add project directory structure for initial prompts + if is_initial_prompt: + project_structure_info = """ + +## Project Directory Structure (node_modules are already installed) +.eslintrc.json +.gitignore +next.config.mjs +next-env.d.ts +package.json +postcss.config.mjs +README.md +tailwind.config.ts +tsconfig.json +.env +src/app/favicon.ico +src/app/globals.css +src/app/layout.tsx +src/app/page.tsx +public/ +node_modules/ +""" + instruction = instruction + project_structure_info + ui.info( + f"Added project structure info to initial prompt", "Claude SDK" + ) + + # Configure tools based on initial prompt status + if is_initial_prompt: + # For initial prompts: use disallowed_tools to explicitly block TodoWrite + allowed_tools = [ + "Read", + "Write", + "Edit", + "MultiEdit", + "Bash", + "Glob", + "Grep", + "LS", + "WebFetch", + "WebSearch", + ] + disallowed_tools = ["TodoWrite"] + + ui.info( + f"TodoWrite tool EXCLUDED via disallowed_tools (is_initial_prompt: {is_initial_prompt})", + "Claude SDK", + ) + ui.debug(f"Allowed tools: {allowed_tools}", "Claude SDK") + ui.debug(f"Disallowed tools: {disallowed_tools}", "Claude SDK") + + # Configure Claude Code options with disallowed_tools + options = ClaudeCodeOptions( + system_prompt=system_prompt, + allowed_tools=allowed_tools, + disallowed_tools=disallowed_tools, + permission_mode="bypassPermissions", + model=cli_model, + continue_conversation=True, + ) + else: + # For non-initial prompts: include TodoWrite in allowed tools + allowed_tools = [ + "Read", + "Write", + "Edit", + "MultiEdit", + "Bash", + "Glob", + "Grep", + "LS", + "WebFetch", + "WebSearch", + "TodoWrite", + ] + + ui.info( + f"TodoWrite tool INCLUDED (is_initial_prompt: {is_initial_prompt})", + "Claude SDK", + ) + ui.debug(f"Allowed tools: {allowed_tools}", "Claude SDK") + + # Configure Claude Code options without disallowed_tools + options = ClaudeCodeOptions( + system_prompt=system_prompt, + allowed_tools=allowed_tools, + permission_mode="bypassPermissions", + model=cli_model, + continue_conversation=True, + ) + + ui.info(f"Using model: {cli_model}", "Claude SDK") + ui.debug(f"Project path: {project_path}", "Claude SDK") + ui.debug(f"Instruction: {instruction[:100]}...", "Claude SDK") + + try: + # Change to project directory + original_cwd = os.getcwd() + os.chdir(project_path) + + # Get project ID for session management + project_id = ( + project_path.split("/")[-1] if "/" in project_path else project_path + ) + existing_session_id = await self.get_session_id(project_id) + + # Update options with resume session if available + if existing_session_id: + options.resumeSessionId = existing_session_id + ui.info(f"Resuming session: {existing_session_id}", "Claude SDK") + + try: + async with ClaudeSDKClient(options=options) as client: + # Send initial query + await client.query(instruction) + + # Stream responses and extract session_id + claude_session_id = None + + async for message_obj in client.receive_messages(): + # Import SDK types for isinstance checks + try: + from anthropic.claude_code.types import ( + SystemMessage, + AssistantMessage, + UserMessage, + ResultMessage, + ) + except ImportError: + try: + from claude_code_sdk.types import ( + SystemMessage, + AssistantMessage, + UserMessage, + ResultMessage, + ) + except ImportError: + # Fallback - check type name strings + SystemMessage = type(None) + AssistantMessage = type(None) + UserMessage = type(None) + ResultMessage = type(None) + + # Handle SystemMessage for session_id extraction + if ( + isinstance(message_obj, SystemMessage) + or "SystemMessage" in str(type(message_obj)) + ): + # Extract session_id if available + if ( + hasattr(message_obj, "session_id") + and message_obj.session_id + ): + claude_session_id = message_obj.session_id + await self.set_session_id( + project_id, claude_session_id + ) + + # Send init message (hidden from UI) + init_message = Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="system", + content=f"Claude Code SDK initialized (Model: {cli_model})", + metadata_json={ + "cli_type": self.cli_type.value, + "mode": "SDK", + "model": cli_model, + "session_id": getattr( + message_obj, "session_id", None + ), + "hidden_from_ui": True, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + yield init_message + + # Handle AssistantMessage (complete messages) + elif ( + isinstance(message_obj, AssistantMessage) + or "AssistantMessage" in str(type(message_obj)) + ): + content = "" + + # Process content - AssistantMessage has content: list[ContentBlock] + if hasattr(message_obj, "content") and isinstance( + message_obj.content, list + ): + for block in message_obj.content: + # Import block types for comparison + from claude_code_sdk.types import ( + TextBlock, + ToolUseBlock, + ToolResultBlock, + ) + + if isinstance(block, TextBlock): + # TextBlock has 'text' attribute + content += block.text + elif isinstance(block, ToolUseBlock): + # ToolUseBlock has 'id', 'name', 'input' attributes + tool_name = block.name + tool_input = block.input + tool_id = block.id + summary = self._create_tool_summary( + tool_name, tool_input + ) + + # Yield tool use message immediately + tool_message = Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "mode": "SDK", + "tool_name": tool_name, + "tool_input": tool_input, + "tool_id": tool_id, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + # Display clean tool usage like Claude Code + tool_display = self._get_clean_tool_display( + tool_name, tool_input + ) + ui.info(tool_display, "") + yield tool_message + elif isinstance(block, ToolResultBlock): + # Handle tool result blocks if needed + pass + + # Yield complete assistant text message if there's text content + if content and content.strip(): + text_message = Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=content.strip(), + metadata_json={ + "cli_type": self.cli_type.value, + "mode": "SDK", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + yield text_message + + # Handle UserMessage (tool results, etc.) + elif ( + isinstance(message_obj, UserMessage) + or "UserMessage" in str(type(message_obj)) + ): + # UserMessage has content: str according to types.py + # UserMessages are typically tool results - we don't need to show them + pass + + # Handle ResultMessage (final session completion) + elif ( + isinstance(message_obj, ResultMessage) + or "ResultMessage" in str(type(message_obj)) + or ( + hasattr(message_obj, "type") + and getattr(message_obj, "type", None) == "result" + ) + ): + ui.success( + f"Session completed in {getattr(message_obj, 'duration_ms', 0)}ms", + "Claude SDK", + ) + + # Create internal result message (hidden from UI) + result_message = Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="result", + content=( + f"Session completed in {getattr(message_obj, 'duration_ms', 0)}ms" + ), + metadata_json={ + "cli_type": self.cli_type.value, + "mode": "SDK", + "duration_ms": getattr( + message_obj, "duration_ms", 0 + ), + "duration_api_ms": getattr( + message_obj, "duration_api_ms", 0 + ), + "total_cost_usd": getattr( + message_obj, "total_cost_usd", 0 + ), + "num_turns": getattr(message_obj, "num_turns", 0), + "is_error": getattr(message_obj, "is_error", False), + "subtype": getattr(message_obj, "subtype", None), + "session_id": getattr( + message_obj, "session_id", None + ), + "hidden_from_ui": True, # Don't show to user + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + yield result_message + break + + # Handle unknown message types + else: + ui.debug( + f"Unknown message type: {type(message_obj)}", + "Claude SDK", + ) + + finally: + # Restore original working directory + os.chdir(original_cwd) + + except Exception as e: + ui.error(f"Exception occurred: {str(e)}", "Claude SDK") + if log_callback: + await log_callback(f"Claude SDK Exception: {str(e)}") + raise + + async def get_session_id(self, project_id: str) -> Optional[str]: + """Get current session ID for project from database""" + try: + # Try to get from database if available (we'll need to pass db session) + return self.session_mapping.get(project_id) + except Exception as e: + ui.warning(f"Failed to get session ID from DB: {e}", "Claude SDK") + return self.session_mapping.get(project_id) + + async def set_session_id(self, project_id: str, session_id: str) -> None: + """Set session ID for project in database and memory""" + try: + # Store in memory as fallback + self.session_mapping[project_id] = session_id + ui.debug( + f"Session ID stored for project {project_id}", "Claude SDK" + ) + except Exception as e: + ui.warning(f"Failed to save session ID: {e}", "Claude SDK") + # Fallback to memory storage + self.session_mapping[project_id] = session_id + + +__all__ = ["ClaudeCodeCLI"] diff --git a/apps/api/app/services/cli/adapters/codex_cli.py b/apps/api/app/services/cli/adapters/codex_cli.py new file mode 100644 index 00000000..d40017d0 --- /dev/null +++ b/apps/api/app/services/cli/adapters/codex_cli.py @@ -0,0 +1,839 @@ +"""Codex CLI provider implementation. + +Moved from unified_manager.py to a dedicated adapter module. +""" +from __future__ import annotations + +import asyncio +import json +import os +import subprocess +import uuid +from datetime import datetime +from typing import Any, AsyncGenerator, Callable, Dict, List, Optional + +from app.core.terminal_ui import ui +from app.models.messages import Message + +from ..base import BaseCLI, CLIType + + +class CodexCLI(BaseCLI): + """Codex CLI implementation with auto-approval and message buffering""" + + def __init__(self, db_session=None): + super().__init__(CLIType.CODEX) + self.db_session = db_session + self._session_store = {} # Fallback for when db_session is not available + + async def check_availability(self) -> Dict[str, Any]: + """Check if Codex CLI is available""" + print(f"[DEBUG] CodexCLI.check_availability called") + try: + # Check if codex is installed and working + print(f"[DEBUG] Running command: codex --version") + result = await asyncio.create_subprocess_shell( + "codex --version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await result.communicate() + + print(f"[DEBUG] Command result: returncode={result.returncode}") + print(f"[DEBUG] stdout: {stdout.decode().strip()}") + print(f"[DEBUG] stderr: {stderr.decode().strip()}") + + if result.returncode != 0: + error_msg = ( + f"Codex CLI not installed or not working (returncode: {result.returncode}). stderr: {stderr.decode().strip()}" + ) + print(f"[DEBUG] {error_msg}") + return { + "available": False, + "configured": False, + "error": error_msg, + } + + print(f"[DEBUG] Codex CLI available!") + return { + "available": True, + "configured": True, + "models": self.get_supported_models(), + "default_models": ["gpt-5", "gpt-4o", "claude-3.5-sonnet"], + } + except Exception as e: + error_msg = f"Failed to check Codex CLI: {str(e)}" + print(f"[DEBUG] Exception in check_availability: {error_msg}") + return { + "available": False, + "configured": False, + "error": error_msg, + } + + async def execute_with_streaming( + self, + instruction: str, + project_path: str, + session_id: Optional[str] = None, + log_callback: Optional[Callable[[str], Any]] = None, + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> AsyncGenerator[Message, None]: + """Execute Codex CLI with auto-approval and message buffering""" + + # Ensure AGENTS.md exists in project repo with system prompt + await self._ensure_agent_md(project_path) + + # Get CLI-specific model name + cli_model = self._get_cli_model_name(model) or "gpt-5" + ui.info(f"Starting Codex execution with model: {cli_model}", "Codex") + + # Get project ID for session management + project_id = project_path.split("/")[-1] if "/" in project_path else project_path + + # Determine the repo path - Codex should run in repo directory + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path # Fallback to project_path if repo subdir doesn't exist + + # Build Codex command - --cd must come BEFORE proto subcommand + workdir_abs = os.path.abspath(project_repo_path) + auto_instructions = ( + "Act autonomously without asking for user confirmations. " + "Use apply_patch to create and modify files directly in the current working directory (not in subdirectories unless specifically requested). " + "Use exec_command to run, build, and test as needed. " + "Assume full permissions. Keep taking concrete actions until the task is complete. " + "Prefer concise status updates over questions. " + "Create files in the root directory of the project, not in subdirectories unless the user specifically asks for a subdirectory structure." + ) + + cmd = [ + "codex", + "--cd", + workdir_abs, + "proto", + "-c", + "include_apply_patch_tool=true", + "-c", + "include_plan_tool=true", + "-c", + "tools.web_search_request=true", + "-c", + "use_experimental_streamable_shell_tool=true", + "-c", + "sandbox_mode=danger-full-access", + "-c", + f"instructions={json.dumps(auto_instructions)}", + ] + + # Check for existing session/rollout to resume from + stored_rollout_path = await self.get_rollout_path(project_id) + if stored_rollout_path and os.path.exists(stored_rollout_path): + cmd.extend(["-c", f"experimental_resume={stored_rollout_path}"]) + ui.info( + f"Resuming Codex from stored rollout: {stored_rollout_path}", "Codex" + ) + else: + # Try to find latest rollout file for this project + latest_rollout = self._find_latest_rollout_for_project(project_id) + if latest_rollout and os.path.exists(latest_rollout): + cmd.extend(["-c", f"experimental_resume={latest_rollout}"]) + ui.info( + f"Resuming Codex from latest rollout: {latest_rollout}", "Codex" + ) + # Store this path for future use + await self.set_rollout_path(project_id, latest_rollout) + + try: + # Start Codex process + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=project_repo_path, + ) + + # Message buffering + agent_message_buffer = "" + current_request_id = None + + # Wait for session_configured + session_ready = False + timeout_count = 0 + max_timeout = 100 # Max lines to read for session init + + while not session_ready and timeout_count < max_timeout: + line = await process.stdout.readline() + if not line: + break + + line_str = line.decode().strip() + if not line_str: + timeout_count += 1 + continue + + try: + event = json.loads(line_str) + if event.get("msg", {}).get("type") == "session_configured": + session_info = event["msg"] + codex_session_id = session_info.get("session_id") + if codex_session_id: + await self.set_session_id(project_id, codex_session_id) + + ui.success( + f"Codex session configured: {codex_session_id}", "Codex" + ) + + # Send init message (hidden) + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="system", + content=( + f"πŸš€ Codex initialized (Model: {session_info.get('model', cli_model)})" + ), + metadata_json={ + "cli_type": self.cli_type.value, + "hidden_from_ui": True, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + # After initialization, set approval policy to auto-approve + await self._set_codex_approval_policy(process, session_id or "") + + session_ready = True + break + except json.JSONDecodeError: + timeout_count += 1 + continue + + if not session_ready: + ui.error("Failed to initialize Codex session", "Codex") + return + + # Send user input + request_id = f"msg_{uuid.uuid4().hex[:8]}" + current_request_id = request_id + + # Add project directory context for initial prompts + final_instruction = instruction + if is_initial_prompt: + try: + # Get actual files in the project repo directory + repo_files: List[str] = [] + if os.path.exists(project_repo_path): + for item in os.listdir(project_repo_path): + if not item.startswith(".git") and item != "AGENTS.md": + repo_files.append(item) + + if repo_files: + project_context = f""" + + +Current files in project directory: {', '.join(sorted(repo_files))} +Work directly in the current directory. Do not create subdirectories unless specifically requested. +""" + final_instruction = instruction + project_context + ui.info( + f"Added current project files context to Codex", "Codex" + ) + else: + project_context = """ + + +This is an empty project directory. Create files directly in the current working directory. +Do not create subdirectories unless specifically requested by the user. +""" + final_instruction = instruction + project_context + ui.info(f"Added empty project context to Codex", "Codex") + except Exception as e: + ui.warning(f"Failed to add project context: {e}", "Codex") + + # Build instruction with image references + if images: + image_refs = [] + for i in range(len(images)): + image_refs.append(f"[Image #{i+1}]") + image_context = ( + f"\n\nI've attached {len(images)} image(s) for you to analyze: {', '.join(image_refs)}" + ) + final_instruction_with_images = final_instruction + image_context + else: + final_instruction_with_images = final_instruction + + items: List[Dict[str, Any]] = [{"type": "text", "text": final_instruction_with_images}] + + # Add images if provided + if images: + import base64 as _b64 + import tempfile as _tmp + + def _iget(obj, key, default=None): + try: + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + except Exception: + return default + + for i, image_data in enumerate(images): + # Support direct local path + local_path = _iget(image_data, "path") + if local_path: + ui.info( + f"πŸ“· Image #{i+1} path sent to Codex: {local_path}", "Codex" + ) + items.append({"type": "local_image", "path": str(local_path)}) + continue + + # Support base64 via either 'base64_data' or legacy 'data' + b64_str = _iget(image_data, "base64_data") or _iget(image_data, "data") + # Or a data URL in 'url' + if not b64_str: + url_val = _iget(image_data, "url") + if isinstance(url_val, str) and url_val.startswith("data:") and "," in url_val: + b64_str = url_val.split(",", 1)[1] + + if b64_str: + try: + # Optional size guard (~3/4 of base64 length) + approx_bytes = int(len(b64_str) * 0.75) + if approx_bytes > 10 * 1024 * 1024: + ui.warning("Skipping image >10MB", "Codex") + continue + + img_bytes = _b64.b64decode(b64_str, validate=False) + mime_type = _iget(image_data, "mime_type") or "image/png" + suffix = ".png" + if "jpeg" in mime_type or "jpg" in mime_type: + suffix = ".jpg" + elif "gif" in mime_type: + suffix = ".gif" + elif "webp" in mime_type: + suffix = ".webp" + + with _tmp.NamedTemporaryFile(delete=False, suffix=suffix) as tmpf: + tmpf.write(img_bytes) + ui.info( + f"πŸ“· Image #{i+1} saved to temporary path: {tmpf.name}", + "Codex", + ) + items.append({"type": "local_image", "path": tmpf.name}) + except Exception as e: + ui.warning(f"Failed to decode attached image: {e}", "Codex") + + # Send to Codex + user_input = {"id": request_id, "op": {"type": "user_input", "items": items}} + + if process.stdin: + json_str = json.dumps(user_input) + process.stdin.write(json_str.encode("utf-8") + b"\n") + await process.stdin.drain() + + # Log items being sent to agent + if images and len(items) > 1: + ui.debug( + f"Sending {len(items)} items to Codex (1 text + {len(items)-1} images)", + "Codex", + ) + for item in items: + if item.get("type") == "local_image": + ui.debug(f" - Image: {item.get('path')}", "Codex") + + ui.debug(f"Sent user input: {request_id}", "Codex") + + # Process streaming events + async for line in process.stdout: + line_str = line.decode().strip() + if not line_str: + continue + + try: + event = json.loads(line_str) + event_id = event.get("id", "") + msg_type = event.get("msg", {}).get("type") + + # Only process events for current request (exclude system events) + if ( + current_request_id + and event_id != current_request_id + and msg_type not in [ + "session_configured", + "mcp_list_tools_response", + ] + ): + continue + + # Buffer agent message deltas + if msg_type == "agent_message_delta": + agent_message_buffer += event["msg"]["delta"] + continue + + # Only flush buffered assistant text on final assistant message or at task completion. + # This avoids creating multiple assistant bubbles separated by tool events. + if msg_type == "agent_message": + # If Codex sent a final message without deltas, use it directly + if not agent_message_buffer: + try: + final_msg = event.get("msg", {}).get("message") + if isinstance(final_msg, str) and final_msg: + agent_message_buffer = final_msg + except Exception: + pass + if not agent_message_buffer: + # Nothing to flush + continue + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=agent_message_buffer, + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + agent_message_buffer = "" + + # Handle specific events + if msg_type == "exec_command_begin": + cmd_str = " ".join(event["msg"]["command"]) + summary = self._create_tool_summary( + "exec_command", {"command": cmd_str} + ) + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "tool_name": "Bash", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif msg_type == "patch_apply_begin": + changes = event["msg"].get("changes", {}) + ui.debug(f"Patch apply begin - changes: {changes}", "Codex") + summary = self._create_tool_summary( + "apply_patch", {"changes": changes} + ) + ui.debug(f"Generated summary: {summary}", "Codex") + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "tool_name": "Edit", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif msg_type == "web_search_begin": + query = event["msg"].get("query", "") + summary = self._create_tool_summary( + "web_search", {"query": query} + ) + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "tool_name": "WebSearch", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif msg_type == "mcp_tool_call_begin": + inv = event["msg"].get("invocation", {}) + server = inv.get("server") + tool = inv.get("tool") + summary = self._create_tool_summary( + "mcp_tool_call", {"server": server, "tool": tool} + ) + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "tool_name": "MCPTool", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif msg_type in ["exec_command_output_delta"]: + # Output chunks from command execution - can be ignored for UI + pass + + elif msg_type in [ + "exec_command_end", + "patch_apply_end", + "mcp_tool_call_end", + ]: + # Tool completion events - just log, don't show to user + ui.debug(f"Tool completed: {msg_type}", "Codex") + + elif msg_type == "task_complete": + # Flush any remaining message buffer before completing + if agent_message_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=agent_message_buffer, + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + agent_message_buffer = "" + + # Task completion - save rollout file path for future resumption + ui.success("Codex task completed", "Codex") + + # Find and store the latest rollout file for this session + try: + latest_rollout = self._find_latest_rollout_for_project(project_id) + if latest_rollout: + await self.set_rollout_path(project_id, latest_rollout) + ui.debug( + f"Saved rollout path for future resumption: {latest_rollout}", + "Codex", + ) + except Exception as e: + ui.warning(f"Failed to save rollout path: {e}", "Codex") + + break + + elif msg_type == "error": + error_msg = event["msg"]["message"] + ui.error(f"Codex error: {error_msg}", "Codex") + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"❌ Error: {error_msg}", + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + # Removed duplicate agent_message handler - already handled above + + except json.JSONDecodeError: + continue + + # Flush any remaining buffer + if agent_message_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=agent_message_buffer, + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + # Clean shutdown + if process.stdin: + try: + shutdown_cmd = {"id": "shutdown", "op": {"type": "shutdown"}} + json_str = json.dumps(shutdown_cmd) + process.stdin.write(json_str.encode("utf-8") + b"\n") + await process.stdin.drain() + process.stdin.close() + ui.debug("Sent shutdown command to Codex", "Codex") + except Exception as e: + ui.debug(f"Failed to send shutdown: {e}", "Codex") + + await process.wait() + + except FileNotFoundError: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content="❌ Codex CLI not found. Please install Codex CLI first.", + metadata_json={"error": "cli_not_found", "cli_type": "codex"}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + except Exception as e: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"❌ Codex execution failed: {str(e)}", + metadata_json={"error": "execution_failed", "cli_type": "codex"}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + async def get_session_id(self, project_id: str) -> Optional[str]: + """Get stored session ID for project""" + # Try to get from database first + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project and project.active_cursor_session_id: + # Parse JSON data that might contain codex session info + try: + session_data = json.loads(project.active_cursor_session_id) + if isinstance(session_data, dict) and "codex" in session_data: + codex_session = session_data["codex"] + ui.debug( + f"Retrieved Codex session from DB: {codex_session}", "Codex" + ) + return codex_session + except (json.JSONDecodeError, TypeError): + # If it's not JSON, might be a plain cursor session ID + pass + except Exception as e: + ui.warning(f"Failed to get Codex session from DB: {e}", "Codex") + + # Fallback to memory storage + return self._session_store.get(project_id) + + async def set_session_id(self, project_id: str, session_id: str) -> None: + """Store session ID for project with database persistence""" + # Store in database + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project: + # Try to parse existing session data + existing_data: Dict[str, Any] = {} + if project.active_cursor_session_id: + try: + existing_data = json.loads(project.active_cursor_session_id) + if not isinstance(existing_data, dict): + # If it's a plain string, preserve it as cursor session + existing_data = { + "cursor": project.active_cursor_session_id + } + except (json.JSONDecodeError, TypeError): + existing_data = {"cursor": project.active_cursor_session_id} + + # Add/update codex session + existing_data["codex"] = session_id + + # Save back to database + project.active_cursor_session_id = json.dumps(existing_data) + self.db_session.commit() + ui.debug( + f"Codex session saved to DB for project {project_id}: {session_id}", + "Codex", + ) + except Exception as e: + ui.error(f"Failed to save Codex session to DB: {e}", "Codex") + + # Store in memory as fallback + self._session_store[project_id] = session_id + ui.debug( + f"Codex session stored in memory for project {project_id}: {session_id}", + "Codex", + ) + + async def get_rollout_path(self, project_id: str) -> Optional[str]: + """Get stored rollout file path for project""" + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project and project.active_cursor_session_id: + try: + session_data = json.loads(project.active_cursor_session_id) + if ( + isinstance(session_data, dict) + and "codex_rollout" in session_data + ): + rollout_path = session_data["codex_rollout"] + ui.debug( + f"Retrieved Codex rollout path from DB: {rollout_path}", + "Codex", + ) + return rollout_path + except (json.JSONDecodeError, TypeError): + pass + except Exception as e: + ui.warning(f"Failed to get Codex rollout path from DB: {e}", "Codex") + return None + + async def set_rollout_path(self, project_id: str, rollout_path: str) -> None: + """Store rollout file path for project""" + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project: + # Try to parse existing session data + existing_data: Dict[str, Any] = {} + if project.active_cursor_session_id: + try: + existing_data = json.loads(project.active_cursor_session_id) + if not isinstance(existing_data, dict): + existing_data = { + "cursor": project.active_cursor_session_id + } + except (json.JSONDecodeError, TypeError): + existing_data = {"cursor": project.active_cursor_session_id} + + # Add/update rollout path + existing_data["codex_rollout"] = rollout_path + + # Save back to database + project.active_cursor_session_id = json.dumps(existing_data) + self.db_session.commit() + ui.debug( + f"Codex rollout path saved to DB for project {project_id}: {rollout_path}", + "Codex", + ) + except Exception as e: + ui.error(f"Failed to save Codex rollout path to DB: {e}", "Codex") + + def _find_latest_rollout_for_project(self, project_id: str) -> Optional[str]: + """Find the latest rollout file using codex_chat.py logic""" + try: + from pathlib import Path + + # Use exact same logic as codex_chat.py _resolve_resume_path for "latest" + root = Path.home() / ".codex" / "sessions" + if not root.exists(): + ui.debug( + f"Codex sessions directory does not exist: {root}", "Codex" + ) + return None + + # Find all rollout files using same pattern as codex_chat.py + candidates = sorted( + root.rglob("rollout-*.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, # Most recent first + ) + + if not candidates: + ui.debug(f"No rollout files found in {root}", "Codex") + return None + + # Return the most recent file (same as codex_chat.py "latest" logic) + latest_file = candidates[0] + rollout_path = str(latest_file.resolve()) + + ui.debug( + f"Found latest rollout file for project {project_id}: {rollout_path}", + "Codex", + ) + return rollout_path + except Exception as e: + ui.warning(f"Failed to find latest rollout file: {e}", "Codex") + return None + + async def _ensure_agent_md(self, project_path: str) -> None: + """Ensure AGENTS.md exists in project repo with system prompt""" + # Determine the repo path + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path + + agent_md_path = os.path.join(project_repo_path, "AGENTS.md") + + # Check if AGENTS.md already exists + if os.path.exists(agent_md_path): + ui.debug(f"AGENTS.md already exists at: {agent_md_path}", "Codex") + return + + try: + # Read system prompt from the source file using relative path + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + # this file is in: app/services/cli/adapters/ + # go up to app/: adapters -> cli -> services -> app + app_dir = os.path.abspath(os.path.join(current_file_dir, "..", "..", "..")) + system_prompt_path = os.path.join(app_dir, "prompt", "system-prompt.md") + + if os.path.exists(system_prompt_path): + with open(system_prompt_path, "r", encoding="utf-8") as f: + system_prompt_content = f.read() + + # Write to AGENTS.md in the project repo + with open(agent_md_path, "w", encoding="utf-8") as f: + f.write(system_prompt_content) + + ui.success(f"Created AGENTS.md at: {agent_md_path}", "Codex") + else: + ui.warning( + f"System prompt file not found at: {system_prompt_path}", + "Codex", + ) + except Exception as e: + ui.error(f"Failed to create AGENTS.md: {e}", "Codex") + + async def _set_codex_approval_policy(self, process, session_id: str): + """Set Codex approval policy to never (full-auto mode)""" + try: + ctl_id = f"ctl_{uuid.uuid4().hex[:8]}" + payload = { + "id": ctl_id, + "op": { + "type": "override_turn_context", + "approval_policy": "never", + "sandbox_policy": {"mode": "danger-full-access"}, + }, + } + + if process.stdin: + json_str = json.dumps(payload) + process.stdin.write(json_str.encode("utf-8") + b"\n") + await process.stdin.drain() + ui.success("Codex approval policy set to auto-approve", "Codex") + except Exception as e: + ui.error(f"Failed to set approval policy: {e}", "Codex") + + +__all__ = ["CodexCLI"] diff --git a/apps/api/app/services/cli/adapters/cursor_agent.py b/apps/api/app/services/cli/adapters/cursor_agent.py new file mode 100644 index 00000000..8437b85c --- /dev/null +++ b/apps/api/app/services/cli/adapters/cursor_agent.py @@ -0,0 +1,561 @@ +"""Cursor Agent provider implementation. + +Moved from unified_manager.py to a dedicated adapter module. +""" +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from datetime import datetime +from typing import Any, AsyncGenerator, Callable, Dict, List, Optional + +from app.models.messages import Message +from app.core.terminal_ui import ui + +from ..base import BaseCLI, CLIType + + +class CursorAgentCLI(BaseCLI): + """Cursor Agent CLI implementation with stream-json support and session continuity""" + + def __init__(self, db_session=None): + super().__init__(CLIType.CURSOR) + self.db_session = db_session + self._session_store = {} # Fallback for when db_session is not available + + async def check_availability(self) -> Dict[str, Any]: + """Check if Cursor Agent CLI is available""" + try: + # Check if cursor-agent is installed and working + result = await asyncio.create_subprocess_shell( + "cursor-agent -h", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await result.communicate() + + if result.returncode != 0: + return { + "available": False, + "configured": False, + "error": ( + "Cursor Agent CLI not installed or not working.\n\nTo install:\n" + "1. Install Cursor: curl https://cursor.com/install -fsS | bash\n" + "2. Login to Cursor: cursor-agent login\n3. Try running your prompt again" + ), + } + + # Check if help output contains expected content + help_output = stdout.decode() + stderr.decode() + if "cursor-agent" not in help_output.lower(): + return { + "available": False, + "configured": False, + "error": ( + "Cursor Agent CLI not responding correctly.\n\nPlease try:\n" + "1. Reinstall: curl https://cursor.com/install -fsS | bash\n" + "2. Login: cursor-agent login\n3. Check installation: cursor-agent -h" + ), + } + + return { + "available": True, + "configured": True, + "models": self.get_supported_models(), + "default_models": ["gpt-5", "sonnet-4"], + } + except Exception as e: + return { + "available": False, + "configured": False, + "error": ( + f"Failed to check Cursor Agent: {str(e)}\n\nTo install:\n" + "1. Install Cursor: curl https://cursor.com/install -fsS | bash\n" + "2. Login: cursor-agent login" + ), + } + + def _handle_cursor_stream_json( + self, event: Dict[str, Any], project_path: str, session_id: str + ) -> Optional[Message]: + """Handle Cursor stream-json format (NDJSON events) to be compatible with Claude Code CLI output""" + event_type = event.get("type") + + if event_type == "system": + # System initialization event + return Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="system", + content=f"πŸ”§ Cursor Agent initialized (Model: {event.get('model', 'unknown')})", + metadata_json={ + "cli_type": self.cli_type.value, + "event_type": "system", + "cwd": event.get("cwd"), + "api_key_source": event.get("apiKeySource"), + "original_event": event, + "hidden_from_ui": True, # Hide system init messages + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif event_type == "user": + # Cursor echoes back the user's prompt. Suppress it to avoid duplicates. + return None + + elif event_type == "assistant": + # Assistant response event (text delta) + message_content = event.get("message", {}).get("content", []) + content = "" + + if message_content and isinstance(message_content, list): + for part in message_content: + if part.get("type") == "text": + content += part.get("text", "") + + if content: + return Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=content, + metadata_json={ + "cli_type": self.cli_type.value, + "event_type": "assistant", + "original_event": event, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif event_type == "tool_call": + subtype = event.get("subtype") + tool_call_data = event.get("tool_call", {}) + if not tool_call_data: + return None + + tool_name_raw = next(iter(tool_call_data), None) + if not tool_name_raw: + return None + + # Normalize tool name: lsToolCall -> ls + tool_name = tool_name_raw.replace("ToolCall", "") + + if subtype == "started": + tool_input = tool_call_data[tool_name_raw].get("args", {}) + summary = self._create_tool_summary(tool_name, tool_input) + + return Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "event_type": "tool_call_started", + "tool_name": tool_name, + "tool_input": tool_input, + "original_event": event, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif subtype == "completed": + result = tool_call_data[tool_name_raw].get("result", {}) + content = "" + if "success" in result: + content = json.dumps(result["success"]) + elif "error" in result: + content = json.dumps(result["error"]) + + return Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="tool_result", + content=content, + metadata_json={ + "cli_type": self.cli_type.value, + "original_format": event, + "tool_name": tool_name, + "hidden_from_ui": True, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + elif event_type == "result": + # Final result event + duration = event.get("duration_ms", 0) + result_text = event.get("result", "") + + if result_text: + return Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="system", + content=( + f"Execution completed in {duration}ms. Final result: {result_text}" + ), + metadata_json={ + "cli_type": self.cli_type.value, + "event_type": "result", + "duration_ms": duration, + "original_event": event, + "hidden_from_ui": True, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + return None + + async def _ensure_agent_md(self, project_path: str) -> None: + """Ensure AGENTS.md exists in project repo with system prompt""" + # Determine the repo path + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path + + agent_md_path = os.path.join(project_repo_path, "AGENTS.md") + + # Check if AGENTS.md already exists + if os.path.exists(agent_md_path): + print(f"πŸ“ [Cursor] AGENTS.md already exists at: {agent_md_path}") + return + + try: + # Read system prompt from the source file using relative path + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + # this file is in: app/services/cli/adapters/ + # go up to app/: adapters -> cli -> services -> app + app_dir = os.path.abspath(os.path.join(current_file_dir, "..", "..", "..")) + system_prompt_path = os.path.join(app_dir, "prompt", "system-prompt.md") + + if os.path.exists(system_prompt_path): + with open(system_prompt_path, "r", encoding="utf-8") as f: + system_prompt_content = f.read() + + # Write to AGENTS.md in the project repo + with open(agent_md_path, "w", encoding="utf-8") as f: + f.write(system_prompt_content) + + print(f"πŸ“ [Cursor] Created AGENTS.md at: {agent_md_path}") + else: + print( + f"⚠️ [Cursor] System prompt file not found at: {system_prompt_path}" + ) + except Exception as e: + print(f"❌ [Cursor] Failed to create AGENTS.md: {e}") + + async def execute_with_streaming( + self, + instruction: str, + project_path: str, + session_id: Optional[str] = None, + log_callback: Optional[Callable[[str], Any]] = None, + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> AsyncGenerator[Message, None]: + """Execute Cursor Agent CLI with stream-json format and session continuity""" + # Ensure AGENTS.md exists for system prompt + await self._ensure_agent_md(project_path) + + # Extract project ID from path (format: .../projects/{project_id}/repo) + # We need the project_id, not "repo" + path_parts = project_path.split("/") + if "repo" in path_parts and len(path_parts) >= 2: + # Get the folder before "repo" + repo_index = path_parts.index("repo") + if repo_index > 0: + project_id = path_parts[repo_index - 1] + else: + project_id = path_parts[-1] if path_parts else project_path + else: + project_id = path_parts[-1] if path_parts else project_path + + stored_session_id = await self.get_session_id(project_id) + + cmd = [ + "cursor-agent", + "--force", + "-p", + instruction, + "--output-format", + "stream-json", # Use stream-json format + ] + + # Add session resume if available (prefer stored session over parameter) + active_session_id = stored_session_id or session_id + if active_session_id: + cmd.extend(["--resume", active_session_id]) + print(f"πŸ”— [Cursor] Resuming session: {active_session_id}") + + # Add API key if available + if os.getenv("CURSOR_API_KEY"): + cmd.extend(["--api-key", os.getenv("CURSOR_API_KEY")]) + + # Add model - prioritize parameter over environment variable + cli_model = self._get_cli_model_name(model) or os.getenv("CURSOR_MODEL") + if cli_model: + cmd.extend(["-m", cli_model]) + print(f"πŸ”§ [Cursor] Using model: {cli_model}") + + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path # Fallback to project_path if repo subdir doesn't exist + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=project_repo_path, + ) + + cursor_session_id = None + assistant_message_buffer = "" + result_received = False # Track if we received result event + + async for line in process.stdout: + line_str = line.decode().strip() + if not line_str: + continue + + try: + # Parse NDJSON event + event = json.loads(line_str) + + event_type = event.get("type") + + # Priority: Extract session ID from type: "result" event (most reliable) + if event_type == "result" and not cursor_session_id: + print(f"πŸ” [Cursor] Result event received: {event}") + session_id_from_result = event.get("session_id") + if session_id_from_result: + cursor_session_id = session_id_from_result + await self.set_session_id(project_id, cursor_session_id) + print( + f"πŸ’Ύ [Cursor] Session ID extracted from result event: {cursor_session_id}" + ) + + # Mark that we received result event + result_received = True + + # Extract session ID from various event types + if not cursor_session_id: + # Try to extract session ID from any event that contains it + potential_session_id = ( + event.get("sessionId") + or event.get("chatId") + or event.get("session_id") + or event.get("chat_id") + or event.get("threadId") + or event.get("thread_id") + ) + + # Also check in nested structures + if not potential_session_id and isinstance( + event.get("message"), dict + ): + potential_session_id = ( + event["message"].get("sessionId") + or event["message"].get("chatId") + or event["message"].get("session_id") + or event["message"].get("chat_id") + ) + + if potential_session_id and potential_session_id != active_session_id: + cursor_session_id = potential_session_id + await self.set_session_id(project_id, cursor_session_id) + print( + f"πŸ’Ύ [Cursor] Updated session ID for project {project_id}: {cursor_session_id}" + ) + print(f" Previous: {active_session_id}") + print(f" New: {cursor_session_id}") + + # If we receive a non-assistant message, flush the buffer first + if event.get("type") != "assistant" and assistant_message_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=assistant_message_buffer, + metadata_json={ + "cli_type": "cursor", + "event_type": "assistant_aggregated", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + assistant_message_buffer = "" + + # Process the event + message = self._handle_cursor_stream_json( + event, project_path, session_id + ) + + if message: + if message.role == "assistant" and message.message_type == "chat": + assistant_message_buffer += message.content + else: + if log_callback: + await log_callback(f"πŸ“ [Cursor] {message.content}") + yield message + + # β˜… CRITICAL: Break after result event to end streaming + if result_received: + print( + f"🏁 [Cursor] Result event received, terminating stream early" + ) + try: + process.terminate() + print(f"πŸ”ͺ [Cursor] Process terminated") + except Exception as e: + print(f"⚠️ [Cursor] Failed to terminate process: {e}") + break + + except json.JSONDecodeError as e: + # Handle malformed JSON + print(f"⚠️ [Cursor] JSON decode error: {e}") + print(f"⚠️ [Cursor] Raw line: {line_str}") + + # Still yield as raw output + message = Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=line_str, + metadata_json={ + "cli_type": "cursor", + "raw_output": line_str, + "parse_error": str(e), + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + yield message + + # Flush any remaining content in the buffer + if assistant_message_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=assistant_message_buffer, + metadata_json={ + "cli_type": "cursor", + "event_type": "assistant_aggregated", + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + await process.wait() + + # Log completion + if cursor_session_id: + print(f"βœ… [Cursor] Session completed: {cursor_session_id}") + + except FileNotFoundError: + error_msg = ( + "❌ Cursor Agent CLI not found. Please install with: curl https://cursor.com/install -fsS | bash" + ) + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=error_msg, + metadata_json={"error": "cli_not_found", "cli_type": "cursor"}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + except Exception as e: + error_msg = f"❌ Cursor Agent execution failed: {str(e)}" + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=error_msg, + metadata_json={ + "error": "execution_failed", + "cli_type": "cursor", + "exception": str(e), + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + async def get_session_id(self, project_id: str) -> Optional[str]: + """Get stored session ID for project to enable session continuity""" + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project and project.active_cursor_session_id: + print( + f"πŸ’Ύ [Cursor] Retrieved session ID from DB: {project.active_cursor_session_id}" + ) + return project.active_cursor_session_id + except Exception as e: + print(f"⚠️ [Cursor] Failed to get session ID from DB: {e}") + + # Fallback to in-memory storage + return self._session_store.get(project_id) + + async def set_session_id(self, project_id: str, session_id: str) -> None: + """Store session ID for project to enable session continuity""" + # Store in database if available + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project: + project.active_cursor_session_id = session_id + self.db_session.commit() + print( + f"πŸ’Ύ [Cursor] Session ID saved to DB for project {project_id}: {session_id}" + ) + return + else: + print(f"⚠️ [Cursor] Project {project_id} not found in DB") + except Exception as e: + print(f"⚠️ [Cursor] Failed to save session ID to DB: {e}") + import traceback + + traceback.print_exc() + else: + print(f"⚠️ [Cursor] No DB session available") + + # Fallback to in-memory storage + self._session_store[project_id] = session_id + print( + f"πŸ’Ύ [Cursor] Session ID stored in memory for project {project_id}: {session_id}" + ) + + +__all__ = ["CursorAgentCLI"] diff --git a/apps/api/app/services/cli/adapters/gemini_cli.py b/apps/api/app/services/cli/adapters/gemini_cli.py new file mode 100644 index 00000000..2b2f880a --- /dev/null +++ b/apps/api/app/services/cli/adapters/gemini_cli.py @@ -0,0 +1,619 @@ +"""Gemini CLI provider implementation using ACP over stdio. + +This adapter launches `gemini --experimental-acp`, communicates via JSON-RPC +over stdio, and streams session/update notifications. Thought chunks are +surfaced to the UI. +""" +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import uuid +from datetime import datetime +from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional + +from app.core.terminal_ui import ui +from app.models.messages import Message + +from ..base import BaseCLI, CLIType +from .qwen_cli import _ACPClient, _mime_for # Reuse minimal ACP client + + +class GeminiCLI(BaseCLI): + """Gemini CLI via ACP. Streams message and thought chunks to UI.""" + + _SHARED_CLIENT: Optional[_ACPClient] = None + _SHARED_INITIALIZED: bool = False + + def __init__(self, db_session=None): + super().__init__(CLIType.GEMINI) + self.db_session = db_session + self._session_store: Dict[str, str] = {} + self._client: Optional[_ACPClient] = None + self._initialized = False + + async def check_availability(self) -> Dict[str, Any]: + try: + proc = await asyncio.create_subprocess_shell( + "gemini --help", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + return { + "available": False, + "configured": False, + "error": "Gemini CLI not found. Install Gemini CLI and ensure it is in PATH.", + } + return { + "available": True, + "configured": True, + "models": self.get_supported_models(), + "default_models": [], + } + except Exception as e: + return {"available": False, "configured": False, "error": str(e)} + + async def _ensure_provider_md(self, project_path: str) -> None: + """Ensure GEMINI.md exists at the project repo root. + + Mirrors CursorAgent behavior: copy app/prompt/system-prompt.md if present. + """ + try: + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path + md_path = os.path.join(project_repo_path, "GEMINI.md") + if os.path.exists(md_path): + ui.debug(f"GEMINI.md already exists at: {md_path}", "Gemini") + return + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + app_dir = os.path.abspath(os.path.join(current_file_dir, "..", "..", "..")) + system_prompt_path = os.path.join(app_dir, "prompt", "system-prompt.md") + content = "# GEMINI\n\n" + if os.path.exists(system_prompt_path): + try: + with open(system_prompt_path, "r", encoding="utf-8") as f: + content += f.read() + except Exception: + pass + with open(md_path, "w", encoding="utf-8") as f: + f.write(content) + ui.success(f"Created GEMINI.md at: {md_path}", "Gemini") + except Exception as e: + ui.warning(f"Failed to create GEMINI.md: {e}", "Gemini") + + async def _ensure_client(self) -> _ACPClient: + if GeminiCLI._SHARED_CLIENT is None: + cmd = ["gemini", "--experimental-acp"] + env = os.environ.copy() + # Prefer device-code-like flow if CLI supports it + env.setdefault("NO_BROWSER", "1") + GeminiCLI._SHARED_CLIENT = _ACPClient(cmd, env=env) + + # Client-side request handlers: auto-approve permissions + async def _handle_permission(params: Dict[str, Any]) -> Dict[str, Any]: + options = params.get("options") or [] + chosen = None + for kind in ("allow_always", "allow_once"): + chosen = next((o for o in options if o.get("kind") == kind), None) + if chosen: + break + if not chosen and options: + chosen = options[0] + if not chosen: + return {"outcome": {"outcome": "cancelled"}} + return { + "outcome": {"outcome": "selected", "optionId": chosen.get("optionId")} + } + + async def _fs_read(params: Dict[str, Any]) -> Dict[str, Any]: + return {"content": ""} + + async def _fs_write(params: Dict[str, Any]) -> Dict[str, Any]: + return {} + + GeminiCLI._SHARED_CLIENT.on_request("session/request_permission", _handle_permission) + GeminiCLI._SHARED_CLIENT.on_request("fs/read_text_file", _fs_read) + GeminiCLI._SHARED_CLIENT.on_request("fs/write_text_file", _fs_write) + + await GeminiCLI._SHARED_CLIENT.start() + + self._client = GeminiCLI._SHARED_CLIENT + + if not GeminiCLI._SHARED_INITIALIZED: + await self._client.request( + "initialize", + { + "clientCapabilities": { + "fs": {"readTextFile": False, "writeTextFile": False} + }, + "protocolVersion": 1, + }, + ) + GeminiCLI._SHARED_INITIALIZED = True + return self._client + + async def execute_with_streaming( + self, + instruction: str, + project_path: str, + session_id: Optional[str] = None, + log_callback: Optional[Callable[[str], Any]] = None, + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> AsyncGenerator[Message, None]: + client = await self._ensure_client() + # Ensure provider markdown exists in project repo + await self._ensure_provider_md(project_path) + turn_id = str(uuid.uuid4())[:8] + try: + ui.debug( + f"[{turn_id}] execute_with_streaming start | model={model or '-'} | images={len(images or [])} | instruction_len={len(instruction or '')}", + "Gemini", + ) + except Exception: + pass + + # Resolve repo cwd + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path + + # Project ID + path_parts = project_path.split("/") + project_id = ( + path_parts[path_parts.index("repo") - 1] + if "repo" in path_parts and path_parts.index("repo") > 0 + else path_parts[-1] + ) + + # Ensure session + stored_session_id = await self.get_session_id(project_id) + ui.debug(f"[{turn_id}] resolved project_id={project_id}", "Gemini") + if not stored_session_id: + # Try creating a session to reuse cached OAuth credentials if present + try: + result = await client.request( + "session/new", {"cwd": project_repo_path, "mcpServers": []} + ) + stored_session_id = result.get("sessionId") + if stored_session_id: + await self.set_session_id(project_id, stored_session_id) + ui.info(f"[{turn_id}] session created: {stored_session_id}", "Gemini") + except Exception as e: + # Authenticate then retry session/new + auth_method = os.getenv("GEMINI_AUTH_METHOD", "oauth-personal") + ui.warning( + f"[{turn_id}] session/new failed; authenticating via {auth_method}: {e}", + "Gemini", + ) + try: + await client.request("authenticate", {"methodId": auth_method}) + result = await client.request( + "session/new", {"cwd": project_repo_path, "mcpServers": []} + ) + stored_session_id = result.get("sessionId") + if stored_session_id: + await self.set_session_id(project_id, stored_session_id) + ui.info(f"[{turn_id}] session created after auth: {stored_session_id}", "Gemini") + except Exception as e2: + ui.error(f"[{turn_id}] authentication/session failed: {e2}", "Gemini") + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"Gemini authentication/session failed: {e2}", + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + return + + q: asyncio.Queue = asyncio.Queue() + thought_buffer: List[str] = [] + text_buffer: List[str] = [] + + def _on_update(params: Dict[str, Any]) -> None: + try: + if params.get("sessionId") != stored_session_id: + return + update = params.get("update") or {} + try: + kind = update.get("sessionUpdate") or update.get("type") + snippet = "" + if isinstance(update.get("text"), str): + snippet = update.get("text")[:80] + elif isinstance((update.get("content") or {}).get("text"), str): + snippet = (update.get("content") or {}).get("text")[:80] + ui.debug( + f"[{turn_id}] notif session/update kind={kind} snippet={snippet!r}", + "Gemini", + ) + except Exception: + pass + q.put_nowait(update) + except Exception: + pass + + client.on_notification("session/update", _on_update) + + # Build prompt parts + parts: List[Dict[str, Any]] = [] + if instruction: + parts.append({"type": "text", "text": instruction}) + if images: + def _iget(obj, key, default=None): + try: + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + except Exception: + return default + + for image in images: + local_path = _iget(image, "path") + b64 = _iget(image, "base64_data") or _iget(image, "data") + if not b64 and _iget(image, "url", "").startswith("data:"): + try: + b64 = _iget(image, "url").split(",", 1)[1] + except Exception: + b64 = None + if local_path and os.path.exists(local_path): + try: + with open(local_path, "rb") as f: + data = f.read() + mime = _mime_for(local_path) + b64 = base64.b64encode(data).decode("utf-8") + parts.append({"type": "image", "mimeType": mime, "data": b64}) + continue + except Exception: + pass + if b64: + parts.append({"type": "image", "mimeType": "image/png", "data": b64}) + + # Send prompt + def _make_prompt_task() -> asyncio.Task: + ui.debug(f"[{turn_id}] sending session/prompt (parts={len(parts)})", "Gemini") + return asyncio.create_task( + client.request( + "session/prompt", {"sessionId": stored_session_id, "prompt": parts} + ) + ) + prompt_task = _make_prompt_task() + + while True: + done, _ = await asyncio.wait( + {prompt_task, asyncio.create_task(q.get())}, + return_when=asyncio.FIRST_COMPLETED, + ) + if prompt_task in done: + ui.debug(f"[{turn_id}] prompt_task completed; draining updates", "Gemini") + # Drain remaining + while not q.empty(): + update = q.get_nowait() + async for m in self._update_to_messages(update, project_path, session_id, thought_buffer, text_buffer): + if m: + yield m + exc = prompt_task.exception() + if exc: + msg = str(exc) + if "Session not found" in msg or "session not found" in msg.lower(): + ui.warning(f"[{turn_id}] session expired; creating a new session and retrying", "Gemini") + try: + result = await client.request( + "session/new", {"cwd": project_repo_path, "mcpServers": []} + ) + stored_session_id = result.get("sessionId") + if stored_session_id: + await self.set_session_id(project_id, stored_session_id) + ui.info(f"[{turn_id}] new session={stored_session_id}; retrying prompt", "Gemini") + prompt_task = _make_prompt_task() + continue + except Exception as e2: + ui.error(f"[{turn_id}] session recovery failed: {e2}", "Gemini") + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"Gemini session recovery failed: {e2}", + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + else: + ui.error(f"[{turn_id}] prompt error: {msg}", "Gemini") + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"Gemini prompt error: {msg}", + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + # Final flush of buffered assistant content (with block) + if thought_buffer or text_buffer: + ui.debug( + f"[{turn_id}] flushing buffered content thought_len={sum(len(x) for x in thought_buffer)} text_len={sum(len(x) for x in text_buffer)}", + "Gemini", + ) + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, text_buffer), + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + thought_buffer.clear() + text_buffer.clear() + break + for task in done: + if task is not prompt_task: + update = task.result() + try: + kind = update.get("sessionUpdate") or update.get("type") + ui.debug(f"[{turn_id}] processing update kind={kind}", "Gemini") + except Exception: + pass + async for m in self._update_to_messages(update, project_path, session_id, thought_buffer, text_buffer): + if m: + yield m + + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="result", + content="Gemini turn completed", + metadata_json={"cli_type": self.cli_type.value, "hidden_from_ui": True}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + ui.info(f"[{turn_id}] turn completed", "Gemini") + + async def _update_to_messages( + self, + update: Dict[str, Any], + project_path: str, + session_id: Optional[str], + thought_buffer: List[str], + text_buffer: List[str], + ) -> AsyncGenerator[Optional[Message], None]: + kind = update.get("sessionUpdate") or update.get("type") + now = datetime.utcnow() + if kind in ("agent_message_chunk", "agent_thought_chunk"): + text = ((update.get("content") or {}).get("text")) or update.get("text") or "" + try: + ui.debug( + f"update chunk kind={kind} len={len(text or '')}", + "Gemini", + ) + except Exception: + pass + if not isinstance(text, str): + text = str(text) + if kind == "agent_thought_chunk": + thought_buffer.append(text) + else: + # First assistant message chunk after thinking: render thinking immediately + if thought_buffer and not text_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, []), + metadata_json={"cli_type": self.cli_type.value, "event_type": "thinking"}, + session_id=session_id, + created_at=now, + ) + thought_buffer.clear() + text_buffer.append(text) + return + elif kind in ("tool_call", "tool_call_update"): + tool_name = self._parse_tool_name(update) + tool_input = self._extract_tool_input(update) + normalized = self._normalize_tool_name(tool_name) if hasattr(self, '_normalize_tool_name') else tool_name + # Render policy: + # - Non-Write tools: render only on tool_call (start) + # - Write tool: render only on tool_call_update (Gemini often emits updates only) + should_render = False + if (normalized == "Write" and kind == "tool_call_update") or ( + normalized != "Write" and kind == "tool_call" + ): + should_render = True + if not should_render: + try: + ui.debug( + f"skip tool event kind={kind} name={tool_name} normalized={normalized}", + "Gemini", + ) + except Exception: + pass + return + try: + ui.info( + f"tool event kind={kind} name={tool_name} input={tool_input}", + "Gemini", + ) + except Exception: + pass + summary = self._create_tool_summary(tool_name, tool_input) + # Flush buffered chat before tool use + if thought_buffer or text_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, text_buffer), + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=now, + ) + thought_buffer.clear() + text_buffer.clear() + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "event_type": kind, + "tool_name": tool_name, + "tool_input": tool_input, + }, + session_id=session_id, + created_at=now, + ) + elif kind == "plan": + try: + ui.info("plan event received", "Gemini") + except Exception: + pass + entries = update.get("entries") or [] + lines = [] + for e in entries[:6]: + title = e.get("title") if isinstance(e, dict) else str(e) + if title: + lines.append(f"β€’ {title}") + content = "\n".join(lines) if lines else "Planning…" + if thought_buffer or text_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, text_buffer), + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=now, + ) + thought_buffer.clear() + text_buffer.clear() + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=content, + metadata_json={"cli_type": self.cli_type.value, "event_type": "plan"}, + session_id=session_id, + created_at=now, + ) + + def _compose_content(self, thought_buffer: List[str], text_buffer: List[str]) -> str: + parts: List[str] = [] + if thought_buffer: + thinking = "".join(thought_buffer).strip() + if thinking: + parts.append(f"\n{thinking}\n\n") + if text_buffer: + parts.append("".join(text_buffer)) + return "".join(parts) + + def _parse_tool_name(self, update: Dict[str, Any]) -> str: + raw_id = update.get("toolCallId") or "" + if isinstance(raw_id, str) and raw_id: + base = raw_id.split("-", 1)[0] + return base or (update.get("title") or update.get("kind") or "tool") + return update.get("title") or update.get("kind") or "tool" + + def _extract_tool_input(self, update: Dict[str, Any]) -> Dict[str, Any]: + tool_input: Dict[str, Any] = {} + path: Optional[str] = None + locs = update.get("locations") + if isinstance(locs, list) and locs: + first = locs[0] + if isinstance(first, dict): + path = ( + first.get("path") + or first.get("file") + or first.get("file_path") + or first.get("filePath") + or first.get("uri") + ) + if isinstance(path, str) and path.startswith("file://"): + path = path[len("file://"):] + if not path: + content = update.get("content") + if isinstance(content, list): + for c in content: + if isinstance(c, dict): + cand = ( + c.get("path") + or c.get("file") + or c.get("file_path") + or (c.get("args") or {}).get("path") + ) + if cand: + path = cand + break + if path: + tool_input["path"] = str(path) + return tool_input + + async def get_session_id(self, project_id: str) -> Optional[str]: + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project and project.active_cursor_session_id: + try: + data = json.loads(project.active_cursor_session_id) + if isinstance(data, dict) and "gemini" in data: + return data["gemini"] + except Exception: + pass + except Exception as e: + ui.warning(f"Gemini get_session_id DB error: {e}", "Gemini") + return self._session_store.get(project_id) + + async def set_session_id(self, project_id: str, session_id: str) -> None: + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project: + data: Dict[str, Any] = {} + if project.active_cursor_session_id: + try: + val = json.loads(project.active_cursor_session_id) + if isinstance(val, dict): + data = val + else: + data = {"cursor": val} + except Exception: + data = {"cursor": project.active_cursor_session_id} + data["gemini"] = session_id + project.active_cursor_session_id = json.dumps(data) + self.db_session.commit() + except Exception as e: + ui.warning(f"Gemini set_session_id DB error: {e}", "Gemini") + self._session_store[project_id] = session_id + + +__all__ = ["GeminiCLI"] diff --git a/apps/api/app/services/cli/adapters/qwen_cli.py b/apps/api/app/services/cli/adapters/qwen_cli.py new file mode 100644 index 00000000..3780627f --- /dev/null +++ b/apps/api/app/services/cli/adapters/qwen_cli.py @@ -0,0 +1,807 @@ +"""Qwen CLI provider implementation using ACP over stdio. + +This adapter launches `qwen --experimental-acp`, speaks JSON-RPC over stdio, +and streams session/update notifications into our Message model. Thought +chunks are surfaced to the UI (unlike some providers that hide them). +""" +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import uuid +from dataclasses import dataclass +import shutil +from datetime import datetime +from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional + +from app.core.terminal_ui import ui +from app.models.messages import Message + +from ..base import BaseCLI, CLIType + + +@dataclass +class _Pending: + fut: asyncio.Future + + +class _ACPClient: + """Minimal JSON-RPC client over newline-delimited JSON on stdio.""" + + def __init__(self, cmd: List[str], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None): + self._cmd = cmd + self._env = env or os.environ.copy() + self._cwd = cwd or os.getcwd() + self._proc: Optional[asyncio.subprocess.Process] = None + self._next_id = 1 + self._pending: Dict[int, _Pending] = {} + self._notif_handlers: Dict[str, List[Callable[[Dict[str, Any]], None]]] = {} + self._request_handlers: Dict[str, Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]] = {} + self._reader_task: Optional[asyncio.Task] = None + + async def start(self) -> None: + if self._proc is not None: + return + self._proc = await asyncio.create_subprocess_exec( + *self._cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._env, + cwd=self._cwd, + ) + + # Start reader + self._reader_task = asyncio.create_task(self._reader_loop()) + + async def stop(self) -> None: + try: + if self._proc and self._proc.returncode is None: + self._proc.terminate() + try: + await asyncio.wait_for(self._proc.wait(), timeout=2.0) + except asyncio.TimeoutError: + self._proc.kill() + finally: + self._proc = None + if self._reader_task: + self._reader_task.cancel() + self._reader_task = None + + def on_notification(self, method: str, handler: Callable[[Dict[str, Any]], None]) -> None: + self._notif_handlers.setdefault(method, []).append(handler) + + def on_request(self, method: str, handler: Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]) -> None: + self._request_handlers[method] = handler + + async def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any: + if not self._proc or not self._proc.stdin: + raise RuntimeError("ACP process not started") + msg_id = self._next_id + self._next_id += 1 + fut: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending[msg_id] = _Pending(fut=fut) + obj = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params or {}} + data = (json.dumps(obj) + "\n").encode("utf-8") + self._proc.stdin.write(data) + await self._proc.stdin.drain() + return await fut + + async def _reader_loop(self) -> None: + assert self._proc and self._proc.stdout + stdout = self._proc.stdout + buffer = b"" + while True: + line = await stdout.readline() + if not line: + break + line = line.strip() + if not line: + continue + try: + msg = json.loads(line.decode("utf-8")) + except Exception: + # best-effort: ignore malformed + continue + + # Response + if isinstance(msg, dict) and "id" in msg and "method" not in msg: + slot = self._pending.pop(int(msg["id"])) if int(msg["id"]) in self._pending else None + if not slot: + continue + if "error" in msg: + slot.fut.set_exception(RuntimeError(str(msg["error"]))) + else: + slot.fut.set_result(msg.get("result")) + continue + + # Request from agent (client-side) + if isinstance(msg, dict) and "method" in msg and "id" in msg: + req_id = msg["id"] + method = msg["method"] + params = msg.get("params") or {} + handler = self._request_handlers.get(method) + if handler: + try: + result = await handler(params) + await self._send({"jsonrpc": "2.0", "id": req_id, "result": result}) + except Exception as e: + await self._send({ + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32000, "message": str(e)}, + }) + else: + await self._send({ + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": "Method not found"}, + }) + continue + + # Notification from agent + if isinstance(msg, dict) and "method" in msg and "id" not in msg: + method = msg["method"] + params = msg.get("params") or {} + for h in self._notif_handlers.get(method, []) or []: + try: + h(params) + except Exception: + pass + + async def _send(self, obj: Dict[str, Any]) -> None: + if not self._proc or not self._proc.stdin: + return + self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8")) + await self._proc.stdin.drain() + + +class QwenCLI(BaseCLI): + """Qwen CLI via ACP. Streams message and thought chunks to UI.""" + + # Shared ACP client across instances to preserve sessions + _SHARED_CLIENT: Optional[_ACPClient] = None + _SHARED_INITIALIZED: bool = False + + def __init__(self, db_session=None): + super().__init__(CLIType.QWEN) + self.db_session = db_session + self._session_store: Dict[str, str] = {} + self._client: Optional[_ACPClient] = None + self._initialized = False + + async def check_availability(self) -> Dict[str, Any]: + try: + proc = await asyncio.create_subprocess_shell( + "qwen --help", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + return { + "available": False, + "configured": False, + "error": "Qwen CLI not found. Install Qwen CLI and ensure it is in PATH.", + } + return { + "available": True, + "configured": True, + "models": self.get_supported_models(), + "default_models": [], + } + except Exception as e: + return {"available": False, "configured": False, "error": str(e)} + + async def _ensure_provider_md(self, project_path: str) -> None: + """Ensure QWEN.md exists at the project repo root. + + Mirrors CursorAgent behavior: copy app/prompt/system-prompt.md if present. + """ + try: + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path + md_path = os.path.join(project_repo_path, "QWEN.md") + if os.path.exists(md_path): + ui.debug(f"QWEN.md already exists at: {md_path}", "Qwen") + return + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + app_dir = os.path.abspath(os.path.join(current_file_dir, "..", "..", "..")) + system_prompt_path = os.path.join(app_dir, "prompt", "system-prompt.md") + content = "# QWEN\n\n" + if os.path.exists(system_prompt_path): + try: + with open(system_prompt_path, "r", encoding="utf-8") as f: + content += f.read() + except Exception: + pass + with open(md_path, "w", encoding="utf-8") as f: + f.write(content) + ui.success(f"Created QWEN.md at: {md_path}", "Qwen") + except Exception as e: + ui.warning(f"Failed to create QWEN.md: {e}", "Qwen") + + async def _ensure_client(self) -> _ACPClient: + # Use shared client across adapter instances + if QwenCLI._SHARED_CLIENT is None: + # Resolve command: env(QWEN_CMD) -> qwen -> qwen-code + candidates = [] + env_cmd = os.getenv("QWEN_CMD") + if env_cmd: + candidates.append(env_cmd) + candidates.extend(["qwen", "qwen-code"]) + resolved = None + for c in candidates: + if shutil.which(c): + resolved = c + break + if not resolved: + raise RuntimeError( + "Qwen CLI not found. Set QWEN_CMD or install 'qwen' CLI in PATH." + ) + cmd = [resolved, "--experimental-acp"] + # Prefer device-code / no-browser flow to avoid launching windows + env = os.environ.copy() + env.setdefault("NO_BROWSER", "1") + QwenCLI._SHARED_CLIENT = _ACPClient(cmd, env=env) + + # Register client-side request handlers + async def _handle_permission(params: Dict[str, Any]) -> Dict[str, Any]: + # Auto-approve: prefer allow_always -> allow_once -> first + options = params.get("options") or [] + chosen = None + for kind in ("allow_always", "allow_once"): + chosen = next((o for o in options if o.get("kind") == kind), None) + if chosen: + break + if not chosen and options: + chosen = options[0] + if not chosen: + return {"outcome": {"outcome": "cancelled"}} + return { + "outcome": {"outcome": "selected", "optionId": chosen.get("optionId")} + } + + async def _fs_read(params: Dict[str, Any]) -> Dict[str, Any]: + # Conservative: deny reading arbitrary files from agent perspective + return {"content": ""} + + async def _fs_write(params: Dict[str, Any]) -> Dict[str, Any]: + # Validate required parameters for file editing + if "old_string" not in params and "content" in params: + # If old_string is missing but content exists, log warning + ui.warning( + f"Qwen edit missing 'old_string' parameter: {params.get('path', 'unknown')}", + "Qwen" + ) + return {"error": "Missing required parameter: old_string"} + # Not fully implemented for safety, but return success to avoid blocking + return {"success": True} + + async def _edit_file(params: Dict[str, Any]) -> Dict[str, Any]: + # Handle edit requests with proper parameter validation + if "old_string" not in params: + ui.warning( + f"Qwen edit missing 'old_string': {params.get('path', params.get('file_path', 'unknown'))}", + "Qwen" + ) + # Return error in format Qwen expects + return {"error": {"code": -32602, "message": "params must have required property 'old_string'"}} + # For now, return success to avoid blocking + return {"success": True} + + QwenCLI._SHARED_CLIENT.on_request("session/request_permission", _handle_permission) + QwenCLI._SHARED_CLIENT.on_request("fs/read_text_file", _fs_read) + QwenCLI._SHARED_CLIENT.on_request("fs/write_text_file", _fs_write) + QwenCLI._SHARED_CLIENT.on_request("edit", _edit_file) + QwenCLI._SHARED_CLIENT.on_request("str_replace_editor", _edit_file) + + await QwenCLI._SHARED_CLIENT.start() + # Attach simple stderr logger (filtering out polling messages) + try: + proc = QwenCLI._SHARED_CLIENT._proc + if proc and proc.stderr: + async def _log_stderr(stream): + while True: + line = await stream.readline() + if not line: + break + decoded = line.decode(errors="ignore").strip() + # Skip polling for token messages + if "polling for token" in decoded.lower(): + continue + # Only log meaningful errors + if decoded and not decoded.startswith("DEBUG"): + ui.warning(decoded, "Qwen STDERR") + asyncio.create_task(_log_stderr(proc.stderr)) + except Exception: + pass + + self._client = QwenCLI._SHARED_CLIENT + + if not QwenCLI._SHARED_INITIALIZED: + try: + await self._client.request( + "initialize", + { + "clientCapabilities": { + "fs": {"readTextFile": False, "writeTextFile": False} + }, + "protocolVersion": 1, + }, + ) + QwenCLI._SHARED_INITIALIZED = True + except Exception as e: + ui.error(f"Qwen initialize failed: {e}", "Qwen") + raise + + return self._client + + async def execute_with_streaming( + self, + instruction: str, + project_path: str, + session_id: Optional[str] = None, + log_callback: Optional[Callable[[str], Any]] = None, + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> AsyncGenerator[Message, None]: + client = await self._ensure_client() + # Ensure provider markdown exists in project repo + await self._ensure_provider_md(project_path) + turn_id = str(uuid.uuid4())[:8] + try: + ui.debug( + f"[{turn_id}] execute_with_streaming start | model={model or '-'} | images={len(images or [])} | instruction_len={len(instruction or '')}", + "Qwen", + ) + except Exception: + pass + + # Resolve repo cwd + project_repo_path = os.path.join(project_path, "repo") + if not os.path.exists(project_repo_path): + project_repo_path = project_path + + # Project ID + path_parts = project_path.split("/") + project_id = ( + path_parts[path_parts.index("repo") - 1] + if "repo" in path_parts and path_parts.index("repo") > 0 + else path_parts[-1] + ) + + # Ensure session + stored_session_id = await self.get_session_id(project_id) + if not stored_session_id: + # Try to reuse cached OAuth by creating a session first + try: + result = await client.request( + "session/new", {"cwd": project_repo_path, "mcpServers": []} + ) + stored_session_id = result.get("sessionId") + if stored_session_id: + await self.set_session_id(project_id, stored_session_id) + ui.info(f"Qwen session created: {stored_session_id}", "Qwen") + except Exception as e: + # Authenticate only if needed, then retry session/new + auth_method = os.getenv("QWEN_AUTH_METHOD", "qwen-oauth") + ui.warning( + f"Qwen session/new failed; authenticating via {auth_method}: {e}", + "Qwen", + ) + try: + await client.request("authenticate", {"methodId": auth_method}) + result = await client.request( + "session/new", {"cwd": project_repo_path, "mcpServers": []} + ) + stored_session_id = result.get("sessionId") + if stored_session_id: + await self.set_session_id(project_id, stored_session_id) + ui.info( + f"Qwen session created after auth: {stored_session_id}", "Qwen" + ) + except Exception as e2: + err = f"Qwen authentication/session failed: {e2}" + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=err, + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + return + + # Subscribe to session/update notifications and stream as Message + q: asyncio.Queue = asyncio.Queue() + thought_buffer: List[str] = [] + text_buffer: List[str] = [] + + def _on_update(params: Dict[str, Any]) -> None: + try: + if params.get("sessionId") != stored_session_id: + return + update = params.get("update") or {} + q.put_nowait(update) + except Exception: + pass + + client.on_notification("session/update", _on_update) + + # Build prompt parts + parts: List[Dict[str, Any]] = [] + if instruction: + parts.append({"type": "text", "text": instruction}) + + # Qwen Coder currently does not support image input. + # If images are provided, ignore them to avoid ACP errors. + if images: + try: + ui.warning( + "Qwen Coder does not support image input yet. Ignoring attached images.", + "Qwen", + ) + except Exception: + pass + + # Send prompt request + # Helper to create a prompt task for current session + def _make_prompt_task() -> asyncio.Task: + ui.debug(f"[{turn_id}] sending session/prompt (parts={len(parts)})", "Qwen") + return asyncio.create_task( + client.request( + "session/prompt", + {"sessionId": stored_session_id, "prompt": parts}, + ) + ) + + prompt_task = _make_prompt_task() + + # Stream notifications until prompt completes + while True: + done, pending = await asyncio.wait( + {prompt_task, asyncio.create_task(q.get())}, + return_when=asyncio.FIRST_COMPLETED, + ) + if prompt_task in done: + ui.debug(f"[{turn_id}] prompt_task completed; draining updates", "Qwen") + # Flush remaining updates quickly + while not q.empty(): + update = q.get_nowait() + async for m in self._update_to_messages(update, project_path, session_id, thought_buffer, text_buffer): + if m: + yield m + # Handle prompt exception (e.g., session not found) with one retry + exc = prompt_task.exception() + if exc: + msg = str(exc) + if "Session not found" in msg or "session not found" in msg.lower(): + ui.warning("Qwen session expired; creating a new session and retrying", "Qwen") + try: + result = await client.request( + "session/new", {"cwd": project_repo_path, "mcpServers": []} + ) + stored_session_id = result.get("sessionId") + if stored_session_id: + await self.set_session_id(project_id, stored_session_id) + prompt_task = _make_prompt_task() + continue # re-enter wait loop + except Exception as e2: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"Qwen session recovery failed: {e2}", + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + else: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="error", + content=f"Qwen prompt error: {msg}", + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + # Final flush of buffered assistant text + if thought_buffer or text_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, text_buffer), + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + thought_buffer.clear() + text_buffer.clear() + break + + # Process one update + for task in done: + if task is not prompt_task: + update = task.result() + # Suppress verbose per-chunk logs; log only tool calls below + async for m in self._update_to_messages(update, project_path, session_id, thought_buffer, text_buffer): + if m: + yield m + + # Yield hidden result/system message for bookkeeping + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="system", + message_type="result", + content="Qwen turn completed", + metadata_json={"cli_type": self.cli_type.value, "hidden_from_ui": True}, + session_id=session_id, + created_at=datetime.utcnow(), + ) + ui.info(f"[{turn_id}] turn completed", "Qwen") + + async def _update_to_messages( + self, + update: Dict[str, Any], + project_path: str, + session_id: Optional[str], + thought_buffer: List[str], + text_buffer: List[str], + ) -> AsyncGenerator[Optional[Message], None]: + kind = update.get("sessionUpdate") or update.get("type") + now = datetime.utcnow() + if kind in ("agent_message_chunk", "agent_thought_chunk"): + text = ((update.get("content") or {}).get("text")) or update.get("text") or "" + if not isinstance(text, str): + text = str(text) + if kind == "agent_thought_chunk": + thought_buffer.append(text) + else: + text_buffer.append(text) + # Do not flush here: we flush only before tool events or at end, + # to match result_qwen.md behavior (message β†’ tools β†’ message ...) + return + elif kind in ("tool_call", "tool_call_update"): + # Qwen emits frequent tool_call_update events and opaque call IDs + # like `call_390e...` that produce noisy "executing..." lines. + # Hide updates entirely and only surface meaningful tool calls. + if kind == "tool_call_update": + return + + tool_name = self._parse_tool_name(update) + tool_input = self._extract_tool_input(update) + summary = self._create_tool_summary(tool_name, tool_input) + + # Suppress unknown/opaque tool names that fall back to "executing..." + try: + tn = (tool_name or "").lower() + is_opaque = ( + tn in ("call", "tool", "toolcall") + or tn.startswith("call_") + or tn.startswith("call-") + ) + if is_opaque or summary.strip().endswith("`executing...`"): + return + except Exception: + pass + + # Flush chat buffer before showing tool usage + if thought_buffer or text_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, text_buffer), + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=now, + ) + thought_buffer.clear() + text_buffer.clear() + + # Show tool use as a visible message + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json={ + "cli_type": self.cli_type.value, + "event_type": "tool_call", # normalized + "tool_name": tool_name, + "tool_input": tool_input, + }, + session_id=session_id, + created_at=now, + ) + # Concise server-side log + try: + path = tool_input.get("path") + ui.info( + f"TOOL {tool_name.upper()}" + (f" {path}" if path else ""), + "Qwen", + ) + except Exception: + pass + elif kind == "plan": + entries = update.get("entries") or [] + lines = [] + for e in entries[:6]: + title = e.get("title") if isinstance(e, dict) else str(e) + if title: + lines.append(f"β€’ {title}") + content = "\n".join(lines) if lines else "Planning…" + # Optionally flush buffer before plan (keep as separate status) + if thought_buffer or text_buffer: + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=self._compose_content(thought_buffer, text_buffer), + metadata_json={"cli_type": self.cli_type.value}, + session_id=session_id, + created_at=now, + ) + thought_buffer.clear() + text_buffer.clear() + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="chat", + content=content, + metadata_json={"cli_type": self.cli_type.value, "event_type": "plan"}, + session_id=session_id, + created_at=now, + ) + else: + # Unknown update kinds ignored + return + + def _compose_content(self, thought_buffer: List[str], text_buffer: List[str]) -> str: + # Qwen formatting per result_qwen.md: merge thoughts + text, and filter noisy call_* lines + import re + parts: List[str] = [] + if thought_buffer: + parts.append("".join(thought_buffer)) + if text_buffer: + parts.append("\n\n") + if text_buffer: + parts.append("".join(text_buffer)) + combined = "".join(parts) + # Remove lines like: call_XXXXXXXX executing... (Qwen internal call IDs) + combined = re.sub(r"(?m)^call[_-][A-Za-z0-9]+.*$\n?", "", combined) + # Trim excessive blank lines + combined = re.sub(r"\n{3,}", "\n\n", combined).strip() + return combined + + def _parse_tool_name(self, update: Dict[str, Any]) -> str: + # Prefer explicit kind from Qwen events + kind = update.get("kind") + if isinstance(kind, str) and kind.strip(): + return kind.strip() + # Fallback: derive from toolCallId by splitting on '-' or '_' + raw_id = update.get("toolCallId") or "" + if isinstance(raw_id, str) and raw_id: + for sep in ("-", "_"): + base = raw_id.split(sep, 1)[0] + if base and base.lower() not in ("call", "tool", "toolcall"): + return base + return update.get("title") or "tool" + + def _extract_tool_input(self, update: Dict[str, Any]) -> Dict[str, Any]: + tool_input: Dict[str, Any] = {} + path: Optional[str] = None + locs = update.get("locations") + if isinstance(locs, list) and locs: + first = locs[0] + if isinstance(first, dict): + path = ( + first.get("path") + or first.get("file") + or first.get("file_path") + or first.get("filePath") + or first.get("uri") + ) + if isinstance(path, str) and path.startswith("file://"): + path = path[len("file://"):] + if not path: + content = update.get("content") + if isinstance(content, list): + for c in content: + if isinstance(c, dict): + cand = ( + c.get("path") + or c.get("file") + or c.get("file_path") + or (c.get("args") or {}).get("path") + ) + if cand: + path = cand + break + if path: + tool_input["path"] = str(path) + return tool_input + + async def get_session_id(self, project_id: str) -> Optional[str]: + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project and project.active_cursor_session_id: + try: + data = json.loads(project.active_cursor_session_id) + if isinstance(data, dict) and "qwen" in data: + return data["qwen"] + except Exception: + pass + except Exception as e: + ui.warning(f"Qwen get_session_id DB error: {e}", "Qwen") + return self._session_store.get(project_id) + + async def set_session_id(self, project_id: str, session_id: str) -> None: + if self.db_session: + try: + from app.models.projects import Project + + project = ( + self.db_session.query(Project) + .filter(Project.id == project_id) + .first() + ) + if project: + data: Dict[str, Any] = {} + if project.active_cursor_session_id: + try: + val = json.loads(project.active_cursor_session_id) + if isinstance(val, dict): + data = val + else: + data = {"cursor": val} + except Exception: + data = {"cursor": project.active_cursor_session_id} + data["qwen"] = session_id + project.active_cursor_session_id = json.dumps(data) + self.db_session.commit() + except Exception as e: + ui.warning(f"Qwen set_session_id DB error: {e}", "Qwen") + self._session_store[project_id] = session_id + + +def _mime_for(path: str) -> str: + p = path.lower() + if p.endswith(".png"): + return "image/png" + if p.endswith(".jpg") or p.endswith(".jpeg"): + return "image/jpeg" + if p.endswith(".gif"): + return "image/gif" + if p.endswith(".webp"): + return "image/webp" + if p.endswith(".bmp"): + return "image/bmp" + return "application/octet-stream" + + +__all__ = ["QwenCLI"] diff --git a/apps/api/app/services/cli/base.py b/apps/api/app/services/cli/base.py new file mode 100644 index 00000000..d4364c64 --- /dev/null +++ b/apps/api/app/services/cli/base.py @@ -0,0 +1,634 @@ +""" +Base abstractions and shared utilities for CLI providers. + +This module defines a precise, minimal adapter contract (BaseCLI) and common +helpers so that adding a new provider remains consistent and easy. +""" +from __future__ import annotations + +import os +import uuid +from abc import ABC, abstractmethod +from datetime import datetime +from enum import Enum +from typing import Any, AsyncGenerator, Callable, Dict, List, Optional + +from app.models.messages import Message + + +def get_project_root() -> str: + """Return project root directory using relative path navigation. + + This function intentionally mirrors the logic previously embedded in + unified_manager.py so imports remain stable after refactor. + """ + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + # base.py is in: app/services/cli/ + # Navigate: cli -> services -> app -> api -> apps -> project-root + project_root = os.path.join(current_file_dir, "..", "..", "..", "..", "..") + return os.path.abspath(project_root) + + +def get_display_path(file_path: str) -> str: + """Convert absolute path to a shorter display path scoped to the project. + + - Strips the project root prefix when present + - Compacts repo-specific prefixes (e.g., data/projects -> …/) + """ + try: + project_root = get_project_root() + if file_path.startswith(project_root): + display_path = file_path.replace(project_root + "/", "") + return display_path.replace("data/projects/", "…/") + except Exception: + pass + return file_path + + +# Model mapping from unified names to CLI-specific names +MODEL_MAPPING: Dict[str, Dict[str, str]] = { + "claude": { + "opus-4.1": "claude-opus-4-1-20250805", + "sonnet-4": "claude-sonnet-4-20250514", + "opus-4": "claude-opus-4-20250514", + "haiku-3.5": "claude-3-5-haiku-20241022", + # Handle claude-prefixed model names + "claude-sonnet-4": "claude-sonnet-4-20250514", + "claude-opus-4.1": "claude-opus-4-1-20250805", + "claude-opus-4": "claude-opus-4-20250514", + "claude-haiku-3.5": "claude-3-5-haiku-20241022", + # Support direct full model names + "claude-opus-4-1-20250805": "claude-opus-4-1-20250805", + "claude-sonnet-4-20250514": "claude-sonnet-4-20250514", + "claude-opus-4-20250514": "claude-opus-4-20250514", + "claude-3-5-haiku-20241022": "claude-3-5-haiku-20241022", + }, + "cursor": { + "gpt-5": "gpt-5", + "sonnet-4": "sonnet-4", + "opus-4.1": "opus-4.1", + "sonnet-4-thinking": "sonnet-4-thinking", + # Handle mapping from unified Claude model names + "claude-sonnet-4": "sonnet-4", + "claude-opus-4.1": "opus-4.1", + "claude-sonnet-4-20250514": "sonnet-4", + "claude-opus-4-1-20250805": "opus-4.1", + }, + "codex": { + "gpt-5": "gpt-5", + "gpt-4o": "gpt-4o", + "gpt-4o-mini": "gpt-4o-mini", + "o1-preview": "o1-preview", + "o1-mini": "o1-mini", + "claude-3.5-sonnet": "claude-3.5-sonnet", + "claude-3-haiku": "claude-3-haiku", + # Handle unified model names + "sonnet-4": "claude-3.5-sonnet", + "claude-sonnet-4": "claude-3.5-sonnet", + "haiku-3.5": "claude-3-haiku", + "claude-haiku-3.5": "claude-3-haiku", + }, + "qwen": { + # Unified name β†’ provider mapping + "qwen3-coder-plus": "qwen-coder", + "Qwen3 Coder Plus": "qwen-coder", + # Allow direct + "qwen-coder": "qwen-coder", + }, + "gemini": { + "gemini-2.5-pro": "gemini-2.5-pro", + "gemini-2.5-flash": "gemini-2.5-flash", + }, +} + + +class CLIType(str, Enum): + """Provider key used across the manager and adapters.""" + + CLAUDE = "claude" + CURSOR = "cursor" + CODEX = "codex" + QWEN = "qwen" + GEMINI = "gemini" + + +class BaseCLI(ABC): + """Abstract adapter contract for CLI providers. + + Subclasses must implement availability checks, streaming execution, and + session persistence. Common utilities (model mapping, content parsing, + tool summaries) are provided here for reuse. + """ + + def __init__(self, cli_type: CLIType): + self.cli_type = cli_type + + # ---- Mandatory adapter interface ------------------------------------ + @abstractmethod + async def check_availability(self) -> Dict[str, Any]: + """Return provider availability/configuration status. + + Expected keys in the returned dict used by the manager: + - available: bool + - configured: bool + - models/default_models (optional): List[str] + - error (optional): str + """ + + @abstractmethod + async def execute_with_streaming( + self, + instruction: str, + project_path: str, + session_id: Optional[str] = None, + log_callback: Optional[Callable[[str], Any]] = None, + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> AsyncGenerator[Message, None]: + """Execute an instruction and yield `Message` objects in real time.""" + + @abstractmethod + async def get_session_id(self, project_id: str) -> Optional[str]: + """Return the active session ID for a project, if any.""" + + @abstractmethod + async def set_session_id(self, project_id: str, session_id: str) -> None: + """Persist the active session ID for a project.""" + + # ---- Common helpers (available to adapters) -------------------------- + def _get_cli_model_name(self, model: Optional[str]) -> Optional[str]: + """Translate unified model name to provider-specific model name. + + If the input is already a provider name or mapping fails, return as-is. + """ + if not model: + return None + + from app.core.terminal_ui import ui + + ui.debug(f"Input model: '{model}' for CLI: {self.cli_type.value}", "Model") + cli_models = MODEL_MAPPING.get(self.cli_type.value, {}) + + # Try exact mapping + if model in cli_models: + mapped_model = cli_models[model] + ui.info( + f"Mapped '{model}' to '{mapped_model}' for {self.cli_type.value}", "Model" + ) + return mapped_model + + # Already a provider-specific name + if model in cli_models.values(): + ui.info( + f"Using direct model name '{model}' for {self.cli_type.value}", "Model" + ) + return model + + # Debug available models + available_models = list(cli_models.keys()) + ui.warning( + f"Model '{model}' not found in mapping for {self.cli_type.value}", "Model" + ) + ui.debug( + f"Available models for {self.cli_type.value}: {available_models}", "Model" + ) + ui.warning(f"Using model as-is: '{model}'", "Model") + return model + + def get_supported_models(self) -> List[str]: + cli_models = MODEL_MAPPING.get(self.cli_type.value, {}) + return list(cli_models.keys()) + list(cli_models.values()) + + def is_model_supported(self, model: str) -> bool: + return ( + model in self.get_supported_models() + or model in MODEL_MAPPING.get(self.cli_type.value, {}).values() + ) + + def parse_message_data(self, data: Dict[str, Any], project_id: str, session_id: str) -> Message: + """Normalize provider-specific message payload to our `Message`.""" + return Message( + id=str(uuid.uuid4()), + project_id=project_id, + role=self._normalize_role(data.get("role", "assistant")), + message_type="chat", + content=self._extract_content(data), + metadata_json={ + **data, + "cli_type": self.cli_type.value, + "original_format": data, + }, + session_id=session_id, + created_at=datetime.utcnow(), + ) + + def _normalize_role(self, role: str) -> str: + role_mapping = { + "model": "assistant", + "ai": "assistant", + "human": "user", + "bot": "assistant", + } + return role_mapping.get(role.lower(), role.lower()) + + def _extract_content(self, data: Dict[str, Any]) -> str: + """Extract best-effort text content from various provider formats.""" + # Claude content array + if "content" in data and isinstance(data["content"], list): + content = "" + for item in data["content"]: + if item.get("type") == "text": + content += item.get("text", "") + elif item.get("type") == "tool_use": + tool_name = item.get("name", "Unknown") + tool_input = item.get("input", {}) + summary = self._create_tool_summary(tool_name, tool_input) + content += f"{summary}\n" + return content + + # Simple text + elif "content" in data: + return str(data["content"]) + + # Gemini parts + elif "parts" in data: + content = "" + for part in data["parts"]: + if "text" in part: + content += part.get("text", "") + elif "functionCall" in part: + func_call = part["functionCall"] + tool_name = func_call.get("name", "Unknown") + tool_input = func_call.get("args", {}) + summary = self._create_tool_summary(tool_name, tool_input) + content += f"{summary}\n" + return content + + # OpenAI/Codex choices + elif "choices" in data and data["choices"]: + choice = data["choices"][0] + if "message" in choice: + return choice["message"].get("content", "") + elif "text" in choice: + return choice.get("text", "") + + # Direct text fields + elif "text" in data: + return str(data["text"]) + elif "message" in data: + if isinstance(data["message"], dict): + return self._extract_content(data["message"]) + return str(data["message"]) + + # Generic response field + elif "response" in data: + return str(data["response"]) + + # Delta streaming + elif "delta" in data and "content" in data["delta"]: + return str(data["delta"]["content"]) + + # Fallback + else: + return str(data) + + def _normalize_tool_name(self, tool_name: str) -> str: + """Normalize tool names across providers to a unified label.""" + key = (tool_name or "").strip() + key_lower = key.replace(" ", "").lower() + tool_mapping = { + # File operations + "read_file": "Read", + "read": "Read", + "write_file": "Write", + "write": "Write", + "edit_file": "Edit", + "replace": "Edit", + "edit": "Edit", + "delete": "Delete", + # Qwen/Gemini variants (CamelCase / spaced) + "readfile": "Read", + "readfolder": "LS", + "readmanyfiles": "Read", + "writefile": "Write", + "findfiles": "Glob", + "savememory": "SaveMemory", + "save memory": "SaveMemory", + "searchtext": "Grep", + # Terminal operations + "shell": "Bash", + "run_terminal_command": "Bash", + # Search operations + "search_file_content": "Grep", + "codebase_search": "Grep", + "grep": "Grep", + "find_files": "Glob", + "glob": "Glob", + "list_directory": "LS", + "list_dir": "LS", + "ls": "LS", + "semSearch": "SemSearch", + # Web operations + "google_web_search": "WebSearch", + "web_search": "WebSearch", + "googlesearch": "WebSearch", + "web_fetch": "WebFetch", + "fetch": "WebFetch", + # Task/Memory operations + "save_memory": "SaveMemory", + # Codex operations + "exec_command": "Bash", + "apply_patch": "Edit", + "mcp_tool_call": "MCPTool", + # Generic simple names + "search": "Grep", + } + return tool_mapping.get(tool_name, tool_mapping.get(key_lower, key)) + + def _get_clean_tool_display(self, tool_name: str, tool_input: Dict[str, Any]) -> str: + """Return a concise, Claude-like tool usage display line.""" + normalized_name = self._normalize_tool_name(tool_name) + + if normalized_name == "Read": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + filename = file_path.split("/")[-1] + return f"Reading {filename}" + return "Reading file" + elif normalized_name == "Write": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + filename = file_path.split("/")[-1] + return f"Writing {filename}" + return "Writing file" + elif normalized_name == "Edit": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + filename = file_path.split("/")[-1] + return f"Editing {filename}" + return "Editing file" + elif normalized_name == "Bash": + command = ( + tool_input.get("command") + or tool_input.get("cmd") + or tool_input.get("script", "") + ) + if command: + cmd_display = command.split()[0] if command.split() else command + return f"Running {cmd_display}" + return "Running command" + elif normalized_name == "LS": + return "Listing directory" + elif normalized_name == "TodoWrite": + return "Planning next steps" + elif normalized_name == "WebSearch": + query = tool_input.get("query", "") + if query: + return f"Searching: {query[:50]}..." + return "Web search" + elif normalized_name == "WebFetch": + url = tool_input.get("url", "") + if url: + domain = ( + url.split("//")[-1].split("/")[0] + if "//" in url + else url.split("/")[0] + ) + return f"Fetching from {domain}" + return "Fetching web content" + else: + return f"Using {tool_name}" + + def _create_tool_summary(self, tool_name: str, tool_input: Dict[str, Any]) -> str: + """Create a visual markdown summary for tool usage. + + NOTE: Special-cases Codex `apply_patch` to render one-line summaries per + file similar to Claude Code. + """ + # Handle apply_patch BEFORE normalization to avoid confusion with Edit + if tool_name == "apply_patch": + changes = tool_input.get("changes", {}) + if isinstance(changes, dict) and changes: + if len(changes) == 1: + path, change = next(iter(changes.items())) + filename = str(path).split("/")[-1] + if isinstance(change, dict): + if "add" in change: + return f"**Write** `{filename}`" + elif "delete" in change: + return f"**Delete** `{filename}`" + elif "update" in change: + upd = change.get("update") or {} + move_path = upd.get("move_path") + if move_path: + new_filename = move_path.split("/")[-1] + return f"**Rename** `{filename}` β†’ `{new_filename}`" + else: + return f"**Edit** `{filename}`" + else: + return f"**Edit** `{filename}`" + else: + return f"**Edit** `{filename}`" + else: + file_summaries: List[str] = [] + for raw_path, change in list(changes.items())[:3]: # max 3 files + path = str(raw_path) + filename = path.split("/")[-1] + if isinstance(change, dict): + if "add" in change: + file_summaries.append(f"β€’ **Write** `{filename}`") + elif "delete" in change: + file_summaries.append(f"β€’ **Delete** `{filename}`") + elif "update" in change: + upd = change.get("update") or {} + move_path = upd.get("move_path") + if move_path: + new_filename = move_path.split("/")[-1] + file_summaries.append( + f"β€’ **Rename** `{filename}` β†’ `{new_filename}`" + ) + else: + file_summaries.append(f"β€’ **Edit** `{filename}`") + else: + file_summaries.append(f"β€’ **Edit** `{filename}`") + else: + file_summaries.append(f"β€’ **Edit** `{filename}`") + + result = "\n".join(file_summaries) + if len(changes) > 3: + result += f"\nβ€’ ... +{len(changes) - 3} more files" + return result + return "**ApplyPatch** `files`" + + # Normalize name after handling apply_patch + normalized_name = self._normalize_tool_name(tool_name) + + if normalized_name == "Edit": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + display_path = get_display_path(file_path) + if len(display_path) > 40: + display_path = "…/" + "/".join(display_path.split("/")[-2:]) + return f"**Edit** `{display_path}`" + return "**Edit** `file`" + elif normalized_name == "Read": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + display_path = get_display_path(file_path) + if len(display_path) > 40: + display_path = "…/" + "/".join(display_path.split("/")[-2:]) + return f"**Read** `{display_path}`" + return "**Read** `file`" + elif normalized_name == "Bash": + command = ( + tool_input.get("command") + or tool_input.get("cmd") + or tool_input.get("script", "") + ) + if command: + display_cmd = command[:40] + "..." if len(command) > 40 else command + return f"**Bash** `{display_cmd}`" + return "**Bash** `command`" + elif normalized_name == "TodoWrite": + return "`Planning for next moves...`" + elif normalized_name == "SaveMemory": + fact = tool_input.get("fact", "") + if fact: + return f"**SaveMemory** `{fact[:40]}{'...' if len(fact) > 40 else ''}`" + return "**SaveMemory** `storing information`" + elif normalized_name == "Grep": + pattern = ( + tool_input.get("pattern") + or tool_input.get("query") + or tool_input.get("search", "") + ) + path = ( + tool_input.get("path") + or tool_input.get("file") + or tool_input.get("directory", "") + ) + if pattern: + if path: + display_path = get_display_path(path) + return f"**Search** `{pattern}` in `{display_path}`" + return f"**Search** `{pattern}`" + return "**Search** `pattern`" + elif normalized_name == "Glob": + if tool_name == "find_files": + name = tool_input.get("name", "") + if name: + return f"**Glob** `{name}`" + return "**Glob** `finding files`" + pattern = tool_input.get("pattern", "") or tool_input.get( + "globPattern", "" + ) + if pattern: + return f"**Glob** `{pattern}`" + return "**Glob** `pattern`" + elif normalized_name == "Write": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + display_path = get_display_path(file_path) + if len(display_path) > 40: + display_path = "…/" + "/".join(display_path.split("/")[-2:]) + return f"**Write** `{display_path}`" + return "**Write** `file`" + elif normalized_name == "MultiEdit": + file_path = ( + tool_input.get("file_path") + or tool_input.get("path") + or tool_input.get("file", "") + ) + if file_path: + display_path = get_display_path(file_path) + if len(display_path) > 40: + display_path = "…/" + "/".join(display_path.split("/")[-2:]) + return f"πŸ”§ **MultiEdit** `{display_path}`" + return "πŸ”§ **MultiEdit** `file`" + elif normalized_name == "LS": + path = ( + tool_input.get("path") + or tool_input.get("directory") + or tool_input.get("dir", "") + ) + if path: + display_path = get_display_path(path) + if len(display_path) > 40: + display_path = "…/" + display_path[-37:] + return f"πŸ“ **LS** `{display_path}`" + return "πŸ“ **LS** `directory`" + elif normalized_name == "WebFetch": + url = tool_input.get("url", "") + if url: + domain = ( + url.split("//")[-1].split("/")[0] + if "//" in url + else url.split("/")[0] + ) + return f"**WebFetch** [{domain}]({url})" + return "**WebFetch** `url`" + elif normalized_name == "WebSearch": + query = tool_input.get("query") or tool_input.get("search_query", "") + query = tool_input.get("query", "") + if query: + short_query = query[:40] + "..." if len(query) > 40 else query + return f"**WebSearch** `{short_query}`" + return "**WebSearch** `query`" + elif normalized_name == "Task": + description = tool_input.get("description", "") + subagent_type = tool_input.get("subagent_type", "") + if description and subagent_type: + return ( + f"πŸ€– **Task** `{subagent_type}`\n> " + f"{description[:50]}{'...' if len(description) > 50 else ''}" + ) + elif description: + return f"πŸ€– **Task** `{description[:40]}{'...' if len(description) > 40 else ''}`" + return "πŸ€– **Task** `subtask`" + elif normalized_name == "ExitPlanMode": + return "βœ… **ExitPlanMode** `planning complete`" + elif normalized_name == "NotebookEdit": + notebook_path = tool_input.get("notebook_path", "") + if notebook_path: + filename = notebook_path.split("/")[-1] + return f"πŸ““ **NotebookEdit** `{filename}`" + return "πŸ““ **NotebookEdit** `notebook`" + elif normalized_name == "MCPTool" or tool_name == "mcp_tool_call": + server = tool_input.get("server", "") + tool_name_inner = tool_input.get("tool", "") + if server and tool_name_inner: + return f"πŸ”§ **MCP** `{server}.{tool_name_inner}`" + return "πŸ”§ **MCP** `tool call`" + elif tool_name == "exec_command": + command = tool_input.get("command", "") + if command: + display_cmd = command[:40] + "..." if len(command) > 40 else command + return f"⚑ **Exec** `{display_cmd}`" + return "⚑ **Exec** `command`" + else: + return f"**{tool_name}** `executing...`" diff --git a/apps/api/app/services/cli/manager.py b/apps/api/app/services/cli/manager.py new file mode 100644 index 00000000..1c53481f --- /dev/null +++ b/apps/api/app/services/cli/manager.py @@ -0,0 +1,291 @@ +"""Unified CLI Manager implementation. + +Moved from unified_manager.py to a dedicated module. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional +import os + +from app.core.terminal_ui import ui +from app.core.websocket.manager import manager as ws_manager +from app.models.messages import Message + +from .base import CLIType +from .adapters import ClaudeCodeCLI, CursorAgentCLI, CodexCLI, QwenCLI, GeminiCLI + + +class UnifiedCLIManager: + """Unified manager for all CLI implementations""" + + def __init__( + self, + project_id: str, + project_path: str, + session_id: str, + conversation_id: str, + db: Any, # SQLAlchemy Session + ): + self.project_id = project_id + self.project_path = project_path + self.session_id = session_id + self.conversation_id = conversation_id + self.db = db + + # Initialize CLI adapters with database session + self.cli_adapters = { + CLIType.CLAUDE: ClaudeCodeCLI(), # Use SDK implementation if available + CLIType.CURSOR: CursorAgentCLI(db_session=db), + CLIType.CODEX: CodexCLI(db_session=db), + CLIType.QWEN: QwenCLI(db_session=db), + CLIType.GEMINI: GeminiCLI(db_session=db), + } + + async def execute_instruction( + self, + instruction: str, + cli_type: CLIType, + fallback_enabled: bool = True, # Kept for backward compatibility but not used + images: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> Dict[str, Any]: + """Execute instruction with specified CLI""" + + # Validate project_path before proceeding + if not self.project_path: + error_msg = "Project path is not available. The project may still be initializing." + ui.error(error_msg, "CLI") + return { + "success": False, + "error": error_msg, + "cli_attempted": cli_type.value, + } + + if not os.path.exists(self.project_path): + error_msg = f"Project directory does not exist: {self.project_path}. The project may have failed to initialize." + ui.error(error_msg, "CLI") + return { + "success": False, + "error": error_msg, + "cli_attempted": cli_type.value, + } + + # Try the specified CLI + if cli_type in self.cli_adapters: + cli = self.cli_adapters[cli_type] + + # Check if CLI is available + status = await cli.check_availability() + if status.get("available") and status.get("configured"): + try: + return await self._execute_with_cli( + cli, instruction, images, model, is_initial_prompt + ) + except Exception as e: + ui.error(f"CLI {cli_type.value} failed: {e}", "CLI") + return { + "success": False, + "error": str(e), + "cli_attempted": cli_type.value, + } + else: + return { + "success": False, + "error": status.get("error", "CLI not available"), + "cli_attempted": cli_type.value, + } + + return { + "success": False, + "error": f"CLI type {cli_type.value} not implemented", + "cli_attempted": cli_type.value, + } + + async def _execute_with_cli( + self, + cli, + instruction: str, + images: Optional[List[Dict[str, Any]]], + model: Optional[str] = None, + is_initial_prompt: bool = False, + ) -> Dict[str, Any]: + """Execute instruction with a specific CLI""" + + ui.info(f"Starting {cli.cli_type.value} execution", "CLI") + if model: + ui.debug(f"Using model: {model}", "CLI") + + messages_collected: List[Message] = [] + has_changes = False + has_error = False # Track if any error occurred + result_success: Optional[bool] = None # Track result event success status + + # Log callback + async def log_callback(message: str): + # CLI output logs are now only printed to console, not sent to UI + pass + + async for message in cli.execute_with_streaming( + instruction=instruction, + project_path=self.project_path, + session_id=self.session_id, + log_callback=log_callback, + images=images, + model=model, + is_initial_prompt=is_initial_prompt, + ): + # Check for error messages or result status + if message.message_type == "error": + has_error = True + ui.error(f"CLI error detected: {message.content[:100]}", "CLI") + + # Check for Cursor result event (stored in metadata) + if message.metadata_json: + event_type = message.metadata_json.get("event_type") + original_event = message.metadata_json.get("original_event", {}) + + if event_type == "result" or original_event.get("type") == "result": + # Cursor sends result event with success/error status + is_error = original_event.get("is_error", False) + subtype = original_event.get("subtype", "") + + # DEBUG: Log the complete result event structure + ui.info(f"πŸ” [Cursor] Result event received:", "DEBUG") + ui.info(f" Full event: {original_event}", "DEBUG") + ui.info(f" is_error: {is_error}", "DEBUG") + ui.info(f" subtype: '{subtype}'", "DEBUG") + ui.info(f" has event.result: {'result' in original_event}", "DEBUG") + ui.info(f" has event.status: {'status' in original_event}", "DEBUG") + ui.info(f" has event.success: {'success' in original_event}", "DEBUG") + + if is_error or subtype == "error": + has_error = True + result_success = False + ui.error( + f"Cursor result: error (is_error={is_error}, subtype='{subtype}')", + "CLI", + ) + elif subtype == "success": + result_success = True + ui.success( + f"Cursor result: success (subtype='{subtype}')", "CLI" + ) + else: + # Handle case where subtype is not "success" but execution was successful + ui.warning( + f"Cursor result: no explicit success subtype (subtype='{subtype}', is_error={is_error})", + "CLI", + ) + # If there's no error indication, assume success + if not is_error: + result_success = True + ui.success( + f"Cursor result: assuming success (no error detected)", "CLI" + ) + + # Save message to database + message.project_id = self.project_id + message.conversation_id = self.conversation_id + self.db.add(message) + self.db.commit() + + messages_collected.append(message) + + # Check if message should be hidden from UI + should_hide = ( + message.metadata_json and message.metadata_json.get("hidden_from_ui", False) + ) + + # Send message via WebSocket only if not hidden + if not should_hide: + ws_message = { + "type": "message", + "data": { + "id": message.id, + "role": message.role, + "message_type": message.message_type, + "content": message.content, + "metadata": message.metadata_json, + "parent_message_id": getattr(message, "parent_message_id", None), + "session_id": message.session_id, + "conversation_id": self.conversation_id, + "created_at": message.created_at.isoformat(), + }, + "timestamp": message.created_at.isoformat(), + } + try: + await ws_manager.send_message(self.project_id, ws_message) + except Exception as e: + ui.error(f"WebSocket send failed: {e}", "Message") + + # Check if changes were made + if message.metadata_json and "changes_made" in message.metadata_json: + has_changes = True + + # Determine final success status + # For Cursor: check result_success if available, otherwise check has_error + # For others: check has_error + ui.info( + f"πŸ” Final success determination: cli_type={cli.cli_type}, result_success={result_success}, has_error={has_error}", + "CLI", + ) + + if cli.cli_type == CLIType.CURSOR and result_success is not None: + success = result_success + ui.info(f"Using Cursor result_success: {result_success}", "CLI") + else: + success = not has_error + ui.info(f"Using has_error logic: not {has_error} = {success}", "CLI") + + if success: + ui.success( + f"Streaming completed successfully. Total messages: {len(messages_collected)}", + "CLI", + ) + else: + ui.error( + f"Streaming completed with errors. Total messages: {len(messages_collected)}", + "CLI", + ) + + return { + "success": success, + "cli_used": cli.cli_type.value, + "has_changes": has_changes, + "message": f"{'Successfully' if success else 'Failed to'} execute with {cli.cli_type.value}", + "error": "Execution failed" if not success else None, + "messages_count": len(messages_collected), + } + + # End _execute_with_cli + + async def check_cli_status( + self, cli_type: CLIType, selected_model: Optional[str] = None + ) -> Dict[str, Any]: + """Check status of a specific CLI""" + if cli_type in self.cli_adapters: + status = await self.cli_adapters[cli_type].check_availability() + + # Add model validation if model is specified + if selected_model and status.get("available"): + cli = self.cli_adapters[cli_type] + if not cli.is_model_supported(selected_model): + status[ + "model_warning" + ] = f"Model '{selected_model}' may not be supported by {cli_type.value}" + status["suggested_models"] = status.get("default_models", []) + else: + status["selected_model"] = selected_model + status["model_valid"] = True + + return status + return { + "available": False, + "configured": False, + "error": f"CLI type {cli_type.value} not implemented", + } + + +__all__ = ["UnifiedCLIManager"] diff --git a/apps/api/app/services/cli/unified_manager.py b/apps/api/app/services/cli/unified_manager.py index 56dd8744..7158c4b7 100644 --- a/apps/api/app/services/cli/unified_manager.py +++ b/apps/api/app/services/cli/unified_manager.py @@ -1,1532 +1,27 @@ """ -Unified CLI Manager for Multi-AI Agent Support -Supports Claude Code SDK, Cursor Agent, Qwen Code, Gemini CLI, and Codex CLI -""" -import asyncio -import json -import os -import subprocess -import uuid -from abc import ABC, abstractmethod -from datetime import datetime -from typing import Optional, Callable, Dict, Any, AsyncGenerator, List -from enum import Enum -import tempfile -import base64 - - -def get_project_root() -> str: - """Get project root directory using relative path navigation""" - current_file_dir = os.path.dirname(os.path.abspath(__file__)) - # unified_manager.py -> cli -> services -> app -> api -> apps -> project-root - project_root = os.path.join(current_file_dir, "..", "..", "..", "..", "..") - return os.path.abspath(project_root) - - -def get_display_path(file_path: str) -> str: - """Convert absolute path to relative display path""" - try: - project_root = get_project_root() - if file_path.startswith(project_root): - # Remove project root from path - display_path = file_path.replace(project_root + "/", "") - return display_path.replace("data/projects/", "…/") - except Exception: - pass - return file_path - -from app.models.messages import Message -from app.models.sessions import Session -from app.core.websocket.manager import manager as ws_manager -from app.core.terminal_ui import ui - -# Claude Code SDK imports -from claude_code_sdk import ClaudeSDKClient, ClaudeCodeOptions - - -# Model mapping from unified names to CLI-specific names -MODEL_MAPPING = { - "claude": { - "opus-4.1": "claude-opus-4-1-20250805", - "sonnet-4": "claude-sonnet-4-20250514", - "opus-4": "claude-opus-4-20250514", - "haiku-3.5": "claude-3-5-haiku-20241022", - # Handle claude-prefixed model names - "claude-sonnet-4": "claude-sonnet-4-20250514", - "claude-opus-4.1": "claude-opus-4-1-20250805", - "claude-opus-4": "claude-opus-4-20250514", - "claude-haiku-3.5": "claude-3-5-haiku-20241022", - # Support direct full model names - "claude-opus-4-1-20250805": "claude-opus-4-1-20250805", - "claude-sonnet-4-20250514": "claude-sonnet-4-20250514", - "claude-opus-4-20250514": "claude-opus-4-20250514", - "claude-3-5-haiku-20241022": "claude-3-5-haiku-20241022" - }, - "cursor": { - "gpt-5": "gpt-5", - "sonnet-4": "sonnet-4", - "opus-4.1": "opus-4.1", - "sonnet-4-thinking": "sonnet-4-thinking", - # Handle mapping from unified Claude model names - "claude-sonnet-4": "sonnet-4", - "claude-opus-4.1": "opus-4.1", - "claude-sonnet-4-20250514": "sonnet-4", - "claude-opus-4-1-20250805": "opus-4.1" - } -} - - -class CLIType(str, Enum): - CLAUDE = "claude" - CURSOR = "cursor" - - -class BaseCLI(ABC): - """Abstract base class for all CLI implementations""" - - def __init__(self, cli_type: CLIType): - self.cli_type = cli_type - - def _get_cli_model_name(self, model: Optional[str]) -> Optional[str]: - """Convert unified model name to CLI-specific model name""" - if not model: - return None - - from app.core.terminal_ui import ui - - ui.debug(f"Input model: '{model}' for CLI: {self.cli_type.value}", "Model") - cli_models = MODEL_MAPPING.get(self.cli_type.value, {}) - - # Try exact match first - if model in cli_models: - mapped_model = cli_models[model] - ui.info(f"Mapped '{model}' to '{mapped_model}' for {self.cli_type.value}", "Model") - return mapped_model - - # Try direct model name (already CLI-specific) - if model in cli_models.values(): - ui.info(f"Using direct model name '{model}' for {self.cli_type.value}", "Model") - return model - - # For debugging: show available models - available_models = list(cli_models.keys()) - ui.warning(f"Model '{model}' not found in mapping for {self.cli_type.value}", "Model") - ui.debug(f"Available models for {self.cli_type.value}: {available_models}", "Model") - ui.warning(f"Using model as-is: '{model}'", "Model") - return model - - def get_supported_models(self) -> List[str]: - """Get list of supported models for this CLI""" - cli_models = MODEL_MAPPING.get(self.cli_type.value, {}) - return list(cli_models.keys()) + list(cli_models.values()) - - def is_model_supported(self, model: str) -> bool: - """Check if a model is supported by this CLI""" - return model in self.get_supported_models() or model in MODEL_MAPPING.get(self.cli_type.value, {}).values() - - @abstractmethod - async def check_availability(self) -> Dict[str, Any]: - """Check if CLI is available and configured""" - pass - - @abstractmethod - async def execute_with_streaming( - self, - instruction: str, - project_path: str, - session_id: Optional[str] = None, - log_callback: Optional[Callable] = None, - images: Optional[List[Dict[str, Any]]] = None, - model: Optional[str] = None, - is_initial_prompt: bool = False - ) -> AsyncGenerator[Message, None]: - """Execute instruction and yield messages in real-time""" - pass - - @abstractmethod - async def get_session_id(self, project_id: str) -> Optional[str]: - """Get current session ID for project""" - pass - - @abstractmethod - async def set_session_id(self, project_id: str, session_id: str) -> None: - """Set session ID for project""" - pass - - - def parse_message_data(self, data: Dict[str, Any], project_id: str, session_id: str) -> Message: - """Parse CLI-specific message data to unified Message format""" - return Message( - id=str(uuid.uuid4()), - project_id=project_id, - role=self._normalize_role(data.get("role", "assistant")), - message_type="chat", - content=self._extract_content(data), - metadata_json={ - **data, - "cli_type": self.cli_type.value, - "original_format": data - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - - def _normalize_role(self, role: str) -> str: - """Normalize different CLI role formats""" - role_mapping = { - "model": "assistant", - "ai": "assistant", - "human": "user", - "bot": "assistant" - } - return role_mapping.get(role.lower(), role.lower()) - - def _extract_content(self, data: Dict[str, Any]) -> str: - """Extract content from CLI-specific data format""" - - # Handle Claude's complex content array structure - if "content" in data and isinstance(data["content"], list): - content = "" - for item in data["content"]: - if item.get("type") == "text": - content += item.get("text", "") - elif item.get("type") == "tool_use": - tool_name = item.get("name", "Unknown") - tool_input = item.get("input", {}) - - # Create simplified tool use summary - summary = self._create_tool_summary(tool_name, tool_input) - content += f"{summary}\n" - return content - - # Handle simple content string - elif "content" in data: - return str(data["content"]) - - # Handle Gemini parts format - elif "parts" in data: - content = "" - for part in data["parts"]: - if "text" in part: - content += part.get("text", "") - elif "functionCall" in part: - func_call = part["functionCall"] - tool_name = func_call.get('name', 'Unknown') - tool_input = func_call.get("args", {}) - summary = self._create_tool_summary(tool_name, tool_input) - content += f"{summary}\n" - return content - - # Handle OpenAI/Codex format with choices - elif "choices" in data and data["choices"]: - choice = data["choices"][0] - if "message" in choice: - return choice["message"].get("content", "") - elif "text" in choice: - return choice.get("text", "") - - # Handle direct text fields - elif "text" in data: - return str(data["text"]) - elif "message" in data: - # Handle nested message structure - if isinstance(data["message"], dict): - return self._extract_content(data["message"]) - return str(data["message"]) - - # Handle response field (common in many APIs) - elif "response" in data: - return str(data["response"]) - - # Handle delta streaming format - elif "delta" in data and "content" in data["delta"]: - return str(data["delta"]["content"]) - - # Fallback: convert entire data to string - else: - return str(data) - - def _normalize_tool_name(self, tool_name: str) -> str: - """Normalize different CLI tool names to unified format""" - tool_mapping = { - # File operations - "read_file": "Read", "read": "Read", - "write_file": "Write", "write": "Write", - "edit_file": "Edit", - "replace": "Edit", "edit": "Edit", - "delete": "Delete", - - # Terminal operations - "shell": "Bash", - "run_terminal_command": "Bash", - - # Search operations - "search_file_content": "Grep", - "codebase_search": "Grep", "grep": "Grep", - "find_files": "Glob", "glob": "Glob", - "list_directory": "LS", - "list_dir": "LS", "ls": "LS", - "semSearch": "SemSearch", - - # Web operations - "google_web_search": "WebSearch", - "web_search": "WebSearch", - "web_fetch": "WebFetch", - - # Task/Memory operations - "save_memory": "SaveMemory", - } - - return tool_mapping.get(tool_name, tool_name) - - def _get_clean_tool_display(self, tool_name: str, tool_input: Dict[str, Any]) -> str: - """Create a clean tool display like Claude Code""" - normalized_name = self._normalize_tool_name(tool_name) - - if normalized_name == "Read": - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - filename = file_path.split("/")[-1] - return f"Reading {filename}" - return "Reading file" - elif normalized_name == "Write": - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - filename = file_path.split("/")[-1] - return f"Writing {filename}" - return "Writing file" - elif normalized_name == "Edit": - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - filename = file_path.split("/")[-1] - return f"Editing {filename}" - return "Editing file" - elif normalized_name == "Bash": - command = tool_input.get("command") or tool_input.get("cmd") or tool_input.get("script", "") - if command: - cmd_display = command.split()[0] if command.split() else command - return f"Running {cmd_display}" - return "Running command" - elif normalized_name == "LS": - return "Listing directory" - elif normalized_name == "TodoWrite": - return "Planning next steps" - elif normalized_name == "WebSearch": - query = tool_input.get("query", "") - if query: - return f"Searching: {query[:50]}..." - return "Web search" - elif normalized_name == "WebFetch": - url = tool_input.get("url", "") - if url: - domain = url.split("//")[-1].split("/")[0] if "//" in url else url.split("/")[0] - return f"Fetching from {domain}" - return "Fetching web content" - else: - return f"Using {tool_name}" - - def _create_tool_summary(self, tool_name: str, tool_input: Dict[str, Any]) -> str: - """Create a visual markdown summary for tool usage""" - # Normalize the tool name first - normalized_name = self._normalize_tool_name(tool_name) - - if normalized_name == "Edit": - # Handle different argument names from different CLIs - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - display_path = get_display_path(file_path) - if len(display_path) > 40: - display_path = "…/" + "/".join(display_path.split("/")[-2:]) - return f"**Edit** `{display_path}`" - return "**Edit** `file`" - elif normalized_name == "Read": - # Handle different argument names from different CLIs - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - display_path = get_display_path(file_path) - if len(display_path) > 40: - display_path = "…/" + "/".join(display_path.split("/")[-2:]) - return f"**Read** `{display_path}`" - return "**Read** `file`" - elif normalized_name == "Bash": - # Handle different command argument names - command = tool_input.get("command") or tool_input.get("cmd") or tool_input.get("script", "") - if command: - display_cmd = command[:40] + "..." if len(command) > 40 else command - return f"**Bash** `{display_cmd}`" - return "**Bash** `command`" - elif normalized_name == "TodoWrite": - return "`Planning for next moves...`" - elif normalized_name == "SaveMemory": - # Handle save_memory from Gemini CLI - fact = tool_input.get("fact", "") - if fact: - return f"**SaveMemory** `{fact[:40]}{'...' if len(fact) > 40 else ''}`" - return "**SaveMemory** `storing information`" - elif normalized_name == "Grep": - # Handle different search tool arguments - pattern = tool_input.get("pattern") or tool_input.get("query") or tool_input.get("search", "") - path = tool_input.get("path") or tool_input.get("file") or tool_input.get("directory", "") - if pattern: - if path: - display_path = get_display_path(path) - return f"**Search** `{pattern}` in `{display_path}`" - return f"**Search** `{pattern}`" - return "**Search** `pattern`" - elif normalized_name == "Glob": - # Handle find_files from Cursor Agent - if tool_name == "find_files": - name = tool_input.get("name", "") - if name: - return f"**Glob** `{name}`" - return "**Glob** `finding files`" - pattern = tool_input.get("pattern", "") or tool_input.get("globPattern", "") - if pattern: - return f"**Glob** `{pattern}`" - return "**Glob** `pattern`" - elif normalized_name == "Write": - # Handle different argument names from different CLIs - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - display_path = get_display_path(file_path) - if len(display_path) > 40: - display_path = "…/" + "/".join(display_path.split("/")[-2:]) - return f"**Write** `{display_path}`" - return "**Write** `file`" - elif normalized_name == "MultiEdit": - # Handle different argument names from different CLIs - file_path = tool_input.get("file_path") or tool_input.get("path") or tool_input.get("file", "") - if file_path: - display_path = get_display_path(file_path) - if len(display_path) > 40: - display_path = "…/" + "/".join(display_path.split("/")[-2:]) - return f"πŸ”§ **MultiEdit** `{display_path}`" - return "πŸ”§ **MultiEdit** `file`" - elif normalized_name == "LS": - # Handle list_dir from Cursor Agent and list_directory from Gemini - path = tool_input.get("path") or tool_input.get("directory") or tool_input.get("dir", "") - if path: - display_path = get_display_path(path) - if len(display_path) > 40: - display_path = "…/" + display_path[-37:] - return f"πŸ“ **LS** `{display_path}`" - return "πŸ“ **LS** `directory`" - elif normalized_name == "Delete": - file_path = tool_input.get("path", "") - if file_path: - display_path = get_display_path(file_path) - if len(display_path) > 40: - display_path = "…/" + "/".join(display_path.split("/")[-2:]) - return f"**Delete** `{display_path}`" - return "**Delete** `file`" - elif normalized_name == "SemSearch": - query = tool_input.get("query", "") - if query: - short_query = query[:40] + "..." if len(query) > 40 else query - return f"**SemSearch** `{short_query}`" - return "**SemSearch** `query`" - elif normalized_name == "WebFetch": - # Handle web_fetch from Gemini CLI - url = tool_input.get("url", "") - prompt = tool_input.get("prompt", "") - if url and prompt: - domain = url.split("//")[-1].split("/")[0] if "//" in url else url.split("/")[0] - short_prompt = prompt[:30] + "..." if len(prompt) > 30 else prompt - return f"**WebFetch** [{domain}]({url})\n> {short_prompt}" - elif url: - domain = url.split("//")[-1].split("/")[0] if "//" in url else url.split("/")[0] - return f"**WebFetch** [{domain}]({url})" - return "**WebFetch** `url`" - elif normalized_name == "WebSearch": - # Handle google_web_search from Gemini CLI and web_search from Cursor Agent - query = tool_input.get("query") or tool_input.get("search_query", "") - query = tool_input.get("query", "") - if query: - short_query = query[:40] + "..." if len(query) > 40 else query - return f"**WebSearch** `{short_query}`" - return "**WebSearch** `query`" - elif normalized_name == "Task": - # Handle Task tool from Claude Code - description = tool_input.get("description", "") - subagent_type = tool_input.get("subagent_type", "") - if description and subagent_type: - return f"πŸ€– **Task** `{subagent_type}`\n> {description[:50]}{'...' if len(description) > 50 else ''}" - elif description: - return f"πŸ€– **Task** `{description[:40]}{'...' if len(description) > 40 else ''}`" - return "πŸ€– **Task** `subtask`" - elif normalized_name == "ExitPlanMode": - # Handle ExitPlanMode from Claude Code - return "βœ… **ExitPlanMode** `planning complete`" - elif normalized_name == "NotebookEdit": - # Handle NotebookEdit from Claude Code - notebook_path = tool_input.get("notebook_path", "") - if notebook_path: - filename = notebook_path.split("/")[-1] - return f"πŸ““ **NotebookEdit** `{filename}`" - return "πŸ““ **NotebookEdit** `notebook`" - else: - return f"**{tool_name}** `executing...`" - - -class ClaudeCodeCLI(BaseCLI): - """Claude Code Python SDK implementation""" - - def __init__(self): - super().__init__(CLIType.CLAUDE) - self.session_mapping: Dict[str, str] = {} - - async def check_availability(self) -> Dict[str, Any]: - """Check if Claude Code CLI is available""" - try: - # First try to check if claude CLI is installed and working - result = await asyncio.create_subprocess_shell( - "claude -h", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - stdout, stderr = await result.communicate() - - if result.returncode != 0: - return { - "available": False, - "configured": False, - "error": "Claude Code CLI not installed or not working.\n\nTo install:\n1. Install Claude Code: npm install -g @anthropic-ai/claude-code\n2. Login to Claude: claude login\n3. Try running your prompt again" - } - - # Check if help output contains expected content - help_output = stdout.decode() + stderr.decode() - if "claude" not in help_output.lower(): - return { - "available": False, - "configured": False, - "error": "Claude Code CLI not responding correctly.\n\nPlease try:\n1. Reinstall: npm install -g @anthropic-ai/claude-code\n2. Login: claude login\n3. Check installation: claude -h" - } - - return { - "available": True, - "configured": True, - "mode": "CLI", - "models": self.get_supported_models(), - "default_models": ["claude-sonnet-4-20250514", "claude-opus-4-1-20250805"] - } - except Exception as e: - return { - "available": False, - "configured": False, - "error": f"Failed to check Claude Code CLI: {str(e)}\n\nTo install:\n1. Install Claude Code: npm install -g @anthropic-ai/claude-code\n2. Login to Claude: claude login" - } - - async def execute_with_streaming( - self, - instruction: str, - project_path: str, - session_id: Optional[str] = None, - log_callback: Optional[Callable] = None, - images: Optional[List[Dict[str, Any]]] = None, - model: Optional[str] = None, - is_initial_prompt: bool = False - ) -> AsyncGenerator[Message, None]: - """Execute instruction using Claude Code Python SDK""" - from app.core.terminal_ui import ui - - ui.info("Starting Claude SDK execution", "Claude SDK") - ui.debug(f"Instruction: {instruction[:100]}...", "Claude SDK") - ui.debug(f"Project path: {project_path}", "Claude SDK") - ui.debug(f"Session ID: {session_id}", "Claude SDK") - - if log_callback: - await log_callback("Starting execution...") - - # Load system prompt - try: - from app.services.claude_act import get_system_prompt - system_prompt = get_system_prompt() - ui.debug(f"System prompt loaded: {len(system_prompt)} chars", "Claude SDK") - except Exception as e: - ui.error(f"Failed to load system prompt: {e}", "Claude SDK") - system_prompt = "You are Claude Code, an AI coding assistant specialized in building modern web applications." - - # Get CLI-specific model name - cli_model = self._get_cli_model_name(model) or "claude-sonnet-4-20250514" - - # Add project directory structure for initial prompts - if is_initial_prompt: - project_structure_info = """ - -## Project Directory Structure (node_modules are already installed) -.eslintrc.json -.gitignore -next.config.mjs -next-env.d.ts -package.json -postcss.config.mjs -README.md -tailwind.config.ts -tsconfig.json -.env -src/app/favicon.ico -src/app/globals.css -src/app/layout.tsx -src/app/page.tsx -public/ -node_modules/ -""" - instruction = instruction + project_structure_info - ui.info(f"Added project structure info to initial prompt", "Claude SDK") - - # Configure tools based on initial prompt status - if is_initial_prompt: - # For initial prompts: use disallowed_tools to explicitly block TodoWrite - allowed_tools = [ - "Read", "Write", "Edit", "MultiEdit", "Bash", "Glob", "Grep", "LS", - "WebFetch", "WebSearch" - ] - disallowed_tools = ["TodoWrite"] - - ui.info(f"TodoWrite tool EXCLUDED via disallowed_tools (is_initial_prompt: {is_initial_prompt})", "Claude SDK") - ui.debug(f"Allowed tools: {allowed_tools}", "Claude SDK") - ui.debug(f"Disallowed tools: {disallowed_tools}", "Claude SDK") - - # Configure Claude Code options with disallowed_tools - options = ClaudeCodeOptions( - system_prompt=system_prompt, - allowed_tools=allowed_tools, - disallowed_tools=disallowed_tools, - permission_mode="bypassPermissions", - model=cli_model, - continue_conversation=True - ) - else: - # For non-initial prompts: include TodoWrite in allowed tools - allowed_tools = [ - "Read", "Write", "Edit", "MultiEdit", "Bash", "Glob", "Grep", "LS", - "WebFetch", "WebSearch", "TodoWrite" - ] - - ui.info(f"TodoWrite tool INCLUDED (is_initial_prompt: {is_initial_prompt})", "Claude SDK") - ui.debug(f"Allowed tools: {allowed_tools}", "Claude SDK") - - # Configure Claude Code options without disallowed_tools - options = ClaudeCodeOptions( - system_prompt=system_prompt, - allowed_tools=allowed_tools, - permission_mode="bypassPermissions", - model=cli_model, - continue_conversation=True - ) - - ui.info(f"Using model: {cli_model}", "Claude SDK") - ui.debug(f"Project path: {project_path}", "Claude SDK") - ui.debug(f"Instruction: {instruction[:100]}...", "Claude SDK") - - try: - # Change to project directory - original_cwd = os.getcwd() - os.chdir(project_path) - - # Get project ID for session management - project_id = project_path.split("/")[-1] if "/" in project_path else project_path - existing_session_id = await self.get_session_id(project_id) - - # Update options with resume session if available - if existing_session_id: - options.resumeSessionId = existing_session_id - ui.info(f"Resuming session: {existing_session_id}", "Claude SDK") - - try: - async with ClaudeSDKClient(options=options) as client: - # Send initial query - await client.query(instruction) - - # Stream responses and extract session_id - claude_session_id = None - - async for message_obj in client.receive_messages(): - - # Import SDK types for isinstance checks - try: - from anthropic.claude_code.types import SystemMessage, AssistantMessage, UserMessage, ResultMessage - except ImportError: - try: - from claude_code_sdk.types import SystemMessage, AssistantMessage, UserMessage, ResultMessage - except ImportError: - # Fallback - check type name strings - SystemMessage = type(None) - AssistantMessage = type(None) - UserMessage = type(None) - ResultMessage = type(None) - - # Handle SystemMessage for session_id extraction - if (isinstance(message_obj, SystemMessage) or - 'SystemMessage' in str(type(message_obj))): - # Extract session_id if available - if hasattr(message_obj, 'session_id') and message_obj.session_id: - claude_session_id = message_obj.session_id - await self.set_session_id(project_id, claude_session_id) - - # Send init message (hidden from UI) - init_message = Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="system", - message_type="system", - content=f"Claude Code SDK initialized (Model: {cli_model})", - metadata_json={ - "cli_type": self.cli_type.value, - "mode": "SDK", - "model": cli_model, - "session_id": getattr(message_obj, 'session_id', None), - "hidden_from_ui": True - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - yield init_message - - # Handle AssistantMessage (complete messages) - elif (isinstance(message_obj, AssistantMessage) or - 'AssistantMessage' in str(type(message_obj))): - - content = "" - - # Process content - AssistantMessage has content: list[ContentBlock] - if hasattr(message_obj, 'content') and isinstance(message_obj.content, list): - for block in message_obj.content: - - # Import block types for comparison - from claude_code_sdk.types import TextBlock, ToolUseBlock, ToolResultBlock - - if isinstance(block, TextBlock): - # TextBlock has 'text' attribute - content += block.text - elif isinstance(block, ToolUseBlock): - # ToolUseBlock has 'id', 'name', 'input' attributes - tool_name = block.name - tool_input = block.input - tool_id = block.id - summary = self._create_tool_summary(tool_name, tool_input) - - # Yield tool use message immediately - tool_message = Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="tool_use", - content=summary, - metadata_json={ - "cli_type": self.cli_type.value, - "mode": "SDK", - "tool_name": tool_name, - "tool_input": tool_input, - "tool_id": tool_id - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - # Display clean tool usage like Claude Code - tool_display = self._get_clean_tool_display(tool_name, tool_input) - ui.info(tool_display, "") - yield tool_message - elif isinstance(block, ToolResultBlock): - # Handle tool result blocks if needed - pass - - # Yield complete assistant text message if there's text content - if content and content.strip(): - text_message = Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="chat", - content=content.strip(), - metadata_json={ - "cli_type": self.cli_type.value, - "mode": "SDK" - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - yield text_message - - # Handle UserMessage (tool results, etc.) - elif (isinstance(message_obj, UserMessage) or - 'UserMessage' in str(type(message_obj))): - # UserMessage has content: str according to types.py - # UserMessages are typically tool results - we don't need to show them - pass - - # Handle ResultMessage (final session completion) - elif ( - isinstance(message_obj, ResultMessage) or - 'ResultMessage' in str(type(message_obj)) or - (hasattr(message_obj, 'type') and getattr(message_obj, 'type', None) == 'result') - ): - ui.success(f"Session completed in {getattr(message_obj, 'duration_ms', 0)}ms", "Claude SDK") - - # Create internal result message (hidden from UI) - result_message = Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="system", - message_type="result", - content=f"Session completed in {getattr(message_obj, 'duration_ms', 0)}ms", - metadata_json={ - "cli_type": self.cli_type.value, - "mode": "SDK", - "duration_ms": getattr(message_obj, 'duration_ms', 0), - "duration_api_ms": getattr(message_obj, 'duration_api_ms', 0), - "total_cost_usd": getattr(message_obj, 'total_cost_usd', 0), - "num_turns": getattr(message_obj, 'num_turns', 0), - "is_error": getattr(message_obj, 'is_error', False), - "subtype": getattr(message_obj, 'subtype', None), - "session_id": getattr(message_obj, 'session_id', None), - "hidden_from_ui": True # Don't show to user - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - yield result_message - break - - # Handle unknown message types - else: - ui.debug(f"Unknown message type: {type(message_obj)}", "Claude SDK") - - finally: - # Restore original working directory - os.chdir(original_cwd) - - except Exception as e: - ui.error(f"Exception occurred: {str(e)}", "Claude SDK") - if log_callback: - await log_callback(f"Claude SDK Exception: {str(e)}") - raise - - - async def get_session_id(self, project_id: str) -> Optional[str]: - """Get current session ID for project from database""" - try: - # Try to get from database if available (we'll need to pass db session) - return self.session_mapping.get(project_id) - except Exception as e: - ui.warning(f"Failed to get session ID from DB: {e}", "Claude SDK") - return self.session_mapping.get(project_id) - - async def set_session_id(self, project_id: str, session_id: str) -> None: - """Set session ID for project in database and memory""" - try: - # Store in memory as fallback - self.session_mapping[project_id] = session_id - ui.debug(f"Session ID stored for project {project_id}", "Claude SDK") - except Exception as e: - ui.warning(f"Failed to save session ID: {e}", "Claude SDK") - # Fallback to memory storage - self.session_mapping[project_id] = session_id - - -class CursorAgentCLI(BaseCLI): - """Cursor Agent CLI implementation with stream-json support and session continuity""" - - def __init__(self, db_session=None): - super().__init__(CLIType.CURSOR) - self.db_session = db_session - self._session_store = {} # Fallback for when db_session is not available - - async def check_availability(self) -> Dict[str, Any]: - """Check if Cursor Agent CLI is available""" - try: - # Check if cursor-agent is installed and working - result = await asyncio.create_subprocess_shell( - "cursor-agent -h", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - stdout, stderr = await result.communicate() - - if result.returncode != 0: - return { - "available": False, - "configured": False, - "error": "Cursor Agent CLI not installed or not working.\n\nTo install:\n1. Install Cursor: curl https://cursor.com/install -fsS | bash\n2. Login to Cursor: cursor-agent login\n3. Try running your prompt again" - } - - # Check if help output contains expected content - help_output = stdout.decode() + stderr.decode() - if "cursor-agent" not in help_output.lower(): - return { - "available": False, - "configured": False, - "error": "Cursor Agent CLI not responding correctly.\n\nPlease try:\n1. Reinstall: curl https://cursor.com/install -fsS | bash\n2. Login: cursor-agent login\n3. Check installation: cursor-agent -h" - } - - return { - "available": True, - "configured": True, - "models": self.get_supported_models(), - "default_models": ["gpt-5", "sonnet-4"] - } - except Exception as e: - return { - "available": False, - "configured": False, - "error": f"Failed to check Cursor Agent: {str(e)}\n\nTo install:\n1. Install Cursor: curl https://cursor.com/install -fsS | bash\n2. Login to Cursor: cursor-agent login" - } - - def _handle_cursor_stream_json(self, event: Dict[str, Any], project_path: str, session_id: str) -> Optional[Message]: - """Handle Cursor stream-json format (NDJSON events) to be compatible with Claude Code CLI output""" - event_type = event.get("type") - - if event_type == "system": - # System initialization event - return Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="system", - message_type="system", - content=f"πŸ”§ Cursor Agent initialized (Model: {event.get('model', 'unknown')})", - metadata_json={ - "cli_type": self.cli_type.value, - "event_type": "system", - "cwd": event.get("cwd"), - "api_key_source": event.get("apiKeySource"), - "original_event": event, - "hidden_from_ui": True # Hide system init messages - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - - elif event_type == "user": - # Cursor echoes back the user's prompt. Suppress it to avoid duplicates. - return None - - elif event_type == "assistant": - # Assistant response event (text delta) - message_content = event.get("message", {}).get("content", []) - content = "" - - if message_content and isinstance(message_content, list): - for part in message_content: - if part.get("type") == "text": - content += part.get("text", "") - - if content: - return Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="chat", - content=content, - metadata_json={ - "cli_type": self.cli_type.value, - "event_type": "assistant", - "original_event": event - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - - elif event_type == "tool_call": - subtype = event.get("subtype") - tool_call_data = event.get("tool_call", {}) - if not tool_call_data: - return None - - tool_name_raw = next(iter(tool_call_data), None) - if not tool_name_raw: - return None - - # Normalize tool name: lsToolCall -> ls - tool_name = tool_name_raw.replace("ToolCall", "") - - if subtype == "started": - tool_input = tool_call_data[tool_name_raw].get("args", {}) - summary = self._create_tool_summary(tool_name, tool_input) - - return Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="chat", - content=summary, - metadata_json={ - "cli_type": self.cli_type.value, - "event_type": "tool_call_started", - "tool_name": tool_name, - "tool_input": tool_input, - "original_event": event - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - - elif subtype == "completed": - result = tool_call_data[tool_name_raw].get("result", {}) - content = "" - if "success" in result: - content = json.dumps(result["success"]) - elif "error" in result: - content = json.dumps(result["error"]) - - return Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="system", - message_type="tool_result", - content=content, - metadata_json={ - "cli_type": self.cli_type.value, - "original_format": event, - "tool_name": tool_name, - "hidden_from_ui": True - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - - elif event_type == "result": - # Final result event - duration = event.get("duration_ms", 0) - result_text = event.get("result", "") - - if result_text: - return Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="system", - message_type="system", - content=f"Execution completed in {duration}ms. Final result: {result_text}", - metadata_json={ - "cli_type": self.cli_type.value, - "event_type": "result", - "duration_ms": duration, - "original_event": event, - "hidden_from_ui": True - }, - session_id=session_id, - created_at=datetime.utcnow() - ) - - return None - - async def _ensure_agent_md(self, project_path: str) -> None: - """Ensure AGENT.md exists in project repo with system prompt""" - # Determine the repo path - project_repo_path = os.path.join(project_path, "repo") - if not os.path.exists(project_repo_path): - project_repo_path = project_path - - agent_md_path = os.path.join(project_repo_path, "AGENT.md") - - # Check if AGENT.md already exists - if os.path.exists(agent_md_path): - print(f"πŸ“ [Cursor] AGENT.md already exists at: {agent_md_path}") - return - - try: - # Read system prompt from the source file using relative path - current_file_dir = os.path.dirname(os.path.abspath(__file__)) - # unified_manager.py -> cli -> services -> app - app_dir = os.path.join(current_file_dir, "..", "..", "..") - app_dir = os.path.abspath(app_dir) - system_prompt_path = os.path.join(app_dir, "prompt", "system-prompt.md") - - if os.path.exists(system_prompt_path): - with open(system_prompt_path, 'r', encoding='utf-8') as f: - system_prompt_content = f.read() - - # Write to AGENT.md in the project repo - with open(agent_md_path, 'w', encoding='utf-8') as f: - f.write(system_prompt_content) - - print(f"πŸ“ [Cursor] Created AGENT.md at: {agent_md_path}") - else: - print(f"⚠️ [Cursor] System prompt file not found at: {system_prompt_path}") - except Exception as e: - print(f"❌ [Cursor] Failed to create AGENT.md: {e}") - - async def execute_with_streaming( - self, - instruction: str, - project_path: str, - session_id: Optional[str] = None, - log_callback: Optional[Callable] = None, - images: Optional[List[Dict[str, Any]]] = None, - model: Optional[str] = None, - is_initial_prompt: bool = False - ) -> AsyncGenerator[Message, None]: - """Execute Cursor Agent CLI with stream-json format and session continuity""" - # Ensure AGENT.md exists for system prompt - await self._ensure_agent_md(project_path) - - # Extract project ID from path (format: .../projects/{project_id}/repo) - # We need the project_id, not "repo" - path_parts = project_path.split("/") - if "repo" in path_parts and len(path_parts) >= 2: - # Get the folder before "repo" - repo_index = path_parts.index("repo") - if repo_index > 0: - project_id = path_parts[repo_index - 1] - else: - project_id = path_parts[-1] if path_parts else project_path - else: - project_id = path_parts[-1] if path_parts else project_path - - stored_session_id = await self.get_session_id(project_id) - - - cmd = [ - "cursor-agent", "--force", - "-p", instruction, - "--output-format", "stream-json" # Use stream-json format - ] - - # Add session resume if available (prefer stored session over parameter) - active_session_id = stored_session_id or session_id - if active_session_id: - cmd.extend(["--resume", active_session_id]) - print(f"πŸ”— [Cursor] Resuming session: {active_session_id}") - - # Add API key if available - if os.getenv("CURSOR_API_KEY"): - cmd.extend(["--api-key", os.getenv("CURSOR_API_KEY")]) - - # Add model - prioritize parameter over environment variable - cli_model = self._get_cli_model_name(model) or os.getenv("CURSOR_MODEL") - if cli_model: - cmd.extend(["-m", cli_model]) - print(f"πŸ”§ [Cursor] Using model: {cli_model}") - - project_repo_path = os.path.join(project_path, "repo") - if not os.path.exists(project_repo_path): - project_repo_path = project_path # Fallback to project_path if repo subdir doesn't exist - - try: - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=project_repo_path - ) - - cursor_session_id = None - assistant_message_buffer = "" - result_received = False # Track if we received result event - - async for line in process.stdout: - line_str = line.decode().strip() - if not line_str: - continue - - try: - # Parse NDJSON event - event = json.loads(line_str) - - event_type = event.get("type") - - # Priority: Extract session ID from type: "result" event (most reliable) - if event_type == "result" and not cursor_session_id: - print(f"πŸ” [Cursor] Result event received: {event}") - session_id_from_result = event.get("session_id") - if session_id_from_result: - cursor_session_id = session_id_from_result - await self.set_session_id(project_id, cursor_session_id) - print(f"πŸ’Ύ [Cursor] Session ID extracted from result event: {cursor_session_id}") - - # Mark that we received result event - result_received = True - - # Extract session ID from various event types - if not cursor_session_id: - # Try to extract session ID from any event that contains it - potential_session_id = ( - event.get("sessionId") or - event.get("chatId") or - event.get("session_id") or - event.get("chat_id") or - event.get("threadId") or - event.get("thread_id") - ) - - # Also check in nested structures - if not potential_session_id and isinstance(event.get("message"), dict): - potential_session_id = ( - event["message"].get("sessionId") or - event["message"].get("chatId") or - event["message"].get("session_id") or - event["message"].get("chat_id") - ) - - if potential_session_id and potential_session_id != active_session_id: - cursor_session_id = potential_session_id - await self.set_session_id(project_id, cursor_session_id) - print(f"πŸ’Ύ [Cursor] Updated session ID for project {project_id}: {cursor_session_id}") - print(f" Previous: {active_session_id}") - print(f" New: {cursor_session_id}") - - # If we receive a non-assistant message, flush the buffer first - if event.get("type") != "assistant" and assistant_message_buffer: - yield Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="chat", - content=assistant_message_buffer, - metadata_json={"cli_type": "cursor", "event_type": "assistant_aggregated"}, - session_id=session_id, - created_at=datetime.utcnow() - ) - assistant_message_buffer = "" - - # Process the event - message = self._handle_cursor_stream_json(event, project_path, session_id) - - if message: - if message.role == "assistant" and message.message_type == "chat": - assistant_message_buffer += message.content - else: - if log_callback: - await log_callback(f"πŸ“ [Cursor] {message.content}") - yield message - - # β˜… CRITICAL: Break after result event to end streaming - if result_received: - print(f"🏁 [Cursor] Result event received, terminating stream early") - try: - process.terminate() - print(f"πŸ”ͺ [Cursor] Process terminated") - except Exception as e: - print(f"⚠️ [Cursor] Failed to terminate process: {e}") - break - - except json.JSONDecodeError as e: - # Handle malformed JSON - print(f"⚠️ [Cursor] JSON decode error: {e}") - print(f"⚠️ [Cursor] Raw line: {line_str}") - - # Still yield as raw output - message = Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="chat", - content=line_str, - metadata_json={"cli_type": "cursor", "raw_output": line_str, "parse_error": str(e)}, - session_id=session_id, - created_at=datetime.utcnow() - ) - yield message - - # Flush any remaining content in the buffer - if assistant_message_buffer: - yield Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="chat", - content=assistant_message_buffer, - metadata_json={"cli_type": "cursor", "event_type": "assistant_aggregated"}, - session_id=session_id, - created_at=datetime.utcnow() - ) - - await process.wait() - - # Log completion - if cursor_session_id: - print(f"βœ… [Cursor] Session completed: {cursor_session_id}") - - except FileNotFoundError: - error_msg = "❌ Cursor Agent CLI not found. Please install with: curl https://cursor.com/install -fsS | bash" - yield Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="error", - content=error_msg, - metadata_json={"error": "cli_not_found", "cli_type": "cursor"}, - session_id=session_id, - created_at=datetime.utcnow() - ) - except Exception as e: - error_msg = f"❌ Cursor Agent execution failed: {str(e)}" - yield Message( - id=str(uuid.uuid4()), - project_id=project_path, - role="assistant", - message_type="error", - content=error_msg, - metadata_json={"error": "execution_failed", "cli_type": "cursor", "exception": str(e)}, - session_id=session_id, - created_at=datetime.utcnow() - ) - - async def get_session_id(self, project_id: str) -> Optional[str]: - """Get stored session ID for project to enable session continuity""" - if self.db_session: - try: - from app.models.projects import Project - project = self.db_session.query(Project).filter(Project.id == project_id).first() - if project and project.active_cursor_session_id: - print(f"πŸ’Ύ [Cursor] Retrieved session ID from DB: {project.active_cursor_session_id}") - return project.active_cursor_session_id - except Exception as e: - print(f"⚠️ [Cursor] Failed to get session ID from DB: {e}") - - # Fallback to in-memory storage - return self._session_store.get(project_id) - - async def set_session_id(self, project_id: str, session_id: str) -> None: - """Store session ID for project to enable session continuity""" - # Store in database if available - if self.db_session: - try: - from app.models.projects import Project - project = self.db_session.query(Project).filter(Project.id == project_id).first() - if project: - project.active_cursor_session_id = session_id - self.db_session.commit() - print(f"πŸ’Ύ [Cursor] Session ID saved to DB for project {project_id}: {session_id}") - return - else: - print(f"⚠️ [Cursor] Project {project_id} not found in DB") - except Exception as e: - print(f"⚠️ [Cursor] Failed to save session ID to DB: {e}") - import traceback - traceback.print_exc() - else: - print(f"⚠️ [Cursor] No DB session available") - - # Fallback to in-memory storage - self._session_store[project_id] = session_id - print(f"πŸ’Ύ [Cursor] Session ID stored in memory for project {project_id}: {session_id}") - - - +Unified CLI facade +This module re-exports the public API for backward compatibility. +Implementations live in: +- Base/Utils: app/services/cli/base.py +- Providers: app/services/cli/adapters/*.py +- Manager: app/services/cli/manager.py +""" -class UnifiedCLIManager: - """Unified manager for all CLI implementations""" - - def __init__( - self, - project_id: str, - project_path: str, - session_id: str, - conversation_id: str, - db: Any # SQLAlchemy Session - ): - self.project_id = project_id - self.project_path = project_path - self.session_id = session_id - self.conversation_id = conversation_id - self.db = db - - # Initialize CLI adapters with database session - self.cli_adapters = { - CLIType.CLAUDE: ClaudeCodeCLI(), # Use SDK implementation if available - CLIType.CURSOR: CursorAgentCLI(db_session=db) - } - - async def execute_instruction( - self, - instruction: str, - cli_type: CLIType, - fallback_enabled: bool = True, # Kept for backward compatibility but not used - images: Optional[List[Dict[str, Any]]] = None, - model: Optional[str] = None, - is_initial_prompt: bool = False - ) -> Dict[str, Any]: - """Execute instruction with specified CLI""" - - # Try the specified CLI - if cli_type in self.cli_adapters: - cli = self.cli_adapters[cli_type] - - # Check if CLI is available - status = await cli.check_availability() - if status.get("available") and status.get("configured"): - try: - return await self._execute_with_cli( - cli, instruction, images, model, is_initial_prompt - ) - except Exception as e: - ui.error(f"CLI {cli_type.value} failed: {e}", "CLI") - return { - "success": False, - "error": str(e), - "cli_attempted": cli_type.value - } - else: - return { - "success": False, - "error": status.get("error", "CLI not available"), - "cli_attempted": cli_type.value - } - - return { - "success": False, - "error": f"CLI type {cli_type.value} not implemented", - "cli_attempted": cli_type.value - } - - async def _execute_with_cli( - self, - cli, - instruction: str, - images: Optional[List[Dict[str, Any]]], - model: Optional[str] = None, - is_initial_prompt: bool = False - ) -> Dict[str, Any]: - """Execute instruction with a specific CLI""" - - ui.info(f"Starting {cli.cli_type.value} execution", "CLI") - if model: - ui.debug(f"Using model: {model}", "CLI") - - messages_collected = [] - has_changes = False - has_error = False # Track if any error occurred - result_success = None # Track result event success status - - # Log callback - async def log_callback(message: str): - # CLI output logs are now only printed to console, not sent to UI - pass - - message_count = 0 - - async for message in cli.execute_with_streaming( - instruction=instruction, - project_path=self.project_path, - session_id=self.session_id, - log_callback=log_callback, - images=images, - model=model, - is_initial_prompt=is_initial_prompt - ): - message_count += 1 - - # Check for error messages or result status - if message.message_type == "error": - has_error = True - ui.error(f"CLI error detected: {message.content[:100]}", "CLI") - - # Check for Cursor result event (stored in metadata) - if message.metadata_json: - event_type = message.metadata_json.get("event_type") - original_event = message.metadata_json.get("original_event", {}) - - if event_type == "result" or original_event.get("type") == "result": - # Cursor sends result event with success/error status - is_error = original_event.get("is_error", False) - subtype = original_event.get("subtype", "") - - # β˜… DEBUG: Log the complete result event structure - ui.info(f"πŸ” [Cursor] Result event received:", "DEBUG") - ui.info(f" Full event: {original_event}", "DEBUG") - ui.info(f" is_error: {is_error}", "DEBUG") - ui.info(f" subtype: '{subtype}'", "DEBUG") - ui.info(f" has event.result: {'result' in original_event}", "DEBUG") - ui.info(f" has event.status: {'status' in original_event}", "DEBUG") - ui.info(f" has event.success: {'success' in original_event}", "DEBUG") - - if is_error or subtype == "error": - has_error = True - result_success = False - ui.error(f"Cursor result: error (is_error={is_error}, subtype='{subtype}')", "CLI") - elif subtype == "success": - result_success = True - ui.success(f"Cursor result: success (subtype='{subtype}')", "CLI") - else: - # β˜… NEW: Handle case where subtype is not "success" but execution was successful - ui.warning(f"Cursor result: no explicit success subtype (subtype='{subtype}', is_error={is_error})", "CLI") - # If there's no error indication, assume success - if not is_error: - result_success = True - ui.success(f"Cursor result: assuming success (no error detected)", "CLI") - - # Save message to database - message.project_id = self.project_id - message.conversation_id = self.conversation_id - self.db.add(message) - self.db.commit() - - messages_collected.append(message) - - # Check if message should be hidden from UI - should_hide = message.metadata_json and message.metadata_json.get("hidden_from_ui", False) - - # Send message via WebSocket only if not hidden - if not should_hide: - ws_message = { - "type": "message", - "data": { - "id": message.id, - "role": message.role, - "message_type": message.message_type, - "content": message.content, - "metadata": message.metadata_json, - "parent_message_id": getattr(message, 'parent_message_id', None), - "session_id": message.session_id, - "conversation_id": self.conversation_id, - "created_at": message.created_at.isoformat() - }, - "timestamp": message.created_at.isoformat() - } - try: - await ws_manager.send_message(self.project_id, ws_message) - except Exception as e: - ui.error(f"WebSocket send failed: {e}", "Message") - - # Check if changes were made - if message.metadata_json and "changes_made" in message.metadata_json: - has_changes = True - - # Determine final success status - # For Cursor: check result_success if available, otherwise check has_error - # For Claude: check has_error - ui.info(f"πŸ” Final success determination: cli_type={cli.cli_type}, result_success={result_success}, has_error={has_error}", "CLI") - - if cli.cli_type == CLIType.CURSOR and result_success is not None: - success = result_success - ui.info(f"Using Cursor result_success: {result_success}", "CLI") - else: - success = not has_error - ui.info(f"Using has_error logic: not {has_error} = {success}", "CLI") - - if success: - ui.success(f"Streaming completed successfully. Total messages: {len(messages_collected)}", "CLI") - else: - ui.error(f"Streaming completed with errors. Total messages: {len(messages_collected)}", "CLI") - - return { - "success": success, - "cli_used": cli.cli_type.value, - "has_changes": has_changes, - "message": f"{'Successfully' if success else 'Failed to'} execute with {cli.cli_type.value}", - "error": "Execution failed" if not success else None, - "messages_count": len(messages_collected) - } - - async def check_cli_status(self, cli_type: CLIType, selected_model: Optional[str] = None) -> Dict[str, Any]: - """Check status of a specific CLI""" - if cli_type in self.cli_adapters: - status = await self.cli_adapters[cli_type].check_availability() - - # Add model validation if model is specified - if selected_model and status.get("available"): - cli = self.cli_adapters[cli_type] - if not cli.is_model_supported(selected_model): - status["model_warning"] = f"Model '{selected_model}' may not be supported by {cli_type.value}" - status["suggested_models"] = status.get("default_models", []) - else: - status["selected_model"] = selected_model - status["model_valid"] = True - - return status - return { - "available": False, - "configured": False, - "error": f"CLI type {cli_type.value} not implemented" - } \ No newline at end of file +from .base import BaseCLI, CLIType, MODEL_MAPPING, get_project_root, get_display_path +from .adapters import ClaudeCodeCLI, CursorAgentCLI, CodexCLI, QwenCLI, GeminiCLI +from .manager import UnifiedCLIManager + +__all__ = [ + "BaseCLI", + "CLIType", + "MODEL_MAPPING", + "get_project_root", + "get_display_path", + "ClaudeCodeCLI", + "CursorAgentCLI", + "CodexCLI", + "QwenCLI", + "GeminiCLI", + "UnifiedCLIManager", +] diff --git a/apps/api/app/services/cli_session_manager.py b/apps/api/app/services/cli_session_manager.py index 24744f3c..c74fd712 100644 --- a/apps/api/app/services/cli_session_manager.py +++ b/apps/api/app/services/cli_session_manager.py @@ -5,7 +5,7 @@ from typing import Dict, Optional, Any from sqlalchemy.orm import Session from app.models.projects import Project -from app.services.cli.unified_manager import CLIType +from app.services.cli.base import CLIType class CLISessionManager: @@ -237,4 +237,4 @@ def cleanup_stale_sessions(self, project_id: str, days_threshold: int = 30) -> i from app.core.terminal_ui import ui ui.info(f"Project {project_id}: Cleared {cleared_count} stale session IDs", "Cleanup") - return cleared_count \ No newline at end of file + return cleared_count diff --git a/apps/api/app/services/filesystem.py b/apps/api/app/services/filesystem.py index 34e17f11..a9788c61 100644 --- a/apps/api/app/services/filesystem.py +++ b/apps/api/app/services/filesystem.py @@ -1,6 +1,7 @@ import os import shutil import subprocess +import platform from pathlib import Path from typing import Optional @@ -17,47 +18,153 @@ def init_git_repo(repo_path: str) -> None: def scaffold_nextjs_minimal(repo_path: str) -> None: """Create Next.js project using official create-next-app""" - import subprocess - import tempfile - import shutil # Get parent directory to create project in parent_dir = Path(repo_path).parent project_name = Path(repo_path).name + # Import ui at the beginning to avoid import issues try: - # Create Next.js app with TypeScript and Tailwind CSS - cmd = [ - "npx", - "create-next-app@latest", - project_name, - "--typescript", - "--tailwind", - "--eslint", - "--app", - "--import-alias", "@/*", - "--use-npm", - "--skip-install", # We'll install dependencies later - "--yes" # Auto-accept all prompts + from app.core.terminal_ui import ui + except ImportError: + # Fallback if ui module is not available + class MockUI: + def info(self, msg, context=""): print(f"[{context}] INFO: {msg}") + def success(self, msg, context=""): print(f"[{context}] SUCCESS: {msg}") + def error(self, msg, context=""): print(f"[{context}] ERROR: {msg}") + def debug(self, msg, context=""): print(f"[{context}] DEBUG: {msg}") + def warning(self, msg, context=""): print(f"[{context}] WARNING: {msg}") + + ui = MockUI() + + def find_node_command(command: str) -> str: + """Find Node.js command in common Windows locations""" + + if platform.system() != "Windows": + return command + + # Common Windows Node.js installation paths + possible_paths = [ + r"C:\Program Files\nodejs", + r"C:\Program Files (x86)\nodejs", + os.path.expanduser(r"~\AppData\Roaming\npm"), + os.path.expanduser(r"~\AppData\Local\Programs\nodejs"), ] + # Add current PATH + path_dirs = os.environ.get("PATH", "").split(os.pathsep) + possible_paths.extend(path_dirs) + + # Look for the command + for path_dir in possible_paths: + if not path_dir: + continue + + # Try different file extensions for Windows + extensions = [".exe", ".cmd", ".bat", ""] + for ext in extensions: + cmd_path = os.path.join(path_dir, f"{command}{ext}") + if os.path.exists(cmd_path): + ui.debug(f"Found {command} at: {cmd_path}", "Filesystem") + return cmd_path + + # If not found, return original command (will fail with clear error) + return command + + try: + # Find Node.js commands + node_cmd = find_node_command("node") + npm_cmd = find_node_command("npm") + npx_cmd = find_node_command("npx") + + # First check if Node.js is available + try: + subprocess.run([node_cmd, "--version"], check=True, capture_output=True, timeout=10) + ui.info(f"Node.js found at: {node_cmd}", "Filesystem") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + raise Exception("Node.js non Γ¨ installato. Installa Node.js da https://nodejs.org/") + + # Then check if npm is available + try: + # Use shell=True for npm on Windows to avoid execution issues + if platform.system() == "Windows": + subprocess.run(f'"{npm_cmd}" --version', shell=True, check=True, capture_output=True, timeout=10) + else: + subprocess.run([npm_cmd, "--version"], check=True, capture_output=True, timeout=10) + ui.info(f"npm found at: {npm_cmd}", "Filesystem") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + raise Exception("npm non Γ¨ disponibile. Assicurati che Node.js sia installato correttamente.") + + # Finally check if npx is available + try: + # Use shell=True for npx on Windows to avoid execution issues + if platform.system() == "Windows": + subprocess.run(f'"{npx_cmd}" --version', shell=True, check=True, capture_output=True, timeout=10) + else: + subprocess.run([npx_cmd, "--version"], check=True, capture_output=True, timeout=10) + ui.info(f"npx found at: {npx_cmd}", "Filesystem") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + raise Exception("npx non Γ¨ disponibile. Assicurati che Node.js e npm siano installati correttamente.") + + # Create Next.js app with TypeScript and Tailwind CSS # Set environment for non-interactive mode env = os.environ.copy() env["CI"] = "true" # Force non-interactive mode - from app.core.terminal_ui import ui - ui.info(f"Running create-next-app with command: {' '.join(cmd)}", "Filesystem") - - # Run create-next-app in the parent directory with timeout - result = subprocess.run( - cmd, - cwd=parent_dir, - check=True, - capture_output=True, - text=True, - env=env, - timeout=300 # 5 minute timeout - ) + # Add Node.js paths to environment + if platform.system() == "Windows": + node_dir = os.path.dirname(node_cmd) + if node_dir not in env.get("PATH", ""): + env["PATH"] = node_dir + os.pathsep + env.get("PATH", "") + + if platform.system() == "Windows": + # Use shell=True on Windows to avoid execution issues + cmd_str = f'"{npx_cmd}" create-next-app@latest {project_name} --typescript --tailwind --eslint --app --import-alias @/* --use-npm --skip-install --yes' + ui.info(f"Running create-next-app with command: {cmd_str}", "Filesystem") + ui.debug(f"Working directory: {parent_dir}", "Filesystem") + ui.debug(f"Environment PATH: {env.get('PATH', '')[:200]}...", "Filesystem") + + # Run create-next-app in the parent directory with timeout + result = subprocess.run( + cmd_str, + cwd=parent_dir, + check=True, + capture_output=True, + text=True, + env=env, + shell=True, + timeout=300 # 5 minute timeout + ) + else: + # Use array format for Unix-like systems + cmd = [ + npx_cmd, + "create-next-app@latest", + project_name, + "--typescript", + "--tailwind", + "--eslint", + "--app", + "--import-alias", "@/*", + "--use-npm", + "--skip-install", # We'll install dependencies later + "--yes" # Auto-accept all prompts + ] + + ui.info(f"Running create-next-app with command: {' '.join(cmd)}", "Filesystem") + ui.debug(f"Working directory: {parent_dir}", "Filesystem") + ui.debug(f"Environment PATH: {env.get('PATH', '')[:200]}...", "Filesystem") + + # Run create-next-app in the parent directory with timeout + result = subprocess.run( + cmd, + cwd=parent_dir, + check=True, + capture_output=True, + text=True, + env=env, + timeout=300 # 5 minute timeout + ) ui.success(f"Created Next.js app: {result.stdout}", "Filesystem") @@ -70,7 +177,13 @@ def scaffold_nextjs_minimal(repo_path: str) -> None: raise Exception(f"Project creation timed out. This might be due to slow network or hung process.") except subprocess.CalledProcessError as e: ui.error(f"Error creating Next.js app: {e}", "Filesystem") - ui.debug(f"Command: {' '.join(cmd)}", "Filesystem") + + # Show command that was executed + if platform.system() == "Windows": + ui.debug(f"Command executed: {cmd_str}", "Filesystem") + else: + ui.debug(f"Command executed: {' '.join(cmd)}", "Filesystem") + ui.debug(f"stdout: {e.stdout}", "Filesystem") ui.debug(f"stderr: {e.stderr}", "Filesystem") @@ -85,6 +198,12 @@ def scaffold_nextjs_minimal(repo_path: str) -> None: error_msg = f"Failed to create Next.js project: {e.stderr or e.stdout or str(e)}" raise Exception(error_msg) + except FileNotFoundError as e: + ui.error(f"File not found error: {e}", "Filesystem") + raise Exception("npx o create-next-app non trovato. Verifica che Node.js sia installato correttamente.") + except Exception as e: + ui.error(f"Unexpected error: {e}", "Filesystem") + raise Exception(f"Errore imprevisto durante la creazione del progetto: {str(e)}") def write_env_file(project_dir: str, content: str) -> None: diff --git a/apps/api/app/services/local_runtime.py b/apps/api/app/services/local_runtime.py index 833b0f47..3b75c704 100644 --- a/apps/api/app/services/local_runtime.py +++ b/apps/api/app/services/local_runtime.py @@ -6,6 +6,7 @@ import hashlib import threading import re +import platform from contextlib import closing from typing import Optional, Dict from app.core.config import settings @@ -15,6 +16,45 @@ _running_processes: Dict[str, subprocess.Popen] = {} _process_logs: Dict[str, list] = {} # Store process logs for each project +def _is_windows() -> bool: + """Check if running on Windows""" + return platform.system() == "Windows" + +def _terminate_process_windows(process: subprocess.Popen) -> None: + """Terminate process on Windows""" + try: + # On Windows, use taskkill to terminate the process tree + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + timeout=10 + ) + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError): + # Fallback: try to terminate the process directly + try: + process.terminate() + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + +def _terminate_process_unix(process: subprocess.Popen) -> None: + """Terminate process on Unix-like systems""" + try: + # Terminate the entire process group + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + + # Wait for process to terminate gracefully + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + # Force kill if it doesn't terminate gracefully + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + process.wait() + except (OSError, ProcessLookupError): + # Process already terminated + pass + def _monitor_preview_errors(project_id: str, process: subprocess.Popen): """κ°„λ‹¨ν•œ Preview μ„œλ²„ μ—λŸ¬ λͺ¨λ‹ˆν„°λ§""" from app.core.websocket.manager import manager @@ -326,14 +366,35 @@ def start_preview_process(project_id: str, repo_path: str, port: Optional[int] = # Only install dependencies if needed if _should_install_dependencies(repo_path): print(f"Installing dependencies for project {project_id}...") - install_result = subprocess.run( - ["npm", "install"], - cwd=repo_path, - env=env, - capture_output=True, - text=True, - timeout=120 # 2 minutes timeout for npm install - ) + print(f"Repository path: {repo_path}") + print(f"Current working directory: {os.getcwd()}") + + # Use shell=True on Windows to avoid execution issues + if _is_windows(): + install_result = subprocess.run( + "npm install", + cwd=repo_path, + env=env, + capture_output=True, + text=True, + timeout=120, # 2 minutes timeout for npm install + shell=True + ) + else: + install_result = subprocess.run( + ["npm", "install"], + cwd=repo_path, + env=env, + capture_output=True, + text=True, + timeout=120 # 2 minutes timeout for npm install + ) + + print(f"npm install completed with return code: {install_result.returncode}") + if install_result.stdout: + print(f"npm install stdout: {install_result.stdout[:500]}...") + if install_result.stderr: + print(f"npm install stderr: {install_result.stderr[:500]}...") if install_result.returncode != 0: raise RuntimeError(f"npm install failed: {install_result.stderr}") @@ -343,27 +404,66 @@ def start_preview_process(project_id: str, repo_path: str, port: Optional[int] = print(f"Dependencies installed successfully for project {project_id}") else: print(f"Dependencies already up to date for project {project_id}, skipping npm install") + print(f"Repository path: {repo_path}") + print(f"node_modules exists: {os.path.exists(os.path.join(repo_path, 'node_modules'))}") # Start development server print(f"Starting Next.js dev server for project {project_id} on port {port}...") - process = subprocess.Popen( - ["npm", "run", "dev", "--", "-p", str(port)], - cwd=repo_path, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - preexec_fn=os.setsid # Create new process group for easier cleanup - ) + print(f"Working directory: {repo_path}") + print(f"Environment: {env}") + + # Prepare process creation arguments + if _is_windows(): + # On Windows, use shell=True to avoid execution issues + cmd = f'npm run dev -- -p {port}' + print(f"Windows command: {cmd}") + process = subprocess.Popen( + cmd, + cwd=repo_path, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + shell=True + ) + else: + # On Unix-like systems, use array format with process group + cmd = ["npm", "run", "dev", "--", "-p", str(port)] + print(f"Unix command: {cmd}") + process = subprocess.Popen( + cmd, + cwd=repo_path, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + preexec_fn=os.setsid # Create new process group for easier cleanup + ) + + print(f"Process created with PID: {process.pid}") # Wait a moment for the server to start + print("Waiting for server to start...") time.sleep(2) # Check if process is still running if process.poll() is not None: stdout, _ = process.communicate() + print(f"Process failed to start. Exit code: {process.returncode}") + print(f"Process output: {stdout}") raise RuntimeError(f"Next.js server failed to start: {stdout}") + print("Process is still running, checking if server is responding...") + + # Try to connect to the server to verify it's working + try: + import requests + response = requests.get(f"http://localhost:{port}", timeout=5) + print(f"Server responded with status: {response.status_code}") + except Exception as e: + print(f"Warning: Could not verify server response: {e}") + print("But process is running, continuing...") + # Start error monitoring thread error_thread = threading.Thread( target=_monitor_preview_errors, @@ -377,11 +477,17 @@ def start_preview_process(project_id: str, repo_path: str, port: Optional[int] = _running_processes[project_id] = process print(f"Next.js dev server started for {project_id} on port {port} (PID: {process.pid})") + print(f"Preview URL: http://localhost:{port}") return process_name, port except subprocess.TimeoutExpired: + print(f"npm install timed out for project {project_id}") raise RuntimeError("npm install timed out after 2 minutes") except Exception as e: + print(f"Unexpected error starting preview for project {project_id}: {e}") + print(f"Error type: {type(e).__name__}") + import traceback + print(f"Traceback: {traceback.format_exc()}") raise RuntimeError(f"Failed to start preview process: {str(e)}") @@ -397,16 +503,10 @@ def stop_preview_process(project_id: str, cleanup_cache: bool = False) -> None: if process: try: - # Terminate the entire process group - os.killpg(os.getpgid(process.pid), signal.SIGTERM) - - # Wait for process to terminate gracefully - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - # Force kill if it doesn't terminate gracefully - os.killpg(os.getpgid(process.pid), signal.SIGKILL) - process.wait() + if _is_windows(): + _terminate_process_windows(process) + else: + _terminate_process_unix(process) print(f"Stopped Next.js dev server for project {project_id} (PID: {process.pid})") @@ -426,12 +526,21 @@ def stop_preview_process(project_id: str, cleanup_cache: bool = False) -> None: try: repo_path = os.path.join(settings.projects_root, project_id, "repo") if os.path.exists(repo_path): - subprocess.run( - ["npm", "cache", "clean", "--force"], - cwd=repo_path, - capture_output=True, - timeout=30 - ) + if _is_windows(): + subprocess.run( + "npm cache clean --force", + cwd=repo_path, + capture_output=True, + timeout=30, + shell=True + ) + else: + subprocess.run( + ["npm", "cache", "clean", "--force"], + cwd=repo_path, + capture_output=True, + timeout=30 + ) print(f"Cleaned npm cache for project {project_id}") except Exception as e: print(f"Failed to clean npm cache for {project_id}: {e}") diff --git a/apps/api/app/services/project/initializer.py b/apps/api/app/services/project/initializer.py index 7b12ec0b..9a7c43ed 100644 --- a/apps/api/app/services/project/initializer.py +++ b/apps/api/app/services/project/initializer.py @@ -71,27 +71,86 @@ async def initialize_project(project_id: str, name: str) -> str: async def cleanup_project(project_id: str) -> bool: """ - Clean up project files and directories - + Clean up project files and directories. Be robust against running preview + processes, transient filesystem locks, and read-only files. + Args: project_id: Project identifier to clean up - + Returns: bool: True if cleanup was successful """ - + + project_root = os.path.join(settings.projects_root, project_id) + + # Nothing to do + if not os.path.exists(project_root): + return False + + # 1) Ensure any running preview processes for this project are terminated try: - project_root = os.path.join(settings.projects_root, project_id) - - if os.path.exists(project_root): - import shutil - shutil.rmtree(project_root) + from app.services.local_runtime import cleanup_project_resources + cleanup_project_resources(project_id) + except Exception as e: + # Do not fail cleanup because of process stop errors + print(f"[cleanup] Warning: failed stopping preview process for {project_id}: {e}") + + # 2) Robust recursive deletion with retries + import time + import errno + import stat + import shutil + + def _onerror(func, path, exc_info): + # Try to chmod and retry if permission error + try: + if not os.path.exists(path): + return + os.chmod(path, stat.S_IWUSR | stat.S_IRUSR | stat.S_IXUSR) + func(path) + except Exception: + pass + + attempts = 0 + max_attempts = 5 + last_err = None + while attempts < max_attempts: + try: + shutil.rmtree(project_root, onerror=_onerror) return True - - return False - + except OSError as e: + last_err = e + # On macOS, ENOTEMPTY (66) or EBUSY can happen if watchers are active + if e.errno in (errno.ENOTEMPTY, errno.EBUSY, 66): + time.sleep(0.25 * (attempts + 1)) + attempts += 1 + continue + else: + print(f"Error cleaning up project {project_id}: {e}") + return False + except Exception as e: + last_err = e + print(f"Error cleaning up project {project_id}: {e}") + return False + + # Final attempt to handle lingering dotfiles + try: + # Remove remaining leaf entries then rmdir tree if any + for root, dirs, files in os.walk(project_root, topdown=False): + for name in files: + try: + os.remove(os.path.join(root, name)) + except Exception: + pass + for name in dirs: + try: + os.rmdir(os.path.join(root, name)) + except Exception: + pass + os.rmdir(project_root) + return True except Exception as e: - print(f"Error cleaning up project {project_id}: {e}") + print(f"Error cleaning up project {project_id}: {e if e else last_err}") return False @@ -264,4 +323,4 @@ def setup_claude_config(project_path: str): except Exception as e: ui.error(f"Failed to setup Claude configuration: {e}", "Claude Config") # Don't fail the whole project creation for this - pass \ No newline at end of file + pass diff --git a/apps/web/app/[project_id]/chat/page.tsx b/apps/web/app/[project_id]/chat/page.tsx index 12220623..9e1deadf 100644 --- a/apps/web/app/[project_id]/chat/page.tsx +++ b/apps/web/app/[project_id]/chat/page.tsx @@ -4,18 +4,42 @@ import { AnimatePresence } from 'framer-motion'; import { MotionDiv, MotionH3, MotionP, MotionButton } from '../../../lib/motion'; import { useRouter, useSearchParams } from 'next/navigation'; import dynamic from 'next/dynamic'; -import { FaCode, FaDesktop, FaMobileAlt, FaPlay, FaStop, FaSync, FaCog, FaRocket, FaFolder, FaFolderOpen, FaFile, FaFileCode, FaCss3Alt, FaHtml5, FaJs, FaReact, FaPython, FaDocker, FaGitAlt, FaMarkdown, FaDatabase, FaPhp, FaJava, FaRust, FaVuejs, FaLock, FaHome, FaChevronUp, FaChevronRight, FaChevronDown } from 'react-icons/fa'; +import { FaCode, FaDesktop, FaMobileAlt, FaPlay, FaStop, FaSync, FaCog, FaRocket, FaFolder, FaFolderOpen, FaFile, FaFileCode, FaCss3Alt, FaHtml5, FaJs, FaReact, FaPython, FaDocker, FaGitAlt, FaMarkdown, FaDatabase, FaPhp, FaJava, FaRust, FaVuejs, FaLock, FaHome, FaChevronUp, FaChevronRight, FaChevronDown, FaArrowLeft, FaArrowRight, FaRedo } from 'react-icons/fa'; import { SiTypescript, SiGo, SiRuby, SiSvelte, SiJson, SiYaml, SiCplusplus } from 'react-icons/si'; import { VscJson } from 'react-icons/vsc'; import ChatLog from '../../../components/ChatLog'; import { ProjectSettings } from '../../../components/settings/ProjectSettings'; import ChatInput from '../../../components/chat/ChatInput'; import { useUserRequests } from '../../../hooks/useUserRequests'; +import { useGlobalSettings } from '@/contexts/GlobalSettingsContext'; // 더 이상 ProjectSettings을 λ‘œλ“œν•˜μ§€ μ•ŠμŒ (메인 νŽ˜μ΄μ§€μ—μ„œ κΈ€λ‘œλ²Œ μ„€μ •μœΌλ‘œ 관리) const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:8080'; +// Define assistant brand colors +const assistantBrandColors: { [key: string]: string } = { + claude: '#DE7356', + cursor: '#6B7280', + qwen: '#A855F7', + gemini: '#4285F4', + codex: '#000000' +}; + +// Function to convert hex to CSS filter for tinting white images +// Since the original image is white (#FFFFFF), we can apply filters more accurately +const hexToFilter = (hex: string): string => { + // For white source images, we need to invert and adjust + const filters: { [key: string]: string } = { + '#DE7356': 'brightness(0) saturate(100%) invert(52%) sepia(73%) saturate(562%) hue-rotate(336deg) brightness(95%) contrast(91%)', // Orange for Claude + '#6B7280': 'brightness(0) saturate(100%) invert(47%) sepia(7%) saturate(625%) hue-rotate(174deg) brightness(92%) contrast(82%)', // Gray for Cursor + '#A855F7': 'brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(1532%) hue-rotate(256deg) brightness(95%) contrast(101%)', // Purple for Qwen + '#4285F4': 'brightness(0) saturate(100%) invert(40%) sepia(97%) saturate(1449%) hue-rotate(198deg) brightness(97%) contrast(101%)', // Blue for Gemini + '#000000': 'brightness(0) saturate(100%)' // Black for Codex + }; + return filters[hex] || ''; +}; + type Entry = { path: string; type: 'file'|'dir'; size?: number }; type Params = { params: { project_id: string } }; type ProjectStatus = 'initializing' | 'active' | 'failed'; @@ -176,7 +200,12 @@ export default function ChatPage({ params }: Params) { const [isStartingPreview, setIsStartingPreview] = useState(false); const [previewInitializationMessage, setPreviewInitializationMessage] = useState('Starting development server...'); const [preferredCli, setPreferredCli] = useState('claude'); + const [selectedModel, setSelectedModel] = useState(''); + const [usingGlobalDefaults, setUsingGlobalDefaults] = useState(true); const [thinkingMode, setThinkingMode] = useState(false); + const [currentRoute, setCurrentRoute] = useState('/'); + const iframeRef = useRef(null); + const [isFileUpdating, setIsFileUpdating] = useState(false); // Guarded trigger that can be called from multiple places safely const triggerInitialPromptIfNeeded = useCallback(() => { @@ -186,6 +215,17 @@ export default function ChatPage({ params }: Params) { // Synchronously guard to prevent double ACT calls initialPromptSentRef.current = true; setInitialPromptSent(true); + + // Store the selected model and assistant in sessionStorage when returning + const cliFromUrl = searchParams?.get('cli'); + const modelFromUrl = searchParams?.get('model'); + if (cliFromUrl) { + sessionStorage.setItem('selectedAssistant', cliFromUrl); + } + if (modelFromUrl) { + sessionStorage.setItem('selectedModel', modelFromUrl); + } + // Don't show the initial prompt in the input field // setPrompt(initialPromptFromUrl); setTimeout(() => { @@ -373,6 +413,7 @@ export default function ChatPage({ params }: Params) { setTimeout(() => { setPreviewUrl(data.url); setIsStartingPreview(false); + setCurrentRoute('/'); // Reset to root route when starting }, 1000); } catch (error) { console.error('Error starting preview:', error); @@ -381,6 +422,19 @@ export default function ChatPage({ params }: Params) { } } + // Navigate to specific route in iframe + const navigateToRoute = (route: string) => { + if (previewUrl && iframeRef.current) { + const baseUrl = previewUrl.split('?')[0]; // Remove any query params + // Ensure route starts with / + const normalizedRoute = route.startsWith('/') ? route : `/${route}`; + const newUrl = `${baseUrl}${normalizedRoute}`; + iframeRef.current.src = newUrl; + setCurrentRoute(normalizedRoute); + } + }; + + async function stop() { try { await fetch(`${API_BASE}/api/projects/${projectId}/preview/stop`, { method: 'POST' }); @@ -524,6 +578,27 @@ export default function ChatPage({ params }: Params) { } } + // Reload currently selected file + async function reloadCurrentFile() { + if (selectedFile && !showPreview) { + try { + const r = await fetch(`${API_BASE}/api/repo/${projectId}/file?path=${encodeURIComponent(selectedFile)}`); + if (r.ok) { + const data = await r.json(); + const newContent = data.content || ''; + // Only update if content actually changed + if (newContent !== content) { + setIsFileUpdating(true); + setContent(newContent); + setTimeout(() => setIsFileUpdating(false), 500); + } + } + } catch (error) { + // Silently fail - this is a background refresh + } + } + } + // Lazy load highlight.js only when needed const [hljs, setHljs] = useState(null); @@ -693,16 +768,66 @@ export default function ChatPage({ params }: Params) { } } - async function loadSettings() { + async function loadSettings(projectSettings?: { cli?: string; model?: string }) { try { - const response = await fetch(`${API_BASE}/api/settings`); - if (response.ok) { - const settings = await response.json(); - setPreferredCli(settings.preferred_cli || 'claude'); + console.log('πŸ”§ loadSettings called with project settings:', projectSettings); + + // Use project settings if available, otherwise check state + const hasCliSet = projectSettings?.cli || preferredCli; + const hasModelSet = projectSettings?.model || selectedModel; + + // Only load global settings if project doesn't have CLI/model settings + if (!hasCliSet || !hasModelSet) { + console.log('⚠️ Missing CLI or model, loading global settings'); + const globalResponse = await fetch(`${API_BASE}/api/settings/global`); + if (globalResponse.ok) { + const globalSettings = await globalResponse.json(); + const defaultCli = globalSettings.default_cli || 'claude'; + + // Only set if not already set by project + if (!hasCliSet) { + console.log('πŸ”„ Setting CLI from global:', defaultCli); + setPreferredCli(defaultCli); + } + + // Set the model for the CLI if not already set + if (!hasModelSet) { + const cliSettings = globalSettings.cli_settings?.[hasCliSet || defaultCli]; + if (cliSettings?.model) { + setSelectedModel(cliSettings.model); + } else { + // Set default model based on CLI + const currentCli = hasCliSet || defaultCli; + if (currentCli === 'claude') { + setSelectedModel('claude-sonnet-4'); + } else if (currentCli === 'cursor') { + setSelectedModel('gpt-5'); + } else if (currentCli === 'codex') { + setSelectedModel('gpt-5'); + } else if (currentCli === 'qwen') { + setSelectedModel('qwen3-coder-plus'); + } else if (currentCli === 'gemini') { + setSelectedModel('gemini-2.5-pro'); + } + } + } + } else { + // Fallback to project settings + const response = await fetch(`${API_BASE}/api/settings`); + if (response.ok) { + const settings = await response.json(); + if (!hasCliSet) setPreferredCli(settings.preferred_cli || 'claude'); + if (!hasModelSet) setSelectedModel(settings.preferred_cli === 'claude' ? 'claude-sonnet-4' : 'gpt-5'); + } + } } } catch (error) { console.error('Failed to load settings:', error); - setPreferredCli('claude'); // fallback + // Only set fallback if not already set + const hasCliSet = projectSettings?.cli || preferredCli; + const hasModelSet = projectSettings?.model || selectedModel; + if (!hasCliSet) setPreferredCli('claude'); + if (!hasModelSet) setSelectedModel('claude-sonnet-4'); } } @@ -711,9 +836,32 @@ export default function ChatPage({ params }: Params) { const r = await fetch(`${API_BASE}/api/projects/${projectId}`); if (r.ok) { const project = await r.json(); + console.log('πŸ“‹ Loading project info:', { + preferred_cli: project.preferred_cli, + selected_model: project.selected_model + }); setProjectName(project.name || `Project ${projectId.slice(0, 8)}`); + + // Set CLI and model from project settings if available + if (project.preferred_cli) { + console.log('βœ… Setting CLI from project:', project.preferred_cli); + setPreferredCli(project.preferred_cli); + } + if (project.selected_model) { + console.log('βœ… Setting model from project:', project.selected_model); + setSelectedModel(project.selected_model); + } + // Determine if we should follow global defaults (no project-specific prefs) + const followGlobal = !project.preferred_cli && !project.selected_model; + setUsingGlobalDefaults(followGlobal); setProjectDescription(project.description || ''); + // Return project settings for use in loadSettings + return { + cli: project.preferred_cli, + model: project.selected_model + }; + // Check if project has initial prompt if (project.initial_prompt) { setHasInitialPrompt(true); @@ -752,6 +900,8 @@ export default function ChatPage({ params }: Params) { localStorage.setItem(`project_${projectId}_hasInitialPrompt`, 'false'); setProjectStatus('active'); setIsInitializing(false); + setUsingGlobalDefaults(true); + return {}; // Return empty object if no project found } } catch (error) { console.error('Failed to load project info:', error); @@ -762,6 +912,8 @@ export default function ChatPage({ params }: Params) { localStorage.setItem(`project_${projectId}_hasInitialPrompt`, 'false'); setProjectStatus('active'); setIsInitializing(false); + setUsingGlobalDefaults(true); + return {}; // Return empty object on error } } @@ -799,9 +951,10 @@ export default function ChatPage({ params }: Params) { }); }; - async function runAct(messageOverride?: string) { + async function runAct(messageOverride?: string, externalImages?: any[]) { let finalMessage = messageOverride || prompt; - if (!finalMessage.trim() && uploadedImages.length === 0) { + const imagesToUse = externalImages || uploadedImages; + if (!finalMessage.trim() && imagesToUse.length === 0) { alert('μž‘μ—… λ‚΄μš©μ„ μž…λ ₯ν•˜κ±°λ‚˜ 이미지λ₯Ό μ—…λ‘œλ“œν•΄μ£Όμ„Έμš”.'); return; } @@ -824,14 +977,32 @@ export default function ChatPage({ params }: Params) { const requestId = crypto.randomUUID(); try { + // Handle images - convert UploadedImage format to API format + const processedImages = imagesToUse.map(img => { + // Check if this is from ChatInput (has 'path' property) or old format (has 'base64') + if (img.path) { + // New format from ChatInput - send path directly + return { + path: img.path, + name: img.filename || img.name || 'image' + }; + } else if (img.base64) { + // Old format - convert to base64_data + return { + name: img.name, + base64_data: img.base64.split(',')[1], // Remove data:image/...;base64, prefix + mime_type: img.base64.split(';')[0].split(':')[1] // Extract mime type + }; + } + return img; // Return as-is if already in correct format + }); + const requestBody = { instruction: finalMessage, - images: uploadedImages.map(img => ({ - name: img.name, - base64_data: img.base64.split(',')[1], // Remove data:image/...;base64, prefix - mime_type: img.base64.split(';')[0].split(':')[1] // Extract mime type - })), + images: processedImages, is_initial_prompt: false, // Mark as continuation message + cli_preference: preferredCli, // Add CLI preference + selected_model: selectedModel, // Add selected model request_id: requestId // β˜… NEW: request_id μΆ”κ°€ }; @@ -862,10 +1033,13 @@ export default function ChatPage({ params }: Params) { // ν”„λ‘¬ν”„νŠΈ 및 μ—…λ‘œλ“œλœ 이미지듀 μ΄ˆκΈ°ν™” setPrompt(''); - uploadedImages.forEach(img => { - URL.revokeObjectURL(img.url); - }); - setUploadedImages([]); + // Clean up old format images if any + if (uploadedImages && uploadedImages.length > 0) { + uploadedImages.forEach(img => { + if (img.url) URL.revokeObjectURL(img.url); + }); + setUploadedImages([]); + } } catch (error) { console.error('Act μ‹€ν–‰ 였λ₯˜:', error); @@ -1043,6 +1217,18 @@ export default function ChatPage({ params }: Params) { previousActiveState.current = hasActiveRequests; }, [hasActiveRequests, previewUrl]); + // Poll for file changes in code view + useEffect(() => { + if (!showPreview && selectedFile) { + const interval = setInterval(() => { + reloadCurrentFile(); + }, 2000); // Check every 2 seconds + + return () => clearInterval(interval); + } + }, [showPreview, selectedFile, projectId]); + + useEffect(() => { let mounted = true; let timer: NodeJS.Timeout | null = null; @@ -1050,11 +1236,11 @@ export default function ChatPage({ params }: Params) { const initializeChat = async () => { if (!mounted) return; - // Load settings first - await loadSettings(); + // Load project info first to get project-specific settings + const projectSettings = await loadProjectInfo(); - // Load project info first to check status - await loadProjectInfo(); + // Then load global settings as fallback, passing project settings + await loadSettings(projectSettings); // Always load the file tree regardless of project status await loadTree('.'); @@ -1101,6 +1287,27 @@ export default function ChatPage({ params }: Params) { }; }, [projectId, previewUrl, loadDeployStatus, checkCurrentDeployment]); + // React to global settings changes when using global defaults + const { settings: globalSettings } = useGlobalSettings(); + useEffect(() => { + if (!usingGlobalDefaults) return; + if (!globalSettings) return; + + const cli = globalSettings.default_cli || 'claude'; + setPreferredCli(cli); + + const modelFromGlobal = globalSettings.cli_settings?.[cli]?.model; + if (modelFromGlobal) { + setSelectedModel(modelFromGlobal); + } else { + // Fallback per CLI + if (cli === 'claude') setSelectedModel('claude-sonnet-4'); + else if (cli === 'cursor') setSelectedModel('gpt-5'); + else if (cli === 'codex') setSelectedModel('gpt-5'); + else setSelectedModel(''); + } + }, [globalSettings, usingGlobalDefaults]); + // Show loading UI if project is initializing @@ -1220,9 +1427,9 @@ export default function ChatPage({ params }: Params) {
- {/* Preview Controls */} - {showPreview && ( -
- {/* Device Mode Toggle */} - {previewUrl && ( -
+ {/* Center Controls */} + {showPreview && previewUrl && ( +
+ {/* Route Navigation */} +
+ + / + { + const value = e.target.value; + setCurrentRoute(value ? `/${value}` : '/'); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + navigateToRoute(currentRoute); + } + }} + className="bg-transparent text-sm text-gray-700 dark:text-gray-300 outline-none w-40" + placeholder="route" + /> + +
+ + {/* Action Buttons Group */} +
+ + + {/* Device Mode Toggle */} +
- )} - - {previewUrl ? ( - <> - - - - ) : null} +
)}
@@ -1372,22 +1598,31 @@ export default function ChatPage({ params }: Params) { {/* Settings Button */} + {/* Stop Button */} + {showPreview && previewUrl && ( + + )} + {/* Publish/Update */} {showPreview && previewUrl && (