diff --git a/.github/mock_server.py b/.github/mock_server.py new file mode 100644 index 0000000..ac765c6 --- /dev/null +++ b/.github/mock_server.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Mock server for testing CLI commands in CI +""" + +import http.server +import socketserver +import json +import threading +import time +import sys + +class MockHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/project/test-123': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + response = { + 'project_id': 'test-123', + 'status': 'completed', + 'message': 'Test project', + 'files': ['Cargo.toml', 'src/main.rs'] + } + self.wfile.write(json.dumps(response).encode()) + elif self.path == '/project/test-123/files/Cargo.toml': + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + self.wfile.write(b'[package]\nname = "test"\nversion = "0.1.0"') + elif self.path == '/project/test-123/files/src/main.rs': + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + self.wfile.write(b'fn main() { println!("Hello"); }') + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == '/compile': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + response = {'success': True, 'run_output': 'Hello'} + self.wfile.write(json.dumps(response).encode()) + elif self.path == '/compile-and-fix': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + response = {'success': True, 'combined_text': '[filename: src/main.rs]\nfn main() { println!("Fixed!"); }'} + self.wfile.write(json.dumps(response).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass + +def main(): + port = 8001 + if len(sys.argv) > 1: + port = int(sys.argv[1]) + + with socketserver.TCPServer(('', port), MockHandler) as httpd: + print(f'Mock server running on port {port}') + server_thread = threading.Thread(target=httpd.serve_forever) + server_thread.daemon = True + server_thread.start() + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("Shutting down mock server...") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/.github/workflows/test-mcp-server.yml b/.github/workflows/test-mcp-server.yml index 738a0e4..9d1c703 100644 --- a/.github/workflows/test-mcp-server.yml +++ b/.github/workflows/test-mcp-server.yml @@ -1,16 +1,16 @@ -name: Test API Endpoints +name: Test API Endpoints and CLI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: jobs: test: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4.2.2 @@ -18,22 +18,22 @@ jobs: - name: Install Python and dependencies uses: actions/setup-python@v4 with: - python-version: '3.10' - + python-version: "3.10" + - name: Install jq and curl run: sudo apt-get install -y jq curl - + - name: Install Python dependencies run: pip install -r requirements.txt - name: Set up env run: | - echo "LLM_API_BASE=${{ env.LLM_API_BASE }}" > .env.temp - echo "LLM_API_KEY=${{ secrets.LLM_API_KEY }}" >> .env.temp - echo "LLM_MODEL=${{ env.LLM_MODEL }}" >> .env.temp - echo "LLM_EMBED_MODEL=${{ env.LLM_EMBED_MODEL }}" >> .env.temp - echo "LLM_EMBED_SIZE=${{ env.LLM_EMBED_SIZE }}" >> .env.temp - + echo "LLM_API_BASE=${{ env.LLM_API_BASE }}" > .env.temp + echo "LLM_API_KEY=${{ secrets.LLM_API_KEY }}" >> .env.temp + echo "LLM_MODEL=${{ env.LLM_MODEL }}" >> .env.temp + echo "LLM_EMBED_MODEL=${{ env.LLM_EMBED_MODEL }}" >> .env.temp + echo "LLM_EMBED_SIZE=${{ env.LLM_EMBED_SIZE }}" >> .env.temp + - name: Run docker compose uses: hoverkraft-tech/compose-action@v2.0.1 with: @@ -75,14 +75,14 @@ jobs: docker logs $(docker ps -q --filter name=api) exit 1 fi - + # Check for success in response if ! echo "$RESPONSE" | jq -e '.success == true' > /dev/null; then echo "Compilation failed:" echo "$RESPONSE" | jq || echo "$RESPONSE" exit 1 fi - + echo "Compilation successful!" echo "$RESPONSE" | jq || echo "$RESPONSE" @@ -103,21 +103,21 @@ jobs: docker logs $(docker ps -q --filter name=api) exit 1 fi - + # Check for success in response if ! echo "$RESPONSE" | jq -e '.success == true' > /dev/null; then echo "Compilation failed:" echo "$RESPONSE" | jq || echo "$RESPONSE" exit 1 fi - + echo "Compilation successful!" echo "$RESPONSE" | jq || echo "$RESPONSE" - name: Test /generate endpoint run: | echo "Testing /generate endpoint..." - + # Generate the project RESPONSE=$(curl -s -S -f -X POST http://localhost:8000/generate \ -H "Content-Type: application/json" \ @@ -132,11 +132,11 @@ jobs: docker logs $(docker ps -q --filter name=api) exit 1 fi - + # Extract project_id from response PROJECT_ID=$(echo "$RESPONSE" | jq -r '.project_id') echo "Project ID: $PROJECT_ID" - + # Poll for project completion (maximum 10 attempts, 15 seconds apart) echo "Polling for project completion..." for i in {1..10}; do @@ -170,52 +170,52 @@ jobs: echo "Waiting 15 seconds before next check..." sleep 15 done - + # Get a file from the project to verify file access works echo "Retrieving main.rs file..." FILE_RESPONSE=$(curl -s -S -f "http://localhost:8000/project/$PROJECT_ID/files/src/main.rs" || echo "CURL_FAILED") - + if [ "$FILE_RESPONSE" = "CURL_FAILED" ]; then echo "Failed to retrieve file" exit 1 fi - + echo "Successfully retrieved file content:" echo "$FILE_RESPONSE" | head -10 - + # Test downloading the project echo "Testing project download..." DOWNLOAD_RESPONSE=$(curl -s -S -f -o "project-$PROJECT_ID.zip" "http://localhost:8000/project/$PROJECT_ID/download" || echo "CURL_FAILED") - + if [ "$DOWNLOAD_RESPONSE" = "CURL_FAILED" ]; then echo "Failed to download project" exit 1 fi - + # Verify zip file was created if [ ! -f "project-$PROJECT_ID.zip" ]; then echo "Project zip file not created" exit 1 fi - + echo "Project download successful!" ls -la "project-$PROJECT_ID.zip" - name: Test /generate-sync endpoint - continue-on-error: true # Allow this step to fail without failing the workflow + continue-on-error: true # Allow this step to fail without failing the workflow id: test-generate-sync run: | echo "Testing /generate-sync endpoint..." RESPONSE=$(curl -X POST http://localhost:8000/generate-sync \ -H "Content-Type: application/json" \ -d '{"description": "A command-line calculator in Rust", "requirements": "Should support addition, subtraction, multiplication, and division"}') - + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/generate-sync \ -H "Content-Type: application/json" \ -d '{"description": "A command-line calculator in Rust", "requirements": "Should support addition, subtraction, multiplication, and division"}') echo "HTTP response code: $HTTP_CODE" - + if [[ "$HTTP_CODE" == "500" ]] && ([[ "$RESPONSE" == *"Invalid API Key"* ]] || [[ "$RESPONSE" == *"local variable"* ]] || [[ "$RESPONSE" == *"Connection error"* ]]); then echo "LLM service error detected - this is expected with invalid API keys or connection issues" echo "status=auth_error" >> $GITHUB_OUTPUT @@ -228,10 +228,10 @@ jobs: echo "status=error" >> $GITHUB_OUTPUT exit 1 fi - + # Save response to file for later use echo "$RESPONSE" > generate_output.txt - + # Check for success in response if ! echo "$RESPONSE" | jq -e '.success == true' > /dev/null; then echo "Generation failed:" @@ -239,7 +239,7 @@ jobs: echo "$RESPONSE" | jq || echo "$RESPONSE" exit 1 fi - + echo "Generate-sync successful! Response contains code files in text format." echo "status=success" >> $GITHUB_OUTPUT echo "$RESPONSE" | jq || echo "$RESPONSE" @@ -247,12 +247,12 @@ jobs: - name: "Test workflow: /generate-sync → /compile" run: | echo "Testing workflow: /generate-sync → /compile..." - + # Check if response contains fallback template if grep -q "FALLBACK TEMPLATE" generate_output.txt; then echo "WARNING: Testing with fallback template code - LLM generation failed but continuing with tests" fi - + # Get the output from the previous step and remove the build status comment # GENERATE_OUTPUT=$(cat generate_output.txt | sed '/^# Build/,$d') # COMPILE_RESPONSE=$(curl -s -S -f -X POST http://localhost:8000/compile \ @@ -267,20 +267,163 @@ jobs: -d "{ \"code\": $(python3 -c "import json, sys; print(json.dumps(sys.stdin.read()))" < <(echo "$GENERATE_OUTPUT")) }" || echo "CURL_FAILED") - + if [ "$COMPILE_RESPONSE" = "CURL_FAILED" ]; then echo "Failed to connect to API service" docker ps exit 1 fi - + # Check for success in response if ! echo "$COMPILE_RESPONSE" | jq -e '.success == true' > /dev/null; then echo "Compilation failed:" echo "$COMPILE_RESPONSE" | jq || echo "$COMPILE_RESPONSE" exit 1 fi - + echo "Workflow test successful! Generated code compiles correctly." echo "$COMPILE_RESPONSE" | jq || echo "$COMPILE_RESPONSE" + test-cli: + runs-on: ubuntu-latest + needs: test # Wait for API tests to complete first + + steps: + - name: Checkout code + uses: actions/checkout@v4.2.2 + + - name: Install Python and dependencies + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install Python dependencies + run: pip install -r requirements.txt + + - name: Test CLI help and version + run: | + echo "Testing CLI basic functionality..." + + # Test help + python -m cli.main --help + + # Test version command + python -m cli.main version + python -m cli.main version --json + + # Test completions command + python -m cli.main completions + + echo "CLI basic commands working ✅" + + - name: Test CLI with mock server + run: | + echo "Testing CLI commands with mock server..." + + # Start mock server from separate file + python .github/mock_server.py & + MOCK_PID=$! + sleep 3 + + # Verify mock server is running + echo "Checking if mock server is running..." + if ! curl -s http://localhost:8001/project/test-123 > /dev/null; then + echo "Mock server not responding, starting again..." + kill $MOCK_PID 2>/dev/null || true + sleep 2 + python .github/mock_server.py & + MOCK_PID=$! + sleep 3 + fi + + # Test CLI commands against mock server + echo "Testing CLI commands..." + + # Test compile from file + echo "[filename: Cargo.toml]\n[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[filename: src/main.rs]\nfn main() { println!(\"Hello\"); }" > test_project.txt + + echo "Testing compile from file..." + python -m cli.main --server http://localhost:8001 compile --code test_project.txt + + # Test compile from project ID + echo "Testing compile from project ID..." + python -m cli.main --server http://localhost:8001 compile --project test-123 + + # Test fix from file + echo "Testing fix from file..." + python -m cli.main --server http://localhost:8001 fix --code test_project.txt --description "test project" --max-attempts 2 + + # Test fix from project ID + echo "Testing fix from project ID..." + python -m cli.main --server http://localhost:8001 fix --project test-123 --description "test project" --max-attempts 2 + + # Test status command + echo "Testing status command..." + python -m cli.main --server http://localhost:8001 status --project test-123 + + # Test cat command + echo "Testing cat command..." + python -m cli.main --server http://localhost:8001 cat --project test-123 --file src/main.rs + + # Test download command (should fail gracefully with mock server) + echo "Testing download command..." + python -m cli.main --server http://localhost:8001 download --project test-123 || echo "Download failed as expected with mock server" + + # Clean up + kill $MOCK_PID 2>/dev/null || true + rm -f test_project.txt + + echo "CLI command tests completed ✅" + + - name: Test CLI error handling + run: | + echo "Testing CLI error handling..." + + # Test missing required arguments + python -m cli.main compile 2>&1 | grep -q "Error: Must specify either --code or --project" && echo "Missing args error handled ✅" || exit 1 + + # Test conflicting arguments + python -m cli.main compile --code test.txt --project test-123 2>&1 | grep -q "Error: Cannot specify both --code and --project" && echo "Conflicting args error handled ✅" || exit 1 + + # Test non-existent file + python -m cli.main compile --code nonexistent.txt 2>&1 | grep -q "File not found" && echo "File not found error handled ✅" || exit 1 + + # Test non-existent project + python -m cli.main --server http://localhost:8001 compile --project nonexistent 2>&1 | grep -q "Project not found" && echo "Project not found error handled ✅" || exit 1 + + echo "CLI error handling tests completed ✅" + + - name: Test CLI JSON output + run: | + echo "Testing CLI JSON output..." + + # Start mock server again for JSON tests + python .github/mock_server.py & + MOCK_PID=$! + sleep 3 + + # Create test project file for JSON tests + echo "[filename: Cargo.toml]\n[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[filename: src/main.rs]\nfn main() { println!(\"Hello\"); }" > test_project.txt + + # Test JSON output with error checking + echo "Testing version --json..." + VERSION_OUTPUT=$(python -m cli.main --server http://localhost:8001 version --json 2>/dev/null || echo "") + if [ -n "$VERSION_OUTPUT" ]; then + echo "$VERSION_OUTPUT" | python -c "import json, sys; json.load(sys.stdin); print('Version JSON output valid ✅')" + else + echo "Version command failed, skipping JSON validation" + fi + + echo "Testing compile --json..." + COMPILE_OUTPUT=$(python -m cli.main --server http://localhost:8001 compile --code test_project.txt --json 2>/dev/null || echo "") + if [ -n "$COMPILE_OUTPUT" ]; then + echo "$COMPILE_OUTPUT" | python -c "import json, sys; json.load(sys.stdin); print('Compile JSON output valid ✅')" + else + echo "Compile command failed, skipping JSON validation" + fi + + # Clean up + kill $MOCK_PID 2>/dev/null || true + rm -f test_project.txt + + echo "CLI JSON output tests completed ✅" diff --git a/.gitignore b/.gitignore index f12497f..8a5059d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env qdrant_data __pycache__ +venv \ No newline at end of file diff --git a/README.md b/README.md index 0f163a1..c362858 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,167 @@ fn main() { --- +## 🖥️ Command Line Interface (CLI) + +The RustCoder CLI provides a command-line interface to interact with the API endpoints and manage projects. + +### 📦 Installation + +Install the CLI dependencies: + +```bash +pip install -r requirements.txt +``` + +### 🚀 Basic Usage + +Run the CLI with: + +```bash +python -m cli.main --help +``` + +Set the server URL via environment variable or flag: + +```bash +export RUSTCODER_SERVER=http://localhost:8000 +# or +python -m cli.main --server http://localhost:8000 +``` + +### 📋 Available Commands + +#### 🔍 Project Generation + +**Generate a project (async):** +```bash +python -m cli.main generate --description "A command-line calculator in Rust" +``` + +**Generate a project (sync):** +```bash +python -m cli.main generate --description "A web API with actix" --sync +``` + +**Generate with requirements file:** +```bash +python -m cli.main generate --description "CLI tool" --requirements requirements.txt +``` + +#### 📊 Project Management + +**Check project status:** +```bash +python -m cli.main status --project +``` + +**Watch project until completion:** +```bash +python -m cli.main status --project --watch +``` + +**Download project as zip:** +```bash +python -m cli.main download --project --out my_project.zip +``` + +**View project file contents:** +```bash +python -m cli.main cat --project --file src/main.rs +``` + +#### 🛠️ Code Compilation & Fixing + +**Compile Rust code from file:** +```bash +python -m cli.main compile --code ./artifacts/multifile.txt +``` + +**Compile Rust code from project ID:** +```bash +python -m cli.main compile --project +``` + +**Auto-fix compilation errors from file:** +```bash +python -m cli.main fix --code ./artifacts/multifile.txt --description "hello world" --max-attempts 5 +``` + +**Auto-fix compilation errors from project ID:** +```bash +python -m cli.main fix --project --description "hello world" --max-attempts 5 +``` + + + +#### ℹ️ Utility Commands + +**Show version info:** +```bash +python -m cli.main version +``` + +**Show version as JSON:** +```bash +python -m cli.main version --json +``` + +**Get shell completions:** +```bash +python -m cli.main completions +``` + +### 🔄 Complete Workflow Example + +1. **Generate a project:** + ```bash + python -m cli.main generate --description "A simple HTTP server" --sync + ``` + +2. **Save the output to a file:** + ```bash + python -m cli.main generate --description "A simple HTTP server" --sync > project.txt + ``` + +3. **Compile the project:** + ```bash + python -m cli.main compile --code project.txt + ``` + +4. **If compilation fails, auto-fix:** + ```bash + python -m cli.main fix --code project.txt --description "A simple HTTP server" --max-attempts 3 + ``` + +### 📁 Input File Format + +The CLI expects multi-file input in the format: + +``` +[filename: Cargo.toml] +[package] +name = "my_project" +version = "0.1.0" +edition = "2021" + +[filename: src/main.rs] +fn main() { + println!("Hello, world!"); +} +``` + +### 🌐 Environment Variables + +- `RUSTCODER_SERVER`: Base URL of the RustCoder API (default: `http://localhost:8000`) + +### 📝 Output Formats + +- **Human-readable**: Default output with colors and formatting +- **JSON**: Use `--json` flag for machine-readable output +- **File redirection**: Pipe output to files or other commands + +--- + ## 🔧 MCP (Model-Compiler-Processor) tools The MCP server is available via the HTTP SSE transport via the `http://localhost:3000/sse` URL. The MCP server can be accessed using the [cmcp command-line client](https://github.com/RussellLuo/cmcp). To install the `cmcp` tool, diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..a8e7ea2 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,346 @@ +import json +import os +import sys +import time +from typing import Optional + +import requests +import typer +from rich import print as rprint +from rich.console import Console +from rich.json import JSON +from rich.table import Table + +app = typer.Typer(add_completion=False, no_args_is_help=True, help="RustCoder CLI - talk to the API and manage jobs") + + +def get_server_url(server: Optional[str]) -> str: + env_url = os.getenv("RUSTCODER_SERVER", "").strip() + url = (server or env_url or "http://localhost:8000").rstrip("/") + return url + + +@app.callback() +def global_options( + ctx: typer.Context, + server: Optional[str] = typer.Option(None, "--server", help="Base URL of RustCoder API (env: RUSTCODER_SERVER)") +): + ctx.obj = {"server": get_server_url(server)} + + +@app.command() +def version(json_out: bool = typer.Option(False, "--json", help="Output as JSON")): + """Show CLI and API endpoint info.""" + data = { + "cli": "rustcoder-cli", + "python": sys.version.split()[0], + } + if json_out: + rprint(JSON.from_data(data)) + else: + table = Table(title="RustCoder CLI") + table.add_column("Key", style="cyan", no_wrap=True) + table.add_column("Value") + for k, v in data.items(): + table.add_row(k, str(v)) + Console().print(table) + + +@app.command() +def completions(shell: Optional[str] = typer.Argument(None, help="Shell type: bash|zsh|fish|powershell")): + """Print shell completion script.""" + # Typer provides completion via Click; rely on `--help` guidance for now + rprint("[yellow]Note:[/yellow] Completions via Typer/Click are typically installed by your environment. Use: python -m typer cli.main utils completion [shell]") + + +@app.command() +def generate( + description: str = typer.Option(..., "--description", help="Project description"), + requirements: Optional[str] = typer.Option(None, "--requirements", help="Requirements text or path to file"), + sync: bool = typer.Option(False, "--sync", help="Run synchronously and return files"), + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), + ctx: typer.Context = typer.Option(None), +): + """Generate a Rust project (sync uses /generate-sync, else /generate).""" + base = ctx.obj["server"] + + req_text = requirements + if requirements and os.path.exists(requirements): + with open(requirements, "r") as f: + req_text = f.read() + + payload = {"description": description, "requirements": req_text} + + if sync: + url = f"{base}/generate-sync" + resp = requests.post(url, json=payload, timeout=120) + resp.raise_for_status() + data = resp.json() + if json_out: + rprint(JSON.from_data(data)) + else: + rprint("[green]Generation completed[/green]") + rprint(data.get("message", "")) + if "combined_text" in data: + rprint("\n[bold]Combined files:[/bold]\n") + sys.stdout.write(data["combined_text"] + "\n") + else: + url = f"{base}/generate" + resp = requests.post(url, json=payload, timeout=30) + resp.raise_for_status() + data = resp.json() + if json_out: + rprint(JSON.from_data(data)) + else: + rprint(f"[green]Started[/green] project_id=[bold]{data.get('project_id')}[/bold]") + + +@app.command() +def status( + project: str = typer.Option(..., "--project", help="Project ID"), + watch: bool = typer.Option(False, "--watch", help="Poll until terminal state"), + interval: float = typer.Option(2.0, "--interval", help="Watch polling interval seconds"), + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), + ctx: typer.Context = typer.Option(None), +): + """Get status for an async project.""" + base = ctx.obj["server"] + url = f"{base}/project/{project}" + while True: + resp = requests.get(url, timeout=15) + if resp.status_code == 404: + rprint(f"[red]Project not found:[/red] {project}") + raise typer.Exit(1) + resp.raise_for_status() + data = resp.json() + if json_out: + rprint(JSON.from_data(data)) + else: + rprint(f"status=[bold]{data.get('status')}[/bold] message={data.get('message', '')}") + files = data.get("files") or [] + if files: + rprint(f"files: {', '.join(files)}") + if not watch or data.get("status") in {"completed", "failed"}: + break + time.sleep(interval) + + +@app.command() +def download( + project: str = typer.Option(..., "--project", help="Project ID"), + out: str = typer.Option(None, "--out", help="Output zip path"), + ctx: typer.Context = typer.Option(None), +): + """Download a generated project as zip.""" + base = ctx.obj["server"] + url = f"{base}/project/{project}/download" + resp = requests.get(url, stream=True, timeout=120) + if resp.status_code == 404: + rprint(f"[red]Project not found:[/red] {project}") + raise typer.Exit(1) + resp.raise_for_status() + out_path = out or f"project-{project}.zip" + with open(out_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + rprint(f"[green]Saved:[/green] {out_path}") + + +@app.command(name="cat") +def cat_file( + project: str = typer.Option(..., "--project", help="Project ID"), + file: str = typer.Option(..., "--file", help="File path inside project"), + ctx: typer.Context = typer.Option(None), +): + """Print a single file from a generated project.""" + base = ctx.obj["server"] + url = f"{base}/project/{project}/files/{file}" + resp = requests.get(url, timeout=30) + if resp.status_code == 404: + rprint("[red]File or project not found[/red]") + raise typer.Exit(1) + resp.raise_for_status() + sys.stdout.write(resp.text) + + +@app.command() +def compile( + code: Optional[str] = typer.Option(None, "--code", help="Path to multi-file text input in [filename: ] blocks"), + project: Optional[str] = typer.Option(None, "--project", help="Project ID to compile (fetches files from API)"), + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), + ctx: typer.Context = typer.Option(None), +): + """Compile a Rust project from multi-file text format via /compile.""" + base = ctx.obj["server"] + + if not code and not project: + rprint("[red]Error: Must specify either --code or --project[/red]") + raise typer.Exit(1) + + if code and project: + rprint("[red]Error: Cannot specify both --code and --project[/red]") + raise typer.Exit(1) + + if code: + # Compile from local file + if not os.path.exists(code): + rprint(f"[red]File not found:[/red] {code}") + raise typer.Exit(1) + with open(code, "r") as f: + code_text = f.read() + else: + # Compile from project ID + rprint(f"[blue]Fetching project files for:[/blue] {project}") + + # Get project status to see available files + status_url = f"{base}/project/{project}" + status_resp = requests.get(status_url, timeout=30) + if status_resp.status_code == 404: + rprint(f"[red]Project not found:[/red] {project}") + raise typer.Exit(1) + status_resp.raise_for_status() + project_data = status_resp.json() + + if project_data.get("status") != "completed": + rprint(f"[red]Project not ready:[/red] status={project_data.get('status')}") + raise typer.Exit(1) + + files = project_data.get("files", []) + if not files: + rprint("[red]No files found in project[/red]") + raise typer.Exit(1) + + # Build combined text from project files + code_text = "" + for file_path in files: + file_url = f"{base}/project/{project}/files/{file_path}" + file_resp = requests.get(file_url, timeout=30) + if file_resp.status_code == 200: + file_content = file_resp.text + code_text += f"[filename: {file_path}]\n{file_content}\n\n" + else: + rprint(f"[yellow]Warning: Could not fetch file:[/yellow] {file_path}") + + if not code_text.strip(): + rprint("[red]No file content could be retrieved[/red]") + raise typer.Exit(1) + + # Compile the code + url = f"{base}/compile" + resp = requests.post(url, json={"code": code_text}, timeout=120) + if resp.status_code >= 400: + rprint(f"[red]Request failed:[/red] {resp.status_code} {resp.text}") + raise typer.Exit(1) + data = resp.json() + if json_out: + rprint(JSON.from_data(data)) + else: + if data.get("success"): + rprint("[green]Build successful[/green]") + if data.get("run_output"): + rprint("[bold]Run output:[/bold]") + sys.stdout.write(str(data["run_output"]) + "\n") + else: + rprint("[red]Build failed[/red]") + if data.get("build_output"): + sys.stdout.write(str(data["build_output"]) + "\n") + + +@app.command() +def fix( + code: Optional[str] = typer.Option(None, "--code", help="Path to multi-file text input in [filename: ] blocks"), + project: Optional[str] = typer.Option(None, "--project", help="Project ID to fix (fetches files from API)"), + description: str = typer.Option(..., "--description", help="Project description"), + max_attempts: int = typer.Option(3, "--max-attempts", min=1, help="Maximum fix attempts"), + json_out: bool = typer.Option(False, "--json", help="Output as JSON"), + ctx: typer.Context = typer.Option(None), +): + """Compile and auto-fix via /compile-and-fix.""" + base = ctx.obj["server"] + + if not code and not project: + rprint("[red]Error: Must specify either --code or --project[/red]") + raise typer.Exit(1) + + if code and project: + rprint("[red]Error: Cannot specify both --code and --project[/red]") + raise typer.Exit(1) + + if code: + # Fix from local file + if not os.path.exists(code): + rprint(f"[red]File not found:[/red] {code}") + raise typer.Exit(1) + with open(code, "r") as f: + code_text = f.read() + else: + # Fix from project ID + rprint(f"[blue]Fetching project files for:[/blue] {project}") + + # Get project status to see available files + status_url = f"{base}/project/{project}" + status_resp = requests.get(status_url, timeout=30) + if status_resp.status_code == 404: + rprint(f"[red]Project not found:[/red] {project}") + raise typer.Exit(1) + status_resp.raise_for_status() + project_data = status_resp.json() + + if project_data.get("status") != "completed": + rprint(f"[red]Project not ready:[/red] status={project_data.get('status')}") + raise typer.Exit(1) + + files = project_data.get("files", []) + if not files: + rprint("[red]No files found in project[/red]") + raise typer.Exit(1) + + # Build combined text from project files + code_text = "" + for file_path in files: + file_url = f"{base}/project/{project}/files/{file_path}" + file_resp = requests.get(file_url, timeout=30) + if file_resp.status_code == 200: + file_content = file_resp.text + code_text += f"[filename: {file_path}]\n{file_content}\n\n" + else: + rprint(f"[yellow]Warning: Could not fetch file:[/yellow] {file_path}") + + if not code_text.strip(): + rprint("[red]No file content could be retrieved[/red]") + raise typer.Exit(1) + + # Fix the code + url = f"{base}/compile-and-fix" + payload = {"code": code_text, "description": description, "max_attempts": max_attempts} + resp = requests.post(url, json=payload, timeout=600) + if resp.status_code >= 400: + rprint(f"[red]Request failed:[/red] {resp.status_code} {resp.text}") + raise typer.Exit(1) + data = resp.json() + if json_out: + rprint(JSON.from_data(data)) + else: + if data.get("success"): + rprint("[green]Fixed and compiled successfully[/green]") + if data.get("combined_text"): + rprint("\n[bold]Combined files:[/bold]\n") + sys.stdout.write(data["combined_text"] + "\n") + if data.get("run_output"): + rprint("[bold]Run output:[/bold]") + sys.stdout.write(str(data["run_output"]) + "\n") + else: + rprint("[red]Failed to fix after attempts[/red]") + if data.get("combined_text"): + rprint("\n[bold]Final files:[/bold]\n") + sys.stdout.write(data["combined_text"] + "\n") + if data.get("build_output"): + rprint("\n[bold]Build output:[/bold]") + sys.stdout.write(str(data["build_output"]) + "\n") + +def main(): + app() + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 071771e..8d1c753 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,7 @@ qdrant-client>=1.3.0 mcp-python>=0.1.0 mcp-proxy>=0.1.0 cmcp>=0.1.0 -openai>=1.0.0 \ No newline at end of file +openai>=1.0.0 +typer>=0.12.3 +rich>=13.7.1 +httpx>=0.27.0 \ No newline at end of file