diff --git a/cortex/cli.py b/cortex/cli.py index 9261a816..8fcf9228 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -21,6 +21,7 @@ from cortex.env_manager import EnvironmentManager, get_env_manager from cortex.installation_history import InstallationHistory, InstallationStatus, InstallationType from cortex.llm.interpreter import CommandInterpreter +from cortex.llm_router import LLMRouter, TaskType from cortex.network_config import NetworkConfig from cortex.notification_manager import NotificationManager from cortex.stack_manager import StackManager @@ -677,7 +678,6 @@ def install( interpreter = CommandInterpreter(api_key=api_key, provider=provider) self._print_status("📦", "Planning installation...") - for _ in range(10): self._animate_spinner("Analyzing system requirements...") self._clear_line() @@ -2126,6 +2126,28 @@ def main(): # Wizard command wizard_parser = subparsers.add_parser("wizard", help="Configure API key interactively") + diagnose_parser = subparsers.add_parser( + "diagnose", + help="Diagnose installation or system errors", + ) + + diagnose_parser.add_argument( + "--image", + type=str, + help="Path to error screenshot (PNG, JPG, WebP)", + ) + + diagnose_parser.add_argument( + "--clipboard", + action="store_true", + help="Read error screenshot from clipboard", + ) + + diagnose_parser.add_argument( + "--text", + type=str, + help="Raw error text (fallback)", + ) # Status command (includes comprehensive health checks) subparsers.add_parser("status", help="Show comprehensive system status and health checks") @@ -2511,6 +2533,65 @@ def main(): dry_run=args.dry_run, parallel=args.parallel, ) + elif args.command == "diagnose": + import io + + from PIL import Image, ImageGrab + + image = None + + if args.image: + try: + image = Image.open(args.image) + except Exception as e: + cli._print_error(f"Failed to load image: {e}") + return 1 + + elif args.clipboard: + try: + + image = ImageGrab.grabclipboard() + if image is None: + cli._print_error("No image found in clipboard") + return 1 + except Exception as e: + cli._print_error(f"Failed to read clipboard: {e}") + return 1 + + # Get API key for LLM router (diagnose_image needs Claude Vision) + api_key = cli._get_api_key() + if not api_key: + return 1 + + router = LLMRouter(claude_api_key=api_key) + if image: + cx_print("🔍 Analyzing error screenshot...") + diagnosis = router.diagnose_image(image) + cx_print(diagnosis) + return 0 + + if args.text: + cx_print("🔍 Analyzing error message...", "info") + + response = router.complete( + messages=[ + { + "role": "system", + "content": "You are a Linux system debugging expert.", + }, + { + "role": "user", + "content": f"Diagnose this error and suggest fixes:\n{args.text}", + }, + ], + task_type=TaskType.ERROR_DEBUGGING, + ) + + cx_print(response.content) + return 0 + cli._print_error("Provide an image, clipboard, or error message") + return 1 + elif args.command == "import": return cli.import_deps(args) elif args.command == "history": diff --git a/cortex/llm_router.py b/cortex/llm_router.py index d4bb3a21..052bf0f3 100644 --- a/cortex/llm_router.py +++ b/cortex/llm_router.py @@ -12,6 +12,8 @@ """ import asyncio +import base64 +import io import json import logging import os @@ -19,11 +21,14 @@ import time from dataclasses import dataclass from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any from anthropic import Anthropic, AsyncAnthropic from openai import AsyncOpenAI, OpenAI +if TYPE_CHECKING: + from PIL import Image + # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -326,6 +331,83 @@ def complete( else: raise + def diagnose_image(self, image: "Image.Image") -> str: + """ + Diagnose an error screenshot using Claude Vision. + Args: + image: A PIL Image object containing the error screenshot. + + Returns: + Diagnosis text from Claude Vision, or a fallback message + if vision support is unavailable. + """ + try: + from PIL import Image + except ImportError: + logger.warning("Pillow not installed, using fallback diagnosis") + return ( + "Image diagnosis unavailable (missing pillow).\n" + "Install Pillow to enable image-based error diagnosis:\n" + " pip install pillow\n" + " # or\n" + " cortex install pillow\n" + ) + + if not self.claude_client: + logger.warning("Claude Vision unavailable, using fallback diagnosis") + return ( + "Claude Vision unavailable.\n" + "Configure Claude API key to enable image diagnosis:\n" + " export ANTHROPIC_API_KEY='your-key'\n" + " # or run:\n" + " cortex wizard\n" + ) + + buf = io.BytesIO() + image.save(buf, format="PNG") + image_bytes = buf.getvalue() + + try: + response = self.claude_client.messages.create( + model="claude-opus-4-1-20250805", + max_tokens=500, + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": base64.b64encode(image_bytes).decode(), + }, + }, + { + "type": "text", + "text": "Diagnose this error screenshot and suggest fixes.", + }, + ], + } + ], + ) + if response.content and hasattr(response.content[0], "text"): + return response.content[0].text + return "Claude Vision returned an empty response." + + except Exception as e: + logger.warning( + "Claude Vision unavailable, using fallback diagnosis", + exc_info=True, + ) + return ( + "Claude Vision unavailable.\n" + "Possible reasons:\n" + "- Invalid or missing API key\n" + "- Network or rate limit error\n" + "- Claude service unavailable\n" + ) + def _complete_claude( self, messages: list[dict[str, str]], diff --git a/docs/image_error_diagnosis.md b/docs/image_error_diagnosis.md new file mode 100644 index 00000000..3b7425bc --- /dev/null +++ b/docs/image_error_diagnosis.md @@ -0,0 +1,26 @@ +## Diagnose Errors from Screenshots + +Cortex can diagnose system and installation errors directly from screenshots using Vision AI. + +This is useful when error messages cannot be easily copied from terminals or GUI dialogs. + +### Image-based diagnosis + +Diagnose an error from an image file: + +cortex diagnose --image /path/to/error.png + +Supported formats: +- PNG +- JPG / JPEG + +### Clipboard-based diagnosis + +Diagnose an error copied to the clipboard: + +cortex diagnose --clipboard + +### Fallback behavior + +If Vision APIs or required dependencies are unavailable, Cortex provides a safe +fallback diagnosis instead of failing. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e59f5b83..2983511b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "rich>=13.0.0", "pyyaml>=6.0.0", "python-dotenv>=1.0.0", + "pillow>=9.0.0", ] [project.optional-dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index 2ccb0205..b650780b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,4 @@ black>=24.0.0 ruff>=0.8.0 isort>=5.13.0 pre-commit>=3.0.0 +pillow>=9.0.0 diff --git a/requirements.txt b/requirements.txt index 166a777e..c8a35033 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ anthropic>=0.18.0 openai>=1.0.0 requests>=2.32.4 +pillow>=9.0.0 # Configuration PyYAML>=6.0.0 diff --git a/tests/test_diagnose_image.py b/tests/test_diagnose_image.py new file mode 100644 index 00000000..041ac932 --- /dev/null +++ b/tests/test_diagnose_image.py @@ -0,0 +1,30 @@ +import pytest + +PIL = pytest.importorskip("PIL", reason="Pillow not installed") +from PIL import Image + +from cortex.llm_router import LLMRouter + + +def test_diagnose_image_fallback_without_claude(): + """ + If Claude is unavailable, diagnose_image should return + a safe fallback message instead of crashing. + """ + router = LLMRouter(claude_api_key=None) + + # Create a dummy image in memory + image = Image.new("RGB", (100, 100), color="red") + + result = router.diagnose_image(image) + + assert isinstance(result, str) + assert "Claude Vision unavailable" in result + + +def test_llmrouter_has_diagnose_image_method(): + """ + Ensure diagnose_image exists on LLMRouter + """ + router = LLMRouter() + assert hasattr(router, "diagnose_image")