diff --git a/pyproject.toml b/pyproject.toml index a3227a48..c784ebc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rogue-ai" -version = "0.1.3" +version = "0.1.4" description = "Rogue agent evaluator by Qualifire" readme = "README.md" requires-python = ">=3.10" diff --git a/rogue/__init__.py b/rogue/__init__.py index cb9f13e3..019505fa 100644 --- a/rogue/__init__.py +++ b/rogue/__init__.py @@ -44,6 +44,6 @@ ] # Version info -__version__ = "0.1.3" +__version__ = "0.1.4" __author__ = "Qualifire" __description__ = "Library for evaluating AI agents against scenarios" diff --git a/rogue/__main__.py b/rogue/__main__.py index cdebc1bc..d8de5482 100644 --- a/rogue/__main__.py +++ b/rogue/__main__.py @@ -9,10 +9,13 @@ from .common.logging.config import configure_logger from .common.tui_installer import RogueTuiInstaller +from .common.update_checker import check_for_updates from .run_cli import run_cli, set_cli_args from .run_server import run_server, set_server_args from .run_tui import run_rogue_tui from .run_ui import run_ui, set_ui_args +from . import __version__ + load_dotenv() @@ -31,6 +34,13 @@ def common_parser() -> ArgumentParser: default=False, help="Enable debug logging", ) + parent_parser.add_argument( + "--version", + action="store_true", + default=False, + help="Show version", + ) + return parent_parser @@ -79,6 +89,10 @@ def parse_args() -> Namespace: def main() -> None: args = parse_args() + if args.version: + print(f"Rogue AI version: {__version__}") + sys.exit(0) + tui_mode = args.mode == "tui" or args.mode is None log_file_path: Path | None = None @@ -142,4 +156,5 @@ def main() -> None: if __name__ == "__main__": + check_for_updates(__version__) main() diff --git a/rogue/common/tui_installer.py b/rogue/common/tui_installer.py index 7b495d57..03d87979 100644 --- a/rogue/common/tui_installer.py +++ b/rogue/common/tui_installer.py @@ -11,6 +11,7 @@ import platformdirs import requests from loguru import logger +from rich.console import Console class RogueTuiInstaller: @@ -43,15 +44,23 @@ def _os(self) -> str: def _get_latest_github_release(self) -> Optional[dict]: """Get the latest release information from GitHub.""" + console = Console() + try: url = f"https://api.github.com/repos/{self._repo}/releases/latest" - response = requests.get( - url, - timeout=10, - headers=self._headers, - ) - response.raise_for_status() - return response.json() + + with console.status( + "[bold blue]Fetching latest release information...", + spinner="dots", + ): + response = requests.get( + url, + timeout=10, + headers=self._headers, + verify=False, # nosec: B501 + ) + response.raise_for_status() + return response.json() except Exception: logger.exception("Error fetching latest release") return None @@ -76,6 +85,8 @@ def _find_asset_for_platform( return None def _download_rogue_tui_to_temp(self) -> str: + console = Console() + # Get latest release release_data = self._get_latest_github_release() if not release_data: @@ -90,25 +101,29 @@ def _download_rogue_tui_to_temp(self) -> str: ) raise Exception("No suitable binary found for current platform.") - logger.info(f"Downloading: {download_url}") - - response = requests.get( - download_url, - timeout=60, - headers={ - "Accept": "application/octet-stream", - **self._headers, - }, - ) - response.raise_for_status() + # Show spinner during download + with console.status( + "[bold green]Downloading rogue-tui binary...", + spinner="dots", + ): + response = requests.get( + download_url, + timeout=60, + headers={ + "Accept": "application/octet-stream", + **self._headers, + }, + verify=False, # nosec: B501 + ) + response.raise_for_status() - # Create a temporary file - with tempfile.NamedTemporaryFile( - delete=False, - suffix="-rogue-tui", - ) as tmp_file: - tmp_file.write(response.content) - tmp_path = tmp_file.name + # Create a temporary file + with tempfile.NamedTemporaryFile( + delete=False, + suffix="-rogue-tui", + ) as tmp_file: + tmp_file.write(response.content) + tmp_path = tmp_file.name # Make it executable os.chmod(tmp_path, 0o755) # nosec: B103 @@ -134,7 +149,6 @@ def _handle_path_env(self, install_dir: Path) -> None: sep = ";" if str(install_dir) not in os.environ.get("PATH", "").split(sep): - logger.info(f"Adding {install_dir} to PATH environment variable.") os.environ["PATH"] += sep + str(install_dir) # TODO update shellrc file to update the path @@ -153,33 +167,41 @@ def _is_rogue_tui_installed(self) -> bool: else: return False - def install_rogue_tui(self) -> bool: + def install_rogue_tui( + self, + upgrade: bool = False, + ) -> bool: """Install rogue-tui from GitHub releases if not already installed.""" + console = Console() # Check if rogue-tui is already available - if self._is_rogue_tui_installed(): - logger.info("rogue-tui is already installed.") + if self._is_rogue_tui_installed() and not upgrade: + console.print("[green]✅ rogue-tui is already installed.[/green]") return True - logger.info("rogue-tui not found. Installing from GitHub releases...") - - # Get platform information - logger.info(f"Detected platform: {self._os}-{self._architecture}") + console.print( + "[yellow]📦 Installing rogue-tui from GitHub releases...[/yellow]", + ) try: tmp_path = self._download_rogue_tui_to_temp() except Exception: + console.print("[red]❌ Failed to download rogue-tui.[/red]") logger.exception("Failed to download rogue-tui.") return False try: # Move to final location install_path = self._get_install_path() - shutil.move(tmp_path, install_path) + + with console.status("[bold yellow]Installing rogue-tui...", spinner="dots"): + shutil.move(tmp_path, install_path) except Exception: + console.print("[red]❌ Failed to install rogue-tui.[/red]") logger.exception("Failed to install rogue-tui.") return False self._handle_path_env(install_path.parent) - logger.info(f"rogue-tui installed successfully to {install_path}") + console.print("[green]✅ rogue-tui installed successfully![/green]") + # logger.debug(f"rogue-tui installed to {install_path}") return True diff --git a/rogue/common/update_checker.py b/rogue/common/update_checker.py new file mode 100644 index 00000000..43e5236b --- /dev/null +++ b/rogue/common/update_checker.py @@ -0,0 +1,253 @@ +""" +Update checking and automatic update functionality for Rogue. + +This module provides oh-my-zsh style update prompts that check PyPI for newer +versions and allow users to update immediately. +""" + +import json +import shutil +import subprocess # nosec: B404 +from datetime import datetime, timedelta +from typing import Any, Dict, Optional +from packaging import version + +import platformdirs +import requests +from loguru import logger +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm +from rich.text import Text + +from ..common.tui_installer import RogueTuiInstaller + + +def check_for_updates(current_version: str) -> None: + """ + Check for available updates and prompt user if a newer version is available. + Similar to oh-my-zsh update experience. + + Args: + current_version: The current version of the application + """ + try: + # Don't check for updates if we've checked recently + cache_info = _get_update_cache() + if _should_skip_update_check(cache_info): + return + + # Get latest version from PyPI + latest_version = _get_latest_version_from_pypi() + if not latest_version: + return + + # Save the check info + _save_update_cache(latest_version, current_version) + + # Compare versions and show update prompt if needed + if _is_newer_version(latest_version, current_version): + _show_update_prompt(latest_version, current_version) + + except Exception: + # Silently handle any errors - update checking shouldn't break the app + logger.debug("Error checking for updates", exc_info=True) + + +def _get_update_cache() -> Dict[str, Any]: + """Get cached update information.""" + cache_file = platformdirs.user_cache_path(appname="rogue") / "update_cache.json" + + if not cache_file.exists(): + return {} + + try: + with open(cache_file, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + +def _should_skip_update_check(cache_info: Dict[str, Any]) -> bool: + """Check if we should skip the update check based on cache.""" + if not cache_info: + return False + + last_check = cache_info.get("last_check") + if not last_check: + return False + + # Skip if we've checked in the last 24 hours + last_check_time = datetime.fromisoformat(last_check) + return datetime.now() - last_check_time < timedelta(hours=24) + + +def _get_latest_version_from_pypi() -> Optional[str]: + """Fetch the latest version from PyPI.""" + try: + response = requests.get( + "https://pypi.org/pypi/rogue-ai/json", + timeout=5, + verify=False, # nosec: B501 + ) + response.raise_for_status() + + data = response.json() + return data.get("info", {}).get("version") + + except Exception: + return None + + +def _save_update_cache(latest_version: str, current_version: str) -> None: + """Save update check information to cache.""" + cache_file = platformdirs.user_cache_path(appname="rogue") / "update_cache.json" + cache_file.parent.mkdir(parents=True, exist_ok=True) + + cache_data = { + "last_check": datetime.now().isoformat(), + "latest_version": latest_version, + "current_version": current_version, + } + + try: + with open(cache_file, "w") as f: + json.dump(cache_data, f, indent=2) + except IOError: + pass # Silently handle write errors + + +def _is_newer_version(latest: str, current: str) -> bool: + """Compare version strings to determine if latest is newer than current.""" + try: + return version.parse(latest) > version.parse(current) + except version.InvalidVersion: + # Handle non-standard version formats gracefully + return False + + +def _show_update_prompt(latest_version: str, current_version: str) -> None: + """Display the update prompt with rich formatting and interactive option.""" + console = Console() + + # Create the update message + title = Text("🚀 Update Available!", style="bold yellow") + + content = Text() + content.append("A new version of rogue-ai is available!\n\n", style="") + content.append("Current version: ", style="dim") + content.append(f"{current_version}\n", style="red") + content.append("Latest version: ", style="dim") + content.append(f"{latest_version}\n\n", style="green bold") + content.append("To update manually, run: ", style="dim") + content.append("uv tool upgrade rogue-ai", style="cyan bold") + content.append(" or ", style="dim") + content.append("uvx --refresh rogue-ai", style="cyan bold") + + # Create a panel with the update message + panel = Panel( + content, + title=title, + border_style="yellow", + padding=(1, 2), + ) + + # Print with some spacing + console.print() + console.print(panel) + + # Ask user if they want to update now + try: + should_update = Confirm.ask( + "[bold cyan]Would you like to update now?[/bold cyan]", + default=True, + ) + + if should_update: + run_update_command() + else: + console.print( + "[dim]Update skipped. Run 'uv tool upgrade rogue-ai' or " + "'uvx --refresh rogue-ai' later to update.[/dim]", + ) + except (KeyboardInterrupt, EOFError): + # Handle Ctrl+C or input interruption gracefully + console.print("\n[dim]Update skipped.[/dim]") + + console.print() + + +def run_update_command() -> None: + """Execute the appropriate update command based on installation method.""" + console = Console() + + try: + console.print( + "[dim]This may take a few minutes to download and install " + "dependencies...[/dim]", + ) + + if not shutil.which("uv"): + console.print( + "[dim]uv not found. please update manually using[/dim]" + "[dim]- uv tool upgrade rogue-ai[/dim]" + "[dim]or[/dim]" + "[dim]- pip install rogue-ai -U[/dim]", + ) + return + + with console.status("[yellow]Updating rogue-ai...[/yellow]", spinner="dots"): + # First, try to upgrade using uv tool + # (for users who installed with uv tool install) + result = subprocess.run( # nosec: B607 B603 + ["uv", "tool", "install", "-U", "rogue-ai"], + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for the update + ) + + # If that fails because it's not installed as a tool, try uvx method + if result.returncode != 0 and "is not installed" in result.stderr: + # For uvx installations, we need to reinstall + result = subprocess.run( # nosec: B607 B603 + ["uvx", "--refresh", "rogue-ai", "--version"], + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for the update + ) + + if result.returncode == 0: + # Install TUI + RogueTuiInstaller().install_rogue_tui( + upgrade=True, + ) + + if result.returncode == 0: + console.print("[bold green]✅ Update completed successfully![/bold green]") + console.print( + "[dim]Restart any running rogue-ai processes to use the " + "new version.[/dim]", + ) + else: + console.print("[bold red]❌ Update failed![/bold red]") + if result.stderr: + console.print(f"[red]Error: {result.stderr.strip()}[/red]") + console.print( + "[dim]Please try running 'uv tool upgrade rogue-ai' or " + "'uvx --refresh rogue-ai' manually.[/dim]", + ) + except subprocess.TimeoutExpired: + console.print("[bold red]❌ Update timed out after 10 minutes![/bold red]") + console.print( + "[dim]This may indicate a network issue. Please try running " + "'uv tool upgrade rogue-ai' or 'uvx --refresh rogue-ai' manually.[/dim]", + ) + except FileNotFoundError: + console.print("[bold red]❌ uv command not found![/bold red]") + console.print("[dim]Please ensure uv is installed and in your PATH.[/dim]") + except Exception as e: + console.print(f"[bold red]❌ Update failed: {e}[/bold red]") + console.print( + "[dim]Please try running 'uv tool upgrade rogue-ai' or " + "'uvx --refresh rogue-ai' manually.[/dim]", + ) diff --git a/rogue/server/services/qualifire_service.py b/rogue/server/services/qualifire_service.py index bfdf1356..129f3c5a 100644 --- a/rogue/server/services/qualifire_service.py +++ b/rogue/server/services/qualifire_service.py @@ -28,6 +28,7 @@ def report_summary( headers={"X-qualifire-key": request.qualifire_api_key}, json=api_evaluation_result.model_dump(mode="json"), timeout=300, + verify=False, # nosec: B501 ) if not response.ok: diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 5db7ec06..ffa567ec 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rogue-ai-sdk" -version = "0.1.3" +version = "0.1.4" description = "Python SDK for Rogue Agent Evaluator" readme = "README.md" requires-python = ">=3.9" diff --git a/uv.lock b/uv.lock index eb76ba59..005dd14e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -3522,7 +3522,7 @@ wheels = [ [[package]] name = "rogue-ai" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -3582,7 +3582,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.1.1" }, { name = "requests", specifier = ">=2.32.4" }, { name = "rich", specifier = ">=14.0.0" }, - { name = "rogue-ai-sdk", specifier = ">=0.1.0" }, + { name = "rogue-ai-sdk", specifier = ">=0.1.3" }, { name = "uvicorn", specifier = ">=0.32.0" }, ] @@ -3609,7 +3609,7 @@ examples = [ [[package]] name = "rogue-ai-sdk" -version = "0.1.0" +version = "0.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3617,9 +3617,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/59/2f1ab358c3e860978832e0df489d82c5c21555218fc53792c0fe4e629253/rogue_ai_sdk-0.1.0.tar.gz", hash = "sha256:24a5ee52abbf38362137c3efacd847d3c1b9e5488f4e5e68907b75d67f8adb91", size = 49452, upload-time = "2025-09-03T09:31:07.684Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/74/793861d6440d73ffcbd9413bc48a06b0d141c1b6a78972fa5cdd20882d4c/rogue_ai_sdk-0.1.3.tar.gz", hash = "sha256:a52f58edd2a611faa1aebede8bd2168d20c6d462b299c570f21ec7c2b251b964", size = 50868, upload-time = "2025-09-22T08:49:11.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/dc/b45fd20575fce537944a1425ed2a1b9d766171150c78929309053e31e0cc/rogue_ai_sdk-0.1.0-py3-none-any.whl", hash = "sha256:5a7615da876c806f516af449fc270e2f21de63656e0aa3df5c891befe4ac011e", size = 18776, upload-time = "2025-09-03T09:31:06.255Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/1ad1f5b5bf09e85202a5a70e7c913e35ad1e0ac8212b37f7159260f8b738/rogue_ai_sdk-0.1.3-py3-none-any.whl", hash = "sha256:f71cd934f3818d2c5d69815b38df9504ea9c975427945764293240250fef3399", size = 20383, upload-time = "2025-09-22T08:49:10.722Z" }, ] [[package]]