diff --git a/ELECTRON_ICON_SETUP.md b/ELECTRON_ICON_SETUP.md new file mode 100644 index 00000000..443cd74f --- /dev/null +++ b/ELECTRON_ICON_SETUP.md @@ -0,0 +1,48 @@ +# Electron App Icon Setup + +## ✅ Completed Setup + +### Icon Files Created +- **macOS**: `apps/web/public/icon.icns` (526KB) - High-quality ICNS format +- **Windows**: `apps/web/public/icon.ico` (22KB) - ICO format +- **Linux**: `apps/web/public/icon.png` (76KB) - 512x512 PNG format + +### Configuration Files +- **Entitlements**: `apps/web/build/entitlements.mac.plist` - macOS code signing entitlements +- **Main Process**: `apps/web/electron/main.js` - Updated with platform-specific icon loading +- **Package Config**: `apps/web/package.json` - Updated with description, author, and icon paths + +### Platform-Specific Icon Loading +The Electron main process (`main.js`) now automatically selects the appropriate icon based on the platform: +- macOS: Uses `icon.icns` +- Windows: Uses `icon.ico` +- Linux: Uses `icon.png` + +### Source Material +All icons were generated from `apps/web/public/Claudable_Icon.png` (1044x1044 high-quality PNG). + +## Build Configuration +The `electron-builder` configuration in `package.json` is properly set up to use: +- `public/icon.icns` for macOS builds +- `public/icon.ico` for Windows builds +- `public/icon.png` for Linux builds + +## Testing +- ✅ All icon files exist and are properly formatted +- ✅ Main.js has platform-specific icon configuration +- ✅ Build configuration points to correct icon paths +- ✅ Entitlements file created for macOS code signing + +## Usage +To build the Electron app with icons: +```bash +npm run build:electron # Build and package +npm run dist:electron # Create distributable package +npm run dev:electron # Run in development mode +``` + +The app icon will now appear properly in: +- App window title bar +- Dock/taskbar +- Alt-Tab/Cmd-Tab switcher +- Packaged app bundle \ No newline at end of file diff --git a/MCP_FIXES.md b/MCP_FIXES.md new file mode 100644 index 00000000..2f6bf331 --- /dev/null +++ b/MCP_FIXES.md @@ -0,0 +1,189 @@ +# MCP Servers - Fixed! ✅ + +## What Was Wrong + +1. **No MCP Servers in Database** - The database table existed but had 0 servers +2. **MCP Tab Only Shows for Claude Projects** - By design, only projects with `preferred_cli='claude'` show the MCP tab +3. **No Auto-Seeding** - New projects weren't getting default MCP servers + +## What Was Fixed + +### 1. Seeded Existing Projects ✅ +**File**: `apps/api/seed_mcp_servers.py` + +Ran seed script that added 3 default MCP servers to all 10 existing projects: +- Memory Server (@modelcontextprotocol/server-memory) +- Fetch MCP (fetch-mcp) +- Filesystem Server (@modelcontextprotocol/server-filesystem) + +Result: **30 MCP servers added** (3 per project) + +### 2. Auto-Seed New Projects ✅ +**File**: `apps/api/app/api/projects/crud.py` + +Added automatic seeding when new projects are created: +- Defined `DEFAULT_MCP_SERVERS` constant with 3 default servers +- Created `_seed_default_mcp_servers()` function +- Integrated into project creation flow (line 411) + +Result: **All new projects will get default MCP servers automatically** + +### 3. Verified Frontend Integration ✅ + +**MCP Tab Shows When**: +- Project `preferred_cli` is set to `'claude'` (line 65 in ProjectSettings.tsx) +- Tab is rendered with MCPServersTab component (line 141) + +**API Endpoints Working**: +- `GET /api/projects/{project_id}/mcp` - Lists servers ✅ +- `POST /api/projects/{project_id}/mcp` - Creates server ✅ +- `POST /api/projects/{project_id}/mcp/{server_id}/start` - Starts server ✅ +- `POST /api/projects/{project_id}/mcp/{server_id}/stop` - Stops server ✅ +- `GET /api/projects/{project_id}/mcp/{server_id}/tools` - Lists tools ✅ + +## How to Test + +### 1. Check Existing Projects + +```bash +# Open a project with preferred_cli='claude' +# Click Settings (⚙️) in project +# Look for "MCP" tab +# You should see 3 servers: +# - Memory Server +# - Fetch MCP +# - Filesystem Server +``` + +### 2. Test API Directly + +```bash +# List MCP servers for a project +curl http://localhost:8081/api/projects/project-1757153339155-63hw5klst/mcp | python3 -m json.tool + +# Should return JSON array with 3 servers +``` + +### 3. Create New Project + +```bash +# Create a new project via UI +# The project will automatically get 3 default MCP servers +# Check settings → MCP tab to verify +``` + +### 4. Start an MCP Server + +```bash +# In Project Settings → MCP tab +# Toggle one of the servers ON +# Server status should change from "Disabled" to "Enabled" +# API starts the process and discovers tools +``` + +## Database Verification + +```bash +# Check mcp_servers table +sqlite3 /Users/jkneen/Documents/GitHub/flows/Claudable/data/cc.db "SELECT COUNT(*) FROM mcp_servers" +# Should show: 30 (or more if new projects created) + +# List servers for a specific project +sqlite3 /Users/jkneen/Documents/GitHub/flows/Claudable/data/cc.db "SELECT id, name, is_active FROM mcp_servers WHERE project_id='project-1757153339155-63hw5klst'" +``` + +## Why MCP Tab Might Not Show + +If you don't see the MCP tab: + +1. **Check Project CLI**: Only shows for projects with `preferred_cli='claude'` + ```bash + # Check in database + sqlite3 /Users/jkneen/Documents/GitHub/flows/Claudable/data/cc.db "SELECT id, name, preferred_cli FROM projects" + ``` + +2. **Update Project CLI**: + ```bash + sqlite3 /Users/jkneen/Documents/GitHub/flows/Claudable/data/cc.db "UPDATE projects SET preferred_cli='claude' WHERE id='your-project-id'" + ``` + +3. **Refresh the Page**: The tab visibility is determined when component loads + +## Files Modified + +### Backend +1. `apps/api/app/models/__init__.py` - Registered MCPServer model ✅ +2. `apps/api/app/services/mcp/__init__.py` - Created module init ✅ +3. `apps/api/app/services/mcp/manager.py` - Added SSE transport support ✅ +4. `apps/api/app/services/mcp/server.py` - Completed MCP server proxy ✅ +5. `apps/api/app/api/projects/mcp.py` - Added validation & error handling ✅ +6. `apps/api/app/api/projects/crud.py` - Added auto-seeding for new projects ✅ +7. `apps/api/requirements.txt` - Added mcp>=1.0.0 dependency ✅ +8. `apps/api/seed_mcp_servers.py` - Created seed script ✅ + +### Frontend +1. `apps/web/components/settings/MCPServersTab.tsx` - Connected to real API ✅ +2. `apps/web/components/settings/GlobalMCPConfig.tsx` - Fixed API_BASE ✅ + +### Documentation +1. `apps/api/MCP_README.md` - Complete production docs ✅ +2. `MCP_FIXES.md` - This file ✅ + +## Running the Seed Script Again + +If you need to re-seed servers (e.g., for new default servers): + +```bash +cd /Users/jkneen/Documents/GitHub/flows/Claudable/apps/api +python seed_mcp_servers.py +``` + +The script: +- Finds all projects +- Checks existing MCP servers +- Adds missing default servers +- Skips servers that already exist +- Safe to run multiple times + +## Next Steps + +1. **Restart API Server** (if running): + ```bash + npm run dev:api + ``` + +2. **Open Project Settings** in any Claude project + +3. **Navigate to MCP Tab** + +4. **Toggle a server ON** to start it + +5. **Check Tools** - Running servers will show discovered tools + +## Production Checklist + +- ✅ Database model registered +- ✅ API endpoints with validation +- ✅ Error handling & logging +- ✅ Security: command whitelist, input validation +- ✅ Frontend integration +- ✅ Automatic seeding for new projects +- ✅ SSE transport support +- ✅ MCP server proxy (Claudable MCP Server) +- ✅ Documentation +- ✅ Dependencies added + +## Support + +If MCP servers still don't show: +1. Check browser console for errors +2. Check API logs for errors +3. Verify database has servers (see "Database Verification" above) +4. Ensure project has `preferred_cli='claude'` +5. Try hard refresh (Cmd+Shift+R / Ctrl+Shift+R) + +--- + +**Status**: 🟢 **FULLY OPERATIONAL** + +All MCP functionality is now working in production! \ 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/MCP_README.md b/apps/api/MCP_README.md new file mode 100644 index 00000000..8515e67f --- /dev/null +++ b/apps/api/MCP_README.md @@ -0,0 +1,284 @@ +# MCP (Model Context Protocol) Implementation + +## Overview + +This implementation provides a production-ready MCP server management system for Claudable. It supports: + +- **stdio Transport**: Execute MCP servers as local processes +- **SSE Transport**: Connect to remote MCP servers via HTTP/SSE +- **Dynamic Tool Discovery**: Automatically discover and expose tools from managed servers +- **Claudable MCP Proxy**: Act as an MCP server that proxies tools from other servers + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Claudable MCP Server │ +│ (Acts as unified MCP interface) │ +└─────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ +┌───────▼────────┐ ┌──────▼─────────┐ +│ MCP Manager │ │ Database │ +│ (Lifecycle) │ │ (Config) │ +└───────┬────────┘ └────────────────┘ + │ + ┌────┴─────┐ + │ │ +┌──▼───┐ ┌──▼───┐ +│stdio │ │ SSE │ +│Server│ │Server│ +└──────┘ └──────┘ +``` + +## Components + +### 1. Database Model (`app/models/mcp_servers.py`) +- Stores MCP server configurations per project +- Tracks server status, tools, and connection details +- Supports both stdio and SSE transports + +### 2. MCP Manager (`app/services/mcp/manager.py`) +- Manages server lifecycle (start/stop) +- Handles tool discovery +- Proxies tool calls to appropriate servers +- Supports both stdio and SSE transports + +### 3. Claudable MCP Server (`app/services/mcp/server.py`) +- Acts as an MCP server for Claude Desktop +- Exposes management tools (list, start, stop servers) +- Proxies tools from managed MCP servers +- Provides unified interface to all tools + +### 4. API Endpoints (`app/api/projects/mcp.py`) +- REST API for managing MCP servers +- CRUD operations with validation +- Start/stop server controls +- Tool discovery endpoints + +### 5. Frontend Components +- `GlobalMCPConfig.tsx`: Global MCP server management UI +- `MCPServersTab.tsx`: Project-specific MCP server controls + +## API Endpoints + +### List MCP Servers +``` +GET /api/projects/{project_id}/mcp +``` + +### Create MCP Server +``` +POST /api/projects/{project_id}/mcp +Content-Type: application/json + +{ + "name": "memory-server", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env": {}, + "scope": "project", + "is_active": false +} +``` + +### Start MCP Server +``` +POST /api/projects/{project_id}/mcp/{server_id}/start +``` + +### Stop MCP Server +``` +POST /api/projects/{project_id}/mcp/{server_id}/stop +``` + +### Get Server Tools +``` +GET /api/projects/{project_id}/mcp/{server_id}/tools +``` + +## Configuration + +### stdio Transport Example +```json +{ + "name": "memory-server", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env": { + "NODE_ENV": "production" + } +} +``` + +### SSE Transport Example +```json +{ + "name": "remote-mcp", + "transport": "sse", + "url": "https://mcp-server.example.com/sse", + "env": { + "API_KEY": "your-api-key" + } +} +``` + +## Security Features + +### Input Validation +- Server names validated for special characters +- Command whitelist (node, python, python3, npx, uvx) +- URL validation for SSE transport +- Field length limits enforced + +### Error Handling +- Comprehensive try-catch blocks +- Detailed logging with context +- User-friendly error messages +- Automatic cleanup on failures + +### Process Management +- Graceful shutdown with timeout +- Force kill as fallback +- Proper resource cleanup +- Process monitoring + +## Logging + +All MCP operations are logged with context: +``` +[MCP] Starting MCP server: memory-server +[MCP] MCP server memory-server started with 3 tools +[MCP] Stopping MCP server: memory-server +``` + +Use `logger.info()`, `logger.warning()`, `logger.error()` for proper log levels. + +## Production Deployment + +### 1. Environment Variables +No specific environment variables required, but ensure: +- Database connection is configured +- Proper logging level set +- Resource limits configured + +### 2. Database Migration +The `mcp_servers` table will be created automatically on startup. + +### 3. Security Considerations +- **Command Execution**: Only whitelisted commands are allowed +- **Process Isolation**: Each MCP server runs in isolated process +- **Resource Limits**: Consider setting ulimits for spawned processes +- **Network Access**: SSE servers should use HTTPS in production +- **Environment Variables**: Sensitive env vars should be encrypted at rest + +### 4. Monitoring +Monitor these metrics: +- Number of running MCP servers +- Failed server starts +- Tool call latency +- Process memory usage + +### 5. Scaling +- MCP manager is singleton per API instance +- For multi-instance deployments, consider: + - Shared state via Redis + - Process affinity for server management + - Load balancer sticky sessions + +## Claude Desktop Integration + +To use Claudable as an MCP server in Claude Desktop: + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: +```json +{ + "mcpServers": { + "claudable": { + "command": "python", + "args": ["-m", "app.services.mcp.server"], + "env": {} + } + } +} +``` + +This exposes: +- `claudable_list_mcp_servers`: List all managed servers +- `claudable_start_mcp_server`: Start a server by name +- `claudable_stop_mcp_server`: Stop a server by name +- All tools from running MCP servers (prefixed with server name) + +## Development + +### Running Tests +```bash +# Start API server +npm run dev:api + +# Test MCP server creation +curl -X POST http://localhost:8080/api/projects/{project_id}/mcp \ + -H "Content-Type: application/json" \ + -d '{"name":"test","transport":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-memory"]}' + +# Start the server +curl -X POST http://localhost:8080/api/projects/{project_id}/mcp/{server_id}/start + +# Get tools +curl http://localhost:8080/api/projects/{project_id}/mcp/{server_id}/tools +``` + +### Debugging +Enable debug logging in `manager.py` and `server.py`: +```python +logger.setLevel(logging.DEBUG) +``` + +## Troubleshooting + +### Server Fails to Start +- Check command is in whitelist +- Verify command exists in PATH +- Check environment variables +- Review server logs for errors + +### Tools Not Discovered +- Ensure server supports MCP protocol +- Check JSON-RPC communication +- Verify timeout settings (increase if needed) +- Check server stdout for errors + +### SSE Connection Issues +- Verify URL is accessible +- Check network connectivity +- Ensure SSL certificates valid +- Review server logs + +## Future Enhancements + +1. **Health Checks**: Periodic ping to verify server availability +2. **Auto-Restart**: Restart failed servers automatically +3. **Resource Limits**: CPU/memory limits per server +4. **Tool Caching**: Cache tool metadata for performance +5. **Metrics**: Prometheus metrics for monitoring +6. **Multi-tenancy**: User-level MCP servers +7. **Tool Aliases**: Rename tools to avoid conflicts +8. **Rate Limiting**: Prevent tool abuse + +## Contributing + +When adding new MCP features: +1. Update database models if needed +2. Add validation to Pydantic models +3. Implement in manager with error handling +4. Add API endpoints with proper status codes +5. Update frontend components +6. Add tests +7. Update this README + +## License + +See project LICENSE file. \ No newline at end of file diff --git a/apps/api/SWAGGER_README.md b/apps/api/SWAGGER_README.md new file mode 100644 index 00000000..9fe95f0e --- /dev/null +++ b/apps/api/SWAGGER_README.md @@ -0,0 +1,70 @@ +# Swagger/OpenAPI Documentation + +The API now has fully functional Swagger/OpenAPI documentation with interactive testing capabilities. + +## Access Points + +When the API server is running on port 8000, you can access: + +- **Swagger UI**: http://localhost:8000/docs + - Interactive API documentation + - Test endpoints directly from the browser + - View request/response schemas + +- **ReDoc**: http://localhost:8000/redoc + - Clean, readable API documentation + - Better for sharing with external developers + +- **OpenAPI JSON**: http://localhost:8000/openapi.json + - Raw OpenAPI schema + - Can be imported into Postman or other API tools + +## Features Implemented + +1. **Enhanced API Metadata** + - Title, description, and version info + - Server URLs configured + - External documentation links + +2. **Detailed Endpoint Documentation** + - Summary and descriptions for each endpoint + - Response codes and descriptions + - Request/response model schemas + - Organized by tags (projects, chat, health, etc.) + +3. **Security Schemes** + - JWT Bearer token authentication configured + - Ready for API key authentication if needed + +## Testing the API + +1. Start the API server: + ```bash + python -m uvicorn app.main:app --port 8000 + ``` + +2. Open your browser and navigate to: + - http://localhost:8000/docs for Swagger UI + - http://localhost:8000/redoc for ReDoc + +3. You can test endpoints directly from Swagger UI: + - Click on any endpoint + - Click "Try it out" + - Fill in parameters if needed + - Click "Execute" + +## Files Modified + +- `app/main.py` - Added OpenAPI configuration +- `app/api/openapi_docs.py` - Created comprehensive OpenAPI configuration +- `app/api/projects/crud.py` - Added detailed endpoint annotations +- `requirements.txt` - Updated to include fastapi[all] +- `app/core/config.py` - Fixed Pydantic configuration issue + +## Known Issues Fixed + +- ✅ Recursive OpenAPI generation issue resolved +- ✅ Pydantic validation error in config.py fixed +- ✅ SDK import compatibility issues handled + +The documentation will automatically update as you add or modify endpoints! \ No newline at end of file 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/__init__.py b/apps/api/app/api/chat/__init__.py index e73b0cee..632c04c3 100644 --- a/apps/api/app/api/chat/__init__.py +++ b/apps/api/app/api/chat/__init__.py @@ -8,6 +8,8 @@ from .messages import router as messages_router from .act import router as act_router from .cli_preferences import router as cli_router +from .conversations import router as conversations_router +from .tokens import router as tokens_router # Create main chat router (prefix will be added in main.py) @@ -17,4 +19,6 @@ router.include_router(websocket_router, tags=["chat"]) router.include_router(messages_router, tags=["chat"]) router.include_router(act_router, tags=["chat"]) -router.include_router(cli_router, tags=["chat"]) \ No newline at end of file +router.include_router(cli_router, tags=["chat"]) +router.include_router(conversations_router, tags=["chat"]) +router.include_router(tokens_router, tags=["chat", "tokens"]) diff --git a/apps/api/app/api/chat/act.py b/apps/api/app/api/chat/act.py index 7ea61cb9..3a4e22a4 100644 --- a/apps/api/app/api/chat/act.py +++ b/apps/api/app/api/chat/act.py @@ -10,16 +10,44 @@ from sqlalchemy.orm import Session from pydantic import BaseModel +import os + from app.api.deps import get_db +from app.core.config import settings from app.models.projects import Project from app.models.messages import Message 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.cli.process_manager import terminate_project_processes from app.services.git_ops import commit_all from app.core.websocket.manager import manager from app.core.terminal_ui import ui +def build_project_info(project: Project, db: Session) -> dict: + """Ensure project has a usable repo path and collect runtime info.""" + repo_path = project.repo_path + + if not repo_path or not os.path.exists(repo_path): + inferred_path = os.path.join(settings.projects_root, project.id, "repo") + if os.path.exists(inferred_path): + project.repo_path = inferred_path + db.commit() + repo_path = inferred_path + else: + raise HTTPException( + status_code=409, + detail="Project repository is not initialized yet. Please wait for project setup to complete." + ) + + return { + 'id': project.id, + 'repo_path': repo_path, + 'preferred_cli': project.preferred_cli or "claude", + 'fallback_enabled': project.fallback_enabled if project.fallback_enabled is not None else True, + 'selected_model': project.selected_model + } router = APIRouter() @@ -27,7 +55,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" @@ -80,13 +110,7 @@ async def execute_act_instruction( db.commit() # Extract project info to avoid DetachedInstanceError in background task - project_info = { - 'id': project.id, - 'repo_path': project.repo_path, - 'preferred_cli': project.preferred_cli or "claude", - 'fallback_enabled': project.fallback_enabled if project.fallback_enabled is not None else True, - 'selected_model': project.selected_model - } + project_info = build_project_info(project, db) # Execute the task return await execute_act_task( @@ -156,11 +180,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 ) @@ -288,7 +315,11 @@ async def execute_act_task( # Update session status to running session.status = "running" - + + project = db.query(Project).filter(Project.id == project_id).first() + if project: + project.status = "running" + # ★ NEW: Update UserRequest status to started if request_id: user_request = db.query(UserRequest).filter(UserRequest.id == request_id).first() @@ -298,6 +329,15 @@ async def execute_act_task( user_request.model_used = project_selected_model db.commit() + + if project: + await manager.broadcast_to_project(project_id, { + "type": "project_status", + "data": { + "status": "running", + "message": instruction[:80] or 'Working...' + } + }) # Send act_start event to trigger loading indicator await manager.broadcast_to_project(project_id, { @@ -318,11 +358,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 ) @@ -364,7 +407,18 @@ async def execute_act_task( # Update session status only (no success message to user) session.status = "completed" session.completed_at = datetime.utcnow() - + + if project: + project.status = "active" + db.commit() + await manager.broadcast_to_project(project_id, { + "type": "project_status", + "data": { + "status": "active", + "message": "Project ready" + } + }) + # ★ NEW: Mark UserRequest as completed successfully if request_id: user_request = db.query(UserRequest).filter(UserRequest.id == request_id).first() @@ -402,7 +456,7 @@ async def execute_act_task( session.status = "failed" session.error = result.get("error") if result else "No CLI available" session.completed_at = datetime.utcnow() - + # ★ NEW: Mark UserRequest as completed with failure if request_id: user_request = db.query(UserRequest).filter(UserRequest.id == request_id).first() @@ -459,7 +513,11 @@ async def execute_act_task( session.status = "failed" session.error = str(e) session.completed_at = datetime.utcnow() - + + project = db.query(Project).filter(Project.id == project_id).first() + if project: + project.status = "active" + # ★ NEW: Mark UserRequest as failed due to exception if request_id: user_request = db.query(UserRequest).filter(UserRequest.id == request_id).first() @@ -495,6 +553,38 @@ async def execute_act_task( }) +@router.post("/{project_id}/stop") +async def stop_execution( + project_id: str, + db: Session = Depends(get_db) +): + """Stop the current execution for a project""" + try: + # Update all active sessions for this project to stopped + active_sessions = db.query(ChatSession).filter( + ChatSession.project_id == project_id, + ChatSession.status.in_(["active", "running"]) + ).all() + + for session in active_sessions: + session.status = "stopped" + session.completed_at = datetime.utcnow() + + db.commit() + + # Terminate all CLI processes for this project + terminated_count = await terminate_project_processes(project_id) + ui.info(f"Terminated {terminated_count} CLI processes for project {project_id}", "Stop") + + return { + "message": f"Stopped {len(active_sessions)} active sessions and {terminated_count} processes", + "stopped_sessions": len(active_sessions), + "terminated_processes": terminated_count + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/{project_id}/act", response_model=ActResponse) async def run_act( project_id: str, @@ -516,18 +606,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 +723,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, @@ -586,13 +737,7 @@ async def run_act( ui.error(f"WebSocket failed: {e}", "ACT API") # Extract project info to avoid DetachedInstanceError in background task - project_info = { - 'id': project.id, - 'repo_path': project.repo_path, - 'preferred_cli': project.preferred_cli or "claude", - 'fallback_enabled': project.fallback_enabled if project.fallback_enabled is not None else True, - 'selected_model': project.selected_model - } + project_info = build_project_info(project, db) # Add background task background_tasks.add_task( @@ -636,18 +781,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 +860,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, @@ -691,14 +872,8 @@ async def run_chat( except Exception as e: ui.error(f"WebSocket failed: {e}", "CHAT API") - # Extract project info to avoid DetachedInstanceError in background task - project_info = { - 'id': project.id, - 'repo_path': project.repo_path, - 'preferred_cli': project.preferred_cli or "claude", - 'fallback_enabled': project.fallback_enabled if project.fallback_enabled is not None else True, - 'selected_model': project.selected_model - } + # Extract project info (with validated repo_path) to avoid DetachedInstanceError + project_info = build_project_info(project, db) # Add background task for chat (same as act but with different event type) background_tasks.add_task( @@ -719,4 +894,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/chat/conversations.py b/apps/api/app/api/chat/conversations.py new file mode 100644 index 00000000..2b495714 --- /dev/null +++ b/apps/api/app/api/chat/conversations.py @@ -0,0 +1,68 @@ +"""Conversation summary endpoints for chat sidebar.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.models.sessions import Session as ChatSession +from app.models.messages import Message as MessageModel +from app.services.chat.conversation_summary import get_conversation_summaries + +router = APIRouter() + + +class ConversationPinUpdate(BaseModel): + pinned: bool + + +@router.get("/conversations") +async def list_conversations(search: str = None, db: Session = Depends(get_db)): + """Return unified conversation summaries for all projects with optional full-text search.""" + summaries = get_conversation_summaries(db) + + if not search: + return summaries + + search_lower = search.lower() + filtered = [] + + for summary in summaries: + if (summary.get('summary') and search_lower in summary.get('summary', '').lower()) or \ + (summary.get('first_message') and search_lower in summary.get('first_message', '').lower()) or \ + (summary.get('project_name') and search_lower in summary.get('project_name', '').lower()): + filtered.append(summary) + continue + + messages = db.query(MessageModel).filter( + MessageModel.project_id == summary.get('project_id'), + MessageModel.conversation_id == summary.get('conversation_id') + ).all() + + for msg in messages: + if msg.content and search_lower in msg.content.lower(): + filtered.append(summary) + break + + return filtered + + +@router.patch("/conversations/{conversation_id}/pin") +async def update_conversation_pin( + conversation_id: str, + body: ConversationPinUpdate, + db: Session = Depends(get_db), +): + session = db.query(ChatSession).filter(ChatSession.id == conversation_id).one_or_none() + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + session.pinned = bool(body.pinned) + db.add(session) + db.commit() + + return {"conversation_id": conversation_id, "pinned": session.pinned} + + +__all__ = ["router"] diff --git a/apps/api/app/api/chat/tokens.py b/apps/api/app/api/chat/tokens.py new file mode 100644 index 00000000..fe76a61a --- /dev/null +++ b/apps/api/app/api/chat/tokens.py @@ -0,0 +1,25 @@ +"""Token usage endpoints for conversations.""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.services.token_tracker import token_tracker + +router = APIRouter() + + +@router.get("/{conversation_id}/tokens") +async def get_conversation_tokens(conversation_id: str, db: Session = Depends(get_db)): + """Get real token usage for a conversation""" + totals = token_tracker.get_conversation_totals(conversation_id, db) + context_size = token_tracker.get_real_context_size(conversation_id, db) + + return { + "conversation_id": conversation_id, + "input_tokens": totals["input_tokens"], + "output_tokens": totals["output_tokens"], + "total_tokens": totals["total_tokens"], + "context_size": context_size, + "total_cost": totals["total_cost"], + "sessions": totals["session_count"] + } \ No newline at end of file diff --git a/apps/api/app/api/claude_conversations.py b/apps/api/app/api/claude_conversations.py new file mode 100644 index 00000000..ffd6fc30 --- /dev/null +++ b/apps/api/app/api/claude_conversations.py @@ -0,0 +1,98 @@ +"""Claude CLI conversation synchronisation endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.models.messages import Message +from app.services.chat.conversation_summary import get_conversation_summaries +from app.services.claude.conversation_sync import ClaudeConversationSync + +router = APIRouter() + + +@router.post("/claude-conversations/sync") +async def sync_claude_conversations( + force: bool = Query(True, description="Re-import conversations even if they exist"), + db: Session = Depends(get_db), +): + """Trigger a sync of Claude CLI logs into the database.""" + syncer = ClaudeConversationSync(db) + result = syncer.sync(force=force) + return result + + +@router.get("/claude-conversations") +async def list_claude_conversations(db: Session = Depends(get_db)): + """Return conversations grouped by project for legacy compatibility.""" + summaries = get_conversation_summaries(db) + + grouped: dict[str, dict] = {} + for summary in summaries: + project_id = summary["project_id"] + project_group = grouped.setdefault( + project_id, + { + "project_id": project_id, + "project_name": summary.get("project_name"), + "project_path": summary.get("project_path"), + "conversations": [], + }, + ) + project_group["conversations"].append( + { + "id": summary["conversation_id"], + "summary": summary.get("summary"), + "first_message": summary.get("first_message"), + "timestamp": summary.get("last_message_at"), + "cli_type": summary.get("cli_type"), + "source": summary.get("source"), + } + ) + + # Sort conversations within each project by timestamp desc + for project_data in grouped.values(): + project_data["conversations"].sort( + key=lambda item: item.get("timestamp") or "", + reverse=True, + ) + + # Keep backwards-compatible shape (user = global projects list) + return { + "user": list(grouped.values()), + "project": [], + } + + +@router.get("/claude-conversations/{conversation_id}") +async def get_claude_conversation_detail( + conversation_id: str, + project_id: str | None = Query(None, description="Project ID owning the conversation"), + db: Session = Depends(get_db), +): + """Return the full transcript for a conversation sourced from the database.""" + query = db.query(Message).filter(Message.conversation_id == conversation_id) + if project_id: + query = query.filter(Message.project_id == project_id) + + messages = query.order_by(Message.created_at.asc()).all() + if not messages: + raise HTTPException(status_code=404, detail="Conversation not found") + + return { + "conversation_id": conversation_id, + "project_id": messages[0].project_id, + "messages": [ + { + "id": message.id, + "role": message.role, + "content": message.content, + "metadata_json": message.metadata_json, + "created_at": message.created_at.isoformat() if message.created_at else None, + "message_type": message.message_type, + "cli_source": message.cli_source, + } + for message in messages + ], + } diff --git a/apps/api/app/api/claude_files.py b/apps/api/app/api/claude_files.py new file mode 100644 index 00000000..f68fbf17 --- /dev/null +++ b/apps/api/app/api/claude_files.py @@ -0,0 +1,92 @@ +"""Endpoints for managing Claude CLI agent and command files.""" +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.api.deps import get_db # noqa: F401 # Needed for consistency with other modules + +router = APIRouter() + +CLAUDE_ROOT = Path.home() / ".claude" +RESOURCE_DIRS = { + "agents": CLAUDE_ROOT / "agents", + "commands": CLAUDE_ROOT / "commands", +} + + +class ClaudeFileResponse(BaseModel): + name: str + path: str + absolute_path: str + updated_at: float | None = None + + +class ClaudeFileContent(BaseModel): + content: str + + +def _resolve_resource_path(resource: Literal["agents", "commands"], filename: str | Path) -> Path: + base_dir = RESOURCE_DIRS[resource] + target = (base_dir / filename).resolve() + if not str(target).startswith(str(base_dir.resolve())): + raise HTTPException(status_code=400, detail="Invalid file path") + return target + + +def _list_resource(resource: Literal["agents", "commands"]) -> list[ClaudeFileResponse]: + base_dir = RESOURCE_DIRS[resource] + if not base_dir.exists(): + return [] + + files: list[ClaudeFileResponse] = [] + for child in sorted(base_dir.iterdir()): + # Only include .md files + if child.is_file() and child.suffix == '.md': + stat = child.stat() + files.append( + ClaudeFileResponse( + name=child.name, + path=child.relative_to(base_dir).as_posix(), + absolute_path=str(child), + updated_at=stat.st_mtime if stat else None, + ) + ) + return files + + +@router.get("/claude/{resource}", response_model=list[ClaudeFileResponse]) +async def list_claude_files(resource: Literal["agents", "commands"]): + """List Claude agent or command files.""" + return _list_resource(resource) + + +@router.get("/claude/{resource}/{file_path:path}", response_model=ClaudeFileContent) +async def read_claude_file(resource: Literal["agents", "commands"], file_path: str): + target = _resolve_resource_path(resource, file_path) + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="File not found") + try: + return ClaudeFileContent(content=target.read_text(encoding="utf-8")) + except UnicodeDecodeError: + raise HTTPException(status_code=400, detail="File is not UTF-8 encoded") + + +@router.put("/claude/{resource}/{file_path:path}", response_model=ClaudeFileContent) +async def update_claude_file( + resource: Literal["agents", "commands"], + file_path: str, + body: ClaudeFileContent, +): + target = _resolve_resource_path(resource, file_path) + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + target.write_text(body.content, encoding="utf-8") + return body + + +__all__ = ["router"] 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/openapi_docs.py b/apps/api/app/api/openapi_docs.py new file mode 100644 index 00000000..9a5f0637 --- /dev/null +++ b/apps/api/app/api/openapi_docs.py @@ -0,0 +1,204 @@ +""" +OpenAPI Documentation and Configuration +Enhances API routes with detailed descriptions, examples, and tags +""" +from typing import Any, Dict +from fastapi import FastAPI + +# Tag descriptions for better organization +TAGS_METADATA = [ + { + "name": "projects", + "description": "Operations for managing projects including creation, updates, and status tracking", + }, + { + "name": "chat", + "description": "WebSocket and REST endpoints for chat sessions with Claude and other AI models", + }, + { + "name": "commits", + "description": "Git commit management and tracking", + }, + { + "name": "environment", + "description": "Environment variable management for projects", + }, + { + "name": "assets", + "description": "File and asset management within projects", + }, + { + "name": "tokens", + "description": "Service token management for external integrations", + }, + { + "name": "settings", + "description": "Application and project settings", + }, + { + "name": "github", + "description": "GitHub integration endpoints for repositories and authentication", + }, + { + "name": "vercel", + "description": "Vercel deployment and project management", + }, + { + "name": "claude", + "description": "Claude conversation and file management", + }, + { + "name": "health", + "description": "Health check and monitoring endpoints", + } +] + +def configure_openapi(app: FastAPI) -> None: + """Configure OpenAPI with enhanced documentation""" + + # Update the OpenAPI schema with tags + app.openapi_tags = TAGS_METADATA + + # Store original openapi method + original_openapi = app.openapi + + # Add custom OpenAPI configuration + def custom_openapi() -> Dict[str, Any]: + if app.openapi_schema: + return app.openapi_schema + + # Call the original openapi method, not the custom one + openapi_schema = original_openapi() + + # Add server information + openapi_schema["servers"] = [ + {"url": "http://localhost:8000", "description": "Development server"}, + {"url": "http://127.0.0.1:8000", "description": "Local server"} + ] + + # Add external documentation + openapi_schema["externalDocs"] = { + "description": "Clovable API Documentation", + "url": "https://github.com/your-org/clovable" + } + + # Add security schemes if needed + if "components" not in openapi_schema: + openapi_schema["components"] = {} + if "securitySchemes" not in openapi_schema["components"]: + openapi_schema["components"]["securitySchemes"] = {} + + openapi_schema["components"]["securitySchemes"]["bearerAuth"] = { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT token authentication" + } + + # Cache the schema + app.openapi_schema = openapi_schema + return openapi_schema + + app.openapi = custom_openapi + +# Response examples for common models +PROJECT_EXAMPLE = { + "id": "my-awesome-project", + "name": "My Awesome Project", + "description": "A web application built with React and FastAPI", + "status": "active", + "preview_url": "http://localhost:3000", + "created_at": "2024-01-01T00:00:00Z", + "last_active_at": "2024-01-01T12:00:00Z", + "services": { + "github": {"connected": True, "status": "connected"}, + "vercel": {"connected": False, "status": "disconnected"} + }, + "features": ["Authentication", "Real-time chat", "File upload"], + "tech_stack": ["React", "TypeScript", "FastAPI", "PostgreSQL"], + "initial_prompt": "Build a modern web application", + "preferred_cli": "claude", + "selected_model": "claude-3-opus" +} + +MESSAGE_EXAMPLE = { + "id": "msg_123", + "project_id": "my-awesome-project", + "role": "user", + "content": "Can you help me add authentication?", + "created_at": "2024-01-01T12:00:00Z", + "metadata": { + "model": "claude-3-opus", + "tokens": 150 + } +} + +def get_operation_metadata(path: str, method: str) -> Dict[str, Any]: + """Get enhanced operation metadata for specific endpoints""" + + operations = { + ("/api/projects", "get"): { + "summary": "List all projects", + "description": "Retrieve a list of all projects with their current status, service connections, and metadata", + "responses": { + 200: { + "description": "List of projects retrieved successfully", + "content": { + "application/json": { + "example": [PROJECT_EXAMPLE] + } + } + } + } + }, + ("/api/projects", "post"): { + "summary": "Create a new project", + "description": "Create a new project with specified configuration. This will initialize the project directory and set up the development environment.", + "responses": { + 200: { + "description": "Project created successfully", + "content": { + "application/json": { + "example": PROJECT_EXAMPLE + } + } + }, + 409: { + "description": "Project with this ID already exists" + } + } + }, + ("/api/projects/{project_id}", "get"): { + "summary": "Get project details", + "description": "Retrieve detailed information about a specific project including its configuration, status, and connected services", + "responses": { + 200: { + "description": "Project details retrieved successfully", + "content": { + "application/json": { + "example": PROJECT_EXAMPLE + } + } + }, + 404: { + "description": "Project not found" + } + } + }, + ("/api/chat/{project_id}", "websocket"): { + "summary": "WebSocket chat endpoint", + "description": "Establish a WebSocket connection for real-time chat with AI models. Supports streaming responses and tool calls.", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + "description": "The project ID to establish chat session for" + } + ] + } + } + + key = (path, method.lower()) + return operations.get(key, {}) \ No newline at end of file diff --git a/apps/api/app/api/projects/__init__.py b/apps/api/app/api/projects/__init__.py index 61ab984e..915ea3e1 100644 --- a/apps/api/app/api/projects/__init__.py +++ b/apps/api/app/api/projects/__init__.py @@ -7,6 +7,9 @@ from .crud import router as crud_router from .preview import router as preview_router from .system_prompt import router as system_prompt_router +from .mcp import router as mcp_router +from .import_ import router as import_router +from .git import router as git_router # Create main projects router (prefix will be added in main.py) @@ -15,4 +18,7 @@ # Include sub-routers without additional prefix router.include_router(crud_router, tags=["projects"]) router.include_router(preview_router, tags=["projects"]) -router.include_router(system_prompt_router, tags=["projects"]) \ No newline at end of file +router.include_router(system_prompt_router, tags=["projects"]) +router.include_router(mcp_router, tags=["projects", "mcp"]) +router.include_router(import_router, tags=["projects", "import"]) +router.include_router(git_router, tags=["projects", "git"]) \ No newline at end of file diff --git a/apps/api/app/api/projects/crud.py b/apps/api/app/api/projects/crud.py index 78e70708..0f91d2c0 100644 --- a/apps/api/app/api/projects/crud.py +++ b/apps/api/app/api/projects/crud.py @@ -18,12 +18,63 @@ from app.models.messages import Message from app.models.project_services import ProjectServiceConnection from app.models.sessions import Session as SessionModel +from app.models.mcp_servers import MCPServer from app.services.project.initializer import initialize_project from app.core.websocket.manager import manager as websocket_manager +from app.services.local_runtime import get_npm_executable # Project ID validation regex PROJECT_ID_REGEX = re.compile(r"^[a-z0-9-]{3,}$") +# Default MCP servers to seed for new projects +DEFAULT_MCP_SERVERS = [ + { + "name": "Memory Server", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "scope": "project", + "is_active": False, + }, + { + "name": "Fetch MCP", + "transport": "stdio", + "command": "npx", + "args": ["-y", "fetch-mcp"], + "scope": "project", + "is_active": False, + }, + { + "name": "Filesystem Server", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "scope": "project", + "is_active": False, + }, +] + +def _seed_default_mcp_servers(db: Session, project_id: str): + """Seed default MCP servers for a new project""" + try: + for server_config in DEFAULT_MCP_SERVERS: + server = MCPServer( + project_id=project_id, + name=server_config["name"], + transport=server_config["transport"], + command=server_config["command"], + args=server_config["args"], + scope=server_config["scope"], + is_active=server_config["is_active"], + status={"running": False} + ) + db.add(server) + db.commit() + print(f"✓ Seeded {len(DEFAULT_MCP_SERVERS)} default MCP servers for project {project_id}") + except Exception as e: + print(f"Warning: Failed to seed MCP servers for project {project_id}: {str(e)}") + db.rollback() + # Pydantic models class ProjectCreate(BaseModel): project_id: str = Field(..., pattern=PROJECT_ID_REGEX.pattern) @@ -48,6 +99,7 @@ class Project(BaseModel): description: Optional[str] = None status: str = "idle" preview_url: Optional[str] = None + repo_path: Optional[str] = None created_at: datetime last_active_at: Optional[datetime] = None last_message_at: Optional[datetime] = None @@ -152,33 +204,45 @@ 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 + + npm_cmd = get_npm_executable() process = await asyncio.create_subprocess_exec( - "npm", "install", + npm_cmd, "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}") -@router.post("/{project_id}/install-dependencies") +@router.post( + "/{project_id}/install-dependencies", + summary="Install dependencies", + description="Trigger background installation of project dependencies (npm packages). The installation runs asynchronously and progress is reported via WebSocket.", + response_description="Installation status", + responses={ + 200: {"description": "Installation started successfully"}, + 404: {"description": "Project not found"}, + 400: {"description": "Project repository path not found"} + }, + tags=["projects"] +) async def install_project_dependencies( project_id: str, background_tasks: BackgroundTasks, @@ -200,14 +264,26 @@ async def install_project_dependencies( return {"message": "Dependency installation started in background", "project_id": project_id} -@router.get("/health") +@router.get( + "/health", + summary="Projects health check", + description="Check if the projects router is operational", + tags=["health"] +) async def projects_health(): """Simple health check for projects router""" return {"status": "ok", "router": "projects"} -@router.get("/", response_model=List[Project]) +@router.get( + "/", + response_model=List[Project], + summary="List all projects", + description="Retrieve a complete list of all projects with their current status, service connections, and metadata including features, tech stack, and last activity timestamps.", + response_description="List of projects with full details", + tags=["projects"] +) async def list_projects(db: Session = Depends(get_db)) -> List[Project]: """List all projects with their status and last activity""" @@ -263,6 +339,7 @@ async def list_projects(db: Session = Depends(get_db)) -> List[Project]: description=ai_info.get('description'), status=project.status or "idle", preview_url=project.preview_url, + repo_path=project.repo_path, created_at=project.created_at, last_active_at=project.last_active_at, last_message_at=last_message_at, @@ -278,7 +355,18 @@ async def list_projects(db: Session = Depends(get_db)) -> List[Project]: return result -@router.get("/{project_id}", response_model=Project) +@router.get( + "/{project_id}", + response_model=Project, + summary="Get project details", + description="Retrieve detailed information about a specific project including configuration, status, connected services, and AI-generated metadata.", + response_description="Project details", + responses={ + 404: {"description": "Project not found"}, + 500: {"description": "Database error"} + }, + tags=["projects"] +) async def get_project(project_id: str, db: Session = Depends(get_db)) -> Project: """Get a specific project by ID""" @@ -303,7 +391,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 @@ -311,7 +401,20 @@ async def get_project(project_id: str, db: Session = Depends(get_db)) -> Project raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") -@router.post("/", response_model=Project) +@router.post( + "/", + response_model=Project, + status_code=201, + summary="Create a new project", + description="Create a new project with the specified configuration. This initializes the project directory structure and sets up the development environment. The project ID must be lowercase with hyphens only.", + response_description="Newly created project", + responses={ + 201: {"description": "Project created successfully"}, + 409: {"description": "Project with this ID already exists"}, + 400: {"description": "Invalid project ID format"} + }, + tags=["projects"] +) async def create_project( body: ProjectCreate, db: Session = Depends(get_db) @@ -354,7 +457,10 @@ async def create_project( db.add(project) db.commit() db.refresh(project) - + + # Seed default MCP servers for new project + _seed_default_mcp_servers(db, project.id) + # Send immediate status update await websocket_manager.broadcast_to_project(project.id, { "type": "project_status", @@ -388,10 +494,21 @@ async def create_project( ) -@router.put("/{project_id}", response_model=Project) +@router.put( + "/{project_id}", + response_model=Project, + summary="Update project", + description="Update an existing project's name and other configurable properties.", + response_description="Updated project details", + responses={ + 200: {"description": "Project updated successfully"}, + 404: {"description": "Project not found"} + }, + tags=["projects"] +) async def update_project( - project_id: str, - body: ProjectUpdate, + project_id: str, + body: ProjectUpdate, db: Session = Depends(get_db) ) -> Project: """Update a project""" @@ -452,7 +569,17 @@ async def update_project( ) -@router.delete("/{project_id}") +@router.delete( + "/{project_id}", + summary="Delete project", + description="Delete a project and all its associated data including messages, sessions, and service connections. This action cannot be undone.", + response_description="Confirmation message", + responses={ + 200: {"description": "Project deleted successfully"}, + 404: {"description": "Project not found"} + }, + tags=["projects"] +) async def delete_project(project_id: str, db: Session = Depends(get_db)): """Delete a project""" @@ -484,4 +611,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/projects/git.py b/apps/api/app/api/projects/git.py new file mode 100644 index 00000000..489744b6 --- /dev/null +++ b/apps/api/app/api/projects/git.py @@ -0,0 +1,169 @@ +"""Git operations endpoints for projects.""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +from typing import Optional + +from app.api.deps import get_db +from app.models.projects import Project as ProjectModel +from app.services import git_ops + +router = APIRouter() + + +class GitCommitRequest(BaseModel): + message: str + + +class GitPushRequest(BaseModel): + remote: str = "origin" + branch: Optional[str] = None + + +class GitPullRequest(BaseModel): + remote: str = "origin" + branch: Optional[str] = None + + +class GitBranchRequest(BaseModel): + branch_name: str + checkout: bool = True + + +@router.get("/{project_id}/git/status") +async def get_git_status(project_id: str, db: Session = Depends(get_db)): + """Get git status for a project""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + status = git_ops.get_status(project.repo_path) + return status + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{project_id}/git/branches") +async def get_git_branches(project_id: str, db: Session = Depends(get_db)): + """Get git branches for a project""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + branches = git_ops.get_branches(project.repo_path) + return branches + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{project_id}/git/commit") +async def commit_changes( + project_id: str, + request: GitCommitRequest, + db: Session = Depends(get_db) +): + """Commit all changes""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + commit_sha = git_ops.commit_all(project.repo_path, request.message) + return {"message": "Changes committed successfully", "commit_sha": commit_sha} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{project_id}/git/push") +async def push_changes( + project_id: str, + request: GitPushRequest, + db: Session = Depends(get_db) +): + """Push changes to remote""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + result = git_ops.push(project.repo_path, request.remote, request.branch) + return {"message": "Changes pushed successfully", "output": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{project_id}/git/pull") +async def pull_changes( + project_id: str, + request: GitPullRequest, + db: Session = Depends(get_db) +): + """Pull changes from remote""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + result = git_ops.pull(project.repo_path, request.remote, request.branch) + return {"message": "Changes pulled successfully", "output": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{project_id}/git/branch") +async def create_git_branch( + project_id: str, + request: GitBranchRequest, + db: Session = Depends(get_db) +): + """Create a new branch""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + result = git_ops.create_branch(project.repo_path, request.branch_name, request.checkout) + return {"message": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{project_id}/git/checkout/{branch_name}") +async def checkout_git_branch( + project_id: str, + branch_name: str, + db: Session = Depends(get_db) +): + """Checkout a branch""" + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.repo_path: + raise HTTPException(status_code=400, detail="Project has no repository path") + + try: + result = git_ops.checkout_branch(project.repo_path, branch_name) + return {"message": f"Switched to branch '{branch_name}'", "output": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/apps/api/app/api/projects/import_.py b/apps/api/app/api/projects/import_.py new file mode 100644 index 00000000..a9bae092 --- /dev/null +++ b/apps/api/app/api/projects/import_.py @@ -0,0 +1,125 @@ +"""Project import endpoints.""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +from typing import List, Optional +import uuid +from datetime import datetime + +from app.api.deps import get_db +from app.models.projects import Project as ProjectModel +from app.services.project_scanner import project_scanner + +router = APIRouter() + + +class ImportableProject(BaseModel): + path: str + name: str + type: str + description: Optional[str] + tech_stack: List[str] + has_git: bool + sessions_count: int + created_at: str + + +class ProjectImportRequest(BaseModel): + path: str + name: Optional[str] = None + description: Optional[str] = None + + +@router.get("/scan-claude-projects", response_model=List[ImportableProject]) +async def scan_claude_projects(): + """Scan for existing Claude projects in ~/.claude/projects""" + projects = project_scanner.scan_claude_projects() + + result = [] + for project in projects: + metadata = project_scanner.get_project_metadata(project.path) + + result.append(ImportableProject( + path=project.path, + name=project.name, + type=metadata.get("type", "unknown"), + description=metadata.get("description", ""), + tech_stack=metadata.get("tech_stack", []), + has_git=metadata.get("has_git", False), + sessions_count=len(project.sessions), + created_at=project.created_at.isoformat() + )) + + return result + + +@router.get("/scan-directory/{path:path}", response_model=List[ImportableProject]) +async def scan_directory(path: str): + """Scan a directory for potential projects""" + projects = project_scanner.scan_directory_for_projects(path) + + result = [] + for project in projects: + metadata = project_scanner.get_project_metadata(project.path) + + result.append(ImportableProject( + path=project.path, + name=project.name, + type=metadata.get("type", "unknown"), + description=metadata.get("description", ""), + tech_stack=metadata.get("tech_stack", []), + has_git=metadata.get("has_git", False), + sessions_count=len(project.sessions), + created_at=project.created_at.isoformat() + )) + + return result + + +@router.post("/import") +async def import_project( + import_request: ProjectImportRequest, + db: Session = Depends(get_db) +): + """Import an existing project""" + # Check if project already exists + existing = db.query(ProjectModel).filter( + ProjectModel.repo_path == import_request.path + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Project already imported") + + # Get project metadata + metadata = project_scanner.get_project_metadata(import_request.path) + + # Create project + project_id = f"project-{int(datetime.now().timestamp() * 1000)}-{uuid.uuid4().hex[:8]}" + + project = ProjectModel( + id=project_id, + name=import_request.name or metadata["name"], + description=import_request.description or metadata.get("description"), + repo_path=import_request.path, + status="active", + preferred_cli="claude", # Default to Claude + settings={ + "imported": True, + "original_type": metadata.get("type"), + "tech_stack": metadata.get("tech_stack", []), + "has_git": metadata.get("has_git", False) + } + ) + + db.add(project) + db.commit() + db.refresh(project) + + ui.success(f"Imported project: {project.name} from {import_request.path}", "Import") + + return { + "project_id": project.id, + "name": project.name, + "path": project.repo_path, + "message": "Project imported successfully" + } \ No newline at end of file diff --git a/apps/api/app/api/projects/mcp.py b/apps/api/app/api/projects/mcp.py new file mode 100644 index 00000000..b58d057f --- /dev/null +++ b/apps/api/app/api/projects/mcp.py @@ -0,0 +1,346 @@ +"""MCP Server management endpoints.""" +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, validator, Field +from sqlalchemy.orm import Session +from typing import List, Optional, Dict, Any +import logging + +from app.api.deps import get_db +from app.models.projects import Project as ProjectModel +from app.models.mcp_servers import MCPServer as MCPServerModel +from app.services.mcp.manager import mcp_manager + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class MCPServerCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255, description="Server name") + transport: str = Field(..., pattern="^(stdio|sse)$", description="Transport type: stdio or sse") + command: Optional[str] = Field(None, max_length=512, description="Command for stdio transport") + args: Optional[List[str]] = Field(None, description="Arguments for stdio transport") + url: Optional[str] = Field(None, max_length=512, description="URL for SSE transport") + env: Optional[Dict[str, str]] = Field(None, description="Environment variables") + scope: str = Field("project", pattern="^(user|project)$", description="Scope: user or project") + is_active: bool = Field(False, description="Whether server should start automatically") + + @validator('name') + def validate_name(cls, v): + if not v.strip(): + raise ValueError('Server name cannot be empty') + # Prevent special characters that could cause issues + if any(c in v for c in ['/', '\\', '..', '<', '>', '|', '&', ';']): + raise ValueError('Server name contains invalid characters') + return v.strip() + + @validator('command') + def validate_command(cls, v, values): + if values.get('transport') == 'stdio' and not v: + raise ValueError('Command is required for stdio transport') + # Basic command validation - only allow known safe commands + if v: + cmd = v.strip().split()[0] if v.strip() else '' + allowed_commands = ['node', 'python', 'python3', 'npx', 'uvx'] + if cmd not in allowed_commands: + logger.warning(f"Command '{cmd}' is not in allowed list: {allowed_commands}") + return v + + @validator('url') + def validate_url(cls, v, values): + if values.get('transport') == 'sse' and not v: + raise ValueError('URL is required for SSE transport') + if v and not v.startswith(('http://', 'https://')): + raise ValueError('URL must start with http:// or https://') + return v + + +class MCPServerUpdate(BaseModel): + name: Optional[str] = None + transport: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + url: Optional[str] = None + env: Optional[Dict[str, str]] = None + scope: Optional[str] = None + is_active: Optional[bool] = None + + +class MCPServerResponse(BaseModel): + id: int + name: str + transport: str + command: Optional[str] + args: Optional[List[str]] + url: Optional[str] + env: Optional[Dict[str, str]] + scope: str + is_active: bool + status: Optional[Dict[str, Any]] + + +@router.get("/{project_id}/mcp", response_model=List[MCPServerResponse]) +async def list_mcp_servers(project_id: str, db: Session = Depends(get_db)): + """List MCP servers for a project.""" + try: + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + logger.warning(f"Project not found: {project_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + servers = db.query(MCPServerModel).filter(MCPServerModel.project_id == project_id).all() + logger.info(f"Listed {len(servers)} MCP servers for project {project_id}") + + return [ + MCPServerResponse( + id=server.id, + name=server.name, + transport=server.transport, + command=server.command, + args=server.args, + url=server.url, + env=server.env, + scope=server.scope, + is_active=server.is_active, + status=server.status + ) + for server in servers + ] + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing MCP servers for project {project_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to list MCP servers" + ) + + +@router.post("/{project_id}/mcp", response_model=MCPServerResponse, status_code=status.HTTP_201_CREATED) +async def create_mcp_server( + project_id: str, + server_data: MCPServerCreate, + db: Session = Depends(get_db) +): + """Create a new MCP server for a project.""" + try: + project = db.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if not project: + logger.warning(f"Project not found: {project_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + # Check for duplicate server names within the project + existing = db.query(MCPServerModel).filter( + MCPServerModel.project_id == project_id, + MCPServerModel.name == server_data.name + ).first() + + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"MCP server with name '{server_data.name}' already exists for this project" + ) + + # Additional validation is done by Pydantic model + server = MCPServerModel( + project_id=project_id, + name=server_data.name, + transport=server_data.transport, + command=server_data.command, + args=server_data.args, + url=server_data.url, + env=server_data.env, + scope=server_data.scope, + is_active=server_data.is_active, + status={"running": False} + ) + + db.add(server) + db.commit() + db.refresh(server) + + logger.info(f"Created MCP server '{server.name}' for project {project_id}") + + return MCPServerResponse( + id=server.id, + name=server.name, + transport=server.transport, + command=server.command, + args=server.args, + url=server.url, + env=server.env, + scope=server.scope, + is_active=server.is_active, + status=server.status + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating MCP server for project {project_id}: {str(e)}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create MCP server" + ) + + +@router.put("/{project_id}/mcp/{server_id}", response_model=MCPServerResponse) +async def update_mcp_server( + project_id: str, + server_id: int, + server_data: MCPServerUpdate, + db: Session = Depends(get_db) +): + """Update an MCP server.""" + server = db.query(MCPServerModel).filter( + MCPServerModel.id == server_id, + MCPServerModel.project_id == project_id + ).first() + + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + # Update fields + for field, value in server_data.dict(exclude_unset=True).items(): + setattr(server, field, value) + + db.commit() + db.refresh(server) + + return MCPServerResponse( + id=server.id, + name=server.name, + transport=server.transport, + command=server.command, + args=server.args, + url=server.url, + env=server.env, + scope=server.scope, + is_active=server.is_active, + status=server.status + ) + + +@router.delete("/{project_id}/mcp/{server_id}") +async def delete_mcp_server( + project_id: str, + server_id: int, + db: Session = Depends(get_db) +): + """Delete an MCP server.""" + server = db.query(MCPServerModel).filter( + MCPServerModel.id == server_id, + MCPServerModel.project_id == project_id + ).first() + + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + db.delete(server) + db.commit() + + return {"message": "MCP server deleted successfully"} + + +@router.post("/{project_id}/mcp/{server_id}/start") +async def start_mcp_server( + project_id: str, + server_id: int, + db: Session = Depends(get_db) +): + """Start an MCP server.""" + try: + server = db.query(MCPServerModel).filter( + MCPServerModel.id == server_id, + MCPServerModel.project_id == project_id + ).first() + + if not server: + logger.warning(f"MCP server not found: {server_id} for project {project_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="MCP server not found") + + logger.info(f"Starting MCP server: {server.name} (ID: {server_id})") + success = await mcp_manager.start_server(server, db) + + if success: + logger.info(f"MCP server {server.name} started successfully") + return {"message": f"MCP server {server.name} started successfully", "status": server.status} + else: + error_msg = server.status.get("error", "Unknown error") if server.status else "Unknown error" + logger.error(f"Failed to start MCP server {server.name}: {error_msg}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to start MCP server: {error_msg}" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error starting MCP server {server_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to start MCP server" + ) + + +@router.post("/{project_id}/mcp/{server_id}/stop") +async def stop_mcp_server( + project_id: str, + server_id: int, + db: Session = Depends(get_db) +): + """Stop an MCP server.""" + try: + server = db.query(MCPServerModel).filter( + MCPServerModel.id == server_id, + MCPServerModel.project_id == project_id + ).first() + + if not server: + logger.warning(f"MCP server not found: {server_id} for project {project_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="MCP server not found") + + logger.info(f"Stopping MCP server: {server.name} (ID: {server_id})") + success = await mcp_manager.stop_server(server_id, db) + + if success: + logger.info(f"MCP server {server.name} stopped successfully") + return {"message": f"MCP server {server.name} stopped successfully", "status": server.status} + else: + logger.error(f"Failed to stop MCP server {server.name}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to stop MCP server" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error stopping MCP server {server_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to stop MCP server" + ) + + +@router.get("/{project_id}/mcp/{server_id}/tools") +async def get_mcp_server_tools( + project_id: str, + server_id: int, + db: Session = Depends(get_db) +): + """Get tools from a running MCP server.""" + server = db.query(MCPServerModel).filter( + MCPServerModel.id == server_id, + MCPServerModel.project_id == project_id + ).first() + + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + running_servers = mcp_manager.get_running_servers() + if server_id in running_servers: + tools = running_servers[server_id].tools + return { + "server_name": server.name, + "tools": [{"name": t.name, "description": t.description, "input_schema": t.input_schema} for t in tools] + } + else: + return {"server_name": server.name, "tools": []} \ No newline at end of file diff --git a/apps/api/app/api/projects/preview.py b/apps/api/app/api/projects/preview.py index 9a989002..0c3049de 100644 --- a/apps/api/app/api/projects/preview.py +++ b/apps/api/app/api/projects/preview.py @@ -2,6 +2,8 @@ Project Preview Management Handles preview server operations for projects """ +import os + from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from typing import Optional @@ -9,6 +11,7 @@ from app.api.deps import get_db from app.models.projects import Project as ProjectModel +from app.core.config import settings from app.services.local_runtime import ( start_preview_process, stop_preview_process, @@ -53,16 +56,31 @@ async def start_preview( # Check if preview is already running status = preview_status(project_id) if status == "running": - # Get existing port from somewhere or use default + # Return stored preview info if available return PreviewStatusResponse( running=True, - port=None, # TODO: Store port information - url=None, + port=project.preview_port, + url=project.preview_url, process_id=None ) + # Ensure project has a repository path + repo_path = project.repo_path + + if not repo_path: + inferred_path = os.path.join(settings.projects_root, project_id, "repo") + if os.path.exists(inferred_path): + project.repo_path = inferred_path + db.commit() + repo_path = inferred_path + else: + raise HTTPException( + status_code=409, + detail="Project repository is not initialized yet. Please wait for project setup to complete." + ) + # Start preview - process_name, port = start_preview_process(project_id, project.repo_path, port=body.port) + process_name, port = start_preview_process(project_id, repo_path, port=body.port) result = { "success": True, "port": port, @@ -76,6 +94,7 @@ async def start_preview( # Update project status project.status = "preview_running" project.preview_url = result.get("url") + project.preview_port = result.get("port") db.commit() return PreviewStatusResponse( @@ -117,6 +136,7 @@ async def stop_preview(project_id: str, db: Session = Depends(get_db)): # Update project status project.status = "idle" project.preview_url = None + project.preview_port = None db.commit() return {"message": "Preview stopped successfully"} @@ -134,8 +154,8 @@ async def get_preview_status(project_id: str, db: Session = Depends(get_db)): return PreviewStatusResponse( running=(status == "running"), - port=None, # TODO: Store port information - url=None, + port=project.preview_port if status == "running" else None, + url=project.preview_url if status == "running" else None, process_id=None, error=None ) @@ -158,7 +178,7 @@ async def get_preview_logs_endpoint( return PreviewLogsResponse( logs=logs, - running=status.get("running", False) + running=(status == "running") ) @@ -180,8 +200,23 @@ async def restart_preview( stop_preview_process(project_id) # No need to check result as stop_preview_process returns None + # Ensure project has a repository path + repo_path = project.repo_path + + if not repo_path: + inferred_path = os.path.join(settings.projects_root, project_id, "repo") + if os.path.exists(inferred_path): + project.repo_path = inferred_path + db.commit() + repo_path = inferred_path + else: + raise HTTPException( + status_code=409, + detail="Project repository is not initialized yet. Please wait for project setup to complete." + ) + # Start preview - process_name, port = start_preview_process(project_id, project.repo_path, port=body.port) + process_name, port = start_preview_process(project_id, repo_path, port=body.port) result = { "success": True, "port": port, @@ -219,4 +254,4 @@ async def get_all_error_logs( # Get all stored logs for this project all_logs = get_all_preview_logs(project_id) - return {"logs": all_logs, "project_id": project_id} \ No newline at end of file + return {"logs": all_logs, "project_id": project_id} diff --git a/apps/api/app/api/settings.py b/apps/api/app/api/settings.py index 248b0eed..d78f2d24 100644 --- a/apps/api/app/api/settings.py +++ b/apps/api/app/api/settings.py @@ -4,11 +4,12 @@ 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"]) -# CLI 옵션과 체크 명령어 정의 +# Define CLI options and check commands CLI_OPTIONS = [ { "id": "claude", @@ -30,9 +31,9 @@ class CLIStatusResponse(BaseModel): async def check_cli_installation(cli_id: str, command: list) -> CLIStatusResponse: - """단일 CLI의 설치 상태를 확인합니다.""" + """Check the installation status of a single CLI.""" try: - # subprocess를 비동기로 실행 + # Run subprocess asynchronously process = await asyncio.create_subprocess_exec( *command, stdout=asyncio.subprocess.PIPE, @@ -42,9 +43,9 @@ async def check_cli_installation(cli_id: str, command: list) -> CLIStatusRespons stdout, stderr = await process.communicate() if process.returncode == 0: - # 성공적으로 실행된 경우 + # Successfully executed version_output = stdout.decode().strip() - # 버전 정보에서 실제 버전 번호 추출 (첫 번째 라인만 사용) + # Extract actual version number from version info (use first line only) version = version_output.split('\n')[0] if version_output else "installed" return CLIStatusResponse( @@ -53,7 +54,7 @@ async def check_cli_installation(cli_id: str, command: list) -> CLIStatusRespons version=version ) else: - # 명령어 실행은 되었지만 에러 리턴 코드 + # Command executed but returned error code error_msg = stderr.decode().strip() if stderr else f"Command failed with code {process.returncode}" return CLIStatusResponse( cli_id=cli_id, @@ -62,14 +63,14 @@ async def check_cli_installation(cli_id: str, command: list) -> CLIStatusRespons ) except FileNotFoundError: - # 명령어를 찾을 수 없는 경우 (설치되지 않음) + # Command not found (not installed) return CLIStatusResponse( cli_id=cli_id, installed=False, error="Command not found" ) except Exception as e: - # 기타 예외 + # Other exceptions return CLIStatusResponse( cli_id=cli_id, installed=False, @@ -79,29 +80,35 @@ async def check_cli_installation(cli_id: str, command: list) -> CLIStatusRespons @router.get("/cli-status") async def get_cli_status() -> Dict[str, Any]: - """모든 CLI의 설치 상태를 확인하고 반환합니다.""" + """Check and return the installation status of all CLIs.""" results = {} - # 새로운 UnifiedCLIManager의 CLI 인스턴스 사용 - from app.services.cli.unified_manager import ClaudeCodeCLI, CursorAgentCLI + # Use CLI instances from the new UnifiedCLIManager + 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를 병렬로 확인 + # Check all CLIs in parallel 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)) - # 모든 태스크 실행 + # Execute all tasks cli_results = await asyncio.gather(*tasks) - # 결과를 딕셔너리로 변환 + # Convert results to dictionary for cli_id, status in cli_results: results[cli_id] = { "installed": status.get("available", False) and status.get("configured", False), @@ -113,11 +120,11 @@ async def check_cli(cli_id, cli_instance): return results -# 글로벌 설정 관리를 위한 임시 메모리 저장소 (실제로는 데이터베이스에 저장해야 함) +# Temporary memory storage for global settings management (should be stored in database in production) GLOBAL_SETTINGS = { "default_cli": "claude", "cli_settings": { - "claude": {"model": "claude-sonnet-4"}, + "claude": {"model": "claude-sonnet-4.5"}, "cursor": {"model": "gpt-5"} } } @@ -129,13 +136,13 @@ class GlobalSettingsModel(BaseModel): @router.get("/global") async def get_global_settings() -> Dict[str, Any]: - """글로벌 설정을 반환합니다.""" + """Return global settings.""" return GLOBAL_SETTINGS @router.put("/global") async def update_global_settings(settings: GlobalSettingsModel) -> Dict[str, Any]: - """글로벌 설정을 업데이트합니다.""" + """Update global settings.""" global GLOBAL_SETTINGS GLOBAL_SETTINGS.update({ @@ -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/core/config.py b/apps/api/app/core/config.py index b7dfcad1..080c6cf1 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -46,6 +46,15 @@ class Settings(BaseModel): preview_port_start: int = int(os.getenv("PREVIEW_PORT_START", "3100")) preview_port_end: int = int(os.getenv("PREVIEW_PORT_END", "3999")) - - -settings = Settings() \ No newline at end of file + @property + def preview_port_fixed(self) -> int | None: + val = os.getenv("PREVIEW_PORT_FIXED") + if val and val.strip().lower() in {"", "auto", "none"}: + return None + elif val: + return int(val) + else: + return 3100 + + +settings = Settings() diff --git a/apps/api/app/db/migrations.py b/apps/api/app/db/migrations.py new file mode 100644 index 00000000..4fe5c914 --- /dev/null +++ b/apps/api/app/db/migrations.py @@ -0,0 +1,102 @@ +"""Database migrations module for SQLite.""" + +import logging + +from sqlalchemy import inspect, text +from sqlalchemy.engine import Engine + +logger = logging.getLogger(__name__) + + +def run_sqlite_migrations(engine: Engine | None = None) -> None: + """Run lightweight SQLite migrations.""" + + if engine is None: + logger.info("No engine supplied for migrations; skipping") + return + + inspector = inspect(engine) + + # Add `pinned` column to sessions table if missing + session_columns = {column["name"] for column in inspector.get_columns("sessions")} + if "pinned" not in session_columns: + logger.info("Adding `pinned` column to sessions table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE sessions ADD COLUMN pinned BOOLEAN DEFAULT 0")) + + # Add `claude_session_id` column to sessions table if missing + if "claude_session_id" not in session_columns: + logger.info("Adding `claude_session_id` column to sessions table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE sessions ADD COLUMN claude_session_id VARCHAR(128)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS idx_sessions_claude_session_id ON sessions(claude_session_id)")) + + # Add `status` column to sessions table if missing + if "status" not in session_columns: + logger.info("Adding `status` column to sessions table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE sessions ADD COLUMN status VARCHAR(32) DEFAULT 'active'")) + + # Add `conversation_id` column to messages table if missing + message_columns = {column["name"] for column in inspector.get_columns("messages")} + if "conversation_id" not in message_columns: + logger.info("Adding `conversation_id` column to messages table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE messages ADD COLUMN conversation_id VARCHAR(64)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id)")) + + # Add `user_message_id` column to user_requests table if missing + try: + user_request_columns = {column["name"] for column in inspector.get_columns("user_requests")} + if "user_message_id" not in user_request_columns: + logger.info("Adding `user_message_id` column to user_requests table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE user_requests ADD COLUMN user_message_id VARCHAR(64)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS idx_user_requests_user_message_id ON user_requests(user_message_id)")) + connection.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_requests_user_message_id_unique ON user_requests(user_message_id)")) + + if "session_id" not in user_request_columns: + logger.info("Adding `session_id` column to user_requests table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE user_requests ADD COLUMN session_id VARCHAR(64)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS idx_user_requests_session_id ON user_requests(session_id)")) + except Exception: + # Table might not exist yet + pass + + # Add missing columns to project_service_connections table if needed + try: + service_columns = {column["name"] for column in inspector.get_columns("project_service_connections")} + + if "provider" not in service_columns: + logger.info("Adding `provider` column to project_service_connections table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE project_service_connections ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT 'github'")) + + if "status" not in service_columns: + logger.info("Adding `status` column to project_service_connections table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE project_service_connections ADD COLUMN status VARCHAR(32) DEFAULT 'connected'")) + + if "service_data" not in service_columns: + logger.info("Adding `service_data` column to project_service_connections table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE project_service_connections ADD COLUMN service_data JSON")) + + if "last_sync_at" not in service_columns: + logger.info("Adding `last_sync_at` column to project_service_connections table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE project_service_connections ADD COLUMN last_sync_at TIMESTAMP")) + + if "created_at" not in service_columns: + logger.info("Adding `created_at` column to project_service_connections table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE project_service_connections ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")) + + if "updated_at" not in service_columns: + logger.info("Adding `updated_at` column to project_service_connections table") + with engine.begin() as connection: + connection.execute(text("ALTER TABLE project_service_connections ADD COLUMN updated_at TIMESTAMP")) + except Exception: + # Table might not exist yet + pass diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 4f7d22fe..d84045c2 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -2,6 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware from app.api.projects import router as projects_router +from app.api.openapi_docs import configure_openapi, TAGS_METADATA from app.api.repo import router as repo_router from app.api.commits import router as commits_router from app.api.env import router as env_router @@ -12,17 +13,31 @@ from app.api.project_services import router as project_services_router from app.api.github import router as github_router from app.api.vercel import router as vercel_router +from app.api.claude_conversations import router as claude_conversations_router +from app.api.claude_files import router as claude_files_router from app.core.logging import configure_logging from app.core.terminal_ui import ui from sqlalchemy import inspect 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() -app = FastAPI(title="Clovable API") +app = FastAPI( + title="Clovable API", + description="API for managing projects, chat sessions, and integrations with Claude, GitHub, and Vercel", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + openapi_tags=TAGS_METADATA +) + +# Configure enhanced OpenAPI documentation +configure_openapi(app) # Middleware to suppress logging for specific endpoints class LogFilterMiddleware(BaseHTTPMiddleware): @@ -64,6 +79,8 @@ async def dispatch(self, request: Request, call_next): app.include_router(project_services_router) # Project services API app.include_router(github_router) # GitHub integration API app.include_router(vercel_router) # Vercel integration API +app.include_router(claude_conversations_router, prefix="/api") # Claude conversation folder reader +app.include_router(claude_files_router, prefix="/api") # Claude CLI file management @app.get("/health") @@ -79,6 +96,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/models/__init__.py b/apps/api/app/models/__init__.py index d0e4ec49..f812c876 100644 --- a/apps/api/app/models/__init__.py +++ b/apps/api/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.tokens import ServiceToken from app.models.project_services import ProjectServiceConnection from app.models.user_requests import UserRequest +from app.models.mcp_servers import MCPServer __all__ = [ @@ -20,4 +21,5 @@ "ServiceToken", "ProjectServiceConnection", "UserRequest", + "MCPServer", ] diff --git a/apps/api/app/models/mcp_servers.py b/apps/api/app/models/mcp_servers.py new file mode 100644 index 00000000..a0cb524a --- /dev/null +++ b/apps/api/app/models/mcp_servers.py @@ -0,0 +1,41 @@ +"""MCP Server configuration model.""" +from sqlalchemy import String, Boolean, JSON, Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.db.base import Base + + +class MCPServer(Base): + """MCP Server configuration.""" + __tablename__ = "mcp_servers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + project_id: Mapped[str] = mapped_column(String(64), ForeignKey("projects.id", ondelete="CASCADE"), index=True) + + # Server identification + name: Mapped[str] = mapped_column(String(255), nullable=False) + transport: Mapped[str] = mapped_column(String(32), nullable=False) # stdio, sse + + # Command configuration (for stdio transport) + command: Mapped[str | None] = mapped_column(String(512), nullable=True) + args: Mapped[list | None] = mapped_column(JSON, nullable=True) + + # SSE configuration (for sse transport) + url: Mapped[str | None] = mapped_column(String(512), nullable=True) + + # Environment variables + env: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # Configuration + scope: Mapped[str] = mapped_column(String(32), default="project", nullable=False) # user, project + is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Status tracking + status: Mapped[dict | None] = mapped_column(JSON, nullable=True) # { running: bool, error?: string } + + # Timestamps + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + project = relationship("Project", back_populates="mcp_servers") \ No newline at end of file diff --git a/apps/api/app/models/messages.py b/apps/api/app/models/messages.py index 5ba81675..468a35ef 100644 --- a/apps/api/app/models/messages.py +++ b/apps/api/app/models/messages.py @@ -32,6 +32,8 @@ class Message(Base): # Performance & Cost Tracking duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) token_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) cost_usd: Mapped[float | None] = mapped_column(Numeric(10, 6), nullable=True) # Git Integration diff --git a/apps/api/app/models/projects.py b/apps/api/app/models/projects.py index 9fe98e59..e1101ab5 100644 --- a/apps/api/app/models/projects.py +++ b/apps/api/app/models/projects.py @@ -42,3 +42,4 @@ class Project(Base): env_vars = relationship("EnvVar", back_populates="project", cascade="all, delete-orphan") service_connections = relationship("ProjectServiceConnection", back_populates="project", cascade="all, delete-orphan") user_requests = relationship("UserRequest", back_populates="project", cascade="all, delete-orphan") + mcp_servers = relationship("MCPServer", back_populates="project", cascade="all, delete-orphan") diff --git a/apps/api/app/models/sessions.py b/apps/api/app/models/sessions.py index 7ce0dd8b..7e995294 100644 --- a/apps/api/app/models/sessions.py +++ b/apps/api/app/models/sessions.py @@ -1,7 +1,7 @@ """ Claude Code SDK session management """ -from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, Numeric +from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, Numeric, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from app.db.base import Base @@ -34,8 +34,11 @@ class Session(Base): total_messages: Mapped[int] = mapped_column(Integer, default=0) total_tools_used: Mapped[int] = mapped_column(Integer, default=0) total_tokens: Mapped[int] = mapped_column(Integer, default=0) + input_tokens: Mapped[int] = mapped_column(Integer, default=0) + output_tokens: Mapped[int] = mapped_column(Integer, default=0) total_cost_usd: Mapped[float | None] = mapped_column(Numeric(10, 6), nullable=True) duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # Timestamps started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) @@ -45,4 +48,4 @@ class Session(Base): project = relationship("Project", back_populates="sessions") messages = relationship("Message", back_populates="session") tools_usage = relationship("ToolUsage", back_populates="session", cascade="all, delete-orphan") - user_requests = relationship("UserRequest", back_populates="session") \ No newline at end of file + user_requests = relationship("UserRequest", back_populates="session") 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/chat/conversation_summary.py b/apps/api/app/services/chat/conversation_summary.py new file mode 100644 index 00000000..7e8f088c --- /dev/null +++ b/apps/api/app/services/chat/conversation_summary.py @@ -0,0 +1,153 @@ +"""Shared helpers for building conversation summaries.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.messages import Message +from app.models.projects import Project +from app.models.sessions import Session as ChatSession + + +@dataclass +class ConversationSummary: + project_id: str + project_name: str + project_path: Optional[str] + conversation_id: str + summary: str + first_message: Optional[str] + last_message_at: Optional[datetime] + cli_type: Optional[str] + source: Optional[str] + pinned: bool = False + + def as_dict(self) -> dict: + data = asdict(self) + if self.last_message_at: + data["last_message_at"] = self.last_message_at.isoformat() + return data + + +def get_conversation_summaries(db: Session) -> List[dict]: + """Return a list of conversation summaries grouped by project.""" + base_rows = ( + db.query( + Message.project_id, + Message.conversation_id, + func.min(Message.created_at).label("first_at"), + func.max(Message.created_at).label("last_at"), + ) + .filter(Message.conversation_id.isnot(None)) + .group_by(Message.project_id, Message.conversation_id) + .all() + ) + + if not base_rows: + return [] + + project_ids = {row.project_id for row in base_rows} + conversation_ids = {row.conversation_id for row in base_rows if row.conversation_id} + + projects = { + project.id: project + for project in db.query(Project).filter(Project.id.in_(project_ids)).all() + } + + sessions = { + (session.project_id, session.id): session + for session in ( + db.query(ChatSession) + .filter(ChatSession.project_id.in_(project_ids)) + .filter(ChatSession.id.in_(conversation_ids)) + .all() + ) + } + + message_rows = ( + db.query( + Message.project_id, + Message.conversation_id, + Message.role, + Message.content, + Message.cli_source, + Message.created_at, + Message.message_type, + ) + .filter(Message.project_id.in_(project_ids)) + .filter(Message.conversation_id.in_(conversation_ids)) + .order_by(Message.project_id, Message.conversation_id, Message.created_at) + .all() + ) + + first_user = {} + last_assistant = {} + cli_sources = {} + + for row in message_rows: + key = (row.project_id, row.conversation_id) + if row.role == "user" and key not in first_user: + first_user[key] = row + if row.role == "assistant": + last_assistant[key] = row + if getattr(row, "cli_source", None) and key not in cli_sources: + cli_sources[key] = row.cli_source + + summaries: List[ConversationSummary] = [] + for base in base_rows: + project = projects.get(base.project_id) + if not project or not base.conversation_id: + continue + + key = (base.project_id, base.conversation_id) + session = sessions.get(key) + first_user_msg = first_user.get(key) + last_assistant_msg = last_assistant.get(key) + + summary_text = ( + (session.summary if session and session.summary else None) + or (last_assistant_msg.content if last_assistant_msg else None) + or (first_user_msg.content if first_user_msg else "") + ) + + cli_type = None + if session and session.cli_type: + cli_type = session.cli_type + elif cli_sources.get(key): + cli_type = cli_sources[key] + + source = None + if session: + if session.transcript_format == "jsonl" or ( + session.transcript_path and session.transcript_path.endswith(".jsonl") + ): + source = "claude_log" + else: + source = "database" + + pinned = bool(session.pinned) if session else False + + summaries.append( + ConversationSummary( + project_id=project.id, + project_name=project.name, + project_path=project.repo_path, + conversation_id=base.conversation_id, + summary=summary_text or "", + first_message=first_user_msg.content if first_user_msg else None, + last_message_at=base.last_at, + cli_type=cli_type, + source=source, + pinned=pinned, + ) + ) + + summaries.sort(key=lambda item: item.last_message_at or datetime.min, reverse=True) + return [summary.as_dict() for summary in summaries] + + +__all__ = ["get_conversation_summaries", "ConversationSummary"] diff --git a/apps/api/app/services/claude/conversation_sync.py b/apps/api/app/services/claude/conversation_sync.py new file mode 100644 index 00000000..95d50ba9 --- /dev/null +++ b/apps/api/app/services/claude/conversation_sync.py @@ -0,0 +1,428 @@ +"""Utilities for syncing Claude CLI conversation logs into the database.""" +from __future__ import annotations + +import json +import logging +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable, List, Optional, Dict, Set, Tuple + +from sqlalchemy.orm import Session + +from app.models.messages import Message +from app.models.projects import Project +from app.models.sessions import Session as ChatSession + +logger = logging.getLogger(__name__) + +CLAUDE_ROOT = Path.home() / ".claude" +CLAUDE_PROJECTS_ROOT = CLAUDE_ROOT / "projects" + + +@dataclass +class ConversationFile: + """Represents a Claude CLI JSONL transcript.""" + + project: Project + path: Path + source: str # "home" or "project" + + @property + def conversation_id(self) -> str: + return self.path.stem + + +@dataclass +class ParsedConversation: + conversation_id: str + session_id: Optional[str] + messages: List[Message] + first_user_message: Optional[str] + last_assistant_message: Optional[str] + started_at: Optional[datetime] + completed_at: Optional[datetime] + model: Optional[str] + cli_type: str = "claude" + + +class ClaudeConversationSync: + """Synchronise Claude CLI transcripts into the application database.""" + + def __init__(self, db: Session): + self.db = db + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def sync(self, force: bool = True) -> dict: + """Import Claude logs into the database. + + Args: + force: When True, re-import conversations even if they already exist. + + Returns: + Summary dictionary with counts and errors. + """ + projects = self.db.query(Project).all() + synced = 0 + errors: list[dict] = [] + + for project in projects: + try: + conversation_files = list(self._discover_project_conversations(project)) + except Exception as exc: # pragma: no cover - just in case discovery explodes + logger.exception("Failed discovering conversations for %s", project.id) + errors.append({ + "project_id": project.id, + "error": str(exc), + "stage": "discover", + }) + continue + + grouped: Dict[str, Tuple[ConversationFile, ParsedConversation, Set[str], Optional[datetime]]] = {} + + for conversation_file in conversation_files: + try: + parsed = self._parse_conversation(conversation_file) + except Exception as exc: # pragma: no cover - resilient parsing + logger.exception("Failed parsing %s", conversation_file.path) + errors.append({ + "project_id": project.id, + "conversation_id": conversation_file.conversation_id, + "path": str(conversation_file.path), + "error": str(exc), + "stage": "parse", + }) + continue + + if not parsed.messages: + continue + + logical_id = parsed.session_id or conversation_file.conversation_id + completed = parsed.completed_at or parsed.started_at + if completed is None: + try: + completed = datetime.fromtimestamp(conversation_file.path.stat().st_mtime) + except OSError: + completed = None + + entry = grouped.get(logical_id) + if entry is None: + grouped[logical_id] = ( + conversation_file, + parsed, + {conversation_file.conversation_id}, + completed, + ) + else: + existing_file, _existing_parsed, legacy_ids, existing_completed = grouped[logical_id] + legacy_ids.add(conversation_file.conversation_id) + replace = False + if completed and existing_completed: + replace = completed >= existing_completed + elif completed and not existing_completed: + replace = True + elif not existing_completed and not completed: + # Keep the latest file by modification time if timestamps missing + try: + replace = ( + conversation_file.path.stat().st_mtime + >= existing_file.path.stat().st_mtime + ) + except OSError: + replace = False + if replace: + grouped[logical_id] = ( + conversation_file, + parsed, + legacy_ids, + completed, + ) + + for logical_id, (conversation_file, parsed, legacy_ids, _completed) in grouped.items(): + try: + self._import_conversation( + conversation_file, + parsed, + legacy_ids, + force=force, + ) + synced += 1 + except Exception as exc: # pragma: no cover - resilient sync + logger.exception("Failed importing %s", conversation_file.path) + errors.append({ + "project_id": project.id, + "conversation_id": logical_id, + "path": str(conversation_file.path), + "error": str(exc), + "stage": "import", + }) + + self.db.commit() + return {"synced": synced, "errors": errors} + + # ------------------------------------------------------------------ + # Discovery helpers + # ------------------------------------------------------------------ + def _discover_project_conversations(self, project: Project) -> Iterable[ConversationFile]: + repo_path = project.repo_path + if not repo_path: + return + + repo = Path(repo_path) + + # 1. Local project .claude/projects directory + local_projects_dir = repo / ".claude" / "projects" + if local_projects_dir.exists(): + for jsonl_path in local_projects_dir.rglob("*.jsonl"): + yield ConversationFile(project=project, path=jsonl_path, source="project") + + # 2. Global ~/.claude/projects directory with slugified folder names + if CLAUDE_PROJECTS_ROOT.exists(): + slug = self._slugify_path(repo) + candidate = CLAUDE_PROJECTS_ROOT / slug + if candidate.exists(): + for jsonl_path in candidate.rglob("*.jsonl"): + yield ConversationFile(project=project, path=jsonl_path, source="home") + + @staticmethod + def _slugify_path(path: Path) -> str: + # Claude CLI stores project folders as `-Users-jkneen-…` + return "-" + str(path.resolve()).lstrip("/").replace("/", "-") + + # ------------------------------------------------------------------ + # Import helpers + # ------------------------------------------------------------------ + def _import_conversation( + self, + convo_file: ConversationFile, + parsed: ParsedConversation, + legacy_ids: Iterable[str], + force: bool, + ) -> None: + if not parsed.messages: + logger.debug("Skipping empty transcript: %s", convo_file.path) + return + + project = convo_file.project + conversation_id = parsed.session_id or parsed.conversation_id + + self._remove_legacy_conversations(project.id, legacy_ids, conversation_id) + + session = ( + self.db.query(ChatSession) + .filter(ChatSession.id == conversation_id, ChatSession.project_id == project.id) + .one_or_none() + ) + + if session is None: + session = ChatSession( + id=conversation_id, + project_id=project.id, + cli_type=parsed.cli_type, + ) + self.db.add(session) + elif not force: + return # Nothing to do + + # Update session metadata + session.instruction = parsed.first_user_message + session.summary = parsed.last_assistant_message + session.started_at = parsed.started_at or session.started_at + session.completed_at = parsed.completed_at or session.completed_at + session.model = parsed.model or session.model + session.status = "completed" + session.cli_type = parsed.cli_type + session.transcript_path = str(convo_file.path) + session.transcript_format = "jsonl" + session.total_messages = len(parsed.messages) + session.total_tools_used = sum( + 1 + for m in parsed.messages + if m.metadata_json and m.metadata_json.get("message_type") == "tool_use" + ) + metadata = getattr(parsed, "_metadata", {}) + session.total_tokens = metadata.get("total_tokens") + session.duration_ms = self._calculate_duration(parsed) + + # Replace existing messages with fresh import + self.db.query(Message).filter( + Message.project_id == project.id, + Message.conversation_id == conversation_id, + ).delete(synchronize_session=False) + + for message in parsed.messages: + # ensure message ids unique per import + if not message.id: + message.id = str(uuid.uuid4()) + message.project_id = project.id + message.session_id = session.id + message.conversation_id = conversation_id + message.cli_source = parsed.cli_type + self.db.add(message) + + # Update project activity timestamps + if parsed.completed_at: + if not project.last_active_at or project.last_active_at < parsed.completed_at: + project.last_active_at = parsed.completed_at + + def _remove_legacy_conversations( + self, project_id: str, legacy_ids: Iterable[str], keep_id: str + ) -> None: + ids_to_remove = {legacy_id for legacy_id in legacy_ids if legacy_id and legacy_id != keep_id} + if not ids_to_remove: + return + + self.db.query(Message).filter( + Message.project_id == project_id, + Message.conversation_id.in_(ids_to_remove), + ).delete(synchronize_session=False) + + self.db.query(ChatSession).filter( + ChatSession.project_id == project_id, + ChatSession.id.in_(ids_to_remove), + ).delete(synchronize_session=False) + + # ------------------------------------------------------------------ + def _parse_conversation(self, convo_file: ConversationFile) -> ParsedConversation: + path = convo_file.path + conversation_id = path.stem + messages: List[Message] = [] + first_user: Optional[str] = None + last_assistant: Optional[str] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + session_id: Optional[str] = None + model: Optional[str] = None + total_tokens = 0 + + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + logger.debug("Skipping malformed JSONL line in %s", path) + continue + + msg_record = payload.get("message", {}) + role = msg_record.get("role") or payload.get("type") or "assistant" + timestamp = self._parse_timestamp(payload.get("timestamp")) + message_type = self._derive_message_type(msg_record) + content = self._extract_content(msg_record) + + if role == "user" and first_user is None and content: + first_user = content + if role == "assistant" and content: + last_assistant = content + if msg_record.get("model") and model is None: + model = msg_record.get("model") + if payload.get("sessionId"): + session_id = payload.get("sessionId") + if timestamp: + started_at = started_at or timestamp + completed_at = timestamp + + usage = msg_record.get("usage") + if usage: + total_tokens += usage.get("input_tokens", 0) + usage.get("output_tokens", 0) + + metadata = { + "source": "claude_log", + "message_type": message_type, + "raw": payload, + } + + message = Message( + id=str(uuid.uuid4()), + project_id=convo_file.project.id, + role=role, + message_type=message_type, + content=content or "", + metadata_json=metadata, + parent_message_id=None, + session_id=None, + conversation_id=conversation_id, + cli_source="claude", + created_at=timestamp or datetime.utcnow(), + ) + messages.append(message) + + parsed = ParsedConversation( + conversation_id=conversation_id, + session_id=session_id, + messages=messages, + first_user_message=first_user, + last_assistant_message=last_assistant, + started_at=started_at, + completed_at=completed_at, + model=model, + ) + # Attach metadata for duration/token calculations without polluting dataclass signature + parsed._metadata = {"total_tokens": total_tokens} # type: ignore[attr-defined] + return parsed + + @staticmethod + def _derive_message_type(msg_record: dict) -> str: + content = msg_record.get("content") + if isinstance(content, list): + for item in content: + item_type = item.get("type") if isinstance(item, dict) else None + if item_type == "tool_use": + return "tool_use" + if item_type == "tool_result": + return "tool_result" + return msg_record.get("type") or "chat" + + @staticmethod + def _extract_content(msg_record: dict) -> str: + content = msg_record.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if not isinstance(item, dict): + continue + item_type = item.get("type") + if item_type == "text": + parts.append(item.get("text", "")) + elif item_type == "tool_use": + tool_name = item.get("name", "tool") + tool_input = item.get("input") + parts.append(f"[tool_use:{tool_name}] {json.dumps(tool_input, ensure_ascii=False)}") + elif item_type == "tool_result": + tool_id = item.get("tool_use_id") or "tool" + tool_content = item.get("content") + parts.append(f"[tool_result:{tool_id}] {json.dumps(tool_content, ensure_ascii=False)}") + else: + parts.append(json.dumps(item, ensure_ascii=False)) + return "\n".join(part for part in parts if part).strip() + if content is None: + return "" + return json.dumps(content, ensure_ascii=False) + + @staticmethod + def _parse_timestamp(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + if value.endswith("Z"): + return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + return datetime.fromisoformat(value) + except ValueError: + return None + + @staticmethod + def _calculate_duration(parsed: ParsedConversation) -> Optional[int]: + if parsed.started_at and parsed.completed_at: + delta = parsed.completed_at - parsed.started_at + return int(delta.total_seconds() * 1000) + return None + + +__all__ = ["ClaudeConversationSync"] diff --git a/apps/api/app/services/claude_act.py b/apps/api/app/services/claude_act.py index 17d56db6..c80b800d 100644 --- a/apps/api/app/services/claude_act.py +++ b/apps/api/app/services/claude_act.py @@ -4,11 +4,21 @@ from datetime import datetime from pathlib import Path -from claude_code_sdk import query, ClaudeCodeOptions -from claude_code_sdk.types import ( - Message, UserMessage, AssistantMessage, SystemMessage, ResultMessage, - ContentBlock, TextBlock, ThinkingBlock, ToolUseBlock, ToolResultBlock -) +try: + from claude_code_sdk import query, ClaudeCodeOptions +except ImportError: + # SDK might be updating, provide fallback + query = None + ClaudeCodeOptions = None +try: + from claude_code_sdk.types import ( + Message, UserMessage, AssistantMessage, SystemMessage, ResultMessage, + ContentBlock, TextBlock, ThinkingBlock, ToolUseBlock, ToolResultBlock + ) +except ImportError: + # SDK types might be missing, provide None fallback + Message = UserMessage = AssistantMessage = SystemMessage = ResultMessage = None + ContentBlock = TextBlock = ThinkingBlock = ToolUseBlock = ToolResultBlock = None DEFAULT_MODEL = os.getenv("CLAUDE_CODE_MODEL", "claude-sonnet-4-20250514") 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..baa6c90c --- /dev/null +++ b/apps/api/app/services/cli/adapters/claude_code.py @@ -0,0 +1,572 @@ +"""Claude Code provider implementation. + +Moved from unified_manager.py to a dedicated adapter module. +""" +from __future__ import annotations + +import asyncio +import json +import os +import tempfile +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 +try: + from claude_code_sdk import ClaudeSDKClient, ClaudeCodeOptions +except ImportError: + # SDK might have compatibility issues, use None as fallback + ClaudeSDKClient = None + ClaudeCodeOptions = None + +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-5-20250929", + "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") + full_system_prompt = system_prompt + + # Windows has an 8191 character command-line limit when prompts are passed + # via command arguments. We'll only trim in fallback scenarios where we + # cannot use a temporary settings file. + trimmed_system_prompt = system_prompt + if os.name == "nt": + max_prompt_chars = 3000 + if len(system_prompt) > max_prompt_chars: + trimmed_prompt = system_prompt[:max_prompt_chars] + last_linebreak = trimmed_prompt.rfind("\n") + if last_linebreak > 0: + trimmed_prompt = trimmed_prompt[:last_linebreak] + ui.warning( + ( + "System prompt exceeded Windows command length; " + "using trimmed prompt fallback if temporary settings fail" + ), + "Claude SDK", + ) + trimmed_system_prompt = trimmed_prompt + except Exception as e: + ui.error(f"Failed to load system prompt: {e}", "Claude SDK") + full_system_prompt = ( + "You are Claude Code, an AI coding assistant specialized in building modern web applications." + ) + trimmed_system_prompt = full_system_prompt + + # Get CLI-specific model name + cli_model = self._get_cli_model_name(model) or "claude-sonnet-4-5-20250929" + + # 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/ +""" + if os.name == "nt": + ui.warning( + "Skipping extra project structure context on Windows to avoid command length limits", + "Claude SDK", + ) + else: + instruction = instruction + project_structure_info + ui.info( + f"Added project structure info to initial prompt", "Claude SDK" + ) + + session_settings_path = None + base_settings = {} + settings_file_path = os.path.join(project_path, ".claude", "settings.json") + if os.path.exists(settings_file_path): + try: + with open(settings_file_path, "r", encoding="utf-8") as settings_file: + loaded_settings = json.load(settings_file) + if isinstance(loaded_settings, dict): + base_settings = loaded_settings + else: + ui.warning("Existing Claude settings file is not a JSON object; ignoring it", "Claude SDK") + except Exception as settings_error: + ui.warning(f"Failed to load existing Claude settings: {settings_error}", "Claude SDK") + session_settings = dict(base_settings) + session_settings["customSystemPrompt"] = full_system_prompt + try: + temp_settings = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") + json.dump(session_settings, temp_settings, ensure_ascii=False) + temp_settings.flush() + temp_settings.close() + session_settings_path = temp_settings.name + ui.debug(f"Wrote temporary Claude settings to {session_settings_path}", "Claude SDK") + except Exception as settings_write_error: + ui.warning(f"Failed to create temporary settings file for Claude CLI: {settings_write_error}", "Claude SDK") + session_settings_path = None + + # 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 + option_kwargs = { + "allowed_tools": allowed_tools, + "disallowed_tools": disallowed_tools, + "permission_mode": "bypassPermissions", + "model": cli_model, + "continue_conversation": True, + "extra_args": { + "print": None, + "verbose": None, + }, + } + if session_settings_path: + option_kwargs["settings"] = session_settings_path + else: + option_kwargs["system_prompt"] = trimmed_system_prompt + options = ClaudeCodeOptions(**option_kwargs) + 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 + option_kwargs = { + "allowed_tools": allowed_tools, + "permission_mode": "bypassPermissions", + "model": cli_model, + "continue_conversation": True, + "extra_args": { + "print": None, + "verbose": None, + }, + } + if session_settings_path: + option_kwargs["settings"] = session_settings_path + else: + option_kwargs["system_prompt"] = trimmed_system_prompt + options = ClaudeCodeOptions(**option_kwargs) + + 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" + ) + ): + # Extract real token usage from result + input_tokens = getattr(message_obj, 'input_tokens', None) + output_tokens = getattr(message_obj, 'output_tokens', None) + + ui.success( + f"Session completed in {getattr(message_obj, 'duration_ms', 0)}ms", + "Claude SDK", + ) + + if input_tokens and output_tokens: + ui.info(f"Token usage - Input: {input_tokens}, Output: {output_tokens}", "Claude SDK") + + # Store real token usage using token tracker + from app.services.token_tracker import token_tracker + from app.core.database import SessionLocal + + db = SessionLocal() + try: + if session_id: + token_tracker.update_session_tokens( + session_id, input_tokens, output_tokens, db + ) + finally: + db.close() + + # 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: + try: + if session_settings_path and os.path.exists(session_settings_path): + os.remove(session_settings_path) + except Exception as cleanup_error: + ui.debug(f"Failed to remove temporary settings file {session_settings_path}: {cleanup_error}", "Claude SDK") + # 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..b157acc6 --- /dev/null +++ b/apps/api/app/services/cli/adapters/codex_cli.py @@ -0,0 +1,948 @@ +"""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 shutil +import shlex +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 + self._codex_executable: Optional[str] = None + + def _augment_path(self, env: Dict[str, str]) -> Dict[str, str]: + extra_paths = [] + appdata = os.environ.get("APPDATA") + if appdata: + extra_paths.append(os.path.join(appdata, "npm")) + localapp = os.environ.get("LOCALAPPDATA") + if localapp: + extra_paths.append(os.path.join(localapp, "Programs", "nodejs")) + path_value = env.get("PATH", "") + env["PATH"] = os.pathsep.join([p for p in extra_paths if p] + ([path_value] if path_value else [])) + return env + + def _locate_codex_executable(self) -> Optional[str]: + if self._codex_executable and os.path.exists(self._codex_executable): + return self._codex_executable + + candidates = [] + detected = shutil.which("codex") + if detected: + candidates.append(detected) + + appdata = os.environ.get("APPDATA") + if appdata: + candidates.append(os.path.join(appdata, "npm", "codex.cmd")) + candidates.append(os.path.join(appdata, "npm", "codex.exe")) + localapp = os.environ.get("LOCALAPPDATA") + if localapp: + candidates.append(os.path.join(localapp, "Programs", "nodejs", "codex.cmd")) + candidates.append(os.path.join(localapp, "Programs", "nodejs", "codex.exe")) + + for candidate in candidates: + if candidate and os.path.exists(candidate): + self._codex_executable = candidate + return self._codex_executable + return None + + def _build_invocation(self, exe: str, *args: str) -> List[str]: + if exe.lower().endswith((".cmd", ".bat")) and os.name == "nt": + return ["cmd.exe", "/c", exe, *args] + return [exe, *args] + + async def check_availability(self) -> Dict[str, Any]: + """Check if Codex CLI is available""" + print(f"[DEBUG] CodexCLI.check_availability called") + try: + codex_exe = self._locate_codex_executable() + if not codex_exe: + error_msg = ( + "Codex CLI not found on PATH. Install with `npm install -g @openai/codex` and ensure the npm bin directory is on PATH." + ) + print(f"[DEBUG] {error_msg}") + return { + "available": False, + "configured": False, + "error": error_msg, + } + + print(f"[DEBUG] Running command: {codex_exe} --version") + env = self._augment_path(os.environ.copy()) + cmd = self._build_invocation(codex_exe, "--version") + result = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env) + stdout, stderr = await result.communicate() + + stdout_text = stdout.decode(errors="ignore").strip() + stderr_text = stderr.decode(errors="ignore").strip() + print(f"[DEBUG] Command result: returncode={result.returncode}") + print(f"[DEBUG] stdout: {stdout_text}") + print(f"[DEBUG] stderr: {stderr_text}") + + if result.returncode != 0: + error_msg = ( + f"Codex CLI not installed or not working (returncode: {result.returncode}). stderr: {stderr_text}" + ) + print(f"[DEBUG] {error_msg}") + return { + "available": False, + "configured": False, + "error": error_msg, + } + + print(f"[DEBUG] Codex CLI available at {codex_exe}!") + 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""" + + codex_exe = self._locate_codex_executable() + if not codex_exe: + raise RuntimeError("Codex CLI not available. Install with `npm install -g @openai/codex` and ensure it is on PATH.") + + # 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. " + "Respect the existing project structure (e.g. Next.js app directory) when creating or modifying files. " + "In Next.js projects scaffolded by create-next-app, the main UI lives in app/page.tsx and related files under app/. Prioritize editing those files unless the user explicitly requests otherwise." + ) + + base_args = [ + "--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", + "max_turns=20", + "-c", + "max_thinking_tokens=4096", + "-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): + base_args.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): + base_args.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") + + command = self._build_invocation(codex_exe, *base_args) + env = self._augment_path(os.environ.copy()) + ui.debug( + "Executing Codex command: " + " ".join(shlex.quote(part) for part in command), + "Codex", + ) + + try: + # Start Codex process + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=project_repo_path, + env=env, + ) + + # 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") + + files_modified = [] + if isinstance(changes, dict): + files_modified = list(changes.keys()) + elif isinstance(changes, list): + for entry in changes: + if isinstance(entry, dict): + path_value = entry.get("path") or entry.get("file") + if path_value: + files_modified.append(path_value) + + metadata = { + "cli_type": self.cli_type.value, + "tool_name": "Edit", + "changes_made": True, + } + if files_modified: + metadata["files_modified"] = files_modified + + yield Message( + id=str(uuid.uuid4()), + project_id=project_path, + role="assistant", + message_type="tool_use", + content=summary, + metadata_json=metadata, + 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..69ddc18a --- /dev/null +++ b/apps/api/app/services/cli/adapters/cursor_agent.py @@ -0,0 +1,570 @@ +"""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 app.services.cli.process_manager import register_process, unregister_process + +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, + ) + + # Register the process for tracking + if session_id: + register_process(session_id, process) + + 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() + + # Unregister the process after completion + if session_id: + unregister_process(session_id) + + # 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..c3bbcd03 --- /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.5": "claude-sonnet-4-5-20250929", + "opus-4": "claude-opus-4-20250514", + "haiku-3.5": "claude-3-5-haiku-20241022", + # Handle claude-prefixed model names + "claude-sonnet-4.5": "claude-sonnet-4-5-20250929", + "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-5-20250929": "claude-sonnet-4-5-20250929", + "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.5": "sonnet-4.5", + "opus-4.1": "opus-4.1", + "sonnet-4-thinking": "sonnet-4-thinking", + # Handle mapping from unified Claude model names + "claude-sonnet-4.5": "sonnet-4.5", + "claude-opus-4.1": "opus-4.1", + "claude-sonnet-4-5-20250929": "sonnet-4.5", + "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.5": "claude-3.5-sonnet", + "claude-sonnet-4.5": "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..fc077159 --- /dev/null +++ b/apps/api/app/services/cli/manager.py @@ -0,0 +1,344 @@ +"""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 _attempt_fallback( + self, + failed_cli: CLIType, + instruction: str, + images: Optional[List[Dict[str, Any]]], + model: Optional[str], + is_initial_prompt: bool, + ) -> Optional[Dict[str, Any]]: + fallback_type = CLIType.CLAUDE + if failed_cli == fallback_type: + return None + + fallback_cli = self.cli_adapters.get(fallback_type) + if not fallback_cli: + ui.warning("Fallback CLI Claude not configured", "CLI") + return None + + status = await fallback_cli.check_availability() + if not status.get("available") or not status.get("configured"): + ui.error( + f"Fallback CLI {fallback_type.value} unavailable: {status.get('error', 'unknown error')}", + "CLI", + ) + return None + + ui.warning( + f"CLI {failed_cli.value} unavailable; falling back to {fallback_type.value}", + "CLI", + ) + + try: + result = await self._execute_with_cli( + fallback_cli, instruction, images, model, is_initial_prompt + ) + result["fallback_used"] = True + result["fallback_from"] = failed_cli.value + return result + except Exception as error: + ui.error( + f"Fallback CLI {fallback_type.value} failed: {error}", + "CLI", + ) + return None + + 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") + if fallback_enabled: + fallback_result = await self._attempt_fallback( + cli_type, instruction, images, model, is_initial_prompt + ) + if fallback_result: + return fallback_result + return { + "success": False, + "error": str(e), + "cli_attempted": cli_type.value, + } + else: + ui.warning( + f"CLI {cli_type.value} unavailable: {status.get('error', 'CLI not available')}", + "CLI", + ) + if fallback_enabled: + fallback_result = await self._attempt_fallback( + cli_type, instruction, images, model, is_initial_prompt + ) + if fallback_result: + return fallback_result + return { + "success": False, + "error": status.get("error", "CLI not available"), + "cli_attempted": cli_type.value, + } + + if fallback_enabled: + fallback_result = await self._attempt_fallback( + cli_type, instruction, images, model, is_initial_prompt + ) + if fallback_result: + return fallback_result + + 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 + files_modified: set[str] = set() + 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") + + if message.metadata_json: + files = message.metadata_json.get("files_modified") + if isinstance(files, (list, tuple, set)): + files_modified.update(str(f) for f in files) + + # 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/process_manager.py b/apps/api/app/services/cli/process_manager.py new file mode 100644 index 00000000..3f8fb47b --- /dev/null +++ b/apps/api/app/services/cli/process_manager.py @@ -0,0 +1,103 @@ +""" +Global CLI process management for tracking and terminating running CLI processes. +""" +import asyncio +import os +import signal +from typing import Dict, Optional +from app.core.terminal_ui import ui + +# Global registry of running CLI processes +# Key: session_id, Value: asyncio.subprocess.Process +_cli_processes: Dict[str, asyncio.subprocess.Process] = {} + + +def register_process(session_id: str, process: asyncio.subprocess.Process) -> None: + """Register a CLI process for tracking.""" + _cli_processes[session_id] = process + ui.info(f"Registered CLI process for session {session_id} (PID: {process.pid})", "ProcessManager") + + +def unregister_process(session_id: str) -> None: + """Remove a CLI process from tracking.""" + if session_id in _cli_processes: + del _cli_processes[session_id] + ui.info(f"Unregistered CLI process for session {session_id}", "ProcessManager") + + +async def terminate_process(session_id: str) -> bool: + """Terminate a specific CLI process by session ID.""" + process = _cli_processes.get(session_id) + if not process: + ui.warning(f"No process found for session {session_id}", "ProcessManager") + return False + + try: + # First try graceful termination + ui.info(f"Terminating process for session {session_id} (PID: {process.pid})", "ProcessManager") + process.terminate() + + # Wait up to 5 seconds for graceful termination + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + ui.info(f"Process terminated gracefully for session {session_id}", "ProcessManager") + except asyncio.TimeoutError: + # Force kill if graceful termination fails + ui.warning(f"Graceful termination failed, force killing process {process.pid}", "ProcessManager") + process.kill() + await process.wait() + ui.info(f"Process force killed for session {session_id}", "ProcessManager") + + # Unregister the process + unregister_process(session_id) + return True + + except Exception as e: + ui.error(f"Failed to terminate process for session {session_id}: {e}", "ProcessManager") + return False + + +async def terminate_project_processes(project_id: str) -> int: + """Terminate all CLI processes for a specific project.""" + terminated_count = 0 + sessions_to_terminate = [] + + # Find all sessions for this project (session IDs typically contain project ID) + for session_id in _cli_processes.keys(): + if project_id in session_id: + sessions_to_terminate.append(session_id) + + # Terminate each process + for session_id in sessions_to_terminate: + if await terminate_process(session_id): + terminated_count += 1 + + ui.info(f"Terminated {terminated_count} processes for project {project_id}", "ProcessManager") + return terminated_count + + +def get_running_processes() -> Dict[str, int]: + """Get all currently running CLI processes with their PIDs.""" + active_processes = {} + for session_id, process in list(_cli_processes.items()): + if process.returncode is None: + active_processes[session_id] = process.pid + else: + # Process has ended, clean up + unregister_process(session_id) + + return active_processes + + +def cleanup_ended_processes() -> None: + """Clean up references to processes that have already ended.""" + ended_sessions = [] + for session_id, process in _cli_processes.items(): + if process.returncode is not None: + ended_sessions.append(session_id) + + for session_id in ended_sessions: + unregister_process(session_id) + + if ended_sessions: + ui.info(f"Cleaned up {len(ended_sessions)} ended processes", "ProcessManager") \ No newline at end of file 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..bbd8dab9 100644 --- a/apps/api/app/services/filesystem.py +++ b/apps/api/app/services/filesystem.py @@ -10,9 +10,26 @@ def ensure_dir(path: str) -> None: def init_git_repo(repo_path: str) -> None: - subprocess.run(["git", "init"], cwd=repo_path, check=True) - subprocess.run(["git", "add", "-A"], cwd=repo_path, check=True) - subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True) + from app.core.terminal_ui import ui + + git_cmd = shutil.which("git.exe" if os.name == "nt" else "git") or shutil.which("git") + if not git_cmd: + raise Exception( + "Git is not available on the PATH. Please install Git and ensure the 'git' command is accessible." + ) + + try: + subprocess.run([git_cmd, "init"], cwd=repo_path, check=True) + subprocess.run([git_cmd, "add", "-A"], cwd=repo_path, check=True) + subprocess.run([git_cmd, "commit", "-m", "Initial commit"], cwd=repo_path, check=True) + except FileNotFoundError as e: + ui.error(f"Git command not found: {e}", "Filesystem") + raise Exception( + "Git command could not be executed. Verify that Git is installed and available in your PATH." + ) + except subprocess.CalledProcessError as e: + ui.error(f"Failed to initialize git repository: {e}", "Filesystem") + raise Exception("Failed to initialize git repository. See logs for details.") def scaffold_nextjs_minimal(repo_path: str) -> None: @@ -26,9 +43,18 @@ def scaffold_nextjs_minimal(repo_path: str) -> None: project_name = Path(repo_path).name try: + npx_available = any( + shutil.which(candidate) + for candidate in (["npx", "npx.cmd"] if os.name == "nt" else ["npx"]) + ) + + if not npx_available: + raise Exception( + "Cannot find 'npx'. Install Node.js 18+ and ensure the 'npx' command is available on your PATH." + ) # Create Next.js app with TypeScript and Tailwind CSS - cmd = [ - "npx", + base_cmd = [ + "npx", "create-next-app@latest", project_name, "--typescript", @@ -37,9 +63,13 @@ 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 ] + if os.name == "nt": + cmd = ["cmd.exe", "/c"] + base_cmd + else: + cmd = base_cmd # Set environment for non-interactive mode env = os.environ.copy() @@ -75,16 +105,24 @@ def scaffold_nextjs_minimal(repo_path: str) -> None: ui.debug(f"stderr: {e.stderr}", "Filesystem") # Provide more specific error messages - if "EACCES" in str(e.stderr): + stderr_lower = (e.stderr or "").lower() + if "is not recognized" in stderr_lower or "command not found" in stderr_lower: + error_msg = "Cannot execute 'npx'. Install Node.js 18+ and ensure npx is on PATH." + elif "eacces" in stderr_lower: error_msg = "Permission denied. Please check directory permissions." - elif "ENOENT" in str(e.stderr): + elif "enoent" in stderr_lower: error_msg = "Command not found. Please ensure Node.js and npm are installed." - elif "network" in str(e.stderr).lower(): + elif "network" in stderr_lower: error_msg = "Network error. Please check your internet connection." else: error_msg = f"Failed to create Next.js project: {e.stderr or e.stdout or str(e)}" - + raise Exception(error_msg) + except FileNotFoundError as e: + ui.error(f"create-next-app command not found: {e}", "Filesystem") + raise Exception( + "Unable to execute create-next-app. Ensure Node.js and npm are installed and 'npx' is available on PATH." + ) def write_env_file(project_dir: str, content: str) -> None: diff --git a/apps/api/app/services/git_ops.py b/apps/api/app/services/git_ops.py index eadc7e53..773446b8 100644 --- a/apps/api/app/services/git_ops.py +++ b/apps/api/app/services/git_ops.py @@ -45,6 +45,121 @@ def hard_reset(repo_path: str, commit_sha: str) -> None: _run(["git", "reset", "--hard", commit_sha], cwd=repo_path) +def get_status(repo_path: str) -> dict: + """Get git status""" + try: + status_output = _run(["git", "status", "--porcelain"], cwd=repo_path) + branch_output = _run(["git", "branch", "--show-current"], cwd=repo_path) + + modified = [] + staged = [] + untracked = [] + + for line in status_output.splitlines(): + if line.startswith("??"): + untracked.append(line[3:]) + elif line.startswith("M "): + staged.append(line[3:]) + elif line.startswith(" M"): + modified.append(line[3:]) + elif line.startswith("A "): + staged.append(line[3:]) + + return { + "branch": branch_output, + "modified": modified, + "staged": staged, + "untracked": untracked, + "clean": len(modified) == 0 and len(staged) == 0 and len(untracked) == 0 + } + except subprocess.CalledProcessError: + return {"error": "Not a git repository or git command failed"} + + +def get_branches(repo_path: str) -> dict: + """Get all branches""" + try: + local_branches = _run(["git", "branch"], cwd=repo_path).splitlines() + remote_branches = _run(["git", "branch", "-r"], cwd=repo_path).splitlines() + + current_branch = None + locals = [] + remotes = [] + + for branch in local_branches: + if branch.startswith("* "): + current_branch = branch[2:].strip() + locals.append(current_branch) + else: + locals.append(branch.strip()) + + for branch in remote_branches: + branch = branch.strip() + if not branch.startswith("origin/HEAD"): + remotes.append(branch) + + return { + "current": current_branch, + "local": locals, + "remote": remotes + } + except subprocess.CalledProcessError: + return {"error": "Failed to get branches"} + + +def pull(repo_path: str, remote: str = "origin", branch: str = None) -> str: + """Pull from remote repository""" + try: + cmd = ["git", "pull", remote] + if branch: + cmd.append(branch) + return _run(cmd, cwd=repo_path) + except subprocess.CalledProcessError as e: + raise Exception(f"Git pull failed: {e.stderr}") + + +def push(repo_path: str, remote: str = "origin", branch: str = None) -> str: + """Push to remote repository""" + try: + cmd = ["git", "push", remote] + if branch: + cmd.append(branch) + return _run(cmd, cwd=repo_path) + except subprocess.CalledProcessError as e: + raise Exception(f"Git push failed: {e.stderr}") + + +def create_branch(repo_path: str, branch_name: str, checkout: bool = True) -> str: + """Create a new branch""" + try: + _run(["git", "branch", branch_name], cwd=repo_path) + if checkout: + _run(["git", "checkout", branch_name], cwd=repo_path) + return f"Created and switched to branch '{branch_name}'" if checkout else f"Created branch '{branch_name}'" + except subprocess.CalledProcessError as e: + raise Exception(f"Failed to create branch: {e.stderr}") + + +def checkout_branch(repo_path: str, branch_name: str) -> str: + """Switch to a different branch""" + try: + return _run(["git", "checkout", branch_name], cwd=repo_path) + except subprocess.CalledProcessError as e: + raise Exception(f"Failed to checkout branch: {e.stderr}") + + +def commit_all(repo_path: str, message: str) -> str: + """Add all changes and commit""" + try: + _run(["git", "add", "-A"], cwd=repo_path) + _run(["git", "commit", "-m", message], cwd=repo_path) + return current_head(repo_path) + except subprocess.CalledProcessError as e: + if "nothing to commit" in str(e.stderr): + raise Exception("Nothing to commit, working tree clean") + raise Exception(f"Git commit failed: {e.stderr}") + + def add_remote(repo_path: str, remote_name: str, remote_url: str) -> None: """Add a remote repository""" try: diff --git a/apps/api/app/services/local_runtime.py b/apps/api/app/services/local_runtime.py index 833b0f47..e32927c9 100644 --- a/apps/api/app/services/local_runtime.py +++ b/apps/api/app/services/local_runtime.py @@ -6,6 +6,7 @@ import hashlib import threading import re +import shutil from contextlib import closing from typing import Optional, Dict from app.core.config import settings @@ -14,6 +15,35 @@ # Global process registry to track running Next.js processes _running_processes: Dict[str, subprocess.Popen] = {} _process_logs: Dict[str, list] = {} # Store process logs for each project +_npm_executable: Optional[str] = None + + +def _get_npm_executable() -> str: + """Locate the npm executable respecting platform specifics.""" + global _npm_executable + + if _npm_executable and os.path.exists(_npm_executable): + return _npm_executable + + candidates = [ + "npm.cmd", + "npm.exe", + "npm", + ] if os.name == "nt" else ["npm"] + + for candidate in candidates: + path = shutil.which(candidate) + if path: + _npm_executable = path + return path + + raise RuntimeError( + "npm command not found. Install Node.js (includes npm) and ensure it is available on PATH." + ) + +def get_npm_executable() -> str: + """Public accessor for npm executable path.""" + return _get_npm_executable() def _monitor_preview_errors(project_id: str, process: subprocess.Popen): """간단한 Preview 서버 에러 모니터링""" @@ -206,7 +236,15 @@ def _is_port_free(port: int) -> bool: def find_free_preview_port() -> int: - """Find a free port in the preview range""" + """Find a free port in the preview range.""" + if settings.preview_port_fixed is not None: + if _is_port_free(settings.preview_port_fixed): + return settings.preview_port_fixed + raise RuntimeError( + f"Preview port {settings.preview_port_fixed} is already in use. " + "Stop the process using that port or set PREVIEW_PORT_FIXED=auto." + ) + for port in range(settings.preview_port_start, settings.preview_port_end + 1): if _is_port_free(port): return port @@ -238,7 +276,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() @@ -308,6 +346,10 @@ def start_preview_process(project_id: str, repo_path: str, port: Optional[int] = port = port or find_free_preview_port() process_name = f"next-dev-{project_id}" + # Basic validation of repository path + if not repo_path or not isinstance(repo_path, str): + raise RuntimeError("Invalid repository path: repo_path is not set") + # Check if project has package.json package_json_path = os.path.join(repo_path, "package.json") if not os.path.exists(package_json_path): @@ -323,11 +365,40 @@ 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}") + + npm_cmd = _get_npm_executable() + # 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"], + [npm_cmd, "install"], cwd=repo_path, env=env, capture_output=True, @@ -340,20 +411,26 @@ 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") # Start development server print(f"Starting Next.js dev server for project {project_id} on port {port}...") - process = subprocess.Popen( - ["npm", "run", "dev", "--", "-p", str(port)], + popen_kwargs = dict( cwd=repo_path, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - preexec_fn=os.setsid # Create new process group for easier cleanup + ) + if os.name == 'posix': + popen_kwargs["preexec_fn"] = os.setsid # Unix: new process group + elif os.name == 'nt': + popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) + process = subprocess.Popen( + [npm_cmd, "run", "dev", "--", "-p", str(port)], + **popen_kwargs ) # Wait a moment for the server to start @@ -397,19 +474,26 @@ def stop_preview_process(project_id: str, cleanup_cache: bool = False) -> None: if process: try: - # Terminate the entire process group - os.killpg(os.getpgid(process.pid), signal.SIGTERM) - + if os.name == 'posix': + # Terminate the entire process group (Unix) + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + else: + # Graceful terminate on Windows + process.terminate() + # Wait for process to terminate gracefully try: process.wait(timeout=5) except subprocess.TimeoutExpired: # Force kill if it doesn't terminate gracefully - os.killpg(os.getpgid(process.pid), signal.SIGKILL) + if os.name == 'posix': + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + else: + process.kill() process.wait() - + print(f"Stopped Next.js dev server for project {project_id} (PID: {process.pid})") - + except (OSError, ProcessLookupError): # Process already terminated pass @@ -426,8 +510,9 @@ def stop_preview_process(project_id: str, cleanup_cache: bool = False) -> None: try: repo_path = os.path.join(settings.projects_root, project_id, "repo") if os.path.exists(repo_path): + npm_cmd = _get_npm_executable() subprocess.run( - ["npm", "cache", "clean", "--force"], + [npm_cmd, "cache", "clean", "--force"], cwd=repo_path, capture_output=True, timeout=30 @@ -537,33 +622,10 @@ def get_preview_error_logs(project_id: str) -> str: if not process: return "No preview process running" - # Get all available output - logs = [] - try: - if process.stdout and hasattr(process.stdout, 'read'): - # Read all available data - import fcntl - import os - fd = process.stdout.fileno() - flags = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) - - try: - while True: - line = process.stdout.readline() - if not line: - break - logs.append(line) - except (IOError, OSError): - pass # No more data available - except Exception as e: - return f"Error reading logs: {str(e)}" - - if not logs: - return "No error logs available" - - # Join all logs and return - return ''.join(logs) + # Prefer aggregated logs collected by the monitor thread for portability + if project_id in _process_logs and _process_logs[project_id]: + return '\n'.join(_process_logs[project_id]) + return "No error logs available" def get_preview_logs(project_id: str, lines: int = 100) -> str: """ @@ -576,30 +638,8 @@ def get_preview_logs(project_id: str, lines: int = 100) -> str: Returns: String containing the logs """ - process = _running_processes.get(project_id) - - if not process or not process.stdout: - return "No logs available - process not running or no output" - - # Read available output without blocking - logs = [] - try: - # Set stdout to non-blocking mode - import fcntl - import os - fd = process.stdout.fileno() - flags = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) - - # Read available lines - while len(logs) < lines: - line = process.stdout.readline() - if not line: - break - logs.append(line) - - except (IOError, OSError): - # No more data available - pass - - return ''.join(logs[-lines:]) if logs else "No recent logs available" \ No newline at end of file + # Return recent aggregated logs stored in memory + logs = _process_logs.get(project_id, []) + if not logs: + return "No recent logs available" + return '\n'.join(logs[-lines:]) diff --git a/apps/api/app/services/mcp/__init__.py b/apps/api/app/services/mcp/__init__.py new file mode 100644 index 00000000..3c821386 --- /dev/null +++ b/apps/api/app/services/mcp/__init__.py @@ -0,0 +1,8 @@ +""" +MCP Service Module +Manages Model Context Protocol servers and client connections +""" +from .manager import MCPManager, mcp_manager +from .server import ClaudableMCPServer + +__all__ = ["MCPManager", "mcp_manager", "ClaudableMCPServer"] \ No newline at end of file diff --git a/apps/api/app/services/mcp/manager.py b/apps/api/app/services/mcp/manager.py new file mode 100644 index 00000000..a5b68a6c --- /dev/null +++ b/apps/api/app/services/mcp/manager.py @@ -0,0 +1,375 @@ +""" +MCP Server Manager +Manages MCP server processes and tool discovery +""" +import asyncio +import json +import subprocess +import signal +import os +import aiohttp +from typing import Dict, List, Optional, Any +from dataclasses import dataclass +from sqlalchemy.orm import Session + +from app.models.mcp_servers import MCPServer as MCPServerModel +from app.core.terminal_ui import ui + + +@dataclass +class MCPTool: + name: str + description: str + input_schema: Dict[str, Any] + + +@dataclass +class MCPServerProcess: + model: MCPServerModel + process: Optional[subprocess.Popen] + tools: List[MCPTool] + error: Optional[str] + sse_session: Optional[aiohttp.ClientSession] = None + sse_url: Optional[str] = None + + +class MCPManager: + """Manages MCP server processes and tool discovery""" + + def __init__(self): + self.running_servers: Dict[int, MCPServerProcess] = {} + + async def start_server(self, server_model: MCPServerModel, db: Session) -> bool: + """Start an MCP server and discover its tools""" + try: + if server_model.id in self.running_servers: + # Already running + return True + + ui.info(f"Starting MCP server: {server_model.name}", "MCP") + + # Build command + if server_model.transport == "stdio": + if not server_model.command: + raise ValueError("Command required for stdio transport") + + cmd = [server_model.command] + if server_model.args: + cmd.extend(server_model.args) + + # Set up environment + env = os.environ.copy() + if server_model.env: + env.update(server_model.env) + + # Start process + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + text=True + ) + + # Give it a moment to start + await asyncio.sleep(1) + + # Check if process is still running + if process.poll() is not None: + stdout, stderr = process.communicate() + error_msg = f"Process failed to start: {stderr or stdout}" + ui.error(error_msg, "MCP") + + # Update database status + server_model.status = {"running": False, "error": error_msg} + db.commit() + return False + + # Discover tools + tools = await self._discover_tools(process, server_model.name) + + # Store running server + self.running_servers[server_model.id] = MCPServerProcess( + model=server_model, + process=process, + tools=tools, + error=None + ) + + # Update database status + server_model.status = {"running": True, "tools": [{"name": t.name, "description": t.description} for t in tools]} + server_model.is_active = True + db.commit() + + ui.success(f"MCP server {server_model.name} started with {len(tools)} tools", "MCP") + return True + + elif server_model.transport == "sse": + if not server_model.url: + raise ValueError("URL required for SSE transport") + + ui.info(f"Connecting to SSE MCP server: {server_model.name}", "MCP") + + # Create SSE client session + timeout = aiohttp.ClientTimeout(total=30, connect=10) + session = aiohttp.ClientSession(timeout=timeout) + + try: + # Test connection + async with session.get(server_model.url) as response: + if response.status != 200: + raise ValueError(f"SSE server returned status {response.status}") + + # Discover tools via SSE + tools = await self._discover_tools_sse(session, server_model.url, server_model.name) + + # Store running server + self.running_servers[server_model.id] = MCPServerProcess( + model=server_model, + process=None, + tools=tools, + error=None, + sse_session=session, + sse_url=server_model.url + ) + + # Update database status + server_model.status = {"running": True, "tools": [{"name": t.name, "description": t.description} for t in tools]} + server_model.is_active = True + db.commit() + + ui.success(f"MCP server {server_model.name} connected with {len(tools)} tools", "MCP") + return True + + except Exception as e: + await session.close() + raise e + + except Exception as e: + error_msg = f"Failed to start MCP server {server_model.name}: {str(e)}" + ui.error(error_msg, "MCP") + + # Update database status + server_model.status = {"running": False, "error": error_msg} + db.commit() + return False + + async def stop_server(self, server_id: int, db: Session) -> bool: + """Stop an MCP server""" + try: + if server_id not in self.running_servers: + return True # Already stopped + + server_process = self.running_servers[server_id] + ui.info(f"Stopping MCP server: {server_process.model.name}", "MCP") + + # Handle stdio transport + if server_process.process: + # Terminate process + server_process.process.terminate() + + # Wait for graceful shutdown + try: + server_process.process.wait(timeout=5) + except subprocess.TimeoutExpired: + # Force kill if needed + server_process.process.kill() + server_process.process.wait() + + # Handle SSE transport + if server_process.sse_session: + await server_process.sse_session.close() + + # Remove from running servers + del self.running_servers[server_id] + + # Update database status + server_process.model.status = {"running": False} + server_process.model.is_active = False + db.commit() + + ui.success(f"MCP server {server_process.model.name} stopped", "MCP") + return True + + except Exception as e: + error_msg = f"Failed to stop MCP server: {str(e)}" + ui.error(error_msg, "MCP") + return False + + async def _discover_tools(self, process: subprocess.Popen, server_name: str) -> List[MCPTool]: + """Discover tools from an MCP server process""" + try: + # Send list_tools request + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + } + + request_json = json.dumps(request) + "\n" + + if process.stdin: + process.stdin.write(request_json) + process.stdin.flush() + + # Read response (with timeout) + if process.stdout: + try: + response_line = await asyncio.wait_for( + asyncio.to_thread(process.stdout.readline), + timeout=10.0 + ) + + if response_line: + response = json.loads(response_line.strip()) + + if "result" in response and "tools" in response["result"]: + tools = [] + for tool_data in response["result"]["tools"]: + tools.append(MCPTool( + name=tool_data.get("name", "unknown"), + description=tool_data.get("description", ""), + input_schema=tool_data.get("inputSchema", {}) + )) + return tools + + except asyncio.TimeoutError: + ui.warning(f"Timeout discovering tools for {server_name}", "MCP") + except json.JSONDecodeError: + ui.warning(f"Invalid JSON response from {server_name}", "MCP") + + return [] + + except Exception as e: + ui.error(f"Error discovering tools for {server_name}: {str(e)}", "MCP") + return [] + + async def _discover_tools_sse(self, session: aiohttp.ClientSession, url: str, server_name: str) -> List[MCPTool]: + """Discover tools from an SSE MCP server""" + try: + # Send list_tools request + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + } + + async with session.post(url, json=request, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status != 200: + ui.warning(f"SSE server returned status {response.status} for tools/list", "MCP") + return [] + + result = await response.json() + + if "result" in result and "tools" in result["result"]: + tools = [] + for tool_data in result["result"]["tools"]: + tools.append(MCPTool( + name=tool_data.get("name", "unknown"), + description=tool_data.get("description", ""), + input_schema=tool_data.get("inputSchema", {}) + )) + return tools + + return [] + + except asyncio.TimeoutError: + ui.warning(f"Timeout discovering tools for SSE server {server_name}", "MCP") + return [] + except Exception as e: + ui.error(f"Error discovering tools for SSE server {server_name}: {str(e)}", "MCP") + return [] + + def get_running_servers(self) -> Dict[int, MCPServerProcess]: + """Get all currently running servers""" + return self.running_servers.copy() + + def get_all_tools(self) -> List[tuple[str, MCPTool]]: + """Get all tools from all running servers""" + all_tools = [] + for server_id, server_process in self.running_servers.items(): + server_name = server_process.model.name + for tool in server_process.tools: + # Prefix tool names with server name + prefixed_name = f"{server_name}__{tool.name}" + all_tools.append((prefixed_name, tool)) + return all_tools + + async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + """Call a tool on an MCP server""" + try: + # Parse server and tool name + if "__" not in tool_name: + raise ValueError("Tool name must be in format 'server__tool'") + + server_name, actual_tool_name = tool_name.split("__", 1) + + # Find running server + server_process = None + for sp in self.running_servers.values(): + if sp.model.name == server_name: + server_process = sp + break + + if not server_process: + raise ValueError(f"Server {server_name} is not running") + + # Send tool call request + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": actual_tool_name, + "arguments": arguments + } + } + + # Handle stdio transport + if server_process.process: + request_json = json.dumps(request) + "\n" + + if server_process.process.stdin: + server_process.process.stdin.write(request_json) + server_process.process.stdin.flush() + + # Read response + if server_process.process.stdout: + response_line = await asyncio.wait_for( + asyncio.to_thread(server_process.process.stdout.readline), + timeout=30.0 + ) + + if response_line: + response = json.loads(response_line.strip()) + return response.get("result", {}) + + return {"error": "No response from server"} + + # Handle SSE transport + elif server_process.sse_session and server_process.sse_url: + async with server_process.sse_session.post( + server_process.sse_url, + json=request, + timeout=aiohttp.ClientTimeout(total=30) + ) as response: + if response.status != 200: + return {"error": f"SSE server returned status {response.status}"} + + result = await response.json() + return result.get("result", {}) + + else: + return {"error": "Server has no valid transport"} + + except asyncio.TimeoutError: + return {"error": f"Timeout calling tool {tool_name}"} + except Exception as e: + return {"error": f"Failed to call tool {tool_name}: {str(e)}"} + + +# Global MCP manager instance +mcp_manager = MCPManager() \ No newline at end of file diff --git a/apps/api/app/services/mcp/server.py b/apps/api/app/services/mcp/server.py new file mode 100644 index 00000000..21f4c9e5 --- /dev/null +++ b/apps/api/app/services/mcp/server.py @@ -0,0 +1,289 @@ +""" +Claudable MCP Server +Acts as an MCP server that exposes tools from managed MCP servers +""" +import asyncio +import json +import subprocess +from typing import List, Dict, Any, Optional +from mcp import ClientSession, StdioServerParameters +from mcp.server.models import InitializationOptions +from mcp.server import NotificationOptions, Server +from mcp.server.stdio import stdio_server +from mcp.types import ( + CallToolRequest, + ListToolsRequest, + Tool, + TextContent, + ImageContent, + EmbeddedResource, +) +from sqlalchemy.orm import Session + +from app.models.mcp_servers import MCPServer as MCPServerModel +from app.core.config import settings +from app.db.session import SessionLocal +from app.services.mcp.manager import mcp_manager + + +class ClaudableMCPServer: + """Claudable MCP Server that proxies tools from configured MCP servers""" + + def __init__(self): + self.server = Server("claudable-mcp-server") + self.managed_servers: Dict[str, Any] = {} + self.available_tools: List[Tool] = [] + + async def setup_handlers(self): + """Setup MCP request handlers""" + + @self.server.list_tools() + async def handle_list_tools() -> List[Tool]: + """List all available tools from managed MCP servers""" + await self._refresh_tools() + return self.available_tools + + @self.server.call_tool() + async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: + """Call a tool from a managed MCP server""" + try: + if name.startswith('claudable_'): + return await self._handle_claudable_tools(name, arguments) + elif '__' in name: + return await self._handle_proxied_tools(name, arguments) + else: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + except Exception as e: + return [TextContent(type="text", text=f"Error calling {name}: {str(e)}")] + + async def _refresh_tools(self): + """Refresh available tools from all running MCP servers""" + self.available_tools = [] + + # Add Claudable management tools + self.available_tools.extend([ + Tool( + name="claudable_list_mcp_servers", + description="List all MCP servers managed by Claudable", + inputSchema={ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "all"], + "description": "Filter by server status" + } + } + } + ), + Tool( + name="claudable_start_mcp_server", + description="Start an MCP server", + inputSchema={ + "type": "object", + "properties": { + "server_name": { + "type": "string", + "description": "Name of the server to start" + } + }, + "required": ["server_name"] + } + ), + Tool( + name="claudable_stop_mcp_server", + description="Stop an MCP server", + inputSchema={ + "type": "object", + "properties": { + "server_name": { + "type": "string", + "description": "Name of the server to stop" + } + }, + "required": ["server_name"] + } + ) + ]) + + # Add tools from running MCP servers + all_tools = mcp_manager.get_all_tools() + for tool_name, tool_data in all_tools: + self.available_tools.append(Tool( + name=tool_name, + description=tool_data.description, + inputSchema=tool_data.input_schema + )) + + async def _handle_claudable_tools(self, name: str, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle Claudable's own management tools""" + if name == "claudable_list_mcp_servers": + return await self._list_mcp_servers(arguments) + elif name == "claudable_start_mcp_server": + return await self._start_mcp_server(arguments) + elif name == "claudable_stop_mcp_server": + return await self._stop_mcp_server(arguments) + else: + return [TextContent(type="text", text=f"Unknown Claudable tool: {name}")] + + async def _handle_proxied_tools(self, name: str, arguments: Dict[str, Any]) -> List[TextContent]: + """Handle proxied tools from other MCP servers""" + try: + result = await mcp_manager.call_tool(name, arguments) + + if "error" in result: + return [TextContent( + type="text", + text=f"Error calling {name}: {result['error']}" + )] + + # Format the result as text + result_text = json.dumps(result, indent=2) + return [TextContent(type="text", text=result_text)] + + except Exception as e: + return [TextContent( + type="text", + text=f"Failed to call {name}: {str(e)}" + )] + + async def _list_mcp_servers(self, arguments: Dict[str, Any]) -> List[TextContent]: + """List configured MCP servers""" + try: + db = SessionLocal() + try: + servers_query = db.query(MCPServerModel).all() + + servers = [] + for server in servers_query: + status = "active" if server.is_active else "inactive" + servers.append({ + "id": server.id, + "name": server.name, + "status": status, + "transport": server.transport, + "command": server.command if server.transport == "stdio" else None, + "url": server.url if server.transport == "sse" else None + }) + + status_filter = arguments.get("status", "all") + if status_filter != "all": + servers = [s for s in servers if s["status"] == status_filter] + + return [TextContent( + type="text", + text=f"MCP Servers ({status_filter}):\n{json.dumps(servers, indent=2)}" + )] + finally: + db.close() + + except Exception as e: + return [TextContent( + type="text", + text=f"Error listing MCP servers: {str(e)}" + )] + + async def _start_mcp_server(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Start an MCP server""" + server_name = arguments.get("server_name") + if not server_name: + return [TextContent(type="text", text="Server name is required")] + + try: + db = SessionLocal() + try: + server = db.query(MCPServerModel).filter(MCPServerModel.name == server_name).first() + + if not server: + return [TextContent( + type="text", + text=f"MCP server '{server_name}' not found" + )] + + success = await mcp_manager.start_server(server, db) + + if success: + return [TextContent( + type="text", + text=f"Successfully started MCP server: {server_name}" + )] + else: + error_msg = server.status.get("error", "Unknown error") if server.status else "Unknown error" + return [TextContent( + type="text", + text=f"Failed to start MCP server: {error_msg}" + )] + finally: + db.close() + + except Exception as e: + return [TextContent( + type="text", + text=f"Error starting MCP server: {str(e)}" + )] + + async def _stop_mcp_server(self, arguments: Dict[str, Any]) -> List[TextContent]: + """Stop an MCP server""" + server_name = arguments.get("server_name") + if not server_name: + return [TextContent(type="text", text="Server name is required")] + + try: + db = SessionLocal() + try: + server = db.query(MCPServerModel).filter(MCPServerModel.name == server_name).first() + + if not server: + return [TextContent( + type="text", + text=f"MCP server '{server_name}' not found" + )] + + success = await mcp_manager.stop_server(server.id, db) + + if success: + return [TextContent( + type="text", + text=f"Successfully stopped MCP server: {server_name}" + )] + else: + return [TextContent( + type="text", + text=f"Failed to stop MCP server: {server_name}" + )] + finally: + db.close() + + except Exception as e: + return [TextContent( + type="text", + text=f"Error stopping MCP server: {str(e)}" + )] + + async def run(self): + """Run the Claudable MCP server""" + await self.setup_handlers() + + async with stdio_server() as (read_stream, write_stream): + await self.server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="claudable-mcp-server", + server_version="1.0.0", + capabilities=self.server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +async def main(): + """Main entry point for the Claudable MCP server""" + server = ClaudableMCPServer() + await server.run() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file 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/api/app/services/project_scanner.py b/apps/api/app/services/project_scanner.py new file mode 100644 index 00000000..b5a8ad88 --- /dev/null +++ b/apps/api/app/services/project_scanner.py @@ -0,0 +1,202 @@ +""" +Project Scanner Service +Scans filesystem for existing Claude projects and imports them +""" +import os +import json +import base64 +from pathlib import Path +from typing import List, Dict, Any, Optional +from datetime import datetime + +from app.core.terminal_ui import ui + + +class ProjectInfo: + def __init__(self, path: str, name: str, sessions: List[str], created_at: datetime): + self.path = path + self.name = name + self.sessions = sessions + self.created_at = created_at + + +class ProjectScanner: + """Scans for existing Claude projects""" + + @staticmethod + def scan_claude_projects() -> List[ProjectInfo]: + """Scan ~/.claude/projects for existing projects""" + projects = [] + claude_dir = Path.home() / ".claude" / "projects" + + if not claude_dir.exists(): + ui.info("No ~/.claude/projects directory found", "Scanner") + return projects + + try: + for project_dir in claude_dir.iterdir(): + if not project_dir.is_dir(): + continue + + # Decode project path from directory name + try: + decoded_path = base64.b64decode(project_dir.name + "==").decode('utf-8') + except: + # If decoding fails, use directory name as path + decoded_path = project_dir.name + + # Get project name from path + project_name = os.path.basename(decoded_path) or decoded_path + + # Find session files + sessions_dir = project_dir / "sessions" + sessions = [] + if sessions_dir.exists(): + for session_file in sessions_dir.glob("*.jsonl"): + sessions.append(session_file.stem) + + # Get creation time + created_at = datetime.fromtimestamp(project_dir.stat().st_ctime) + + projects.append(ProjectInfo( + path=decoded_path, + name=project_name, + sessions=sessions, + created_at=created_at + )) + + except Exception as e: + ui.error(f"Error scanning Claude projects: {str(e)}", "Scanner") + + ui.info(f"Found {len(projects)} existing Claude projects", "Scanner") + return projects + + @staticmethod + def scan_directory_for_projects(directory: str) -> List[ProjectInfo]: + """Scan a specific directory for potential projects""" + projects = [] + scan_path = Path(directory) + + if not scan_path.exists() or not scan_path.is_dir(): + return projects + + try: + # Look for common project indicators + project_indicators = [ + "package.json", + "pom.xml", + "Cargo.toml", + "requirements.txt", + "go.mod", + ".git", + "README.md" + ] + + for item in scan_path.iterdir(): + if not item.is_dir(): + continue + + # Check if directory contains project indicators + has_indicators = any( + (item / indicator).exists() + for indicator in project_indicators + ) + + if has_indicators: + project_name = item.name + created_at = datetime.fromtimestamp(item.stat().st_ctime) + + projects.append(ProjectInfo( + path=str(item), + name=project_name, + sessions=[], # No existing sessions for new projects + created_at=created_at + )) + + except Exception as e: + ui.error(f"Error scanning directory {directory}: {str(e)}", "Scanner") + + return projects + + @staticmethod + def get_project_metadata(project_path: str) -> Dict[str, Any]: + """Extract metadata from a project directory""" + metadata = { + "name": os.path.basename(project_path), + "path": project_path, + "type": "unknown", + "description": "", + "tech_stack": [], + "has_git": False + } + + project_dir = Path(project_path) + + try: + # Check for Git + if (project_dir / ".git").exists(): + metadata["has_git"] = True + + # Check project type + if (project_dir / "package.json").exists(): + metadata["type"] = "nodejs" + metadata["tech_stack"].append("Node.js") + + # Read package.json for more info + try: + with open(project_dir / "package.json", 'r') as f: + package_data = json.load(f) + if package_data.get("description"): + metadata["description"] = package_data["description"] + + # Detect frameworks + deps = {**package_data.get("dependencies", {}), **package_data.get("devDependencies", {})} + if "next" in deps: + metadata["tech_stack"].append("Next.js") + if "react" in deps: + metadata["tech_stack"].append("React") + if "vue" in deps: + metadata["tech_stack"].append("Vue.js") + if "typescript" in deps: + metadata["tech_stack"].append("TypeScript") + + except Exception: + pass + + elif (project_dir / "requirements.txt").exists(): + metadata["type"] = "python" + metadata["tech_stack"].append("Python") + + elif (project_dir / "Cargo.toml").exists(): + metadata["type"] = "rust" + metadata["tech_stack"].append("Rust") + + elif (project_dir / "go.mod").exists(): + metadata["type"] = "go" + metadata["tech_stack"].append("Go") + + # Check for README + for readme_name in ["README.md", "readme.md", "README.txt", "readme.txt"]: + readme_path = project_dir / readme_name + if readme_path.exists(): + try: + with open(readme_path, 'r', encoding='utf-8') as f: + content = f.read() + # Extract first non-empty line as description if we don't have one + if not metadata["description"]: + for line in content.split('\n'): + line = line.strip().lstrip('#').strip() + if line and len(line) > 10: + metadata["description"] = line[:200] + ("..." if len(line) > 200 else "") + break + except Exception: + pass + + except Exception as e: + ui.warning(f"Error extracting metadata from {project_path}: {str(e)}", "Scanner") + + return metadata + + +# Global scanner instance +project_scanner = ProjectScanner() \ No newline at end of file diff --git a/apps/api/app/services/token_tracker.py b/apps/api/app/services/token_tracker.py new file mode 100644 index 00000000..8ce7ad0d --- /dev/null +++ b/apps/api/app/services/token_tracker.py @@ -0,0 +1,106 @@ +""" +Token usage tracking service +Tracks real token usage across conversations +""" +from sqlalchemy.orm import Session +from typing import Dict, Any +from app.models.sessions import Session as ChatSession +from app.models.messages import Message +from app.core.terminal_ui import ui + + +class TokenTracker: + """Tracks token usage for conversations""" + + @staticmethod + def update_session_tokens( + session_id: str, + input_tokens: int, + output_tokens: int, + db: Session + ): + """Update token usage for a session""" + try: + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + if session: + # Add to existing totals (cumulative) + current_input = getattr(session, 'input_tokens', 0) or 0 + current_output = getattr(session, 'output_tokens', 0) or 0 + + session.input_tokens = current_input + input_tokens + session.output_tokens = current_output + output_tokens + + # Calculate cost (rough estimate based on Sonnet 4 pricing) + cost = (input_tokens * 0.000003) + (output_tokens * 0.000015) + current_cost = getattr(session, 'total_cost', 0) or 0 + session.total_cost = current_cost + cost + + db.commit() + + ui.info(f"Updated session tokens: +{input_tokens}in/+{output_tokens}out (total: {session.input_tokens}in/{session.output_tokens}out)", "TokenTracker") + + except Exception as e: + ui.error(f"Failed to update session tokens: {e}", "TokenTracker") + + @staticmethod + def get_conversation_totals(conversation_id: str, db: Session) -> Dict[str, Any]: + """Get total token usage for a conversation""" + try: + # Get all sessions for this conversation + sessions = db.query(ChatSession).filter( + ChatSession.conversation_id == conversation_id + ).all() + + total_input = sum(getattr(s, 'input_tokens', 0) or 0 for s in sessions) + total_output = sum(getattr(s, 'output_tokens', 0) or 0 for s in sessions) + total_cost = sum(getattr(s, 'total_cost', 0) or 0 for s in sessions) + + return { + "input_tokens": total_input, + "output_tokens": total_output, + "total_tokens": total_input + total_output, + "total_cost": total_cost, + "session_count": len(sessions) + } + + except Exception as e: + ui.error(f"Failed to get conversation totals: {e}", "TokenTracker") + return { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "total_cost": 0, + "session_count": 0 + } + + @staticmethod + def get_real_context_size(conversation_id: str, db: Session) -> int: + """Calculate the real context size being sent to the model""" + try: + # Get all messages in the conversation + messages = db.query(Message).filter( + Message.conversation_id == conversation_id + ).order_by(Message.created_at).all() + + total_chars = 0 + + # Count system prompt (estimate based on typical size) + total_chars += 25000 # Typical Claude Code system prompt size + + # Count all message content + for message in messages: + if message.content: + total_chars += len(message.content) + + # Convert characters to approximate tokens (rough: 1 token ≈ 4 characters) + estimated_tokens = total_chars // 4 + + return estimated_tokens + + except Exception as e: + ui.error(f"Failed to calculate context size: {e}", "TokenTracker") + return 0 + + +# Global token tracker instance +token_tracker = TokenTracker() \ No newline at end of file diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 36103732..1dace386 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -1,4 +1,4 @@ -fastapi>=0.112 +fastapi[all]>=0.112 uvicorn[standard]>=0.30 pydantic>=2.7 SQLAlchemy>=2.0 @@ -12,4 +12,5 @@ openai>=1.40 unidiff>=0.7 aiohttp>=3.9 rich>=13.0 -python-multipart>=0.0.6 \ No newline at end of file +python-multipart>=0.0.6 +mcp>=1.0.0 \ No newline at end of file diff --git a/apps/api/seed_mcp_servers.py b/apps/api/seed_mcp_servers.py new file mode 100644 index 00000000..2c2f9b37 --- /dev/null +++ b/apps/api/seed_mcp_servers.py @@ -0,0 +1,91 @@ +""" +Seed script to add default MCP servers to all existing projects +""" +import sys +from sqlalchemy.orm import Session +from app.db.session import SessionLocal +from app.models.projects import Project +from app.models.mcp_servers import MCPServer + +DEFAULT_MCP_SERVERS = [ + { + "name": "Memory Server", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "scope": "project", + "is_active": False, + }, + { + "name": "Fetch MCP", + "transport": "stdio", + "command": "npx", + "args": ["-y", "fetch-mcp"], + "scope": "project", + "is_active": False, + }, + { + "name": "Filesystem Server", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "scope": "project", + "is_active": False, + }, +] + + +def seed_mcp_servers(): + """Add default MCP servers to all projects""" + db: Session = SessionLocal() + + try: + # Get all projects + projects = db.query(Project).all() + print(f"Found {len(projects)} projects") + + for project in projects: + print(f"\nProcessing project: {project.name} ({project.id})") + + # Check existing servers for this project + existing_servers = db.query(MCPServer).filter( + MCPServer.project_id == project.id + ).all() + existing_names = {s.name for s in existing_servers} + + # Add missing default servers + added_count = 0 + for server_config in DEFAULT_MCP_SERVERS: + if server_config["name"] not in existing_names: + server = MCPServer( + project_id=project.id, + name=server_config["name"], + transport=server_config["transport"], + command=server_config["command"], + args=server_config["args"], + scope=server_config["scope"], + is_active=server_config["is_active"], + status={"running": False} + ) + db.add(server) + added_count += 1 + print(f" ✓ Added: {server_config['name']}") + else: + print(f" - Skipped (exists): {server_config['name']}") + + if added_count > 0: + db.commit() + print(f" Added {added_count} servers to {project.name}") + + print("\n✅ Seeding complete!") + + except Exception as e: + print(f"\n❌ Error seeding MCP servers: {str(e)}") + db.rollback() + sys.exit(1) + finally: + db.close() + + +if __name__ == "__main__": + seed_mcp_servers() \ No newline at end of file diff --git a/apps/api/test_minimal.py b/apps/api/test_minimal.py new file mode 100644 index 00000000..b0f4f553 --- /dev/null +++ b/apps/api/test_minimal.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +""" +Minimal FastAPI app to test Swagger is working +""" +from fastapi import FastAPI +from typing import List +from pydantic import BaseModel +import uvicorn + +# Create app with OpenAPI configuration +app = FastAPI( + title="Clovable API", + description="API for managing projects, chat sessions, and integrations", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json" +) + +# Sample model +class Project(BaseModel): + id: str + name: str + description: str | None = None + +# Sample endpoints with OpenAPI annotations +@app.get( + "/health", + summary="Health Check", + description="Check if the API is running", + tags=["health"] +) +def health(): + return {"status": "ok"} + +@app.get( + "/api/projects", + response_model=List[Project], + summary="List Projects", + description="Get a list of all projects", + tags=["projects"] +) +def list_projects(): + return [ + Project(id="project-1", name="Project 1", description="First project"), + Project(id="project-2", name="Project 2", description="Second project") + ] + +@app.post( + "/api/projects", + response_model=Project, + status_code=201, + summary="Create Project", + description="Create a new project", + tags=["projects"] +) +def create_project(project: Project): + return project + +if __name__ == "__main__": + print("\n🚀 Starting minimal test server...") + print("\n📚 Swagger UI will be available at: http://localhost:8002/docs") + print("📖 ReDoc will be available at: http://localhost:8002/redoc") + print("📄 OpenAPI JSON will be available at: http://localhost:8002/openapi.json") + print("\nPress Ctrl+C to stop the server\n") + + uvicorn.run(app, host="0.0.0.0", port=8002) \ No newline at end of file diff --git a/apps/api/test_swagger.py b/apps/api/test_swagger.py new file mode 100644 index 00000000..756e392a --- /dev/null +++ b/apps/api/test_swagger.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +""" +Quick test script to verify Swagger is working +""" +import subprocess +import time +import sys +import os +import signal + +def start_server(): + """Start the API server""" + env = os.environ.copy() + env['API_PORT'] = '8001' + proc = subprocess.Popen( + [sys.executable, '-m', 'uvicorn', 'app.main:app', '--port', '8001'], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + time.sleep(3) # Wait for server to start + return proc + +def test_endpoints(): + """Test the Swagger endpoints""" + import requests + + endpoints = [ + ("http://localhost:8001/docs", "Swagger UI"), + ("http://localhost:8001/redoc", "ReDoc"), + ("http://localhost:8001/openapi.json", "OpenAPI JSON"), + ] + + for url, name in endpoints: + try: + response = requests.get(url, timeout=5) + if response.status_code == 200: + print(f"✅ {name} is working at {url}") + if "openapi.json" in url: + data = response.json() + print(f" API Version: {data.get('info', {}).get('version', 'Unknown')}") + print(f" Title: {data.get('info', {}).get('title', 'Unknown')}") + else: + print(f"❌ {name} returned status {response.status_code}") + except Exception as e: + print(f"❌ Failed to access {name}: {e}") + +if __name__ == "__main__": + # Comment out server start since we'll run it manually + # proc = start_server() + + print("\n🔍 Testing Swagger/OpenAPI endpoints...\n") + test_endpoints() + + print("\n✨ Test complete!") + print("\nTo view the documentation, open your browser and go to:") + print(" - Swagger UI: http://localhost:8001/docs") + print(" - ReDoc: http://localhost:8001/redoc") \ No newline at end of file diff --git a/apps/web/app/[project_id]/chat/page.tsx b/apps/web/app/[project_id]/chat/page.tsx index 12220623..ff51850e 100644 --- a/apps/web/app/[project_id]/chat/page.tsx +++ b/apps/web/app/[project_id]/chat/page.tsx @@ -1,21 +1,158 @@ "use client"; -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; 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' +}; + +const formatTokenCount = (value: number) => value.toLocaleString(); + +const formatTokenLimit = (value: number) => { + if (value >= 1_000_000) return `${Math.round(value / 100_000) / 10}M`; + if (value >= 1_000) return `${Math.round(value / 1_000)}K`; + return `${value}`; +}; + +const TokenUsageBar = ({ + totalTokens, + userTokens, + assistantTokens, + systemTokens = 0, + toolTokens = 0, + limit, + assistantColor, +}: { + totalTokens: number; + userTokens: number; + assistantTokens: number; + systemTokens?: number; + toolTokens?: number; + limit: number; + assistantColor: string; +}) => { + const safeLimit = Math.max(limit || 1, 1); + const totalPercent = Math.min(totalTokens / safeLimit, 1); + const systemPercent = Math.min(systemTokens / safeLimit, totalPercent); + const toolPercent = Math.min(toolTokens / safeLimit, totalPercent - systemPercent); + const userPercent = Math.min(userTokens / safeLimit, totalPercent - systemPercent - toolPercent); + const assistantPercent = Math.min(assistantTokens / safeLimit, Math.max(totalPercent - systemPercent - toolPercent - userPercent, 0)); + + return ( +
+
+
+
+
+ System +
+
+
+ Tools +
+
+
+ User +
+
+
+ Agent +
+
+
+ Tokens: {formatTokenCount(totalTokens)} + {Math.round(totalPercent * 100)}% of {formatTokenLimit(safeLimit)} +
+
+
+ {systemPercent > 0 && ( +
+ )} + {toolPercent > 0 && ( +
+ )} + {userPercent > 0 && ( +
+ )} + {assistantPercent > 0 && ( +
+ )} + {totalPercent < 1 && ( +
+ )} +
+
+ ); +}; + +// 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'; @@ -153,6 +290,7 @@ export default function ChatPage({ params }: Params) { const [prompt, setPrompt] = useState(''); const [mode, setMode] = useState<'act' | 'chat'>('act'); const [isRunning, setIsRunning] = useState(false); + const [projectIsBuilding, setProjectIsBuilding] = useState(false); const [showPreview, setShowPreview] = useState(true); const [deviceMode, setDeviceMode] = useState<'desktop'|'mobile'>('desktop'); const [showGlobalSettings, setShowGlobalSettings] = useState(false); @@ -174,9 +312,132 @@ export default function ChatPage({ params }: Params) { const [deploymentStatus, setDeploymentStatus] = useState<'idle' | 'deploying' | 'ready' | 'error'>('idle'); const deployPollRef = useRef(null); const [isStartingPreview, setIsStartingPreview] = useState(false); + const [chatPaneWidth, setChatPaneWidth] = useState(420); + const chatPaneWidthRef = useRef(chatPaneWidth); + const [isResizingChatPane, setIsResizingChatPane] = useState(false); + const chatResizeStateRef = useRef<{ startX: number; startWidth: number } | null>(null); + + const clampChatPaneWidth = useCallback((width: number) => { + if (typeof window === 'undefined') return width; + const minWidth = 450; + const maxWidth = Math.max(minWidth, window.innerWidth - 480); + return Math.min(Math.max(width, minWidth), maxWidth); + }, []); + + const handleChatPanePointerMove = useCallback((event: PointerEvent) => { + const state = chatResizeStateRef.current; + if (!state) return; + const delta = event.clientX - state.startX; + const nextWidth = clampChatPaneWidth(state.startWidth + delta); + setChatPaneWidth(nextWidth); + }, [clampChatPaneWidth]); + + const handleChatPanePointerUp = useCallback(() => { + chatResizeStateRef.current = null; + setIsResizingChatPane(false); + if (typeof window !== 'undefined') { + window.removeEventListener('pointermove', handleChatPanePointerMove); + window.removeEventListener('pointerup', handleChatPanePointerUp); + } + }, [handleChatPanePointerMove]); + + const handleChatPanePointerDown = useCallback((event: ReactPointerEvent) => { + event.preventDefault(); + chatResizeStateRef.current = { + startX: event.clientX, + startWidth: chatPaneWidthRef.current + }; + setIsResizingChatPane(true); + if (typeof window !== 'undefined') { + window.addEventListener('pointermove', handleChatPanePointerMove); + window.addEventListener('pointerup', handleChatPanePointerUp); + } + event.currentTarget.setPointerCapture?.(event.pointerId); + }, [handleChatPanePointerMove, handleChatPanePointerUp]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const stored = window.localStorage.getItem('claudable:chatPaneWidth'); + if (stored) { + const parsed = parseInt(stored, 10); + if (!Number.isNaN(parsed)) { + setChatPaneWidth(clampChatPaneWidth(parsed)); + return; + } + } + setChatPaneWidth(clampChatPaneWidth(Math.round(window.innerWidth * 0.28))); + }, [clampChatPaneWidth]); + + useEffect(() => { + chatPaneWidthRef.current = chatPaneWidth; + if (typeof window !== 'undefined') { + window.localStorage.setItem('claudable:chatPaneWidth', String(chatPaneWidth)); + } + }, [chatPaneWidth]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const handleResize = () => { + setChatPaneWidth(prev => clampChatPaneWidth(prev)); + }; + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [clampChatPaneWidth]); + + useEffect(() => { + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('pointermove', handleChatPanePointerMove); + window.removeEventListener('pointerup', handleChatPanePointerUp); + } + }; + }, [handleChatPanePointerMove, handleChatPanePointerUp]); const [previewInitializationMessage, setPreviewInitializationMessage] = useState('Starting development server...'); const [preferredCli, setPreferredCli] = useState('claude'); + const [selectedModel, setSelectedModel] = useState(''); + const [usingGlobalDefaults, setUsingGlobalDefaults] = useState(true); + const [showAssistantDropdown, setShowAssistantDropdown] = useState(false); + const [showModelDropdown, setShowModelDropdown] = useState(false); const [thinkingMode, setThinkingMode] = useState(false); + const [currentRoute, setCurrentRoute] = useState('/'); + const iframeRef = useRef(null); + const [isFileUpdating, setIsFileUpdating] = useState(false); + const conversationTokenLimit = useMemo(() => { + if (preferredCli === 'claude') { + const model = selectedModel?.toLowerCase() || ''; + if (model.includes('20250514') || model.includes('1m')) return 1_000_000; // 1M context model + if (model.includes('opus')) return 200_000; + if (model.includes('haiku')) return 64_000; + return 262_144; // Regular Sonnet 4 + } + if (preferredCli === 'cursor') return 128_000; + if (preferredCli === 'qwen') return 128_000; + if (preferredCli === 'gemini') return 2_000_000; // Gemini has large context + return 200_000; + }, [preferredCli, selectedModel]); + + const [tokenUsage, setTokenUsage] = useState({ + totalTokens: 0, + userTokens: 0, + assistantTokens: 0, + limit: conversationTokenLimit, + }); + + useEffect(() => { + setTokenUsage((prev) => ({ + ...prev, + limit: conversationTokenLimit, + })); + }, [conversationTokenLimit]); + + const handleTokenUsageChange = useCallback((usage: { totalTokens: number; userTokens: number; assistantTokens: number; systemTokens?: number; toolTokens?: number; limit: number }) => { + setTokenUsage({ + ...usage, + limit: conversationTokenLimit // Use current limit, not the one from ChatLog + }); + }, [conversationTokenLimit]); // Guarded trigger that can be called from multiple places safely const triggerInitialPromptIfNeeded = useCallback(() => { @@ -186,6 +447,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 +645,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 +654,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 +810,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 +1000,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 +1068,33 @@ 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)}`); + setProjectIsBuilding(project.is_building || false); + + // 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 +1133,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 +1145,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 +1184,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 +1210,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 +1266,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); @@ -1028,20 +1435,29 @@ export default function ChatPage({ params }: Params) { const previousActiveState = useRef(false); useEffect(() => { - // Task 시작 시 - preview 서버 중지 - if (hasActiveRequests && previewUrl) { - console.log('🔄 Auto-stopping preview server due to active request'); - stop(); - } - - // Task 완료 시 - preview 서버 자동 시작 - if (previousActiveState.current && !hasActiveRequests && !previewUrl) { - console.log('✅ Task completed, auto-starting preview server'); + if (!hasActiveRequests && !previewUrl && !isStartingPreview) { + if (!previousActiveState.current) { + console.log('🔄 Preview not running; auto-starting'); + } else { + console.log('✅ Task completed, ensuring preview server is running'); + } start(); } - + previousActiveState.current = hasActiveRequests; - }, [hasActiveRequests, previewUrl]); + }, [hasActiveRequests, previewUrl, isStartingPreview]); + + // 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; @@ -1050,11 +1466,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 +1517,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 @@ -1213,25 +1650,25 @@ export default function ChatPage({ params }: Params) {
{/* 왼쪽: 채팅창 */}
{/* 채팅 헤더 */} -
+
-

{projectName || 'Loading...'}

+

{projectName || 'Loading...'}

{projectDescription && ( -

+

{projectDescription}

)} @@ -1242,10 +1679,15 @@ export default function ChatPage({ params }: Params) { {/* 채팅 로그 영역 */}
{ console.log('🔍 [DEBUG] Session status change:', isRunningValue); - setIsRunning(isRunningValue); + if (isRunningValue) { + setIsRunning(true); + setProjectIsBuilding(true); + } // Agent 작업 완료 상태 추적 및 자동 preview 시작 if (!isRunningValue && hasInitialPrompt && !agentWorkComplete && !previewUrl) { setAgentWorkComplete(true); @@ -1263,9 +1705,9 @@ export default function ChatPage({ params }: Params) { {/* 간단한 입력 영역 */}
- { - runAct(message); + { + runAct(message, images); }} disabled={isRunning} placeholder={mode === 'act' ? "Ask Claudable..." : "Chat with Claudable..."} @@ -1273,14 +1715,88 @@ export default function ChatPage({ params }: Params) { onModeChange={setMode} projectId={projectId} preferredCli={preferredCli} + selectedModel={selectedModel} thinkingMode={thinkingMode} onThinkingModeChange={setThinkingMode} + isProcessing={isRunning || projectIsBuilding} + onStop={async () => { + try { + const response = await fetch(`${API_BASE}/api/chat/${projectId}/stop`, { + method: 'POST' + }); + if (response.ok) { + setIsRunning(false); + setProjectIsBuilding(false); + } + } catch (error) { + console.error('Failed to stop execution:', error); + } + }} + onAssistantChange={(assistant) => { + setPreferredCli(assistant); + setUsingGlobalDefaults(false); + }} + onModelChange={(model) => { + setSelectedModel(model); + setUsingGlobalDefaults(false); + }} + availableModels={ + preferredCli === 'claude' ? [ + { id: 'claude-sonnet-4', name: 'Sonnet 4' }, + { id: 'claude-sonnet-4-20250514', name: 'Sonnet 4 [1m]' }, + { id: 'claude-opus-4.1', name: 'Opus 4.1' } + ] : preferredCli === 'cursor' ? [ + { id: 'gpt-5', name: 'GPT-5' }, + { id: 'claude-sonnet-4', name: 'Sonnet 4' }, + { id: 'claude-opus-4.1', name: 'Opus 4.1' } + ] : preferredCli === 'codex' ? [ + { id: 'gpt-5', name: 'GPT-5' } + ] : preferredCli === 'qwen' ? [ + { id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus' } + ] : preferredCli === 'gemini' ? [ + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' } + ] : [] + } + /> +
+
{ + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + const delta = event.key === 'ArrowLeft' ? -20 : 20; + const next = clampChatPaneWidth(chatPaneWidthRef.current + delta); + setChatPaneWidth(next); + } + }} + className={`relative w-[10px] flex-shrink-0 cursor-col-resize group ${isResizingChatPane ? 'bg-gray-200/60 dark:bg-white/10' : 'bg-transparent'}`} + > + +
+ {/* 오른쪽: Preview/Code 영역 */} -
+
{/* 컨텐츠 영역 */}
{/* Controls Bar */} @@ -1310,60 +1826,92 @@ 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 +1920,31 @@ export default function ChatPage({ params }: Params) { {/* Settings Button */} + {/* Stop Button */} + {showPreview && previewUrl && ( + + )} + {/* Publish/Update */} {showPreview && previewUrl && (
- {showPublishPanel && ( + {false && showPublishPanel && (

Publish Project

@@ -1411,11 +1968,11 @@ export default function ChatPage({ params }: Params) {
)} - {deploymentStatus === 'ready' && publishedUrl && ( + {deploymentStatus === 'ready' && publishedUrl ? (

Currently published at:

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