diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0d3578ce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Architecture Overview + +Claudable is a full-stack web application that connects AI coding agents (Claude Code, Cursor CLI, etc.) with a web-based interface for building and deploying Next.js applications. The architecture consists of: + +### Frontend (apps/web) +- **Next.js 14** application with TypeScript +- **App Router** architecture with pages in `app/` directory +- **Tailwind CSS** for styling with dark mode support +- **Framer Motion** for animations +- **React Context** for global state management (Auth, GlobalSettings) +- **Component Architecture**: Shared components for project management, modals, and UI elements + +### Backend (apps/api) +- **FastAPI** Python web framework +- **SQLAlchemy** for database operations with SQLite (local) / PostgreSQL (production) +- **WebSocket** support for real-time communication +- **Claude Code SDK** integration for AI agent communication +- **Modular structure**: + - `api/` - REST endpoints + - `core/` - Configuration and utilities + - `models/` - Database models + - `services/` - Business logic + - `db/` - Database connections + +### Key Integrations +- **Multiple AI Agents**: Claude Code (primary), Cursor CLI, Codex CLI, Gemini CLI, Qwen Code +- **Deployment**: Vercel integration for hosting +- **Version Control**: GitHub integration for repositories +- **Database**: Supabase for production PostgreSQL +- **File Management**: Local SQLite database with project files stored in `data/` + +## Development Commands + +### Primary Development +```bash +# Start full development environment (both frontend and backend) +npm run dev + +# Frontend only (Next.js dev server) +npm run dev:web + +# Backend only (FastAPI with uvicorn) +npm run dev:api +``` + +### Database Operations +```bash +# Reset database to initial state (WARNING: Deletes all data) +npm run db:reset + +# Create backup of SQLite database +npm run db:backup + +# Restore from backup (manual operation) +``` + +### Environment Management +```bash +# Setup environment files and Python venv +npm run setup + +# Clean all dependencies and environments +npm run clean + +# Ensure environment is properly configured +npm run ensure:env +npm run ensure:venv +``` + +### Testing and Quality +The project uses: +- Next.js built-in TypeScript checking +- FastAPI automatic OpenAPI documentation at `http://localhost:8080/docs` +- Manual testing through web interface + +## Project Structure + +``` +Claudable/ +├── apps/ +│ ├── web/ # Next.js frontend +│ │ ├── app/ # App Router pages +│ │ ├── components/# Reusable React components +│ │ ├── contexts/ # React Context providers +│ │ └── types/ # TypeScript type definitions +│ └── api/ # FastAPI backend +│ └── app/ +│ ├── api/ # REST endpoints +│ ├── core/ # Configuration & utilities +│ ├── models/# Database models +│ └── services/ # Business logic +├── scripts/ # Build and development scripts +├── data/ # SQLite database and project files +└── assets/ # Static assets and documentation images +``` + +## Configuration + +### Environment Setup +Copy `.env.example` to `.env` and configure: + +**Required:** +- `ANTHROPIC_API_KEY` - For Claude Code SDK integration + +**Optional:** +- `API_PORT` - Backend server port (default: 8080) +- `WEB_PORT` - Frontend server port (default: 3000) +- `DATABASE_URL` - PostgreSQL connection (for production) +- Service integrations: GitHub, Vercel, Supabase tokens + +### Port Configuration +The application automatically detects available ports: +- Frontend: http://localhost:3000 (or next available) +- Backend: http://localhost:8080 (or next available) +- API Documentation: http://localhost:8080/docs + +## Development Workflow + +1. **Project Creation**: Users describe their app idea through the web interface +2. **AI Agent Selection**: Choose from Claude Code, Cursor CLI, Codex CLI, Gemini CLI, or Qwen Code +3. **Code Generation**: Selected AI agent generates Next.js application code +4. **Live Preview**: Real-time preview with hot-reload during development +5. **Deployment**: One-click deployment to Vercel with GitHub integration + +## Key Features Implementation + +### Multi-Agent Support +- Agent detection and installation checking via `scripts/` utilities +- Per-project agent preference stored in database +- Model selection per agent (GPT-5, Claude Sonnet 4, etc.) + +### Real-time Communication +- WebSocket connection between frontend and backend +- Live code generation updates +- Preview server management + +### Project Management +- SQLite database for local development +- Project files stored in `data/projects/` +- Preview servers run on dynamic port allocation +- Automatic cleanup and resource management + +## Development Tips + +- Backend API documentation is auto-generated at `/docs` endpoint +- Frontend builds use Next.js with Turbo for faster compilation +- Database schema changes require manual migration planning +- All AI agent communication goes through the Claude Code SDK wrapper \ No newline at end of file diff --git a/README.md b/README.md index a631f4fe..5e1f7201 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Claudable -CLovable +Claudable
-

Connect Claude Code. Build what you want. Deploy instantly.

+

Connect CLI Agent • Build what you want • Deploy instantly

Powered by OPACTOR

@@ -10,6 +10,12 @@ Join Discord Community + +OPACTOR Website + + +Follow Aaron +

## What is Claudable? @@ -21,7 +27,7 @@ This open-source project empowers you to build and deploy professional web appli How to start? Simply login to Claude Code (or Cursor CLI), start Claudable, and describe what you want to build. That's it. There is no additional subscription cost for app builder. ## Features -Claudable Demo +Claudable Demo - **Powerful Agent Performance**: Leverage the full power of Claude Code and Cursor CLI Agent capabilities with native MCP support - **Natural Language to Code**: Simply describe what you want to build, and Claudable generates production-ready Next.js code @@ -33,23 +39,81 @@ How to start? Simply login to Claude Code (or Cursor CLI), start Claudable, and - **Supabase Database**: Connect production PostgreSQL with authentication ready to use - **Automated Error Detection**: Detect errors in your app and fix them automatically -## Technology Stack -**AI Cooding Agent:** -- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup)**: Advanced AI coding agent. We strongly recommend you to use Claude Code for the best experience. +## Demo Examples + +### Codex CLI Example +Codex CLI Demo + +### Qwen Code Example +Qwen Code Demo + +## Supported AI Coding Agents + +Claudable supports multiple AI coding agents, giving you the flexibility to choose the best tool for your needs: + +- **Claude Code** - Anthropic's advanced AI coding agent +- **Codex CLI** - OpenAI's lightweight coding agent +- **Cursor CLI** - Powerful multi-model AI agent +- **Gemini CLI** - Google's open-source AI agent +- **Qwen Code** - Alibaba's open-source coding CLI + +### Claude Code (Recommended) +**[Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup)** - Anthropic's advanced AI coding agent with Claude Opus 4.1 +- **Features**: Deep codebase awareness, MCP support, Unix philosophy, direct terminal integration +- **Context**: Native 256K tokens +- **Pricing**: Included with ChatGPT Plus/Pro/Team/Edu/Enterprise plans +- **Installation**: ```bash - # Install npm install -g @anthropic-ai/claude-code - # Login claude # then > /login ``` -- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)**: Intelligent coding agent for complex coding tasks. It's little bit slower than Claude Code, but it's more powerful. + +### Codex CLI +**[Codex CLI](https://github.com/openai/codex)** - OpenAI's lightweight coding agent with GPT-5 support +- **Features**: High reasoning capabilities, local execution, multiple operating modes (interactive, auto-edit, full-auto) +- **Context**: Varies by model +- **Pricing**: Included with ChatGPT Plus/Pro/Business/Edu/Enterprise plans +- **Installation**: + ```bash + npm install -g @openai/codex + codex # login with ChatGPT account + ``` + +### Cursor CLI +**[Cursor CLI](https://cursor.com/en/cli)** - Powerful AI agent with access to cutting-edge models +- **Features**: Multi-model support (Anthropic, OpenAI, Gemini), MCP integration, AGENTS.md support +- **Context**: Model dependent +- **Pricing**: Free tier available, Pro plans for advanced features +- **Installation**: ```bash - # Install curl https://cursor.com/install -fsS | bash - # Login cursor-agent login ``` +### Gemini CLI +**[Gemini CLI](https://developers.google.com/gemini-code-assist/docs/gemini-cli)** - Google's open-source AI agent with Gemini 2.5 Pro +- **Features**: 1M token context window, Google Search grounding, MCP support, extensible architecture +- **Context**: 1M tokens (with free tier: 60 req/min, 1000 req/day) +- **Pricing**: Free with Google account, paid tiers for higher limits +- **Installation**: + ```bash + npm install -g @google/gemini-cli + gemini # follow authentication flow + ``` + +### Qwen Code +**[Qwen Code](https://github.com/QwenLM/qwen-code)** - Alibaba's open-source CLI for Qwen3-Coder models +- **Features**: 256K-1M token context, multiple model sizes (0.5B to 480B), Apache 2.0 license +- **Context**: 256K native, 1M with extrapolation +- **Pricing**: Completely free and open-source +- **Installation**: + ```bash + npm install -g @qwen-code/qwen-code@latest + qwen --version + ``` + +## Technology Stack + **Database & Deployment:** - **[Supabase](https://supabase.com/)**: Connect production-ready PostgreSQL database directly to your project. - **[Vercel](https://vercel.com/)**: Publish your work immediately with one-click deployment @@ -208,20 +272,22 @@ If you encounter the error: `Error output dangerously skip permissions cannot be - Anon Key: Public key for client-side - Service Role Key: Secret key for server-side -## Design Comparison -*Same prompt, different results* - -### Claudable -Claudable Design +## License -[View Claudable Live Demo →](https://claudable-preview.vercel.app/) +MIT License. -### Lovable -Lovable Design +## 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 -[View Lovable Live Demo →](https://preview--goal-track-studio.lovable.app/) +We're working hard to deliver the features you've been asking for. Stay tuned! -## License +## Star History -MIT License. \ No newline at end of file +[![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..300160a9 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 ) @@ -516,18 +525,79 @@ async def run_act( 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 +642,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 +706,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 +785,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 +825,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/github.py b/apps/api/app/api/github.py index 8c70a81b..129c2491 100644 --- a/apps/api/app/api/github.py +++ b/apps/api/app/api/github.py @@ -327,8 +327,9 @@ async def push_github_repository(project_id: str, db: Session = Depends(get_db)) if not repo_path or not os.path.exists(repo_path): raise HTTPException(status_code=500, detail="Local repository path not found") - # Branch - default_branch = connection.service_data.get("default_branch", "main") + # Branch: GitHub may return null for default_branch on empty repos. + # Normalize to 'main' and persist after first successful push. + default_branch = connection.service_data.get("default_branch") or "main" # Commit any pending changes (optional harmless) commit_all(repo_path, "Publish from Lovable UI") @@ -348,6 +349,9 @@ async def push_github_repository(project_id: str, db: Session = Depends(get_db)) "last_push_at": datetime.utcnow().isoformat() + "Z", "last_pushed_branch": default_branch, }) + # Ensure default_branch is set after first push + if not data.get("default_branch"): + data["default_branch"] = default_branch svc.service_data = data db.commit() except Exception as e: @@ -370,4 +374,4 @@ async def push_github_repository(project_id: str, db: Session = Depends(get_db)) logger = logging.getLogger(__name__) logger.warning(f"Failed updating Vercel connection after push: {e}") - return GitPushResponse(success=True, message="Pushed to GitHub", branch=default_branch) \ No newline at end of file + return GitPushResponse(success=True, message="Pushed to GitHub", branch=default_branch) diff --git a/apps/api/app/api/projects/crud.py b/apps/api/app/api/projects/crud.py index 78e70708..2878a09a 100644 --- a/apps/api/app/api/projects/crud.py +++ b/apps/api/app/api/projects/crud.py @@ -152,29 +152,29 @@ async def init_project_task(): async def install_dependencies_background(project_id: str, project_path: str): - """Install dependencies in background""" + """Install dependencies in background (npm)""" try: import subprocess import os - - # Check if package.json exists + package_json_path = os.path.join(project_path, "package.json") if os.path.exists(package_json_path): print(f"Installing dependencies for project {project_id}...") - - # Run npm install in background + process = await asyncio.create_subprocess_exec( "npm", "install", cwd=project_path, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() - + if process.returncode == 0: print(f"Dependencies installed successfully for project {project_id}") else: - print(f"Failed to install dependencies for project {project_id}: {stderr.decode()}") + print( + f"Failed to install dependencies for project {project_id}: {stderr.decode()}" + ) except Exception as e: print(f"Error installing dependencies: {e}") @@ -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/api/vercel.py b/apps/api/app/api/vercel.py index c2e12ad5..ba16c17f 100644 --- a/apps/api/app/api/vercel.py +++ b/apps/api/app/api/vercel.py @@ -271,11 +271,19 @@ async def deploy_to_vercel( # Initialize Vercel service vercel_service = VercelService(vercel_token) + # Resolve branch: prefer GitHub connection's default/last pushed branch + preferred_branch = ( + github_connection.service_data.get("last_pushed_branch") + or github_connection.service_data.get("default_branch") + or request.branch + or "main" + ) + # Create deployment deployment_result = await vercel_service.create_deployment( project_name=vercel_data.get("project_name"), github_repo_id=github_repo_id, - branch=request.branch, + branch=preferred_branch, framework=vercel_data.get("framework", "nextjs") ) @@ -467,4 +475,4 @@ async def get_active_monitoring(): return {"active_projects": active_projects} except Exception as e: logger.error(f"Failed to get active monitoring: {e}") - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500, detail=str(e)) 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..0a48c930 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 @@ -12,10 +12,31 @@ You are an expert fullstack developer with deep knowledge of the modern web deve Not every interaction requires code changes - you're happy to discuss architecture, explain concepts, debug issues, or provide guidance without modifying the codebase. When code changes are needed, you make efficient and effective updates while following modern fullstack best practices for maintainability, security, and performance. +When starting a new task: +1. Run ONE command: `ls -la` +2. IMMEDIATELY start working with the correct paths +CRITICAL: File paths in Next.js projects: +- If you see `app/` directory: use `app/page.tsx` (no leading slash) +- If you see `src/` directory: use `src/app/page.tsx` (no leading slash) +- NEVER use `/app/page.tsx` or `./app/page.tsx` - these are wrong! + +For the FIRST interaction on a new project: +- Take time to understand what the user wants to build +- Consider what existing beautiful designs you can draw inspiration from +- List the features you'll implement in the first version (don't do too much, but make it look good) +- List possible colors, gradients, animations, fonts and styles you'll use +- When the user asks for a specific design, follow it to the letter +- Consider editing tailwind.config.ts and index.css first if custom styles are needed +- Focus on creating a beautiful, working first impression - go above and beyond +- The MOST IMPORTANT thing is that the app is beautiful and works without build errors +- Take your time to wow the user with a really beautiful and well-coded app + ## Product Principles (MVP approach) - 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 @@ -26,6 +47,15 @@ Not every interaction requires code changes - you're happy to discuss architectu - Use "use client" directive only when client-side interactivity is required - Implement proper metadata API for SEO optimization - Follow Next.js 15 caching strategies and revalidation patterns +- Use STABLE versions of dependencies - avoid beta/alpha/experimental syntax: + - Tailwind CSS: Use v3 stable with standard @tailwind directives + - Avoid experimental features unless explicitly requested + - Ensure all syntax is compatible with production environments +- When using external images with next/image component, ALWAYS configure the domain in next.config.mjs: + - Add image domains to `images.remotePatterns` with protocol, hostname, port, and pathname + - For placeholder images (via.placeholder.com, picsum.photos, etc.), configure them properly + - Use standard tag for external images if configuration is not feasible + - Never use external image URLs without proper configuration ### Supabase Integration - Use Row Level Security (RLS) for data access control @@ -49,6 +79,7 @@ Not every interaction requires code changes - you're happy to discuss architectu - Create type-safe API routes and server actions - Use proper generic types for reusable components - Implement discriminated unions for complex state management +- Ensure all dependencies are properly typed - avoid any type errors ### Deployment & Performance - Optimize for Vercel deployment with proper environment variables @@ -62,10 +93,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,17 +104,25 @@ 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 - Implement proper loading states and optimistic updates -- Use Supabase client-side SDK for real-time features -- Implement proper error handling for database operations +- Use Supabase client-side SDK for real-time features when needed +- Use Tanstack Query (React Query) for server state management with object format: + ```typescript + const { data, isLoading, error } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }); + ``` +- Implement local state with useState/useContext, avoid prop drilling +- Cache responses when appropriate - Use React's useTransition for pending states - - Default to the simplest approach; do not connect a database client unless explicitly requested by the user - - For temporary persistence without DB, prefer component state or localStorage - - Avoid introducing persistent storage by default +- Default to the simplest approach; do not connect a database client unless explicitly requested +- For temporary persistence without DB, prefer component state or localStorage +- Avoid introducing persistent storage by default ### Security & Validation - Validate all user inputs with Zod schemas @@ -98,11 +137,24 @@ Not every interaction requires code changes - you're happy to discuss architectu - Use Read tool to analyze image content and provide relevant assistance ### Design Guidelines -- You should use framer motion for animations +- Use Framer Motion for all animations and transitions - Define and use Design Tokens (colors, spacing, typography, radii, shadows) and reuse them across components - Add appropriate animation effects to components; prefer consistent durations/easings via tokens -- In addition to shadcn/ui and Radix UI, actively leverage available stock images to deliver production-ready design - - You should only use valid URLs you know exist. +- Consider beautiful design inspiration from existing products when creating interfaces +- Use gradients sparingly - avoid text gradients on critical UI text for better readability +- Text gradients should only be used on large headings with sufficient contrast +- Prioritize readability: ensure sufficient color contrast (WCAG AA standards minimum) +- Use solid colors for body text, buttons, and important UI elements +- Implement smooth hover effects and micro-interactions +- Apply modern typography with proper font weights and sizes +- Create visual hierarchy with proper spacing and layout +- For images: + - Prefer using local images stored in public/ directory over external URLs + - If using placeholder services (via.placeholder.com, picsum.photos), configure them in next.config.mjs first + - Always verify next.config.mjs has proper remotePatterns configuration before using external images + - Use standard tag as fallback if Next Image configuration is complex +- Never implement light/dark mode toggle in initial versions - it's not a priority +- Focus on making the default theme beautiful and polished ## Implementation Standards @@ -112,13 +164,25 @@ Not every interaction requires code changes - you're happy to discuss architectu - Add necessary imports and dependencies - Ensure proper TypeScript typing throughout - Include appropriate comments for complex logic +- Don't catch errors with try/catch blocks unless specifically requested - let errors bubble up for debugging +- Use extensive console.log for debugging and following code flow +- Write complete, syntactically correct code - no partial implementations or TODO comments ### UI/UX Standards -- Create responsive designs that work on all devices -- Use Tailwind CSS utility classes effectively +- ALWAYS generate responsive designs that work on all devices +- Use Tailwind CSS utility classes extensively for layout, spacing, colors, and design - Implement proper loading states and skeleton screens -- Follow modern design patterns and accessibility standards +- Follow modern design patterns and accessibility standards (ARIA labels, semantic HTML) +- Ensure text readability: + - Use high contrast between text and background (minimum 4.5:1 for normal text, 3:1 for large text) + - Avoid gradient text on buttons, forms, and body content + - Use readable font sizes (minimum 14px for body text) + - Test designs against both light and dark backgrounds - Create smooth animations and transitions when appropriate +- Use toast notifications for important user feedback events +- Prefer shadcn/ui components when available - create custom wrappers if modifications needed +- Use lucide-react for icons throughout the application +- Use Recharts library for charts and data visualization ### Database & API Design - Design normalized database schemas @@ -135,15 +199,45 @@ Not every interaction requires code changes - you're happy to discuss architectu - **Never** modify files without explicit user request - **Never** add features that weren't specifically requested - **Never** compromise on security or validation +- **Never** waste time with file exploration - ONE `ls` command is enough +- **Never** use pwd, find, or read files just to verify they exist +- **Never** confuse paths - use `app/page.tsx` NOT `/app/page.tsx` - **Always** write complete, immediately functional code - **Always** follow the established patterns in the existing codebase - **Always** use the specified tech stack (Next.js 15, Supabase, Vercel, Zod) +- **Always** start implementing within 2 commands of task start +- **Always** check errors progressively: TypeScript → ESLint → Build (in that order) ## Rules -- Always run "npm run build" after completing code changes to verify the build works correctly +- Always work from the project root directory "/" - all file paths and operations should be relative to the root +- Initial project check: Run `ls -la` ONCE and start working +- File path rules for Next.js (CRITICAL): + - Standard structure: `app/page.tsx`, `app/layout.tsx`, `app/globals.css` + - With src: `src/app/page.tsx`, `src/app/layout.tsx`, `src/app/globals.css` + - NO leading slashes - use relative paths from project root + - NO `./` prefix - just use direct paths like `app/page.tsx` +- NEVER use pwd, find, or multiple ls commands +- NEVER read files just to check existence - trust the initial ls +- Use STABLE, production-ready code patterns: + - Tailwind CSS: Always use v3 with `@tailwind base/components/utilities` + - PostCSS: Use standard configuration with tailwindcss and autoprefixer plugins + - Package versions: Prefer stable releases over beta/alpha versions + - If creating custom themes, use tailwind.config.ts, not experimental CSS features +- Error checking sequence (use these BEFORE final build): + 1. Run `npx tsc --noEmit` for TypeScript type checking (fastest) + 2. Run `npx next lint` for ESLint errors (fast) + 3. Only after fixing all errors, run `npm run build` as final verification - Never run "npm run dev" or start servers; the user will handle server processes - Never run "npm install". The node_modules are already installed. +- When encountering npm errors: +- If "Cannot read properties of null" error: remove node_modules and package-lock.json, then reinstall +- If .pnpm directory exists in node_modules: project uses pnpm, don't mix with npm + - ImportProcessor errors about packages (tailwind, supabase/ssr): these are warnings, can be ignored +- Before using any external image URL with next/image: + 1. Check if next.config.mjs exists and has remotePatterns configured + 2. If not configured, either add the configuration or use standard tag + 3. Common domains needing configuration: via.placeholder.com, picsum.photos, unsplash.com, etc. - If a user's request is too vague to implement, ask brief clarifying follow-up questions before proceeding - Do not connect any database client or persist to Supabase unless the user explicitly requests it - Do not edit README.md without user request -- User give you useful information in tag. You should use it to understand the project and the user's request. \ No newline at end of file +- User give you useful information in tag. You should use it to understand the project and the user's request. 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..6d5f4300 --- /dev/null +++ b/apps/api/app/services/cli/adapters/claude_code.py @@ -0,0 +1,470 @@ +"""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") + + 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..b679ab7d --- /dev/null +++ b/apps/api/app/services/cli/adapters/codex_cli.py @@ -0,0 +1,861 @@ +"""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 (essential) + # If needed, set CLAUDABLE_DISABLE_AGENTS_MD=1 to skip. + try: + if str(os.getenv("CLAUDABLE_DISABLE_AGENTS_MD", "")).lower() in ( + "1", + "true", + "yes", + "on", + ): + ui.debug("AGENTS.md auto-creation disabled by env", "Codex") + else: + await self._ensure_agent_md(project_path) + except Exception as _e: + ui.debug(f"AGENTS.md ensure failed (continuing): {_e}", "Codex") + + # 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)}", + ] + + # Optionally resume from a previous rollout. Disabled by default to avoid + # stale system prompts or behaviors leaking between runs. + enable_resume = str(os.getenv("CLAUDABLE_CODEX_RESUME", "")).lower() in ( + "1", + "true", + "yes", + "on", + ) + if enable_resume: + 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) + else: + ui.debug("Codex resume disabled (fresh session)", "Codex") + + 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..26f9b621 --- /dev/null +++ b/apps/api/app/services/cli/adapters/qwen_cli.py @@ -0,0 +1,821 @@ +"""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 + path = params.get('path', params.get('file_path', 'unknown')) + + # Log the edit attempt for debugging + ui.debug(f"Qwen edit request: path={path}, has_old_string={'old_string' in params}", "Qwen") + + if "old_string" not in params: + ui.warning( + f"Qwen edit missing 'old_string': {path}", + "Qwen" + ) + # Return success anyway to not block Qwen's workflow + # This allows Qwen to continue even with malformed requests + return {"success": True} + + # For safety, we don't actually perform the edit but return success + ui.debug(f"Qwen edit would modify: {path}", "Qwen") + 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 + # Skip ImportProcessor errors (these are just warnings about npm packages) + if "[ERROR] [ImportProcessor]" in decoded: + continue + # Skip ENOENT errors for node_modules paths + if "ENOENT" in decoded and ("node_modules" in decoded or "tailwind" in decoded or "supabase" in decoded): + 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..599ccfcc --- /dev/null +++ b/apps/api/app/services/cli/manager.py @@ -0,0 +1,271 @@ +"""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 + +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""" + + # 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..1c0555f0 100644 --- a/apps/api/app/services/filesystem.py +++ b/apps/api/app/services/filesystem.py @@ -37,7 +37,7 @@ def scaffold_nextjs_minimal(repo_path: str) -> None: "--app", "--import-alias", "@/*", "--use-npm", - "--skip-install", # We'll install dependencies later + "--skip-install", # We'll install dependencies later (handled by backend) "--yes" # Auto-accept all prompts ] diff --git a/apps/api/app/services/local_runtime.py b/apps/api/app/services/local_runtime.py index 833b0f47..39c831ce 100644 --- a/apps/api/app/services/local_runtime.py +++ b/apps/api/app/services/local_runtime.py @@ -238,7 +238,7 @@ def _should_install_dependencies(repo_path: str) -> bool: with open(package_json_path, 'rb') as f: current_hash += hashlib.md5(f.read()).hexdigest() - # Hash package-lock.json if it exists + # Hash npm's package-lock.json if it exists if os.path.exists(package_lock_path): with open(package_lock_path, 'rb') as f: current_hash += hashlib.md5(f.read()).hexdigest() @@ -323,9 +323,36 @@ def start_preview_process(project_id: str, repo_path: str, port: Optional[int] = }) try: + # Normalize repository to npm to avoid mixed package managers + try: + pnpm_lock = os.path.join(repo_path, "pnpm-lock.yaml") + yarn_lock = os.path.join(repo_path, "yarn.lock") + pnpm_dir = os.path.join(repo_path, "node_modules", ".pnpm") + if os.path.exists(pnpm_lock) or os.path.exists(yarn_lock) or os.path.isdir(pnpm_dir): + print("Detected non-npm artifacts (pnpm/yarn). Cleaning to use npm...") + # Remove node_modules to avoid arborist crashes + try: + import shutil + shutil.rmtree(os.path.join(repo_path, "node_modules"), ignore_errors=True) + except Exception as _e: + print(f"Warning: failed to remove node_modules: {_e}") + # Remove other lockfiles + try: + if os.path.exists(pnpm_lock): + os.remove(pnpm_lock) + except Exception: + pass + try: + if os.path.exists(yarn_lock): + os.remove(yarn_lock) + except Exception: + pass + except Exception as _e: + print(f"Warning during npm normalization: {_e}") + # Only install dependencies if needed if _should_install_dependencies(repo_path): - print(f"Installing dependencies for project {project_id}...") + print(f"Installing dependencies for project {project_id} with npm...") install_result = subprocess.run( ["npm", "install"], cwd=repo_path, @@ -340,7 +367,7 @@ def start_preview_process(project_id: str, repo_path: str, port: Optional[int] = # Save hash after successful install _save_install_hash(repo_path) - print(f"Dependencies installed successfully for project {project_id}") + print(f"Dependencies installed successfully for project {project_id} using npm") else: print(f"Dependencies already up to date for project {project_id}, skipping npm install") @@ -602,4 +629,4 @@ def get_preview_logs(project_id: str, lines: int = 100) -> str: # No more data available pass - return ''.join(logs[-lines:]) if logs else "No recent logs available" \ No newline at end of file + return ''.join(logs[-lines:]) if logs else "No recent logs available" 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..b00991fc 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 +1600,31 @@ export default function ChatPage({ params }: Params) { {/* Settings Button */} + {/* Stop Button */} + {showPreview && previewUrl && ( + + )} + {/* Publish/Update */} {showPreview && previewUrl && (
- {showPublishPanel && ( + {false && showPublishPanel && (

Publish Project

@@ -1572,7 +1809,7 @@ export default function ChatPage({ params }: Params) { style={{ height: '100%' }} > {previewUrl ? ( -
+