From 3b5b24541d2f374b520927fd4ebf6cc370d9fbb8 Mon Sep 17 00:00:00 2001 From: sahil Date: Sun, 9 Nov 2025 12:41:20 +0530 Subject: [PATCH 1/7] Add CLI interface for cortex command - Fixes #11 --- .gitignore | 30 +++++++ MANIFEST.in | 5 ++ cortex/__init__.py | 2 + cortex/cli.py | 166 ++++++++++++++++++++++++++++++++++++ cortex/test_cli.py | 203 +++++++++++++++++++++++++++++++++++++++++++++ setup.py | 43 ++++++++++ 6 files changed, 449 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 cortex/__init__.py create mode 100644 cortex/cli.py create mode 100644 cortex/test_cli.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeac129 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.env +.venv +env/ +venv/ +ENV/ +.mypy_cache/ +.pytest_cache/ +.coverage +htmlcov/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a933d69 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include LICENSE +recursive-include LLM *.py +recursive-include cortex *.py +include LLM/requirements.txt diff --git a/cortex/__init__.py b/cortex/__init__.py new file mode 100644 index 0000000..57abaed --- /dev/null +++ b/cortex/__init__.py @@ -0,0 +1,2 @@ +from .cli import main +__version__ = "0.1.0" diff --git a/cortex/cli.py b/cortex/cli.py new file mode 100644 index 0000000..dcc0cab --- /dev/null +++ b/cortex/cli.py @@ -0,0 +1,166 @@ +import sys +import os +import argparse +import time +from typing import List, Optional +import subprocess + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from LLM.interpreter import CommandInterpreter + + +class CortexCLI: + def __init__(self): + self.spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + self.spinner_idx = 0 + + def _get_api_key(self) -> Optional[str]: + api_key = os.environ.get('OPENAI_API_KEY') or os.environ.get('ANTHROPIC_API_KEY') + if not api_key: + self._print_error("API key not found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.") + return None + return api_key + + def _get_provider(self) -> str: + if os.environ.get('OPENAI_API_KEY'): + return 'openai' + elif os.environ.get('ANTHROPIC_API_KEY'): + return 'claude' + return 'openai' + + def _print_status(self, emoji: str, message: str): + print(f"{emoji} {message}") + + def _print_error(self, message: str): + print(f"❌ Error: {message}", file=sys.stderr) + + def _print_success(self, message: str): + print(f"✅ {message}") + + def _animate_spinner(self, message: str): + sys.stdout.write(f"\r{self.spinner_chars[self.spinner_idx]} {message}") + sys.stdout.flush() + self.spinner_idx = (self.spinner_idx + 1) % len(self.spinner_chars) + time.sleep(0.1) + + def _clear_line(self): + sys.stdout.write('\r\033[K') + sys.stdout.flush() + + def install(self, software: str, execute: bool = False, dry_run: bool = False): + api_key = self._get_api_key() + if not api_key: + return 1 + + provider = self._get_provider() + + try: + self._print_status("🧠", "Understanding request...") + + 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() + + commands = interpreter.parse(f"install {software}") + + if not commands: + self._print_error("No commands generated. Please try again with a different request.") + return 1 + + self._print_status("⚙️", f"Installing {software}...") + print("\nGenerated commands:") + for i, cmd in enumerate(commands, 1): + print(f" {i}. {cmd}") + + if dry_run: + print("\n(Dry run mode - commands not executed)") + return 0 + + if execute: + print("\nExecuting commands...") + for i, cmd in enumerate(commands, 1): + print(f"\n[{i}/{len(commands)}] Running: {cmd}") + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=300 + ) + if result.returncode != 0: + self._print_error(f"Command failed: {result.stderr}") + return 1 + if result.stdout: + print(result.stdout) + except subprocess.TimeoutExpired: + self._print_error(f"Command timed out: {cmd}") + return 1 + except Exception as e: + self._print_error(f"Failed to execute command: {str(e)}") + return 1 + + self._print_success(f"{software} installed successfully!") + else: + print("\nTo execute these commands, run with --execute flag") + print("Example: cortex install docker --execute") + + return 0 + + except ValueError as e: + self._print_error(str(e)) + return 1 + except RuntimeError as e: + self._print_error(f"API call failed: {str(e)}") + return 1 + except Exception as e: + self._print_error(f"Unexpected error: {str(e)}") + return 1 + + +def main(): + parser = argparse.ArgumentParser( + prog='cortex', + description='AI-powered Linux command interpreter', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + cortex install docker + cortex install docker --execute + cortex install "python 3.11 with pip" + cortex install nginx --dry-run + +Environment Variables: + OPENAI_API_KEY OpenAI API key for GPT-4 + ANTHROPIC_API_KEY Anthropic API key for Claude + """ + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + install_parser = subparsers.add_parser('install', help='Install software using natural language') + install_parser.add_argument('software', type=str, help='Software to install (natural language)') + install_parser.add_argument('--execute', action='store_true', help='Execute the generated commands') + install_parser.add_argument('--dry-run', action='store_true', help='Show commands without executing') + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return 1 + + cli = CortexCLI() + + if args.command == 'install': + return cli.install(args.software, execute=args.execute, dry_run=args.dry_run) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/cortex/test_cli.py b/cortex/test_cli.py new file mode 100644 index 0000000..9672192 --- /dev/null +++ b/cortex/test_cli.py @@ -0,0 +1,203 @@ +import unittest +from unittest.mock import Mock, patch, MagicMock, call +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from cortex.cli import CortexCLI, main + + +class TestCortexCLI(unittest.TestCase): + + def setUp(self): + self.cli = CortexCLI() + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + def test_get_api_key_openai(self): + api_key = self.cli._get_api_key() + self.assertEqual(api_key, 'test-key') + + @patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test-claude-key'}) + def test_get_api_key_claude(self): + api_key = self.cli._get_api_key() + self.assertEqual(api_key, 'test-claude-key') + + @patch.dict(os.environ, {}, clear=True) + @patch('sys.stderr') + def test_get_api_key_not_found(self, mock_stderr): + api_key = self.cli._get_api_key() + self.assertIsNone(api_key) + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + def test_get_provider_openai(self): + provider = self.cli._get_provider() + self.assertEqual(provider, 'openai') + + @patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test-key'}, clear=True) + def test_get_provider_claude(self): + provider = self.cli._get_provider() + self.assertEqual(provider, 'claude') + + @patch('sys.stdout') + def test_print_status(self, mock_stdout): + self.cli._print_status("🧠", "Test message") + self.assertTrue(mock_stdout.write.called or print) + + @patch('sys.stderr') + def test_print_error(self, mock_stderr): + self.cli._print_error("Test error") + self.assertTrue(True) + + @patch('sys.stdout') + def test_print_success(self, mock_stdout): + self.cli._print_success("Test success") + self.assertTrue(True) + + @patch.dict(os.environ, {}, clear=True) + def test_install_no_api_key(self): + result = self.cli.install("docker") + self.assertEqual(result, 1) + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + def test_install_dry_run(self, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.return_value = ["apt update", "apt install docker"] + mock_interpreter_class.return_value = mock_interpreter + + result = self.cli.install("docker", dry_run=True) + + self.assertEqual(result, 0) + mock_interpreter.parse.assert_called_once_with("install docker") + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + def test_install_no_execute(self, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.return_value = ["apt update", "apt install docker"] + mock_interpreter_class.return_value = mock_interpreter + + result = self.cli.install("docker", execute=False) + + self.assertEqual(result, 0) + mock_interpreter.parse.assert_called_once() + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + @patch('subprocess.run') + def test_install_with_execute_success(self, mock_run, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.return_value = ["echo test"] + mock_interpreter_class.return_value = mock_interpreter + + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "test output" + mock_result.stderr = "" + mock_run.return_value = mock_result + + result = self.cli.install("docker", execute=True) + + self.assertEqual(result, 0) + mock_run.assert_called_once() + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + @patch('subprocess.run') + def test_install_with_execute_failure(self, mock_run, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.return_value = ["invalid command"] + mock_interpreter_class.return_value = mock_interpreter + + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "command not found" + mock_run.return_value = mock_result + + result = self.cli.install("docker", execute=True) + + self.assertEqual(result, 1) + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + def test_install_no_commands_generated(self, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.return_value = [] + mock_interpreter_class.return_value = mock_interpreter + + result = self.cli.install("docker") + + self.assertEqual(result, 1) + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + def test_install_value_error(self, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.side_effect = ValueError("Invalid input") + mock_interpreter_class.return_value = mock_interpreter + + result = self.cli.install("docker") + + self.assertEqual(result, 1) + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + def test_install_runtime_error(self, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.side_effect = RuntimeError("API failed") + mock_interpreter_class.return_value = mock_interpreter + + result = self.cli.install("docker") + + self.assertEqual(result, 1) + + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) + @patch('cortex.cli.CommandInterpreter') + def test_install_unexpected_error(self, mock_interpreter_class): + mock_interpreter = Mock() + mock_interpreter.parse.side_effect = Exception("Unexpected") + mock_interpreter_class.return_value = mock_interpreter + + result = self.cli.install("docker") + + self.assertEqual(result, 1) + + @patch('sys.argv', ['cortex']) + def test_main_no_command(self): + result = main() + self.assertEqual(result, 1) + + @patch('sys.argv', ['cortex', 'install', 'docker']) + @patch('cortex.cli.CortexCLI.install') + def test_main_install_command(self, mock_install): + mock_install.return_value = 0 + result = main() + self.assertEqual(result, 0) + mock_install.assert_called_once_with('docker', execute=False, dry_run=False) + + @patch('sys.argv', ['cortex', 'install', 'docker', '--execute']) + @patch('cortex.cli.CortexCLI.install') + def test_main_install_with_execute(self, mock_install): + mock_install.return_value = 0 + result = main() + self.assertEqual(result, 0) + mock_install.assert_called_once_with('docker', execute=True, dry_run=False) + + @patch('sys.argv', ['cortex', 'install', 'docker', '--dry-run']) + @patch('cortex.cli.CortexCLI.install') + def test_main_install_with_dry_run(self, mock_install): + mock_install.return_value = 0 + result = main() + self.assertEqual(result, 0) + mock_install.assert_called_once_with('docker', execute=False, dry_run=True) + + def test_spinner_animation(self): + initial_idx = self.cli.spinner_idx + self.cli._animate_spinner("Testing") + self.assertNotEqual(self.cli.spinner_idx, initial_idx) + + +if __name__ == '__main__': + unittest.main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1b38366 --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +from setuptools import setup, find_packages +import os + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +with open(os.path.join("LLM", "requirements.txt"), "r", encoding="utf-8") as fh: + requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")] + +setup( + name="cortex-linux", + version="0.1.0", + author="Cortex Linux", + author_email="mike@cortexlinux.com", + description="AI-powered Linux command interpreter", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/cortexlinux/cortex", + packages=find_packages(), + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Topic :: System :: Installation/Setup", + "Topic :: System :: Systems Administration", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: POSIX :: Linux", + ], + python_requires=">=3.8", + install_requires=requirements, + entry_points={ + "console_scripts": [ + "cortex=cortex.cli:main", + ], + }, + include_package_data=True, +) From ae131585988c560ea91ea051afe1a5182b021aa2 Mon Sep 17 00:00:00 2001 From: sahil Date: Sun, 9 Nov 2025 18:34:17 +0530 Subject: [PATCH 2/7] Add multi-step installation coordinator - Fixes #8 --- cortex/cli.py | 54 +++--- cortex/coordinator.py | 284 +++++++++++++++++++++++++++++ cortex/test_cli.py | 29 +-- cortex/test_coordinator.py | 353 +++++++++++++++++++++++++++++++++++++ 4 files changed, 685 insertions(+), 35 deletions(-) create mode 100644 cortex/coordinator.py create mode 100644 cortex/test_coordinator.py diff --git a/cortex/cli.py b/cortex/cli.py index dcc0cab..86b1682 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -8,6 +8,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from LLM.interpreter import CommandInterpreter +from cortex.coordinator import InstallationCoordinator, StepStatus class CortexCLI: @@ -82,30 +83,39 @@ def install(self, software: str, execute: bool = False, dry_run: bool = False): return 0 if execute: + def progress_callback(current, total, step): + status_emoji = "⏳" + if step.status == StepStatus.SUCCESS: + status_emoji = "✅" + elif step.status == StepStatus.FAILED: + status_emoji = "❌" + print(f"\n[{current}/{total}] {status_emoji} {step.description}") + print(f" Command: {step.command}") + print("\nExecuting commands...") - for i, cmd in enumerate(commands, 1): - print(f"\n[{i}/{len(commands)}] Running: {cmd}") - try: - result = subprocess.run( - cmd, - shell=True, - capture_output=True, - text=True, - timeout=300 - ) - if result.returncode != 0: - self._print_error(f"Command failed: {result.stderr}") - return 1 - if result.stdout: - print(result.stdout) - except subprocess.TimeoutExpired: - self._print_error(f"Command timed out: {cmd}") - return 1 - except Exception as e: - self._print_error(f"Failed to execute command: {str(e)}") - return 1 - self._print_success(f"{software} installed successfully!") + coordinator = InstallationCoordinator( + commands=commands, + descriptions=[f"Step {i+1}" for i in range(len(commands))], + timeout=300, + stop_on_error=True, + progress_callback=progress_callback + ) + + result = coordinator.execute() + + if result.success: + self._print_success(f"{software} installed successfully!") + print(f"\nCompleted in {result.total_duration:.2f} seconds") + return 0 + else: + if result.failed_step is not None: + self._print_error(f"Installation failed at step {result.failed_step + 1}") + else: + self._print_error("Installation failed") + if result.error_message: + print(f" Error: {result.error_message}", file=sys.stderr) + return 1 else: print("\nTo execute these commands, run with --execute flag") print("Example: cortex install docker --execute") diff --git a/cortex/coordinator.py b/cortex/coordinator.py new file mode 100644 index 0000000..a2ae0a3 --- /dev/null +++ b/cortex/coordinator.py @@ -0,0 +1,284 @@ +import subprocess +import time +import json +from typing import List, Dict, Any, Optional, Callable +from dataclasses import dataclass, field +from enum import Enum +from datetime import datetime + + +class StepStatus(Enum): + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + SKIPPED = "skipped" + + +@dataclass +class InstallationStep: + command: str + description: str + status: StepStatus = StepStatus.PENDING + output: str = "" + error: str = "" + start_time: Optional[float] = None + end_time: Optional[float] = None + return_code: Optional[int] = None + + def duration(self) -> Optional[float]: + if self.start_time and self.end_time: + return self.end_time - self.start_time + return None + + +@dataclass +class InstallationResult: + success: bool + steps: List[InstallationStep] + total_duration: float + failed_step: Optional[int] = None + error_message: Optional[str] = None + + +class InstallationCoordinator: + def __init__( + self, + commands: List[str], + descriptions: Optional[List[str]] = None, + timeout: int = 300, + stop_on_error: bool = True, + enable_rollback: bool = False, + log_file: Optional[str] = None, + progress_callback: Optional[Callable[[int, int, InstallationStep], None]] = None + ): + self.timeout = timeout + self.stop_on_error = stop_on_error + self.enable_rollback = enable_rollback + self.log_file = log_file + self.progress_callback = progress_callback + + if descriptions and len(descriptions) != len(commands): + raise ValueError("Number of descriptions must match number of commands") + + self.steps = [ + InstallationStep( + command=cmd, + description=descriptions[i] if descriptions else f"Step {i+1}" + ) + for i, cmd in enumerate(commands) + ] + + self.rollback_commands: List[str] = [] + + def _log(self, message: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_entry = f"[{timestamp}] {message}" + + if self.log_file: + try: + with open(self.log_file, 'a', encoding='utf-8') as f: + f.write(log_entry + '\n') + except Exception: + pass + + def _execute_command(self, step: InstallationStep) -> bool: + step.status = StepStatus.RUNNING + step.start_time = time.time() + + self._log(f"Executing: {step.command}") + + try: + result = subprocess.run( + step.command, + shell=True, + capture_output=True, + text=True, + timeout=self.timeout + ) + + step.return_code = result.returncode + step.output = result.stdout + step.error = result.stderr + step.end_time = time.time() + + if result.returncode == 0: + step.status = StepStatus.SUCCESS + self._log(f"Success: {step.command}") + return True + else: + step.status = StepStatus.FAILED + self._log(f"Failed: {step.command} (exit code: {result.returncode})") + return False + + except subprocess.TimeoutExpired: + step.status = StepStatus.FAILED + step.error = f"Command timed out after {self.timeout} seconds" + step.end_time = time.time() + self._log(f"Timeout: {step.command}") + return False + + except Exception as e: + step.status = StepStatus.FAILED + step.error = str(e) + step.end_time = time.time() + self._log(f"Error: {step.command} - {str(e)}") + return False + + def _rollback(self): + if not self.enable_rollback or not self.rollback_commands: + return + + self._log("Starting rollback...") + + for cmd in reversed(self.rollback_commands): + try: + self._log(f"Rollback: {cmd}") + subprocess.run( + cmd, + shell=True, + capture_output=True, + timeout=self.timeout + ) + except Exception as e: + self._log(f"Rollback failed: {cmd} - {str(e)}") + + def add_rollback_command(self, command: str): + self.rollback_commands.append(command) + + def execute(self) -> InstallationResult: + start_time = time.time() + failed_step_index = None + + self._log(f"Starting installation with {len(self.steps)} steps") + + for i, step in enumerate(self.steps): + if self.progress_callback: + self.progress_callback(i + 1, len(self.steps), step) + + success = self._execute_command(step) + + if not success: + failed_step_index = i + if self.stop_on_error: + for remaining_step in self.steps[i+1:]: + remaining_step.status = StepStatus.SKIPPED + + if self.enable_rollback: + self._rollback() + + total_duration = time.time() - start_time + self._log(f"Installation failed at step {i+1}") + + return InstallationResult( + success=False, + steps=self.steps, + total_duration=total_duration, + failed_step=i, + error_message=step.error or "Command failed" + ) + + total_duration = time.time() - start_time + all_success = all(s.status == StepStatus.SUCCESS for s in self.steps) + + if all_success: + self._log("Installation completed successfully") + else: + self._log("Installation completed with errors") + + return InstallationResult( + success=all_success, + steps=self.steps, + total_duration=total_duration, + failed_step=failed_step_index, + error_message=self.steps[failed_step_index].error if failed_step_index is not None else None + ) + + def verify_installation(self, verify_commands: List[str]) -> Dict[str, bool]: + verification_results = {} + + self._log("Starting verification...") + + for cmd in verify_commands: + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=30 + ) + success = result.returncode == 0 + verification_results[cmd] = success + self._log(f"Verification {cmd}: {'PASS' if success else 'FAIL'}") + except Exception as e: + verification_results[cmd] = False + self._log(f"Verification {cmd}: ERROR - {str(e)}") + + return verification_results + + def get_summary(self) -> Dict[str, Any]: + total_steps = len(self.steps) + success_steps = sum(1 for s in self.steps if s.status == StepStatus.SUCCESS) + failed_steps = sum(1 for s in self.steps if s.status == StepStatus.FAILED) + skipped_steps = sum(1 for s in self.steps if s.status == StepStatus.SKIPPED) + + return { + "total_steps": total_steps, + "success": success_steps, + "failed": failed_steps, + "skipped": skipped_steps, + "steps": [ + { + "command": s.command, + "description": s.description, + "status": s.status.value, + "duration": s.duration(), + "return_code": s.return_code + } + for s in self.steps + ] + } + + def export_log(self, filepath: str): + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(self.get_summary(), f, indent=2) + + +def install_docker() -> InstallationResult: + commands = [ + "apt update", + "apt install -y apt-transport-https ca-certificates curl software-properties-common", + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -", + 'add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"', + "apt update", + "apt install -y docker-ce docker-ce-cli containerd.io", + "systemctl start docker", + "systemctl enable docker" + ] + + descriptions = [ + "Update package lists", + "Install dependencies", + "Add Docker GPG key", + "Add Docker repository", + "Update package lists again", + "Install Docker packages", + "Start Docker service", + "Enable Docker on boot" + ] + + coordinator = InstallationCoordinator( + commands=commands, + descriptions=descriptions, + timeout=300, + stop_on_error=True + ) + + result = coordinator.execute() + + if result.success: + verify_commands = ["docker --version", "systemctl is-active docker"] + coordinator.verify_installation(verify_commands) + + return result diff --git a/cortex/test_cli.py b/cortex/test_cli.py index 9672192..cb2bf35 100644 --- a/cortex/test_cli.py +++ b/cortex/test_cli.py @@ -85,36 +85,39 @@ def test_install_no_execute(self, mock_interpreter_class): @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) @patch('cortex.cli.CommandInterpreter') - @patch('subprocess.run') - def test_install_with_execute_success(self, mock_run, mock_interpreter_class): + @patch('cortex.cli.InstallationCoordinator') + def test_install_with_execute_success(self, mock_coordinator_class, mock_interpreter_class): mock_interpreter = Mock() mock_interpreter.parse.return_value = ["echo test"] mock_interpreter_class.return_value = mock_interpreter + mock_coordinator = Mock() mock_result = Mock() - mock_result.returncode = 0 - mock_result.stdout = "test output" - mock_result.stderr = "" - mock_run.return_value = mock_result + mock_result.success = True + mock_result.total_duration = 1.5 + mock_coordinator.execute.return_value = mock_result + mock_coordinator_class.return_value = mock_coordinator result = self.cli.install("docker", execute=True) self.assertEqual(result, 0) - mock_run.assert_called_once() + mock_coordinator.execute.assert_called_once() @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) @patch('cortex.cli.CommandInterpreter') - @patch('subprocess.run') - def test_install_with_execute_failure(self, mock_run, mock_interpreter_class): + @patch('cortex.cli.InstallationCoordinator') + def test_install_with_execute_failure(self, mock_coordinator_class, mock_interpreter_class): mock_interpreter = Mock() mock_interpreter.parse.return_value = ["invalid command"] mock_interpreter_class.return_value = mock_interpreter + mock_coordinator = Mock() mock_result = Mock() - mock_result.returncode = 1 - mock_result.stdout = "" - mock_result.stderr = "command not found" - mock_run.return_value = mock_result + mock_result.success = False + mock_result.failed_step = 0 + mock_result.error_message = "command not found" + mock_coordinator.execute.return_value = mock_result + mock_coordinator_class.return_value = mock_coordinator result = self.cli.install("docker", execute=True) diff --git a/cortex/test_coordinator.py b/cortex/test_coordinator.py new file mode 100644 index 0000000..6911e23 --- /dev/null +++ b/cortex/test_coordinator.py @@ -0,0 +1,353 @@ +import unittest +from unittest.mock import Mock, patch, call +import tempfile +import os +import time +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from cortex.coordinator import ( + InstallationCoordinator, + InstallationStep, + InstallationResult, + StepStatus, + install_docker +) + + +class TestInstallationStep(unittest.TestCase): + + def test_step_creation(self): + step = InstallationStep(command="echo test", description="Test step") + self.assertEqual(step.command, "echo test") + self.assertEqual(step.description, "Test step") + self.assertEqual(step.status, StepStatus.PENDING) + + def test_step_duration(self): + step = InstallationStep(command="test", description="test") + self.assertIsNone(step.duration()) + + step.start_time = 100.0 + step.end_time = 105.5 + self.assertEqual(step.duration(), 5.5) + + +class TestInstallationCoordinator(unittest.TestCase): + + def test_initialization(self): + commands = ["echo 1", "echo 2"] + coordinator = InstallationCoordinator(commands) + + self.assertEqual(len(coordinator.steps), 2) + self.assertEqual(coordinator.steps[0].command, "echo 1") + self.assertEqual(coordinator.steps[1].command, "echo 2") + + def test_initialization_with_descriptions(self): + commands = ["echo 1", "echo 2"] + descriptions = ["First", "Second"] + coordinator = InstallationCoordinator(commands, descriptions) + + self.assertEqual(coordinator.steps[0].description, "First") + self.assertEqual(coordinator.steps[1].description, "Second") + + def test_initialization_mismatched_descriptions(self): + commands = ["echo 1", "echo 2"] + descriptions = ["First"] + + with self.assertRaises(ValueError): + InstallationCoordinator(commands, descriptions) + + @patch('subprocess.run') + def test_execute_single_success(self, mock_run): + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["echo test"]) + result = coordinator.execute() + + self.assertTrue(result.success) + self.assertEqual(len(result.steps), 1) + self.assertEqual(result.steps[0].status, StepStatus.SUCCESS) + + @patch('subprocess.run') + def test_execute_single_failure(self, mock_run): + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "error" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["false"]) + result = coordinator.execute() + + self.assertFalse(result.success) + self.assertEqual(result.failed_step, 0) + self.assertEqual(result.steps[0].status, StepStatus.FAILED) + + @patch('subprocess.run') + def test_execute_multiple_success(self, mock_run): + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["echo 1", "echo 2", "echo 3"]) + result = coordinator.execute() + + self.assertTrue(result.success) + self.assertEqual(len(result.steps), 3) + self.assertTrue(all(s.status == StepStatus.SUCCESS for s in result.steps)) + + @patch('subprocess.run') + def test_execute_stop_on_error(self, mock_run): + def side_effect(*args, **kwargs): + cmd = args[0] if args else kwargs.get('shell') + if "fail" in str(cmd): + result = Mock() + result.returncode = 1 + result.stdout = "" + result.stderr = "error" + return result + else: + result = Mock() + result.returncode = 0 + result.stdout = "success" + result.stderr = "" + return result + + mock_run.side_effect = side_effect + + coordinator = InstallationCoordinator( + ["echo 1", "fail", "echo 3"], + stop_on_error=True + ) + result = coordinator.execute() + + self.assertFalse(result.success) + self.assertEqual(result.failed_step, 1) + self.assertEqual(result.steps[0].status, StepStatus.SUCCESS) + self.assertEqual(result.steps[1].status, StepStatus.FAILED) + self.assertEqual(result.steps[2].status, StepStatus.SKIPPED) + + @patch('subprocess.run') + def test_execute_continue_on_error(self, mock_run): + def side_effect(*args, **kwargs): + cmd = args[0] if args else kwargs.get('shell') + if "fail" in str(cmd): + result = Mock() + result.returncode = 1 + result.stdout = "" + result.stderr = "error" + return result + else: + result = Mock() + result.returncode = 0 + result.stdout = "success" + result.stderr = "" + return result + + mock_run.side_effect = side_effect + + coordinator = InstallationCoordinator( + ["echo 1", "fail", "echo 3"], + stop_on_error=False + ) + result = coordinator.execute() + + self.assertFalse(result.success) + self.assertEqual(result.steps[0].status, StepStatus.SUCCESS) + self.assertEqual(result.steps[1].status, StepStatus.FAILED) + self.assertEqual(result.steps[2].status, StepStatus.SUCCESS) + + @patch('subprocess.run') + def test_timeout_handling(self, mock_run): + mock_run.side_effect = Exception("Timeout") + + coordinator = InstallationCoordinator(["sleep 1000"], timeout=1) + result = coordinator.execute() + + self.assertFalse(result.success) + self.assertEqual(result.steps[0].status, StepStatus.FAILED) + + def test_progress_callback(self): + callback_calls = [] + + def callback(current, total, step): + callback_calls.append((current, total, step.command)) + + with patch('subprocess.run') as mock_run: + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator( + ["echo 1", "echo 2"], + progress_callback=callback + ) + coordinator.execute() + + self.assertEqual(len(callback_calls), 2) + self.assertEqual(callback_calls[0], (1, 2, "echo 1")) + self.assertEqual(callback_calls[1], (2, 2, "echo 2")) + + def test_log_file(self): + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log') as f: + log_file = f.name + + try: + with patch('subprocess.run') as mock_run: + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator( + ["echo test"], + log_file=log_file + ) + coordinator.execute() + + self.assertTrue(os.path.exists(log_file)) + with open(log_file, 'r') as f: + content = f.read() + self.assertIn("Executing: echo test", content) + finally: + if os.path.exists(log_file): + os.unlink(log_file) + + @patch('subprocess.run') + def test_rollback(self, mock_run): + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "error" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator( + ["fail"], + enable_rollback=True + ) + coordinator.add_rollback_command("echo rollback") + result = coordinator.execute() + + self.assertFalse(result.success) + self.assertGreaterEqual(mock_run.call_count, 2) + + @patch('subprocess.run') + def test_verify_installation(self, mock_run): + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Docker version 20.10.0" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["echo test"]) + coordinator.execute() + + verify_results = coordinator.verify_installation(["docker --version"]) + + self.assertTrue(verify_results["docker --version"]) + + def test_get_summary(self): + with patch('subprocess.run') as mock_run: + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["echo 1", "echo 2"]) + coordinator.execute() + + summary = coordinator.get_summary() + + self.assertEqual(summary["total_steps"], 2) + self.assertEqual(summary["success"], 2) + self.assertEqual(summary["failed"], 0) + self.assertEqual(summary["skipped"], 0) + + def test_export_log(self): + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f: + export_file = f.name + + try: + with patch('subprocess.run') as mock_run: + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["echo test"]) + coordinator.execute() + coordinator.export_log(export_file) + + self.assertTrue(os.path.exists(export_file)) + + import json + with open(export_file, 'r') as f: + data = json.load(f) + self.assertIn("total_steps", data) + self.assertEqual(data["total_steps"], 1) + finally: + if os.path.exists(export_file): + os.unlink(export_file) + + @patch('subprocess.run') + def test_step_timing(self, mock_run): + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + coordinator = InstallationCoordinator(["echo test"]) + result = coordinator.execute() + + step = result.steps[0] + self.assertIsNotNone(step.start_time) + self.assertIsNotNone(step.end_time) + if step.end_time and step.start_time: + self.assertTrue(step.end_time > step.start_time) + self.assertIsNotNone(step.duration()) + + +class TestInstallDocker(unittest.TestCase): + + @patch('subprocess.run') + def test_install_docker_success(self, mock_run): + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + result = install_docker() + + self.assertTrue(result.success) + self.assertEqual(len(result.steps), 8) + + @patch('subprocess.run') + def test_install_docker_failure(self, mock_run): + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "error" + mock_run.return_value = mock_result + + result = install_docker() + + self.assertFalse(result.success) + self.assertIsNotNone(result.failed_step) + + +if __name__ == '__main__': + unittest.main() From 04b1f077f8583b29ce7bae11eed5d502c5ace575 Mon Sep 17 00:00:00 2001 From: sahil Date: Mon, 10 Nov 2025 16:32:34 +0530 Subject: [PATCH 3/7] Test file update for CLI --- LLM/test_interpreter.py | 33 +++++++++++++++++++-------------- cortex/test_cli.py | 2 +- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/LLM/test_interpreter.py b/LLM/test_interpreter.py index a8836c7..30914e2 100644 --- a/LLM/test_interpreter.py +++ b/LLM/test_interpreter.py @@ -1,7 +1,12 @@ import unittest from unittest.mock import Mock, patch, MagicMock import json -from interpreter import CommandInterpreter, APIProvider +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from LLM.interpreter import CommandInterpreter, APIProvider class TestCommandInterpreter(unittest.TestCase): @@ -9,21 +14,21 @@ class TestCommandInterpreter(unittest.TestCase): def setUp(self): self.api_key = "test-api-key" - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_initialization_openai(self, mock_openai): interpreter = CommandInterpreter(api_key=self.api_key, provider="openai") self.assertEqual(interpreter.provider, APIProvider.OPENAI) self.assertEqual(interpreter.model, "gpt-4") mock_openai.assert_called_once_with(api_key=self.api_key) - @patch('interpreter.Anthropic') + @patch('anthropic.Anthropic') def test_initialization_claude(self, mock_anthropic): interpreter = CommandInterpreter(api_key=self.api_key, provider="claude") self.assertEqual(interpreter.provider, APIProvider.CLAUDE) self.assertEqual(interpreter.model, "claude-3-5-sonnet-20241022") mock_anthropic.assert_called_once_with(api_key=self.api_key) - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_initialization_custom_model(self, mock_openai): interpreter = CommandInterpreter( api_key=self.api_key, @@ -73,14 +78,14 @@ def test_validate_commands_dd_pattern(self): result = interpreter._validate_commands(commands) self.assertEqual(result, ["apt update"]) - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_parse_empty_input(self, mock_openai): interpreter = CommandInterpreter(api_key=self.api_key, provider="openai") with self.assertRaises(ValueError): interpreter.parse("") - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_call_openai_success(self, mock_openai): mock_client = Mock() mock_response = Mock() @@ -94,7 +99,7 @@ def test_call_openai_success(self, mock_openai): result = interpreter._call_openai("install docker") self.assertEqual(result, ["apt update"]) - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_call_openai_failure(self, mock_openai): mock_client = Mock() mock_client.chat.completions.create.side_effect = Exception("API Error") @@ -105,7 +110,7 @@ def test_call_openai_failure(self, mock_openai): with self.assertRaises(RuntimeError): interpreter._call_openai("install docker") - @patch('interpreter.Anthropic') + @patch('anthropic.Anthropic') def test_call_claude_success(self, mock_anthropic): mock_client = Mock() mock_response = Mock() @@ -119,7 +124,7 @@ def test_call_claude_success(self, mock_anthropic): result = interpreter._call_claude("install docker") self.assertEqual(result, ["apt update"]) - @patch('interpreter.Anthropic') + @patch('anthropic.Anthropic') def test_call_claude_failure(self, mock_anthropic): mock_client = Mock() mock_client.messages.create.side_effect = Exception("API Error") @@ -130,7 +135,7 @@ def test_call_claude_failure(self, mock_anthropic): with self.assertRaises(RuntimeError): interpreter._call_claude("install docker") - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_parse_with_validation(self, mock_openai): mock_client = Mock() mock_response = Mock() @@ -144,7 +149,7 @@ def test_parse_with_validation(self, mock_openai): result = interpreter.parse("test command", validate=True) self.assertEqual(result, ["apt update"]) - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_parse_without_validation(self, mock_openai): mock_client = Mock() mock_response = Mock() @@ -158,7 +163,7 @@ def test_parse_without_validation(self, mock_openai): result = interpreter.parse("test command", validate=False) self.assertEqual(result, ["apt update", "rm -rf /"]) - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_parse_with_context(self, mock_openai): mock_client = Mock() mock_response = Mock() @@ -197,7 +202,7 @@ def test_parse_commands_empty_commands(self): result = interpreter._parse_commands(response) self.assertEqual(result, ["apt update", "apt install docker"]) - @patch('interpreter.OpenAI') + @patch('openai.OpenAI') def test_parse_docker_installation(self, mock_openai): mock_client = Mock() mock_response = Mock() @@ -217,7 +222,7 @@ def test_parse_docker_installation(self, mock_openai): result = interpreter.parse("install docker") self.assertGreater(len(result), 0) - self.assertIn("docker", result[0].lower() or result[1].lower()) + self.assertTrue(any("docker" in cmd.lower() for cmd in result)) if __name__ == "__main__": diff --git a/cortex/test_cli.py b/cortex/test_cli.py index cb2bf35..635ad06 100644 --- a/cortex/test_cli.py +++ b/cortex/test_cli.py @@ -18,7 +18,7 @@ def test_get_api_key_openai(self): api_key = self.cli._get_api_key() self.assertEqual(api_key, 'test-key') - @patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test-claude-key'}) + @patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test-claude-key', 'OPENAI_API_KEY': ''}, clear=True) def test_get_api_key_claude(self): api_key = self.cli._get_api_key() self.assertEqual(api_key, 'test-claude-key') From dfa2794aa1b7ffd7385e55fac2c1bdaceaf84b12 Mon Sep 17 00:00:00 2001 From: sahil Date: Tue, 11 Nov 2025 21:07:54 +0530 Subject: [PATCH 4/7] CLI test integration fix. --- LLM/interpreter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LLM/interpreter.py b/LLM/interpreter.py index 67f9525..cdebe46 100644 --- a/LLM/interpreter.py +++ b/LLM/interpreter.py @@ -22,7 +22,7 @@ def __init__( if model: self.model = model else: - self.model = "gpt-4" if self.provider == APIProvider.OPENAI else "claude-3-5-sonnet-20241022" + self.model = "gpt-4o-mini" if self.provider == APIProvider.OPENAI else "claude-3-5-sonnet-20241022" self._initialize_client() From 7dd773645526bbd188922aa52bdc9a79a8fdee54 Mon Sep 17 00:00:00 2001 From: Sahil Bhatane <118365864+Sahilbhatane@users.noreply.github.com> Date: Tue, 11 Nov 2025 21:23:25 +0530 Subject: [PATCH 5/7] Update model selection for OpenAI provider --- LLM/interpreter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LLM/interpreter.py b/LLM/interpreter.py index cdebe46..67f9525 100644 --- a/LLM/interpreter.py +++ b/LLM/interpreter.py @@ -22,7 +22,7 @@ def __init__( if model: self.model = model else: - self.model = "gpt-4o-mini" if self.provider == APIProvider.OPENAI else "claude-3-5-sonnet-20241022" + self.model = "gpt-4" if self.provider == APIProvider.OPENAI else "claude-3-5-sonnet-20241022" self._initialize_client() From 217fff8ea2df1bbecb3282b59a9e8d78ea6eb3a1 Mon Sep 17 00:00:00 2001 From: sahil Date: Fri, 14 Nov 2025 20:50:35 +0530 Subject: [PATCH 6/7] issue #8 - Enhance installation coordinator workflow for Docker --- cortex/coordinator.py | 14 ++++++++++++-- cortex/test_coordinator.py | 8 +++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cortex/coordinator.py b/cortex/coordinator.py index a2ae0a3..e9975c3 100644 --- a/cortex/coordinator.py +++ b/cortex/coordinator.py @@ -42,6 +42,8 @@ class InstallationResult: class InstallationCoordinator: + """Coordinates multi-step software installation processes.""" + def __init__( self, commands: List[str], @@ -52,6 +54,7 @@ def __init__( log_file: Optional[str] = None, progress_callback: Optional[Callable[[int, int, InstallationStep], None]] = None ): + """Initialize an installation run with optional logging and rollback.""" self.timeout = timeout self.stop_on_error = stop_on_error self.enable_rollback = enable_rollback @@ -144,9 +147,11 @@ def _rollback(self): self._log(f"Rollback failed: {cmd} - {str(e)}") def add_rollback_command(self, command: str): + """Register a rollback command executed if a step fails.""" self.rollback_commands.append(command) def execute(self) -> InstallationResult: + """Run each installation step and capture structured results.""" start_time = time.time() failed_step_index = None @@ -195,6 +200,7 @@ def execute(self) -> InstallationResult: ) def verify_installation(self, verify_commands: List[str]) -> Dict[str, bool]: + """Execute verification commands and return per-command success.""" verification_results = {} self._log("Starting verification...") @@ -249,8 +255,10 @@ def install_docker() -> InstallationResult: commands = [ "apt update", "apt install -y apt-transport-https ca-certificates curl software-properties-common", - "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -", - 'add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"', + "install -m 0755 -d /etc/apt/keyrings", + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg", + "chmod a+r /etc/apt/keyrings/docker.gpg", + 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null', "apt update", "apt install -y docker-ce docker-ce-cli containerd.io", "systemctl start docker", @@ -260,7 +268,9 @@ def install_docker() -> InstallationResult: descriptions = [ "Update package lists", "Install dependencies", + "Create keyrings directory", "Add Docker GPG key", + "Set key permissions", "Add Docker repository", "Update package lists again", "Install Docker packages", diff --git a/cortex/test_coordinator.py b/cortex/test_coordinator.py index 6911e23..442b816 100644 --- a/cortex/test_coordinator.py +++ b/cortex/test_coordinator.py @@ -1,8 +1,7 @@ import unittest -from unittest.mock import Mock, patch, call +from unittest.mock import Mock, patch import tempfile import os -import time import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) @@ -10,7 +9,6 @@ from cortex.coordinator import ( InstallationCoordinator, InstallationStep, - InstallationResult, StepStatus, install_docker ) @@ -316,7 +314,7 @@ def test_step_timing(self, mock_run): self.assertIsNotNone(step.start_time) self.assertIsNotNone(step.end_time) if step.end_time and step.start_time: - self.assertTrue(step.end_time > step.start_time) + self.assertGreater(step.end_time, step.start_time) self.assertIsNotNone(step.duration()) @@ -333,7 +331,7 @@ def test_install_docker_success(self, mock_run): result = install_docker() self.assertTrue(result.success) - self.assertEqual(len(result.steps), 8) + self.assertEqual(len(result.steps), 10) @patch('subprocess.run') def test_install_docker_failure(self, mock_run): From 6a3049d7c7c6dad5ff76935557436110c9630848 Mon Sep 17 00:00:00 2001 From: sahil Date: Fri, 14 Nov 2025 21:05:22 +0530 Subject: [PATCH 7/7] issue #8 - Enhance installation coordinator workflow for Docker --- cortex/coordinator.py | 153 +++++++++++++++++++++------ test/run_all_tests.py | 21 ++++ {cortex => test}/test_cli.py | 0 {cortex => test}/test_coordinator.py | 27 ++++- 4 files changed, 169 insertions(+), 32 deletions(-) create mode 100644 test/run_all_tests.py rename {cortex => test}/test_cli.py (100%) rename {cortex => test}/test_coordinator.py (92%) diff --git a/cortex/coordinator.py b/cortex/coordinator.py index e9975c3..c61031b 100644 --- a/cortex/coordinator.py +++ b/cortex/coordinator.py @@ -73,6 +73,55 @@ def __init__( ] self.rollback_commands: List[str] = [] + + @classmethod + def from_plan( + cls, + plan: List[Dict[str, str]], + *, + timeout: int = 300, + stop_on_error: bool = True, + enable_rollback: Optional[bool] = None, + log_file: Optional[str] = None, + progress_callback: Optional[Callable[[int, int, InstallationStep], None]] = None + ) -> "InstallationCoordinator": + """Create a coordinator from a structured plan produced by an LLM. + + Each plan entry should contain at minimum a ``command`` key and + optionally ``description`` and ``rollback`` fields. Rollback commands are + registered automatically when present. + """ + + commands: List[str] = [] + descriptions: List[str] = [] + rollback_commands: List[str] = [] + + for index, step in enumerate(plan): + command = step.get("command") + if not command: + raise ValueError("Each plan step must include a 'command'") + + commands.append(command) + descriptions.append(step.get("description", f"Step {index + 1}")) + + rollback_cmd = step.get("rollback") + if rollback_cmd: + rollback_commands.append(rollback_cmd) + + coordinator = cls( + commands, + descriptions, + timeout=timeout, + stop_on_error=stop_on_error, + enable_rollback=enable_rollback if enable_rollback is not None else bool(rollback_commands), + log_file=log_file, + progress_callback=progress_callback, + ) + + for rollback_cmd in rollback_commands: + coordinator.add_rollback_command(rollback_cmd) + + return coordinator def _log(self, message: str): timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -252,38 +301,52 @@ def export_log(self, filepath: str): def install_docker() -> InstallationResult: - commands = [ - "apt update", - "apt install -y apt-transport-https ca-certificates curl software-properties-common", - "install -m 0755 -d /etc/apt/keyrings", - "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg", - "chmod a+r /etc/apt/keyrings/docker.gpg", - 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null', - "apt update", - "apt install -y docker-ce docker-ce-cli containerd.io", - "systemctl start docker", - "systemctl enable docker" - ] - - descriptions = [ - "Update package lists", - "Install dependencies", - "Create keyrings directory", - "Add Docker GPG key", - "Set key permissions", - "Add Docker repository", - "Update package lists again", - "Install Docker packages", - "Start Docker service", - "Enable Docker on boot" + plan = [ + { + "command": "apt update", + "description": "Update package lists" + }, + { + "command": "apt install -y apt-transport-https ca-certificates curl software-properties-common", + "description": "Install dependencies" + }, + { + "command": "install -m 0755 -d /etc/apt/keyrings", + "description": "Create keyrings directory" + }, + { + "command": "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg", + "description": "Add Docker GPG key" + }, + { + "command": "chmod a+r /etc/apt/keyrings/docker.gpg", + "description": "Set key permissions" + }, + { + "command": 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null', + "description": "Add Docker repository" + }, + { + "command": "apt update", + "description": "Update package lists again" + }, + { + "command": "apt install -y docker-ce docker-ce-cli containerd.io", + "description": "Install Docker packages" + }, + { + "command": "systemctl start docker", + "description": "Start Docker service", + "rollback": "systemctl stop docker" + }, + { + "command": "systemctl enable docker", + "description": "Enable Docker on boot", + "rollback": "systemctl disable docker" + } ] - - coordinator = InstallationCoordinator( - commands=commands, - descriptions=descriptions, - timeout=300, - stop_on_error=True - ) + + coordinator = InstallationCoordinator.from_plan(plan, timeout=300, stop_on_error=True) result = coordinator.execute() @@ -292,3 +355,31 @@ def install_docker() -> InstallationResult: coordinator.verify_installation(verify_commands) return result + + +def example_cuda_install_plan() -> List[Dict[str, str]]: + """Return a sample CUDA installation plan for LLM integration tests.""" + + return [ + { + "command": "apt update", + "description": "Refresh package repositories" + }, + { + "command": "apt install -y build-essential dkms", + "description": "Install build tooling" + }, + { + "command": "sh cuda_installer.run --silent", + "description": "Install CUDA drivers", + "rollback": "rm -rf /usr/local/cuda" + }, + { + "command": "nvidia-smi", + "description": "Verify GPU driver status" + }, + { + "command": "nvcc --version", + "description": "Validate CUDA compiler installation" + } + ] diff --git a/test/run_all_tests.py b/test/run_all_tests.py new file mode 100644 index 0000000..353fe6b --- /dev/null +++ b/test/run_all_tests.py @@ -0,0 +1,21 @@ +import os +import sys +import unittest + + +def main() -> int: + """Discover and run all unittest modules within the test directory.""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(current_dir, "..")) + + if project_root not in sys.path: + sys.path.insert(0, project_root) + + suite = unittest.defaultTestLoader.discover(start_dir=current_dir, pattern="test_*.py") + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cortex/test_cli.py b/test/test_cli.py similarity index 100% rename from cortex/test_cli.py rename to test/test_cli.py diff --git a/cortex/test_coordinator.py b/test/test_coordinator.py similarity index 92% rename from cortex/test_coordinator.py rename to test/test_coordinator.py index 442b816..acdb45a 100644 --- a/cortex/test_coordinator.py +++ b/test/test_coordinator.py @@ -10,7 +10,8 @@ InstallationCoordinator, InstallationStep, StepStatus, - install_docker + install_docker, + example_cuda_install_plan ) @@ -41,6 +42,20 @@ def test_initialization(self): self.assertEqual(coordinator.steps[0].command, "echo 1") self.assertEqual(coordinator.steps[1].command, "echo 2") + def test_from_plan_initialization(self): + plan = [ + {"command": "echo 1", "description": "First step"}, + {"command": "echo 2", "rollback": "echo rollback"} + ] + + coordinator = InstallationCoordinator.from_plan(plan) + + self.assertEqual(len(coordinator.steps), 2) + self.assertEqual(coordinator.steps[0].description, "First step") + self.assertEqual(coordinator.steps[1].description, "Step 2") + self.assertTrue(coordinator.enable_rollback) + self.assertEqual(coordinator.rollback_commands, ["echo rollback"]) + def test_initialization_with_descriptions(self): commands = ["echo 1", "echo 2"] descriptions = ["First", "Second"] @@ -347,5 +362,15 @@ def test_install_docker_failure(self, mock_run): self.assertIsNotNone(result.failed_step) +class TestInstallationPlans(unittest.TestCase): + + def test_example_cuda_install_plan_structure(self): + plan = example_cuda_install_plan() + + self.assertGreaterEqual(len(plan), 5) + self.assertTrue(all("command" in step for step in plan)) + self.assertTrue(any("rollback" in step for step in plan)) + + if __name__ == '__main__': unittest.main()