Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 136 additions & 3 deletions cortex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import sys
import time
from datetime import datetime
from typing import Any

# Suppress noisy log messages in normal operation
logging.getLogger("httpx").setLevel(logging.WARNING)
Expand All @@ -15,9 +16,8 @@
from cortex.coordinator import InstallationCoordinator, StepStatus
from cortex.installation_history import InstallationHistory, InstallationStatus, InstallationType
from cortex.llm.interpreter import CommandInterpreter

# Import the new Notification Manager
from cortex.notification_manager import NotificationManager
from cortex.stack_manager import StackManager
from cortex.user_preferences import (
PreferencesManager,
format_preference_value,
Expand Down Expand Up @@ -171,6 +171,113 @@ def notify(self, args):

# -------------------------------

def stack(self, args: argparse.Namespace) -> int:
"""Handle `cortex stack` commands (list/describe/install/dry-run)."""
try:
manager = StackManager()

# Validate --dry-run requires a stack name
if args.dry_run and not args.name:
self._print_error(
"--dry-run requires a stack name (e.g., `cortex stack ml --dry-run`)"
)
return 1

# List stacks (default when no name/describe)
if args.list or (not args.name and not args.describe):
return self._handle_stack_list(manager)

# Describe a specific stack
if args.describe:
return self._handle_stack_describe(manager, args.describe)

# Install a stack (only remaining path)
return self._handle_stack_install(manager, args)

except FileNotFoundError as e:
self._print_error(f"stacks.json not found. Ensure cortex/stacks.json exists: {e}")
return 1
except ValueError as e:
self._print_error(f"stacks.json is invalid or malformed: {e}")
return 1

def _handle_stack_list(self, manager: StackManager) -> int:
"""List all available stacks."""
stacks = manager.list_stacks()
cx_print("\n📦 Available Stacks:\n", "info")
for stack in stacks:
pkg_count = len(stack.get("packages", []))
console.print(f" [green]{stack.get('id', 'unknown')}[/green]")
console.print(f" {stack.get('name', 'Unnamed Stack')}")
console.print(f" {stack.get('description', 'No description')}")
console.print(f" [dim]({pkg_count} packages)[/dim]\n")
cx_print("Use: cortex stack <name> to install a stack", "info")
return 0

def _handle_stack_describe(self, manager: StackManager, stack_id: str) -> int:
"""Describe a specific stack."""
stack = manager.find_stack(stack_id)
if not stack:
self._print_error(f"Stack '{stack_id}' not found. Use --list to see available stacks.")
return 1
description = manager.describe_stack(stack_id)
console.print(description)
return 0

def _handle_stack_install(self, manager: StackManager, args: argparse.Namespace) -> int:
"""Install a stack with optional hardware-aware selection."""
original_name = args.name
suggested_name = manager.suggest_stack(args.name)

if suggested_name != original_name:
cx_print(
f"💡 No GPU detected, using '{suggested_name}' instead of '{original_name}'",
"info",
)

stack = manager.find_stack(suggested_name)
if not stack:
self._print_error(
f"Stack '{suggested_name}' not found. Use --list to see available stacks."
)
return 1

packages = stack.get("packages", [])
if not packages:
self._print_error(f"Stack '{suggested_name}' has no packages configured.")
return 1

if args.dry_run:
return self._handle_stack_dry_run(stack, packages)

return self._handle_stack_real_install(stack, packages)

def _handle_stack_dry_run(self, stack: dict[str, Any], packages: list[str]) -> int:
"""Preview packages that would be installed without executing."""
cx_print(f"\n📋 Stack: {stack['name']}", "info")
console.print("\nPackages that would be installed:")
for pkg in packages:
console.print(f" • {pkg}")
console.print(f"\nTotal: {len(packages)} packages")
cx_print("\nDry run only - no commands executed", "warning")
return 0

def _handle_stack_real_install(self, stack: dict[str, Any], packages: list[str]) -> int:
"""Install all packages in the stack."""
cx_print(f"\n🚀 Installing stack: {stack['name']}\n", "success")

# Batch into a single LLM request
packages_str = " ".join(packages)
result = self.install(software=packages_str, execute=True, dry_run=False)

if result != 0:
self._print_error(f"Failed to install stack '{stack['name']}'")
return 1

self._print_success(f"\n✅ Stack '{stack['name']}' installed successfully!")
console.print(f"Installed {len(packages)} packages")
return 0

# Run system health checks
def doctor(self):
from cortex.doctor import SystemDoctor
Expand All @@ -179,12 +286,24 @@ def doctor(self):
return doctor.run_checks()

def install(self, software: str, execute: bool = False, dry_run: bool = False):
# Validate input first
is_valid, error = validate_install_request(software)
if not is_valid:
self._print_error(error)
return 1

# Special-case the ml-cpu stack:
# The LLM sometimes generates outdated torch==1.8.1+cpu installs
# which fail on modern Python. For the "pytorch-cpu jupyter numpy pandas"
# combo, force a supported CPU-only PyTorch recipe instead.
normalized = " ".join(software.split()).lower()

if normalized == "pytorch-cpu jupyter numpy pandas":
software = (
"pip3 install torch torchvision torchaudio "
"--index-url https://download.pytorch.org/whl/cpu && "
"pip3 install jupyter numpy pandas"
)

api_key = self._get_api_key()
if not api_key:
return 1
Expand Down Expand Up @@ -569,6 +688,7 @@ def show_rich_help():
table.add_row("rollback <id>", "Undo installation")
table.add_row("notify", "Manage desktop notifications") # Added this line
table.add_row("cache stats", "Show LLM cache statistics")
table.add_row("stack <name>", "Install the stack")
table.add_row("doctor", "System health check")

console.print(table)
Expand Down Expand Up @@ -665,6 +785,17 @@ def main():
send_parser.add_argument("--actions", nargs="*", help="Action buttons")
# --------------------------

# Stack command
stack_parser = subparsers.add_parser("stack", help="Manage pre-built package stacks")
stack_parser.add_argument(
"name", nargs="?", help="Stack name to install (ml, ml-cpu, webdev, devops, data)"
)
stack_group = stack_parser.add_mutually_exclusive_group()
stack_group.add_argument("--list", "-l", action="store_true", help="List all available stacks")
stack_group.add_argument("--describe", "-d", metavar="STACK", help="Show details about a stack")
stack_parser.add_argument(
"--dry-run", action="store_true", help="Show what would be installed (requires stack name)"
)
# Cache commands
cache_parser = subparsers.add_parser("cache", help="Cache operations")
cache_subs = cache_parser.add_subparsers(dest="cache_action", help="Cache actions")
Expand Down Expand Up @@ -699,6 +830,8 @@ def main():
# Handle the new notify command
elif args.command == "notify":
return cli.notify(args)
elif args.command == "stack":
return cli.stack(args)
elif args.command == "doctor":
return cli.doctor()
elif args.command == "cache":
Expand Down
102 changes: 102 additions & 0 deletions cortex/stack_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""
Stack command: Pre-built package combinations
Usage:
cortex stack --list # List all stacks
cortex stack ml # Install ML stack (auto-detects GPU)
cortex stack ml-cpu # Install CPU-only version
cortex stack webdev --dry-run # Preview webdev stack
"""

import json
from pathlib import Path
from typing import Any

from cortex.hardware_detection import has_nvidia_gpu


class StackManager:
"""Manages pre-built package stacks with hardware awareness"""

def __init__(self) -> None:
# stacks.json is in the same directory as this file (cortex/)
self.stacks_file = Path(__file__).parent / "stacks.json"
self._stacks = None

def load_stacks(self) -> dict[str, Any]:
"""Load stacks from JSON file"""
if self._stacks is not None:
return self._stacks

try:
with open(self.stacks_file) as f:
self._stacks = json.load(f)
return self._stacks
except FileNotFoundError as e:
raise FileNotFoundError(f"Stacks config not found at {self.stacks_file}") from e
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in {self.stacks_file}") from e

def list_stacks(self) -> list[dict[str, Any]]:
"""Get all available stacks"""
stacks = self.load_stacks()
return stacks.get("stacks", [])

def find_stack(self, stack_id: str) -> dict[str, Any] | None:
"""Find a stack by ID"""
for stack in self.list_stacks():
if stack["id"] == stack_id:
return stack
return None

def get_stack_packages(self, stack_id: str) -> list[str]:
"""Get package list for a stack"""
stack = self.find_stack(stack_id)
return stack.get("packages", []) if stack else []

def suggest_stack(self, base_stack: str) -> str:
"""
Suggest hardware-appropriate stack variant.
For the 'ml' stack, returns 'ml' if a GPU is detected, otherwise 'ml-cpu'.
Other stacks are returned unchanged.

Args:
base_stack: The requested stack identifier.

Returns:
The suggested stack identifier (may differ from input).
"""
if base_stack == "ml":
return "ml" if has_nvidia_gpu() else "ml-cpu"
return base_stack

def describe_stack(self, stack_id: str) -> str:
"""
Generate a formatted description of a stack.

Args:
stack_id: The stack identifier to describe.

Returns:
A multi-line formatted string with stack name, description,
packages, tags, and hardware requirements. Returns a not-found
message if the stack doesn't exist.
"""
stack = self.find_stack(stack_id)
if not stack:
return f"Stack '{stack_id}' not found"

output = f"\n📦 Stack: {stack['name']}\n"
output += f"Description: {stack['description']}\n\n"
output += "Packages included:\n"

for idx, pkg in enumerate(stack.get("packages", []), 1):
output += f" {idx}. {pkg}\n"

tags = stack.get("tags", [])
if tags:
output += f"\nTags: {', '.join(tags)}\n"

hardware = stack.get("hardware", "any")
output += f"Hardware: {hardware}\n"

return output
39 changes: 39 additions & 0 deletions cortex/stacks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"stacks": [
{
"id": "ml",
"name": "Machine Learning (GPU)",
"description": "PyTorch, CUDA, Jupyter, pandas, numpy, matplotlib",
"packages": ["pytorch", "cuda", "jupyter", "numpy", "pandas", "matplotlib"],
"hardware": "gpu"
},
{
"id": "ml-cpu",
"name": "Machine Learning (CPU)",
"description": "PyTorch CPU-only version",
"packages": ["pytorch-cpu", "jupyter", "numpy", "pandas"],
"hardware": "cpu"
},
{
"id": "webdev",
"name": "Web Development",
"description": "Node, npm, nginx, postgres",
"packages": ["nodejs", "npm", "nginx", "postgresql"],
"hardware": "any"
},
{
"id": "devops",
"name": "DevOps Tools",
"description": "Docker, kubectl, terraform, ansible",
"packages": ["docker", "kubectl", "terraform", "ansible"],
"hardware": "any"
},
{
"id": "data",
"name": "Data Science",
"description": "Python, pandas, jupyter, postgres client",
"packages": ["python3", "pandas", "jupyter", "sqlalchemy"],
"hardware": "any"
}
]
}
Loading
Loading