Browse Source

new updates

xcad 9 months ago
parent
commit
23fba94147

+ 236 - 50
cli/core/config.py

@@ -1,79 +1,265 @@
-from typing import Any, Dict, Optional
+"""Global configuration management for the boilerplate CLI."""
+
+from dataclasses import dataclass, field
 from pathlib import Path
+from typing import Any, Dict, List, Optional
+import json
+import logging
+import yaml
 
+from .exceptions import ConfigurationError
 
-class ConfigManager:
-  """Placeholder for configuration management.
+logger = logging.getLogger('boilerplates')
+
+
+@dataclass
+class LibraryConfig:
+  """Configuration for a single library."""
+  name: str
+  type: str  # 'local' or 'git'
+  path: Optional[str] = None  # For local libraries
+  repo: Optional[str] = None  # For git libraries
+  branch: str = "main"  # For git libraries
+  priority: int = 0  # Higher priority = checked first
+
+
+@dataclass
+class Config:
+  """Global configuration management."""
   
-  This will handle loading and saving user configuration including:
-  - Variable default values (highest priority)
-  - Module settings
-  - User preferences
+  # Paths
+  config_dir: Path = field(default_factory=lambda: Path.home() / ".boilerplates")
+  cache_dir: Path = field(default_factory=lambda: Path.home() / ".boilerplates" / "cache")
   
-  TODO: Implement actual configuration persistence and loading
-  """
+  # Libraries
+  libraries: List[LibraryConfig] = field(default_factory=list)
+  
+  # Application settings
+  log_level: str = "INFO"
+  default_editor: str = "vim"
+  auto_update_remotes: bool = False
+  template_validation: bool = True
   
-  def __init__(self, config_dir: Optional[Path] = None):
-    """Initialize the configuration manager.
+  # UI settings
+  use_rich_output: bool = True
+  confirm_generation: bool = True
+  show_summary: bool = True
+  
+  def __post_init__(self):
+    """Ensure directories exist."""
+    self.config_dir.mkdir(parents=True, exist_ok=True)
+    self.cache_dir.mkdir(parents=True, exist_ok=True)
+  
+  @classmethod
+  def load(cls, config_path=None):
+    """Load configuration from file or use defaults.
     
     Args:
-        config_dir: Directory to store configuration files. 
-                   Defaults to ~/.boilerplates/
+        config_path: Optional path to config file. If not provided,
+                    uses ~/.boilerplates/config.yaml
+    
+    Returns:
+        Config instance with loaded or default values
     """
-    if config_dir is None:
-      config_dir = Path.home() / ".boilerplates"
+    if config_path is None:
+      config_path = Path.home() / ".boilerplates" / "config.yaml"
     
-    self.config_dir = config_dir
-    self.config_dir.mkdir(parents=True, exist_ok=True)
+    if config_path.exists():
+      try:
+        with open(config_path, 'r') as f:
+          data = yaml.safe_load(f) or {}
+        
+        # Parse libraries if present
+        libraries = []
+        for lib_data in data.get('libraries', []):
+          try:
+            libraries.append(LibraryConfig(**lib_data))
+          except TypeError as e:
+            logger.warning(f"Invalid library configuration: {lib_data}, error: {e}")
+        
+        # Remove libraries from data to avoid duplicate in Config init
+        if 'libraries' in data:
+          del data['libraries']
+        
+        # Convert path strings to Path objects
+        if 'config_dir' in data:
+          data['config_dir'] = Path(data['config_dir'])
+        if 'cache_dir' in data:
+          data['cache_dir'] = Path(data['cache_dir'])
+        
+        config = cls(**data, libraries=libraries)
+        logger.debug(f"Loaded configuration from {config_path}")
+        return config
+        
+      except yaml.YAMLError as e:
+        raise ConfigurationError("config.yaml", f"Invalid YAML format: {e}")
+      except Exception as e:
+        logger.warning(f"Failed to load config from {config_path}: {e}, using defaults")
+        return cls()
+    else:
+      logger.debug(f"No config file found at {config_path}, using defaults")
+      return cls()
   
-  def get_variable_defaults(self, module_name: str) -> Dict[str, Any]:
-    """Get user-configured default values for variables in a module.
+  def save(self, config_path=None):
+    """Save configuration to file.
     
     Args:
-        module_name: Name of the module (e.g., 'compose', 'terraform')
-        
-    Returns:
-        Dictionary mapping variable names to their user-configured default values
-        
-    TODO: Implement actual config file loading
+        config_path: Optional path to save config. If not provided,
+                    uses ~/.boilerplates/config.yaml
     """
-    # Placeholder implementation - returns empty dict
-    return {}
+    if config_path is None:
+      config_path = self.config_dir / "config.yaml"
+    
+    data = {
+      'config_dir': str(self.config_dir),
+      'cache_dir': str(self.cache_dir),
+      'log_level': self.log_level,
+      'default_editor': self.default_editor,
+      'auto_update_remotes': self.auto_update_remotes,
+      'template_validation': self.template_validation,
+      'use_rich_output': self.use_rich_output,
+      'confirm_generation': self.confirm_generation,
+      'show_summary': self.show_summary,
+      'libraries': [
+        {
+          'name': lib.name,
+          'type': lib.type,
+          'path': lib.path,
+          'repo': lib.repo,
+          'branch': lib.branch,
+          'priority': lib.priority
+        }
+        for lib in self.libraries
+      ]
+    }
+    
+    # Remove None values from library configs
+    for lib in data['libraries']:
+      lib = {k: v for k, v in lib.items() if v is not None}
+    
+    try:
+      config_path.parent.mkdir(parents=True, exist_ok=True)
+      with open(config_path, 'w') as f:
+        yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
+      logger.debug(f"Saved configuration to {config_path}")
+    except Exception as e:
+      raise ConfigurationError("config.yaml", f"Failed to save: {e}")
   
-  def save_variable_defaults(self, module_name: str, variable_defaults: Dict[str, Any]) -> None:
-    """Save user-configured default values for variables in a module.
+  def add_library(self, library):
+    """Add a library configuration.
     
     Args:
-        module_name: Name of the module (e.g., 'compose', 'terraform')
-        variable_defaults: Dictionary mapping variable names to their default values
-        
-    TODO: Implement actual config file saving
+        library: LibraryConfig instance to add
     """
-    # Placeholder implementation - does nothing
-    pass
+    # Check for duplicate names
+    existing_names = {lib.name for lib in self.libraries}
+    if library.name in existing_names:
+      raise ConfigurationError(
+        f"library:{library.name}",
+        f"Library with name '{library.name}' already exists"
+      )
+    
+    self.libraries.append(library)
+    # Sort by priority (highest first)
+    self.libraries.sort(key=lambda l: l.priority, reverse=True)
   
-  def get_module_config(self, module_name: str) -> Dict[str, Any]:
-    """Get module-specific configuration.
+  def remove_library(self, name):
+    """Remove a library configuration by name.
     
     Args:
-        module_name: Name of the module
+        name: Name of the library to remove
         
     Returns:
-        Dictionary with module configuration
-        
-    TODO: Implement actual config loading
+        True if library was removed, False if not found
     """
-    # Placeholder implementation - returns empty dict
-    return {}
+    original_count = len(self.libraries)
+    self.libraries = [lib for lib in self.libraries if lib.name != name]
+    return len(self.libraries) < original_count
   
-  def save_module_config(self, module_name: str, config: Dict[str, Any]) -> None:
-    """Save module-specific configuration.
+  def get_library(self, name):
+    """Get a library configuration by name.
     
     Args:
-        module_name: Name of the module
-        config: Dictionary with module configuration
+        name: Name of the library
         
-    TODO: Implement actual config saving
+    Returns:
+        LibraryConfig if found, None otherwise
     """
-    # Placeholder implementation - does nothing
-    pass
+    for lib in self.libraries:
+      if lib.name == name:
+        return lib
+    return None
+
+
+# Global configuration instance
+_config = None
+
+
+def get_config():
+  """Get the global configuration instance.
+  
+  Returns:
+      The global Config instance, loading it if necessary
+  """
+  global _config
+  if _config is None:
+    _config = Config.load()
+  return _config
+
+
+def set_config(config):
+  """Set the global configuration instance.
+  
+  Args:
+      config: Config instance to use globally
+  """
+  global _config
+  _config = config
+
+
+# Legacy ConfigManager for backwards compatibility
+class ConfigManager:
+  """Legacy configuration manager for module configs.
+  
+  This is kept for backwards compatibility but uses json files.
+  """
+  
+  def __init__(self, config_dir=None):
+    if config_dir is None:
+      config_dir = Path.home() / ".boilerplates"
+    self.config_dir = config_dir
+    self.config_dir.mkdir(parents=True, exist_ok=True)
+  
+  def get_variable_defaults(self, module_name):
+    """Get user-configured default values for variables in a module."""
+    config_file = self.config_dir / f"{module_name}_vars.json"
+    if config_file.exists():
+      try:
+        with open(config_file, 'r') as f:
+          return json.load(f)
+      except json.JSONDecodeError:
+        logger.warning(f"Invalid JSON in {config_file}")
+    return {}
+  
+  def save_variable_defaults(self, module_name, variable_defaults):
+    """Save user-configured default values for variables in a module."""
+    config_file = self.config_dir / f"{module_name}_vars.json"
+    with open(config_file, 'w') as f:
+      json.dump(variable_defaults, f, indent=2)
+  
+  def get_module_config(self, module_name):
+    """Get module-specific configuration."""
+    config_file = self.config_dir / f"{module_name}.json"
+    if config_file.exists():
+      try:
+        with open(config_file, 'r') as f:
+          return json.load(f)
+      except json.JSONDecodeError:
+        logger.warning(f"Invalid JSON in {config_file}")
+    return {}
+  
+  def save_module_config(self, module_name, config):
+    """Save module-specific configuration."""
+    config_file = self.config_dir / f"{module_name}.json"
+    with open(config_file, 'w') as f:
+      json.dump(config, f, indent=2)

+ 89 - 0
cli/core/exceptions.py

@@ -0,0 +1,89 @@
+"""Custom exceptions for the boilerplate CLI application."""
+
+
+class BoilerplateError(Exception):
+  """Base exception for all boilerplate-related errors."""
+  pass
+
+
+class TemplateError(BoilerplateError):
+  """Base exception for template-related errors."""
+  pass
+
+
+class TemplateNotFoundError(TemplateError):
+  """Raised when a template cannot be found."""
+  
+  def __init__(self, template_id: str, module_name: str = None):
+    if module_name:
+      message = f"Template '{template_id}' not found in module '{module_name}'"
+    else:
+      message = f"Template '{template_id}' not found"
+    super().__init__(message)
+    self.template_id = template_id
+    self.module_name = module_name
+
+
+class InvalidTemplateError(TemplateError):
+  """Raised when a template has invalid format or content."""
+  
+  def __init__(self, template_path: str, reason: str):
+    message = f"Invalid template at '{template_path}': {reason}"
+    super().__init__(message)
+    self.template_path = template_path
+    self.reason = reason
+
+
+class TemplateValidationError(TemplateError):
+  """Raised when template validation fails."""
+  
+  def __init__(self, template_id: str, errors: list):
+    message = f"Template '{template_id}' validation failed:\n" + "\n".join(f"  - {e}" for e in errors)
+    super().__init__(message)
+    self.template_id = template_id
+    self.errors = errors
+
+
+class VariableError(BoilerplateError):
+  """Base exception for variable-related errors."""
+  pass
+
+
+class UndefinedVariableError(VariableError):
+  """Raised when a template references undefined variables."""
+  
+  def __init__(self, variable_names: set, template_id: str = None):
+    var_list = ", ".join(sorted(variable_names))
+    if template_id:
+      message = f"Template '{template_id}' references undefined variables: {var_list}"
+    else:
+      message = f"Undefined variables: {var_list}"
+    super().__init__(message)
+    self.variable_names = variable_names
+    self.template_id = template_id
+
+
+class LibraryError(BoilerplateError):
+  """Base exception for library-related errors."""
+  pass
+
+
+class RemoteLibraryError(LibraryError):
+  """Raised when operations on remote libraries fail."""
+  
+  def __init__(self, library_name: str, operation: str, reason: str):
+    message = f"Remote library '{library_name}' {operation} failed: {reason}"
+    super().__init__(message)
+    self.library_name = library_name
+    self.operation = operation
+    self.reason = reason
+
+
+class ConfigurationError(BoilerplateError):
+  """Raised when configuration is invalid or missing."""
+  
+  def __init__(self, config_item: str, reason: str):
+    message = f"Configuration error for '{config_item}': {reason}"
+    super().__init__(message)
+    self.config_item = config_item
+    self.reason = reason

+ 248 - 17
cli/core/library.py

@@ -1,18 +1,21 @@
 from pathlib import Path
-from typing import TYPE_CHECKING
+import subprocess
+import logging
+from .config import get_config, LibraryConfig
+from .exceptions import RemoteLibraryError
 
-if TYPE_CHECKING:
-  from .template import Template
+logger = logging.getLogger('boilerplates')
 
 
 class Library:
   """Represents a single library with a specific path."""
   
-  def __init__(self, name: str, path: Path):
+  def __init__(self, name: str, path: Path, priority: int = 0):
     self.name = name
     self.path = path
+    self.priority = priority  # Higher priority = checked first
 
-  def find_by_id(self, module_name: str, files: list[str], template_id: str) -> "Template | None":
+  def find_by_id(self, module_name, files, template_id):
     """
     Find a template by its ID in this library.
     
@@ -33,7 +36,7 @@ class Library:
         return template
     return None
 
-  def find(self, module_name: str, files: list[str], sorted: bool = False) -> list["Template"]:
+  def find(self, module_name, files, sorted=False):
     """Find templates in this library for a specific module."""
     from .template import Template  # Import here to avoid circular import
     
@@ -57,21 +60,247 @@ class Library:
     return templates
 
 
+class RemoteLibrary(Library):
+  """Support for Git-based remote template libraries."""
+  
+  def __init__(self, name: str, repo_url: str, branch: str = "main", priority: int = 0):
+    """Initialize a remote library.
+    
+    Args:
+        name: Name of the library
+        repo_url: Git repository URL
+        branch: Branch to use (default: main)
+        priority: Library priority (higher = checked first)
+    """
+    self.repo_url = repo_url
+    self.branch = branch
+    
+    # Set up local cache path
+    config = get_config()
+    local_cache = config.cache_dir / name
+    
+    # Initialize parent with cache path
+    super().__init__(name, local_cache, priority)
+    
+    # Update the cache on initialization if configured
+    if config.auto_update_remotes:
+      try:
+        self.update()
+      except Exception as e:
+        logger.warning(f"Failed to auto-update remote library '{name}': {e}")
+  
+  def update(self) -> bool:
+    """Pull latest changes from remote repository.
+    
+    Returns:
+        True if update was successful, False otherwise
+    """
+    try:
+      if not self.path.exists():
+        # Clone repository
+        logger.info(f"Cloning remote library '{self.name}' from {self.repo_url}")
+        self.path.parent.mkdir(parents=True, exist_ok=True)
+        
+        result = subprocess.run(
+          ["git", "clone", "-b", self.branch, self.repo_url, str(self.path)],
+          capture_output=True,
+          text=True,
+          check=True
+        )
+        
+        if result.returncode != 0:
+          raise RemoteLibraryError(
+            self.name, "clone", 
+            f"Git clone failed: {result.stderr}"
+          )
+        
+        logger.info(f"Successfully cloned library '{self.name}'")
+        return True
+        
+      else:
+        # Pull updates
+        logger.info(f"Updating remote library '{self.name}'")
+        
+        # First, fetch to see if there are updates
+        result = subprocess.run(
+          ["git", "fetch", "origin", self.branch],
+          cwd=self.path,
+          capture_output=True,
+          text=True
+        )
+        
+        if result.returncode != 0:
+          logger.warning(f"Failed to fetch updates for '{self.name}': {result.stderr}")
+          return False
+        
+        # Check if we're behind
+        result = subprocess.run(
+          ["git", "rev-list", "--count", f"HEAD..origin/{self.branch}"],
+          cwd=self.path,
+          capture_output=True,
+          text=True
+        )
+        
+        behind_count = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
+        
+        if behind_count > 0:
+          # Pull the updates
+          result = subprocess.run(
+            ["git", "pull", "origin", self.branch],
+            cwd=self.path,
+            capture_output=True,
+            text=True,
+            check=True
+          )
+          
+          if result.returncode != 0:
+            raise RemoteLibraryError(
+              self.name, "pull",
+              f"Git pull failed: {result.stderr}"
+            )
+          
+          logger.info(f"Successfully updated library '{self.name}' ({behind_count} new commits)")
+          return True
+        else:
+          logger.debug(f"Library '{self.name}' is already up to date")
+          return True
+          
+    except subprocess.CalledProcessError as e:
+      raise RemoteLibraryError(
+        self.name, "update",
+        f"Command failed: {e.stderr if hasattr(e, 'stderr') else str(e)}"
+      )
+    except Exception as e:
+      raise RemoteLibraryError(
+        self.name, "update",
+        str(e)
+      )
+  
+  def get_info(self) -> dict:
+    """Get information about the remote library.
+    
+    Returns:
+        Dictionary with library information
+    """
+    info = {
+      'name': self.name,
+      'type': 'remote',
+      'repo': self.repo_url,
+      'branch': self.branch,
+      'priority': self.priority,
+      'cached': self.path.exists(),
+      'cache_path': str(self.path)
+    }
+    
+    if self.path.exists():
+      try:
+        # Get current commit hash
+        result = subprocess.run(
+          ["git", "rev-parse", "HEAD"],
+          cwd=self.path,
+          capture_output=True,
+          text=True
+        )
+        if result.returncode == 0:
+          info['current_commit'] = result.stdout.strip()[:8]
+        
+        # Get last update time
+        result = subprocess.run(
+          ["git", "log", "-1", "--format=%ci"],
+          cwd=self.path,
+          capture_output=True,
+          text=True
+        )
+        if result.returncode == 0:
+          info['last_updated'] = result.stdout.strip()
+          
+      except Exception as e:
+        logger.debug(f"Failed to get git info for '{self.name}': {e}")
+    
+    return info
+
+
 class LibraryManager:
-  """Manager for multiple libraries."""
+  """Manager for multiple libraries with priority-based ordering."""
   
   def __init__(self):
     self.libraries = []
-    # Initialize with the default library
-    script_dir = Path(__file__).parent.parent.parent  # Go up from cli/core/ to project root
-    default_library = Library("default", script_dir / "library")
-    self.libraries.append(default_library)
+    self._initialize_libraries()
+  
+  def _initialize_libraries(self):
+    """Initialize libraries from configuration."""
+    config = get_config()
+    
+    # First, add configured libraries
+    for lib_config in config.libraries:
+      try:
+        library = self._create_library_from_config(lib_config)
+        if library:
+          self.libraries.append(library)
+          logger.debug(f"Loaded library '{lib_config.name}' with priority {lib_config.priority}")
+      except Exception as e:
+        logger.warning(f"Failed to load library '{lib_config.name}': {e}")
+    
+    # Then add the default built-in library if not already configured
+    if not any(lib.name == "default" for lib in self.libraries):
+      script_dir = Path(__file__).parent.parent.parent  # Go up from cli/core/ to project root
+      default_library = Library("default", script_dir / "library", priority=-1)  # Lower priority
+      self.libraries.append(default_library)
+    
+    # Sort libraries by priority (highest first)
+    self._sort_by_priority()
+  
+  def _create_library_from_config(self, lib_config):
+    """Create a Library instance from configuration.
+    
+    Args:
+        lib_config: LibraryConfig instance
+        
+    Returns:
+        Library instance or None if creation fails
+    """
+    if lib_config.type == "local":
+      if lib_config.path:
+        path = Path(lib_config.path).expanduser()
+        if path.exists():
+          return Library(lib_config.name, path, lib_config.priority)
+        else:
+          logger.warning(f"Local library path does not exist: {path}")
+          return None
+    elif lib_config.type == "git":
+      if lib_config.repo:
+        return RemoteLibrary(
+          lib_config.name,
+          lib_config.repo,
+          lib_config.branch,
+          lib_config.priority
+        )
+      else:
+        logger.warning(f"Git library '{lib_config.name}' missing repo URL")
+        return None
+    else:
+      logger.warning(f"Unknown library type: {lib_config.type}")
+      return None
+  
+  def _sort_by_priority(self):
+    """Sort libraries by priority (highest first)."""
+    self.libraries.sort(key=lambda lib: lib.priority, reverse=True)
   
   def add_library(self, library: Library):
-    """Add a library to the collection."""
+    """Add a library to the collection.
+    
+    Args:
+        library: Library instance to add
+    """
+    # Check for duplicate names
+    if any(lib.name == library.name for lib in self.libraries):
+      logger.warning(f"Library '{library.name}' already exists, replacing")
+      self.libraries = [lib for lib in self.libraries if lib.name != library.name]
+    
     self.libraries.append(library)
+    self._sort_by_priority()
 
-  def find(self, module_name: str, files: list[str], sorted: bool = False) -> list["Template"]:
+  def find(self, module_name, files, sorted=False):
     """Find templates across all libraries for a specific module."""
     all_templates = []
     
@@ -84,7 +313,7 @@ class LibraryManager:
 
     return all_templates
 
-  def find_by_id(self, module_name: str, files: list[str], template_id: str) -> "Template | None":
+  def find_by_id(self, module_name, files, template_id):
     """
     Find a template by its ID across all libraries.
     
@@ -103,11 +332,13 @@ class LibraryManager:
         Template object if found across any library, None otherwise.
         
     Note:
-        This method searches through all registered libraries in order, returning the first
-        matching template found. This allows for library precedence and template overriding.
+        This method searches through all registered libraries in priority order (highest first),
+        returning the first matching template found. This allows higher-priority libraries
+        to override templates from lower-priority ones.
     """
-    for library in self.libraries:
+    for library in self.libraries:  # Already sorted by priority
       template = library.find_by_id(module_name, files, template_id)
       if template:
+        logger.debug(f"Found template '{template_id}' in library '{library.name}' (priority: {library.priority})")
         return template
     return None

+ 136 - 87
cli/core/module.py

@@ -1,136 +1,185 @@
 from abc import ABC
 from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, Dict, Any
 import logging
 from typer import Typer, Option, Argument
 from rich.console import Console
+from .config import get_config
+from .exceptions import TemplateNotFoundError, TemplateValidationError
 from .library import LibraryManager
+from .prompt import PromptHandler
 from .variables import VariableRegistry
-from .processor import VariableProcessor
 
 logger = logging.getLogger('boilerplates')
+console = Console()  # Single shared console instance
 
 
 class Module(ABC):
-  """Simplified base module with clearer responsibilities."""
+  """Streamlined base module with minimal redundancy."""
   
-  # Class attributes set by subclasses
-  name: str = None
-  description: str = None  
-  files: List[str] = None
+  # Required class attributes for subclasses
+  name = None
+  description = None  
+  files = None
   
   def __init__(self):
-    # Validate required attributes
     if not all([self.name, self.description, self.files]):
-      raise ValueError(f"Module {self.__class__.__name__} must define name, description, and files")
+      raise ValueError(
+        f"Module {self.__class__.__name__} must define name, description, and files"
+      )
     
-    self.app = Typer()
     self.libraries = LibraryManager()
     self.variables = VariableRegistry()
     
-    # Allow subclasses to initialize their variables
-    self._init_variables()
-    
-  def _init_variables(self):
-    """Override in subclasses to register module-specific variables."""
-    pass
-
-
-
-  def _get_groups_with_template_vars(self, template_vars: List[str]) -> List[str]:
-    """Get group names that contain at least one template variable.
-    
-    Args:
-        template_vars: List of variable names used in the template
-        
-    Returns:
-        List of group names that have variables used by the template
-    """
-    grouped_vars = self.variables.get_variables_for_template(template_vars)
-    return list(grouped_vars.keys())
-
+    # Allow subclasses to initialize variables if they override this
+    if hasattr(self, '_init_variables'):
+      self._init_variables()
 
 
   def list(self):
     """List all templates."""
     templates = self.libraries.find(self.name, self.files, sorted=True)
     for template in templates:
-      print(f"{template.id} - {template.name}")
+      console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
     return templates
 
-  def show(self, id: str = Argument(..., metavar="template", help="The template to show details for")):
-    """Show details about a template"""
-    logger.debug(f"Showing details for template: {id} in module: {self.name}")
-
-    template = self.libraries.find_by_id(module_name=self.name, files=self.files, template_id=id)
+  def show(self, id: str = Argument(..., help="Template ID")):
+    """Show template details."""
+    logger.debug(f"Showing template: {id}")
     
+    template = self._get_template(id)
     if not template:
-      logger.error(f"Template with ID '{id}' not found")
-      print(f"Template with ID '{id}' not found.")
       return
-
-    console = Console()
     
-    # Build title with version if available
-    version_suffix = f" v{template.version}" if template.version else ""
-    title = f"[bold magenta]{template.name} ({template.id}{version_suffix})[/bold magenta]"
+    # Header
+    version = f" v{template.version}" if template.version else ""
+    console.print(f"[bold magenta]{template.name} ({template.id}{version})[/bold magenta]")
+    console.print(f"[dim white]{template.description}[/dim white]\n")
     
-    # Print header
-    console.print(title)
-    console.print(f"[dim white]{template.description}[/dim white]")
-    console.print()
+    # Metadata (only print if exists)
+    metadata = [
+      ("Author", template.author),
+      ("Date", template.date),
+      ("Tags", ', '.join(template.tags) if template.tags else None)
+    ]
     
-    # Build and print metadata fields
-    metadata = []
-    if template.author:
-      metadata.append(f"Author: [cyan]{template.author}[/cyan]")
-    if template.date:
-      metadata.append(f"Date: [cyan]{template.date}[/cyan]")
-    if template.tags:
-      metadata.append(f"Tags: [cyan]{', '.join(template.tags)}[/cyan]")
+    for label, value in metadata:
+      if value:
+        console.print(f"{label}: [cyan]{value}[/cyan]")
     
-    # Find variable groups used by this template
-    template_var_groups = self._get_groups_with_template_vars(template.vars)
+    # Variable groups
+    if template.vars:
+      groups = self.variables.get_variables_for_template(template.vars)
+      if groups:
+        console.print(f"Functions: [cyan]{', '.join(groups.keys())}[/cyan]")
     
-    if template_var_groups:
-      metadata.append(f"Functions: [cyan]{', '.join(template_var_groups)}[/cyan]")
-    
-    # Print all metadata
-    for item in metadata:
-      console.print(item)
-    
-    # Template content
+    # Content
     if template.content:
       console.print(f"\n{template.content}")
+  
+  def _get_template(self, template_id: str, raise_on_missing: bool = False):
+    """Get template by ID with unified error handling."""
+    template = self.libraries.find_by_id(self.name, self.files, template_id)
+    
+    if not template:
+      logger.error(f"Template '{template_id}' not found")
+      if raise_on_missing:
+        raise TemplateNotFoundError(template_id, self.name)
+      console.print(f"[red]Template '{template_id}' not found[/red]")
+    
+    return template
 
-
-  def generate(self, id: str = Argument(..., help="Template ID"),
-              out: Optional[Path] = Option(None, "--out", "-o")):
+  def generate(
+    self,
+    id: str = Argument(..., help="Template ID"),
+    out: Optional[Path] = Option(None, "--out", "-o")
+  ):
     """Generate from template."""
-    # Find template
-    template = self.libraries.find_by_id(self.name, self.files, id)
-    if not template:
-      print(f"Template '{id}' not found.")
-      return
+    logger.debug(f"Generating template: {id}")
     
-    # Process variables
-    processor = VariableProcessor(self.variables)
-    values = processor.process(template)
+    template = self._get_template(id, raise_on_missing=True)
     
-    # Render and output
-    content = template.render(values)
+    # Validate template (will raise TemplateValidationError if validation fails)
+    self._validate_template(template, id)
     
+    # Process variables and render
+    values = self._process_variables(template)
+    
+    try:
+      content = template.render(values)
+    except Exception as e:
+      logger.error(f"Failed to render template: {e}")
+      raise
+    
+    # Output result
     if out:
       out.parent.mkdir(parents=True, exist_ok=True)
       out.write_text(content)
-      print(f"✅ Generated to {out}")
+      console.print(f"[green]✅ Generated to {out}[/green]")
     else:
-      print(f"\n{'='*60}\nGenerated Content:\n{'='*60}")
-      print(content)
+      console.print(f"\n{'='*60}\nGenerated Content:\n{'='*60}")
+      console.print(content)
+  
+  def _validate_template(self, template, template_id: str) -> None:
+    """Validate template and raise error if validation fails."""
+    errors = template.validate(set(self.variables.variables.keys()))
+    
+    if errors:
+      logger.error(f"Template '{template_id}' validation failed")
+      raise TemplateValidationError(template_id, errors)
+  
+  def _process_variables(self, template) -> Dict[str, Any]:
+    """Process template variables with prompting."""
+    grouped_vars = self.variables.get_variables_for_template(list(template.vars))
+    if not grouped_vars:
+      return {}
+    
+    # Collect all defaults
+    defaults = {
+      var.name: var.default 
+      for group_vars in grouped_vars.values() 
+      for var in group_vars 
+      if var.default is not None
+    }
+    defaults.update(template.var_defaults)  # Template defaults override
+    
+    # Use rich output if enabled
+    if not get_config().use_rich_output:
+      # Simple fallback - just prompt for missing values
+      values = defaults.copy()
+      for group_vars in grouped_vars.values():
+        for var in group_vars:
+          if var.name not in values:
+            desc = f" ({var.description})" if var.description else ""
+            values[var.name] = input(f"Enter {var.name}{desc}: ")
+      return values
+    
+    # Format for PromptHandler
+    formatted_groups = {}
+    for group_name, variables in grouped_vars.items():
+      group_info = self.variables.groups.get(group_name, {})
+      formatted_groups[group_name] = {
+        'display_name': group_info.get('display_name', group_name.title()),
+        'description': group_info.get('description', ''),
+        'icon': group_info.get('icon', ''),
+        'vars': {},
+        'enabler': self.variables.group_enablers.get(group_name, '')
+      }
+      
+      # Add usage patterns to each variable config
+      for var in variables:
+        var_config = var.to_prompt_config()
+        # Add usage patterns if this variable is used in the template
+        if var.name in template.var_usage:
+          var_config['usage_patterns'] = template.var_usage[var.name]
+        formatted_groups[group_name]['vars'][var.name] = var_config
+    
+    return PromptHandler(formatted_groups, defaults)()
   
   def register_cli(self, app: Typer):
-    """Register this module with the CLI app."""
-    self.app.command()(self.list)
-    self.app.command()(self.show)
-    self.app.command()(self.generate)
-    app.add_typer(self.app, name=self.name, help=self.description)
+    """Register module commands with the main app."""
+    module_app = Typer()
+    module_app.command()(self.list)
+    module_app.command()(self.show)
+    module_app.command()(self.generate)
+    app.add_typer(module_app, name=self.name, help=self.description)

+ 0 - 59
cli/core/processor.py

@@ -1,59 +0,0 @@
-from typing import Any, Dict, List
-import logging
-from .variables import VariableRegistry
-from .template import Template
-from .prompt import PromptHandler
-
-logger = logging.getLogger('boilerplates')
-
-
-class VariableProcessor:
-  """Variable processor for template generation."""
-  
-  def __init__(self, variable_registry: VariableRegistry):
-    self.registry = variable_registry
-  
-  def process(self, template: Template) -> Dict[str, Any]:
-    """Process variables for a template."""
-    
-    # Get variables needed by template
-    grouped_vars = self.registry.get_variables_for_template(template.vars)
-    
-    if not grouped_vars:
-      return {}
-    
-    # Convert to format expected by PromptHandler
-    formatted_groups = {}
-    for group_name, variables in grouped_vars.items():
-      group_info = self.registry.groups.get(group_name, {
-        'display_name': group_name.title(),
-        'description': '',
-        'icon': ''
-      })
-      
-      # Convert variables to dict format expected by PromptHandler
-      vars_dict = {}
-      for var in variables:
-        vars_dict[var.name] = var.to_prompt_config()
-      
-      formatted_groups[group_name] = {
-        'display_name': group_info['display_name'],
-        'description': group_info['description'],
-        'icon': group_info['icon'],
-        'vars': vars_dict,
-        'enabler': self.registry.group_enablers.get(group_name, '')
-      }
-    
-    # Resolve defaults (template defaults override variable defaults)
-    defaults = {}
-    for group_vars in grouped_vars.values():
-      for var in group_vars:
-        if var.default is not None:
-          defaults[var.name] = var.default
-    
-    # Template defaults have higher priority
-    defaults.update(template.var_defaults)
-    
-    # Prompt for values using the PromptHandler
-    prompt = PromptHandler(formatted_groups, defaults)
-    return prompt()  # Call the handler directly

+ 346 - 324
cli/core/prompt.py

@@ -1,18 +1,14 @@
-from typing import Dict, Any, List, Optional, Union
+from typing import Dict, Any, List, Optional
 import logging
 from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
 from rich.table import Table
-from rich.panel import Panel
-from rich.text import Text
-from rich.markdown import Markdown
 from rich import box
-import re
 
 logger = logging.getLogger('boilerplates')
 
 class PromptHandler:
-  """Advanced prompt handler with Rich UI for complex variable group logic."""
+  """Prompt handler with Rich UI for variable configuration."""
 
   def __init__(self, variable_groups: Dict[str, Any], resolved_defaults: Dict[str, Any] = None):
     """Initialize the prompt handler.
@@ -26,293 +22,394 @@ class PromptHandler:
     self.console = Console()
     self.final_values = {}
     
+    # Map prompt types to their handlers
+    self.prompt_handlers = {
+      'boolean': self._prompt_boolean,
+      'integer': self._prompt_integer,
+      'float': self._prompt_float,
+      'choice': self._prompt_choice,
+      'list': self._prompt_list,
+      'string': self._prompt_string
+    }
+    
   def __call__(self) -> Dict[str, Any]:
-    """Execute the complex prompting logic and return final variable values."""
-    logger.debug(f"Starting advanced prompt handler with {len(self.variable_groups)} variable groups")
+    """Execute the prompting logic and return final variable values."""
+    logger.debug(f"Starting prompt handler with {len(self.variable_groups)} variable groups")
 
-    # Process each variable group with the complex logic
-    # Maintain order by processing 'general' group first if it exists
-    ordered_groups = []
-    if 'general' in self.variable_groups:
-      ordered_groups.append(('general', self.variable_groups['general']))
-    
-    # Add remaining groups in their original order
-    for group_name, group_data in self.variable_groups.items():
-      if group_name != 'general':
-        ordered_groups.append((group_name, group_data))
-    
-    for group_name, group_data in ordered_groups:
+    # Process groups in order (general first if exists)
+    for group_name, group_data in self._get_ordered_groups():
       self._process_variable_group(group_name, group_data)
     
     self._show_summary()
     return self.final_values
   
-  def _process_variable_group(self, group_name: str, group_data: Dict[str, Any]):
-    """Process a single variable group with complex prompting logic.
+  def _get_ordered_groups(self) -> List[tuple]:
+    """Get groups in processing order (general first)."""
+    ordered = []
+    if 'general' in self.variable_groups:
+      ordered.append(('general', self.variable_groups['general']))
     
-    Logic flow:
-    1. Check if group has variables with no default values → always prompt
-    2. If group is not enabled → ask user if they want to enable it
-    3. If group is enabled → prompt for variables without values
-    4. Ask if user wants to change existing variable values
-    """
+    for name, data in self.variable_groups.items():
+      if name != 'general':
+        ordered.append((name, data))
     
+    return ordered
+  
+  def _process_variable_group(self, group_name: str, group_data: Dict[str, Any]):
+    """Process a single variable group."""
     variables = group_data.get('vars', {})
     if not variables:
       return
 
-    # Show compact group header only if there are variables to configure
-    vars_without_defaults = self._get_variables_without_defaults(variables)
-    vars_with_defaults = self._get_variables_with_defaults(variables)
+    # Flatten multivalue variables to check which ones are truly required
+    required_items, optional_items = self._categorize_variables(variables)
     
-    # Only show header if we need user interaction  
-    if not (vars_without_defaults or vars_with_defaults):
+    if not (required_items or optional_items):
       return
-      
-    # Use icon from group configuration
-    group_icon = group_data.get('icon', '')
-    group_display_name = group_data.get('display_name', group_name.title())
-    icon_display = f"{group_icon} " if group_icon else ""
-    self.console.print(f"\n{icon_display}[bold magenta]{group_display_name} Variables[/bold magenta]")
-
-    # Check if this group has an enabler variable
+    
+    # Apply defaults for all optional items
+    for var_name, key, default_value in optional_items:
+      if key is not None:
+        # Multivalue with key
+        if var_name not in self.final_values:
+          self.final_values[var_name] = {}
+        self.final_values[var_name][key] = default_value
+      else:
+        # Simple variable
+        self.final_values[var_name] = default_value
+    
+    # Check for enabler variable
     enabler_var_name = group_data.get('enabler', '')
+    has_enabler = enabler_var_name and enabler_var_name in variables
+    
+    # Determine if group should be processed
+    if has_enabler:
+      # Handle enabler group
+      enabled = self._prompt_enabler(group_name, enabler_var_name)
+      self.final_values[enabler_var_name] = enabled
+      if not enabled:
+        return
+    elif not required_items:
+      # No required items, ask if user wants to configure optional ones
+      if not self._should_process_optional_group(group_name):
+        return
     
-    # Always set default values for variables in this group
-    for var_name in vars_with_defaults:
-      default_value = self.resolved_defaults.get(var_name)
-      self.final_values[var_name] = default_value
+    # Show group header
+    self._show_group_header(group_name, group_data)
     
-    if enabler_var_name and enabler_var_name in variables:
-      # For groups with enablers, handle everything in _handle_group_with_enabler
-      self._handle_group_with_enabler(group_name, group_data, variables, vars_without_defaults, vars_with_defaults)
-    else:
-      # Original flow for groups without enablers
-      # Step 2: Determine if group should be enabled
-      group_enabled = self._determine_group_enabled_status(group_name, group_data, variables, vars_without_defaults)
-      
-      # When group is not enabled
-      if not group_enabled:
-        return
+    # Process required items first
+    if required_items:
+      for var_name, key, _ in required_items:
+        if var_name == enabler_var_name:
+          continue  # Already handled
         
-      # Step 3: Prompt for required variables (those without defaults)
-      if vars_without_defaults:
-        for var_name in vars_without_defaults:
-          var_data = variables[var_name]
-          value = self._prompt_for_variable(var_name, var_data, required=True)
-          self.final_values[var_name] = value
-      
-      # Step 4: Handle variables with defaults - ask if user wants to change them
-      if vars_with_defaults:
-        self._handle_variables_with_defaults(group_name, vars_with_defaults, variables)
-    # Groups are now more compact, minimal spacing needed
-  
-  def _get_variables_without_defaults(self, variables: Dict[str, Any]) -> List[str]:
-    """Get list of variable names that have no default values."""
-    return [
-      var_name for var_name, var_data in variables.items()
-      if var_name not in self.resolved_defaults or self.resolved_defaults[var_name] is None
+        if key is not None:
+          # Multivalue required key
+          value = self._prompt_for_multivalue_key(
+            var_name, key, variables[var_name], required=True
+          )
+          if var_name not in self.final_values:
+            self.final_values[var_name] = {}
+          self.final_values[var_name][key] = value
+        else:
+          # Simple required variable
+          self.final_values[var_name] = self._prompt_for_variable(
+            var_name, variables[var_name], required=True
+          )
+    
+    # Process optional items if user wants to change them
+    # Filter out already-prompted items from required_items
+    already_prompted = set((var_name, key) for var_name, key, _ in required_items)
+    optional_to_prompt = [
+      (var_name, key, default) for var_name, key, default in optional_items 
+      if var_name != enabler_var_name and (var_name, key) not in already_prompted
     ]
+    
+    if optional_to_prompt:
+      self._handle_optional_items(group_name, optional_to_prompt, variables)
   
-  def _get_variables_with_defaults(self, variables: Dict[str, Any]) -> List[str]:
-    """Get list of variable names that have default values."""
-    return [
-      var_name for var_name, var_data in variables.items()
-      if var_name in self.resolved_defaults and self.resolved_defaults[var_name] is not None
-    ]
+  def _handle_optional_items(self, group_name: str, optional_items: list, variables: Dict[str, Any]):
+    """Handle optional items (variables or multivalue keys with defaults)."""
+    # Group items by variable for preview
+    vars_to_show = {}
+    for var_name, key, default in optional_items:
+      if var_name not in vars_to_show:
+        vars_to_show[var_name] = []
+      vars_to_show[var_name].append((key, default))
+    
+    # Show preview
+    self._show_preview(list(vars_to_show.keys()))
+    
+    # Ask if user wants to customize
+    try:
+      want_to_customize = Confirm.ask(f"Do you want to change {group_name} values?", default=False)
+    except (EOFError, KeyboardInterrupt):
+      logger.debug(f"User interrupted customization for {group_name}, using defaults")
+      return
+    
+    if want_to_customize:
+      for var_name, key, default in optional_items:
+        var_data = variables[var_name]
+        
+        if key is not None:
+          # Multivalue item
+          value = self._prompt_for_multivalue_key(
+            var_name, key, var_data, required=False
+          )
+          if isinstance(key, int):
+            # Handle list index
+            if var_name not in self.final_values:
+              self.final_values[var_name] = []
+            while len(self.final_values[var_name]) <= key:
+              self.final_values[var_name].append(None)
+            self.final_values[var_name][key] = value
+          else:
+            # Handle dict key
+            if var_name not in self.final_values:
+              self.final_values[var_name] = {}
+            self.final_values[var_name][key] = value
+        else:
+          # Simple variable
+          current_value = self.final_values.get(var_name)
+          self.final_values[var_name] = self._prompt_for_variable(
+            var_name, var_data, required=False, current_value=current_value
+          )
   
-  def _determine_group_enabled_status(self, group_name: str, group_data: Dict[str, Any], variables: Dict[str, Any], vars_without_defaults: List[str]) -> bool:
-    """Determine if a variable group should be enabled based on complex logic."""
+  def _categorize_variables(self, variables: Dict[str, Any]) -> tuple:
+    """Categorize variables into required and optional items.
     
-    # Check if this group has an enabler variable
-    enabler_var_name = group_data.get('enabler', '')
-    if enabler_var_name and enabler_var_name in variables:
-      # This is a group controlled by an enabler variable
-      # The enabler variable will be prompted separately
-      # For now, assume it's enabled so we can prompt for the enabler
-      return True
-    
-    # If there are required variables (no defaults), group must be enabled
-    if vars_without_defaults:
-      logger.debug(f"Group {group_name} has required variables, enabling automatically")
-      return True
-    
-    # Check if any variable in the group is marked as required
-    has_required_vars = any(var_data.get('required', False) for var_data in variables.values())
-    if has_required_vars:
-      logger.debug(f"Group {group_name} has variables marked as required, enabling automatically")
-      return True
-    
-    # Check if group is enabled by default values or should ask user
-    vars_with_defaults = self._get_variables_with_defaults(variables)
-    if not vars_with_defaults:
-      logger.debug(f"Group {group_name} has no variables with defaults, skipping")
-      return False
+    Returns:
+      (required_items, optional_items) where each item is (var_name, key_or_index, default_value)
+      For simple variables, key_or_index is None.
+    """
+    required_items = []
+    optional_items = []
+    
+    for var_name, var_data in variables.items():
+      patterns = var_data.get('usage_patterns', {})
+      
+      if patterns and patterns.get('keys'):
+        # Multivalue with specific keys
+        for key in patterns['keys']:
+          # Check if this specific key has a default
+          default = None
+          if var_name in self.resolved_defaults and isinstance(self.resolved_defaults[var_name], dict):
+            default = self.resolved_defaults[var_name].get(key)
+          
+          if default is None:
+            required_items.append((var_name, key, None))
+          else:
+            optional_items.append((var_name, key, default))
+      
+      elif patterns and patterns.get('indices'):
+        # Multivalue with specific indices  
+        for idx in patterns['indices']:
+          # Check if this specific index has a default
+          default = None
+          if var_name in self.resolved_defaults and isinstance(self.resolved_defaults[var_name], list):
+            if idx < len(self.resolved_defaults[var_name]):
+              default = self.resolved_defaults[var_name][idx]
+          
+          if default is None:
+            required_items.append((var_name, idx, None))
+          else:
+            optional_items.append((var_name, idx, default))
+      
+      else:
+        # Simple variable
+        default = self.resolved_defaults.get(var_name)
+        if default is None:
+          required_items.append((var_name, None, None))
+        else:
+          optional_items.append((var_name, None, default))
     
-    # Ask user if they want to enable this optional group
+    return required_items, optional_items
+  
+  def _prompt_for_multivalue_key(self, var_name: str, key, var_data: Dict[str, Any], required: bool = False) -> Any:
+    """Prompt for a specific key/index of a multivalue variable."""
+    var_type = var_data.get('type', 'string')
+    
+    # Build prompt message
+    if isinstance(key, int):
+      prompt_msg = f"{var_name}[{key}]"
+    else:
+      prompt_msg = f"{var_name}['{key}']"
+    
+    if var_data.get('description'):
+      prompt_msg = f"Enter {prompt_msg} ({var_data['description']})"
+    else:
+      prompt_msg = f"Enter {prompt_msg}"
+    
+    if required:
+      prompt_msg += " [red](Required)[/red]"
+    
+    # Get current value if exists
+    current_value = None
+    if var_name in self.final_values:
+      if isinstance(self.final_values[var_name], dict) and key in self.final_values[var_name]:
+        current_value = self.final_values[var_name][key]
+      elif isinstance(self.final_values[var_name], list) and isinstance(key, int) and key < len(self.final_values[var_name]):
+        current_value = self.final_values[var_name][key]
+    
+    # Prompt based on type
+    handler = self.prompt_handlers.get(var_type, self.prompt_handlers['string'])
+    if var_type == 'string':
+      return handler(prompt_msg, current_value, required)
+    else:
+      return handler(prompt_msg, current_value)
+  
+  def _should_process_optional_group(self, group_name: str) -> bool:
+    """Ask if user wants to configure optional settings for a group."""
     try:
-      enable_group = Confirm.ask(
+      return Confirm.ask(f"Do you want to configure {group_name} settings?", default=False)
+    except (EOFError, KeyboardInterrupt):
+      return False
+  
+  
+  def _prompt_enabler(self, group_name: str, enabler_var_name: str) -> bool:
+    """Prompt for a group enabler variable."""
+    current_value = self.final_values.get(enabler_var_name, False)
+    try:
+      return Confirm.ask(
         f"Do you want to enable [bold]{group_name}[/bold]?",
-        default=False
+        default=bool(current_value)
       )
-      
-      # If group is enabled and has variables with defaults, ask if they want to change values
-      if enable_group and vars_with_defaults:
-        # This will be handled in the main flow after group is enabled
-        pass
-        
-      return enable_group
     except (EOFError, KeyboardInterrupt):
-      # For optional group configuration, gracefully handle interruption
-      logger.debug(f"User interrupted prompt for group {group_name}, defaulting to disabled")
+      logger.debug(f"User interrupted enabler prompt for {group_name}")
       return False
   
+  def _show_group_header(self, group_name: str, group_data: Dict[str, Any]):
+    """Display group header."""
+    icon = group_data.get('icon', '')
+    display_name = group_data.get('display_name', group_name.title())
+    icon_display = f"{icon} " if icon else ""
+    self.console.print(f"\n{icon_display}[bold magenta]{display_name} Variables[/bold magenta]")
   
-  def _show_group_preview(self, group_name: str, vars_with_defaults: List[str]):
-    """Show configured values in dim white below header."""
-    if not vars_with_defaults:
-      return
-      
-    # Create a clean display of configured values
-    var_previews = []
-    for var_name in vars_with_defaults:
-      default_value = self.resolved_defaults.get(var_name, "None")
-      # Truncate long values for cleaner display
-      display_value = str(default_value)
-      if len(display_value) > 25:
-        display_value = display_value[:22] + "..."
-      var_previews.append(f"{var_name}={display_value}")
-    
-    # Show configured values in dim white
-    vars_text = ", ".join(var_previews)
-    self.console.print(f"[dim white]({vars_text})[/dim white]")
   
-  def _handle_variables_with_defaults(self, group_name: str, vars_with_defaults: List[str], variables: Dict[str, Any]):
-    """Handle variables that have default values."""
-    
-    # Show preview of current values before asking if user wants to change them
-    self._show_group_preview(group_name, vars_with_defaults)
-    
-    # Ask if user wants to customize any of these values (defaults already set earlier)
-    try:
-      want_to_customize = Confirm.ask(f"Do you want to change {group_name} values?", default=False)
-    except (EOFError, KeyboardInterrupt):
-      logger.debug(f"User interrupted customization prompt for group {group_name}, using defaults")
+  def _show_preview(self, variables: List[str]):
+    """Show preview of configured values."""
+    if not variables:
       return
     
-    if want_to_customize:
-      # Directly prompt for each variable without asking if they want to change it
-      for var_name in vars_with_defaults:
-        var_data = variables[var_name]
-        current_value = self.final_values[var_name]
-        
-        # Directly prompt for the new value
-        new_value = self._prompt_for_variable(var_name, var_data, required=False, current_value=current_value)
-        self.final_values[var_name] = new_value
+    previews = []
+    for var_name in variables:
+      value = self.final_values.get(var_name)
+      if value is None:
+        display_value = "not set"
+      elif isinstance(value, dict):
+        # Show dict values compactly
+        items = [f"{k}={v}" for k, v in value.items()]
+        display_value = "{" + ", ".join(items[:2]) + ("..." if len(items) > 2 else "") + "}"
+      elif isinstance(value, list):
+        # Show list values compactly
+        display_value = "[" + ", ".join(str(v) for v in value[:2]) + (", ..." if len(value) > 2 else "") + "]"
+      else:
+        display_value = str(value)[:22] + "..." if len(str(value)) > 25 else str(value)
+      previews.append(f"{var_name}={display_value}")
+    
+    self.console.print(f"[dim white]({', '.join(previews)})[/dim white]")
+  
   
   def _prompt_for_variable(self, var_name: str, var_data: Dict[str, Any], required: bool = False, current_value: Any = None) -> Any:
-    """Prompt user for a single variable with new format: Enter VARIABLE_NAME (DESCRIPTION) (DEFAULT)."""
+    """Prompt user for a single variable.
     
+    Note: Multivalue variables with patterns are handled separately via _prompt_for_multivalue_key.
+    This method only handles simple variables or multivalue without specific patterns.
+    """
     var_type = var_data.get('type', 'string')
-    description = var_data.get('description', '')
-    options = var_data.get('options', [])
     
-    # Build new format prompt: Enter VARIABLE_NAME (DESCRIPTION) (DEFAULT_VALUE)
-    prompt_parts = ["Enter", f"[bold]{var_name}[/bold]"]
+    # Build prompt message
+    prompt_message = self._build_prompt_message(var_name, var_data, required, current_value)
     
-    # Add description in parentheses if available
-    if description:
-      prompt_parts.append(f"({description})")
+    # Get handler and execute prompt
+    handler = self.prompt_handlers.get(var_type, self.prompt_handlers['string'])
     
-    # Show default value if available
-    if current_value is not None:
-      prompt_parts.append(f"[dim]({current_value})[/dim]")
-    elif required:
-      prompt_parts.append("[red](Required)[/red]")
-    
-    prompt_message = " ".join(prompt_parts)
-    
-    # Handle different variable types
     try:
-      if var_type == 'boolean':
-        return self._prompt_boolean(prompt_message, current_value)
-      elif var_type == 'integer':
-        return self._prompt_integer(prompt_message, current_value)
-      elif var_type == 'float':
-        return self._prompt_float(prompt_message, current_value)
-      elif var_type == 'choice' and options:
-        return self._prompt_choice(prompt_message, options, current_value)
-      elif var_type == 'list':
-        return self._prompt_list(prompt_message, current_value)
-      else:  # string or unknown type
-        return self._prompt_string(prompt_message, current_value, required)
-        
+      # Special handling for choice type (needs options)
+      if var_type == 'choice':
+        return handler(prompt_message, current_value, var_data.get('options', []))
+      # Special handling for string type (needs required flag)
+      elif var_type == 'string':
+        return handler(prompt_message, current_value, required)
+      else:
+        return handler(prompt_message, current_value)
     except KeyboardInterrupt:
-      # Let KeyboardInterrupt propagate up to be handled at module level
       raise
     except Exception as e:
-      logger.error(f"Error prompting for variable {var_name}: {e}")
-      self.console.print(f"[red]Error getting input for {var_name}. Using default string prompt.[/red]")
+      logger.error(f"Error prompting for {var_name}: {e}")
+      self.console.print(f"[red]Error getting input for {var_name}[/red]")
+      # Fallback to string prompt
       return self._prompt_string(prompt_message, current_value, required)
   
+  def _build_prompt_message(self, var_name: str, var_data: Dict[str, Any], required: bool, current_value: Any) -> str:
+    """Build the prompt message for a variable."""
+    parts = ["Enter", f"[bold]{var_name}[/bold]"]
+    
+    if description := var_data.get('description'):
+      parts.append(f"({description})")
+    
+    if current_value is not None:
+      parts.append(f"[dim]({current_value})[/dim]")
+    elif required:
+      parts.append("[red](Required)[/red]")
+    
+    return " ".join(parts)
+  
   def _prompt_string(self, prompt_message: str, current_value: Any = None, required: bool = False) -> str:
-    """Prompt for string input with validation."""
-    default_val = str(current_value) if current_value is not None else None
+    """Prompt for string input."""
+    default = str(current_value) if current_value is not None else None
     
     while True:
       try:
-        value = Prompt.ask(prompt_message, default=default_val)
-        
-        # Handle None values that can occur when user provides no input
-        if value is None:
-          value = ""
+        value = Prompt.ask(prompt_message, default=default) or ""
         
         if required and not value.strip():
-          self.console.print("[red]This field is required and cannot be empty[/red]")
+          self.console.print("[red]This field is required[/red]")
           continue
           
         return value.strip()
       except (EOFError, KeyboardInterrupt):
-        # Let KeyboardInterrupt propagate up for proper cancellation
-        raise KeyboardInterrupt("Template generation cancelled by user")
+        raise KeyboardInterrupt("Operation cancelled by user")
   
   def _prompt_boolean(self, prompt_message: str, current_value: Any = None) -> bool:
     """Prompt for boolean input."""
-    default_val = bool(current_value) if current_value is not None else None
+    default = bool(current_value) if current_value is not None else None
     try:
-      return Confirm.ask(prompt_message, default=default_val)
+      return Confirm.ask(prompt_message, default=default)
     except (EOFError, KeyboardInterrupt):
-      raise KeyboardInterrupt("Template generation cancelled by user")
+      raise KeyboardInterrupt("Operation cancelled by user")
   
   def _prompt_integer(self, prompt_message: str, current_value: Any = None) -> int:
-    """Prompt for integer input with validation."""
-    default_val = int(current_value) if current_value is not None else None
+    """Prompt for integer input."""
+    default = int(current_value) if current_value is not None else None
     
     while True:
       try:
-        return IntPrompt.ask(prompt_message, default=default_val)
+        return IntPrompt.ask(prompt_message, default=default)
       except ValueError:
         self.console.print("[red]Please enter a valid integer[/red]")
       except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Template generation cancelled by user")
+        raise KeyboardInterrupt("Operation cancelled by user")
   
   def _prompt_float(self, prompt_message: str, current_value: Any = None) -> float:
-    """Prompt for float input with validation."""
-    default_val = float(current_value) if current_value is not None else None
+    """Prompt for float input."""
+    default = float(current_value) if current_value is not None else None
     
     while True:
       try:
-        return FloatPrompt.ask(prompt_message, default=default_val)
+        return FloatPrompt.ask(prompt_message, default=default)
       except ValueError:
         self.console.print("[red]Please enter a valid number[/red]")
       except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Template generation cancelled by user")
+        raise KeyboardInterrupt("Operation cancelled by user")
   
-  def _prompt_choice(self, prompt_message: str, options: List[Any], current_value: Any = None) -> Any:
-    """Prompt for choice from a list of options."""
+  def _prompt_choice(self, prompt_message: str, current_value: Any = None, options: List[Any] = None) -> Any:
+    """Prompt for choice from options."""
+    if not options:
+      return self._prompt_string(prompt_message, current_value)
     
-    # Show available options
-    self.console.print(f"\n[dim]Available options:[/dim]")
+    # Show options
+    self.console.print("\n[dim]Available options:[/dim]")
     for i, option in enumerate(options, 1):
       marker = "→" if option == current_value else " "
       self.console.print(f"  {marker} {i}. {option}")
@@ -321,138 +418,63 @@ class PromptHandler:
       try:
         choice = Prompt.ask(f"{prompt_message} (1-{len(options)})")
         
+        # Try numeric selection
         try:
-          choice_idx = int(choice) - 1
-          if 0 <= choice_idx < len(options):
-            return options[choice_idx]
-          else:
-            self.console.print(f"[red]Please enter a number between 1 and {len(options)}[/red]")
+          idx = int(choice) - 1
+          if 0 <= idx < len(options):
+            return options[idx]
         except ValueError:
-          # Try to match by string value
-          matching_options = [opt for opt in options if str(opt).lower() == choice.lower()]
-          if matching_options:
-            return matching_options[0]
-          self.console.print(f"[red]Please enter a valid option number (1-{len(options)}) or exact option name[/red]")
+          # Try string match
+          matches = [opt for opt in options if str(opt).lower() == choice.lower()]
+          if matches:
+            return matches[0]
+        
+        self.console.print(f"[red]Invalid choice. Enter 1-{len(options)} or option name[/red]")
       except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Template generation cancelled by user")
+        raise KeyboardInterrupt("Operation cancelled by user")
   
   def _prompt_list(self, prompt_message: str, current_value: Any = None) -> List[str]:
-    """Prompt for list input (comma-separated values)."""
-    
-    current_str = ""
-    if current_value and isinstance(current_value, list):
-      current_str = ", ".join(str(item) for item in current_value)
-    elif current_value:
-      current_str = str(current_value)
+    """Prompt for list input (comma-separated)."""
+    default = ", ".join(str(item) for item in current_value) if isinstance(current_value, list) else str(current_value or "")
     
-    self.console.print(f"[dim]Enter values separated by commas[/dim]")
+    self.console.print("[dim]Enter values separated by commas[/dim]")
     
     try:
-      value = Prompt.ask(prompt_message, default=current_str)
-      
-      if not value.strip():
-        return []
-      
-      # Split by comma and clean up
-      items = [item.strip() for item in value.split(',') if item.strip()]
-      return items
-    except (EOFError, KeyboardInterrupt):
-      raise KeyboardInterrupt("Template generation cancelled by user")
-  
-  def _handle_group_with_enabler(self, group_name: str, group_data: Dict[str, Any], 
-                                 variables: Dict[str, Any], vars_without_defaults: List[str], 
-                                 vars_with_defaults: List[str]):
-    """Handle groups that have an enabler variable."""
-    enabler_var_name = group_data.get('enabler', '')
-    enabler_var_data = variables.get(enabler_var_name, {})
-    current_enabler_value = self.final_values.get(enabler_var_name, False)
-    
-    # Ask if they want to enable the feature
-    try:
-      enable_feature = Confirm.ask(
-        f"Do you want to enable [bold]{group_name}[/bold]?",
-        default=bool(current_enabler_value)
-      )
-      self.final_values[enabler_var_name] = enable_feature
-      
-      if not enable_feature:
-        # If the feature is disabled, skip all other variables in this group
-        return
-        
+      value = Prompt.ask(prompt_message, default=default)
+      return [item.strip() for item in value.split(',') if item.strip()] if value.strip() else []
     except (EOFError, KeyboardInterrupt):
-      logger.debug(f"User interrupted enabler prompt for group {group_name}, using default")
-      return
-    
-    # Now handle required variables (those without defaults)
-    if vars_without_defaults:
-      # Remove enabler from the list if it's there
-      vars_without_defaults = [v for v in vars_without_defaults if v != enabler_var_name]
-      
-      for var_name in vars_without_defaults:
-        var_data = variables[var_name]
-        value = self._prompt_for_variable(var_name, var_data, required=True)
-        self.final_values[var_name] = value
-    
-    # Handle variables with defaults
-    if vars_with_defaults:
-      # Remove enabler from the list
-      remaining_vars = [v for v in vars_with_defaults if v != enabler_var_name]
-      
-      if remaining_vars:
-        # Show preview and ask if they want to change values
-        self._show_group_preview(group_name, remaining_vars)
-        
-        try:
-          want_to_customize = Confirm.ask(f"Do you want to change {group_name} values?", default=False)
-        except (EOFError, KeyboardInterrupt):
-          logger.debug(f"User interrupted customization prompt for group {group_name}, using defaults")
-          return
-        
-        if want_to_customize:
-          for var_name in remaining_vars:
-            var_data = variables[var_name]
-            current_value = self.final_values[var_name]
-            new_value = self._prompt_for_variable(var_name, var_data, required=False, current_value=current_value)
-            self.final_values[var_name] = new_value
+      raise KeyboardInterrupt("Operation cancelled by user")
   
   def _show_summary(self):
-    """Display a compact summary of all configured variables."""
+    """Display summary of configured variables."""
     if not self.final_values:
       return
     
-    # Only show detailed table if there are many variables (>5)
-    if len(self.final_values) > 5:
+    # Compact summary for few variables, table for many
+    if len(self.final_values) <= 5:
+      summaries = []
+      for name, value in self.final_values.items():
+        display_value = self._truncate_value(value, 20)
+        summaries.append(f"[cyan]{name}[/cyan]=[green]{display_value}[/green]")
+      self.console.print(f"\n[dim]Using:[/dim] {', '.join(summaries)}")
+    else:
       table = Table(box=box.SIMPLE)
       table.add_column("Variable", style="cyan")
       table.add_column("Value", style="green")
       
-      for var_name, value in self.final_values.items():
-        # Format value for display and truncate if too long
-        if isinstance(value, list):
-          display_value = ", ".join(str(item) for item in value)
-        else:
-          display_value = str(value)
-        
-        if len(display_value) > 50:
-          display_value = display_value[:47] + "..."
-        
-        table.add_row(var_name, display_value)
+      for name, value in self.final_values.items():
+        display_value = self._truncate_value(value, 50)
+        table.add_row(name, display_value)
       
       self.console.print(table)
-    else:
-      # For few variables, show a compact inline summary
-      var_summaries = []
-      for var_name, value in self.final_values.items():
-        display_value = str(value)
-        if len(display_value) > 20:
-          display_value = display_value[:17] + "..."
-        var_summaries.append(f"[cyan]{var_name}[/cyan]=[green]{display_value}[/green]")
-      
-      summary_text = ", ".join(var_summaries)
-      self.console.print(f"\n[dim]Using:[/dim] {summary_text}")
     
     self.console.print()
     
-    # Ask user if they want to proceed with template generation
+    # Confirm generation
     if not Confirm.ask("Proceed with generation?", default=True):
-      raise KeyboardInterrupt("Template generation cancelled by user")
+      raise KeyboardInterrupt("Generation cancelled by user")
+  
+  def _truncate_value(self, value: Any, max_length: int) -> str:
+    """Truncate value for display."""
+    display_value = ", ".join(str(item) for item in value) if isinstance(value, list) else str(value)
+    return display_value[:max_length-3] + "..." if len(display_value) > max_length else display_value

+ 3 - 4
cli/core/registry.py

@@ -1,19 +1,18 @@
 """Module registry system."""
-from typing import Type, Dict, List
 
 
 class ModuleRegistry:
   """Simple module registry without magic."""
   
   def __init__(self):
-    self._modules: Dict[str, Type] = {}
+    self._modules = {}
   
-  def register(self, module_class: Type) -> None:
+  def register(self, module_class):
     """Register a module class."""
     # Module class defines its own name attribute
     self._modules[module_class.name] = module_class
   
-  def create_instances(self) -> List:
+  def create_instances(self):
     """Create instances of all registered modules."""
     instances = []
     for name in sorted(self._modules.keys()):

+ 143 - 23
cli/core/template.py

@@ -1,9 +1,10 @@
 from pathlib import Path
-from typing import Any, Dict, Set, Tuple
+from typing import Any, Dict, Set, Tuple, List
 import logging
 import re
-from jinja2 import Environment, BaseLoader, meta, nodes
+from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
 import frontmatter
+from .exceptions import TemplateValidationError
 
 
 class Template:
@@ -16,7 +17,7 @@ class Template:
       loader=BaseLoader(),
       trim_blocks=True,           # Remove first newline after block tags
       lstrip_blocks=True,         # Strip leading whitespace from block tags  
-      keep_trailing_newline=False # Remove trailing newlines
+      keep_trailing_newline=False  # Remove trailing newlines
     )
   
   def __init__(self, file_path: Path, frontmatter_data: Dict[str, Any], content: str):
@@ -42,7 +43,8 @@ class Template:
     # Extract variables and defaults from the template content
     # vars: Set[str] - All Jinja2 variable names found in template (e.g., {'app_name', 'port', 'debug'})
     # var_defaults: Dict[str, Any] - Default values from | default() filters (e.g., {'app_name': 'my-app', 'port': 8080})
-    self.vars, self.var_defaults = self._parse_template_variables(content)
+    # var_usage: Dict[str, Dict] - How variables are used (simple, array indices, dict keys)
+    self.vars, self.var_defaults, self.var_usage = self._parse_template_variables(content)
 
   @classmethod
   def from_file(cls, file_path: Path) -> "Template":
@@ -65,37 +67,152 @@ class Template:
       post = frontmatter.load(f)
     return post.metadata, post.content
   
-  def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any]]:
-    """Parse Jinja2 template to extract variables and their default values.
+  def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any], Dict[str, Dict]]:
+    """Parse Jinja2 template to extract variables, defaults, and usage patterns.
     
     Examples:
-        {{ app_name | default('my-app') }} → vars={'app_name'}, defaults={'app_name': 'my-app'}
-        {{ port | default(8080) }} → vars={'port'}, defaults={'port': 8080}
-        {{ unused_var }} → vars={'unused_var'}, defaults={}
+        {{ app_name | default('my-app') }} → Simple variable
+        {{ service_port['http'] }} → Dict with key 'http'
+        {{ service_port.https }} → Dict with key 'https' (dot notation)
+        {{ docker_network[0] }} → Array with index 0
+        {{ ports[item.name] }} → Dynamic dict key
     
     Returns:
-        Tuple of (all_variable_names, variable_defaults)
+        Tuple of (all_variable_names, variable_defaults, variable_usage_patterns)
     """
     try:
       env = self._create_jinja_env()
       ast = env.parse(template_content)
       
-      # Extract all undeclared variables
+      # Start with variables found by Jinja2's meta utility
       all_variables = meta.find_undeclared_variables(ast)
       
+      # Add variables used in Getattr and Getitem nodes
+      for node in ast.find_all((nodes.Getattr, nodes.Getitem)):
+          current_node = node.node
+          while isinstance(current_node, (nodes.Getattr, nodes.Getitem)):
+              current_node = current_node.node
+          if isinstance(current_node, nodes.Name):
+              all_variables.add(current_node.name)
+      
       # Extract default values from | default() filters
-      defaults = {
-        node.node.name: node.args[0].value
-        for node in ast.find_all(nodes.Filter)
-        if node.name == 'default' 
-        and isinstance(node.node, nodes.Name) 
-        and node.args 
-        and isinstance(node.args[0], nodes.Const)
-      }
+      defaults = {}
+      for node in ast.find_all(nodes.Filter):
+        if node.name == 'default' and node.args and isinstance(node.args[0], nodes.Const):
+          # Handle simple variable defaults: {{ var | default(value) }}
+          if isinstance(node.node, nodes.Name):
+            defaults[node.node.name] = node.args[0].value
+          # Handle dict access defaults: {{ var['key'] | default(value) }}
+          elif isinstance(node.node, nodes.Getitem):
+            if isinstance(node.node.node, nodes.Name) and isinstance(node.node.arg, nodes.Const):
+              var_name = node.node.node.name
+              key = node.node.arg.value
+              if var_name not in defaults:
+                defaults[var_name] = {}
+              if not isinstance(defaults[var_name], dict):
+                defaults[var_name] = {}
+              defaults[var_name][key] = node.args[0].value
       
-      return all_variables, defaults
-    except Exception:
-      return set(), {}
+      # Analyze variable usage patterns for multivalue support
+      usage_patterns = self._analyze_variable_patterns(template_content)
+      
+      return all_variables, defaults, usage_patterns
+    except Exception as e:
+      logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
+      return set(), {}, {}
+  
+  def _analyze_variable_patterns(self, template_content: str) -> Dict[str, Dict]:
+    """Analyze how variables are used in the template to detect multivalue patterns.
+    
+    Returns a dict mapping variable names to their usage info:
+    {
+      'service_port': {
+        'keys': ['http', 'https'],  # Keys used with this variable
+        'indices': [],               # Numeric indices used
+      }
+    }
+    """
+    patterns = {}
+    
+    # Pattern for dict access: variable['key'] or variable["key"]
+    dict_pattern = r'{{\s*(\w+)\[[\'"]([\w-]+)[\'"]\]'
+    for match in re.finditer(dict_pattern, template_content):
+      var_name, key = match.groups()
+      if var_name not in patterns:
+        patterns[var_name] = {'keys': [], 'indices': []}
+      if key not in patterns[var_name]['keys']:
+        patterns[var_name]['keys'].append(key)
+    
+    # Pattern for numeric index: variable[0], variable[1], etc.
+    index_pattern = r'{{\s*(\w+)\[(\d+)\]'
+    for match in re.finditer(index_pattern, template_content):
+      var_name, index = match.groups()
+      if var_name not in patterns:
+        patterns[var_name] = {'keys': [], 'indices': []}
+      idx = int(index)
+      if idx not in patterns[var_name]['indices']:
+        patterns[var_name]['indices'].append(idx)
+    
+    # Sort indices if present
+    for var_name in patterns:
+      patterns[var_name]['indices'].sort()
+    
+    return patterns
+
+  def validate(self, registered_variables=None):
+    """Validate template integrity.
+    
+    Args:
+        registered_variables: Optional set of variable names registered by the module.
+                             If provided, checks for undefined variables.
+    
+    Returns:
+        List of validation error messages. Empty list if valid.
+    """
+    errors = []
+    
+    # Check for Jinja2 syntax errors
+    try:
+      env = self._create_jinja_env()
+      env.from_string(self.content)
+    except TemplateSyntaxError as e:
+      errors.append(f"Invalid Jinja2 syntax at line {e.lineno}: {e.message}")
+    except Exception as e:
+      errors.append(f"Template parsing error: {str(e)}")
+    
+    # Check for undefined variables if registered variables are provided
+    if registered_variables is not None:
+      # Variables that are used in template but not defined anywhere
+      undefined = self.vars - set(self.var_defaults.keys()) - registered_variables
+      if undefined:
+        var_list = ", ".join(sorted(undefined))
+        errors.append(f"Undefined variables: {var_list}")
+    
+    # Check for missing required frontmatter fields
+    if not self.name or self.name == self.file_path.parent.name:
+      errors.append("Missing 'name' in frontmatter")
+    
+    if not self.description or self.description == 'No description available':
+      errors.append("Missing 'description' in frontmatter")
+    
+    # Check for empty content (unless it's intentionally a metadata-only template)
+    if not self.content.strip() and not self.files:
+      errors.append("Template has no content")
+    
+    return errors
+  
+  def validate_strict(self, registered_variables=None):
+    """Validate template and raise exception if invalid.
+    
+    Args:
+        registered_variables: Optional set of variable names registered by the module.
+    
+    Raises:
+        TemplateValidationError: If validation fails
+    """
+    errors = self.validate(registered_variables)
+    if errors:
+      raise TemplateValidationError(self.id, errors)
 
   def to_dict(self) -> Dict[str, Any]:
     """Convert to dictionary for display."""
@@ -123,7 +240,10 @@ class Template:
     try:
       env = self._create_jinja_env()
       jinja_template = env.from_string(self.content)
-      rendered_content = jinja_template.render(**variable_values)
+      # Merge template defaults with provided values
+      # All variables should be defined at this point due to validation
+      merged_variable_values = {**self.var_defaults, **variable_values}
+      rendered_content = jinja_template.render(**merged_variable_values)
       
       # Clean up excessive blank lines and whitespace
       rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)

+ 5 - 3
cli/core/variables.py

@@ -10,9 +10,10 @@ class Variable:
   description: str = ""
   default: Any = None
   type: str = "string"
-  options: List[Any] = field(default_factory=list)
+  options: List[Any] = field(default_factory=list)  # FIXME: not needed
   group: str = "general"
-  required: bool = False
+  required: bool = False  # FIXME: not needed
+  multivalue: bool = False  # If True, variable can accept multiple values as dict/list
   
   def to_prompt_config(self) -> Dict[str, Any]:
     """Convert to prompt configuration."""
@@ -22,7 +23,8 @@ class Variable:
       'type': self.type,
       'options': self.options,
       'required': self.required,
-      'default': self.default
+      'default': self.default,
+      'multivalue': self.multivalue
     }
 
 

+ 40 - 6
cli/modules/compose.py

@@ -16,18 +16,22 @@ class ComposeModule(Module):
       "general", "General Settings",
       "Basic configuration for Docker Compose services"
     )
-    
+
+    self.variables.register_group(
+      "swarm", "Docker Swarm Settings",
+      "Settings for deploying services in Docker Swarm mode", icon="󰒋 ", enabler="swarm"
+    )
+
     self.variables.register_group(
       "traefik", "Traefik Configuration", 
-      "Reverse proxy settings", icon="󰞉", enabler="traefik"
+      "Reverse proxy settings", icon="󰞉 ", enabler="traefik"
     )
     
     # Register variables
     self.variables.register_variable(Variable(
       name="service_name",
       description="Name of the service",
-      group="general",
-      required=True
+      group="general"
     ))
     
     self.variables.register_variable(Variable(
@@ -35,7 +39,23 @@ class ComposeModule(Module):
       description="Container name",
       group="general"
     ))
-    
+
+    self.variables.register_variable(Variable(
+      name="service_port",
+      description="Port(s) the service listens on (can be single or multiple)",
+      type="integer",
+      group="general",
+      multivalue=True
+    ))
+
+    self.variables.register_variable(Variable(
+      name="swarm",
+      description="Enable Docker Swarm mode",
+      type="boolean",
+      default=False,
+      group="swarm"
+    ))
+
     self.variables.register_variable(Variable(
       name="traefik",
       description="Enable Traefik",
@@ -47,9 +67,23 @@ class ComposeModule(Module):
     self.variables.register_variable(Variable(
       name="traefik_host",
       description="Traefik hostname",
-      default=None,
       group="traefik"
     ))
 
+    self.variables.register_variable(Variable(
+      name="traefik_certresolver",
+      description="Traefik certificate resolver",
+      group="traefik"
+    ))
+    
+    # Add docker_network as a multivalue example
+    self.variables.register_variable(Variable(
+      name="docker_network",
+      description="Docker network(s) to connect to",
+      type="string",
+      group="general",
+      multivalue=True
+    ))
+
 # Register the module
 registry.register(ComposeModule)

+ 2 - 2
library/compose/nginx/compose.yaml

@@ -10,7 +10,7 @@ tags:
   - reverse-proxy
 ---
 services:
-  {{ service_name | default('nginx') }}:
+  {{ service_name }}:
     image: docker.io/library/nginx:1.28.0-alpine
     {% if not swarm %}
     container_name: {{ container_name | default('nginx') }}
@@ -31,7 +31,7 @@ services:
     {% endif %}
     {% if not traefik %}
     ports:
-      - "{{ service_port['http'] | default(8080) }}:80"
+      - "{{ service_port['http']  }}:80"
       - "{{ service_port['https'] | default(8443) }}:443"
     {% endif %}
     # volumes:

+ 17 - 0
library/compose/test-mixed/compose.yaml

@@ -0,0 +1,17 @@
+---
+name: "Test Mixed Defaults"
+description: "Test template with mixed default scenarios"
+version: "0.0.1"
+---
+services:
+  {{ service_name }}:
+    container_name: {{ container_name | default('test') }}
+    ports:
+      # http has no default (required)
+      - "{{ service_port['http'] }}:80"  
+      # https has a default (optional)
+      - "{{ service_port['https'] | default(8443) }}:443"
+      # admin has a default (optional)  
+      - "{{ service_port['admin'] | default(9090) }}:9090"
+    networks:
+      - {{ network_name }}

+ 66 - 0
library/compose/test-multivalue/compose.yaml

@@ -0,0 +1,66 @@
+---
+name: "Test Multivalue Template"
+description: "Demo template showing multivalue variable usage"
+version: "0.0.1"
+date: "2025-01-07"
+author: "Test"
+tags:
+  - test
+  - multivalue
+---
+services:
+  {{ service_name | default('app') }}:
+    container_name: {{ container_name | default('my-app') }}
+    image: nginx:alpine
+    
+    # Example: service_port can be a single value or dict
+    {% if service_port is mapping %}
+    ports:
+      {% for name, port in service_port.items() %}
+      - "{{ port }}:{{ port }}"  # {{ name }} port
+      {% endfor %}
+    {% elif service_port is iterable and service_port is not string %}
+    ports:
+      {% for port in service_port %}
+      - "{{ port }}:{{ port }}"
+      {% endfor %}
+    {% else %}
+    ports:
+      - "{{ service_port }}:80"
+    {% endif %}
+    
+    # Example: docker_network can be single or multiple
+    {% if docker_network is mapping %}
+    networks:
+      {% for name, net in docker_network.items() %}
+      {{ net }}:
+        aliases:
+          - {{ service_name }}-{{ name }}
+      {% endfor %}
+    {% elif docker_network is iterable and docker_network is not string %}
+    networks:
+      {% for net in docker_network %}
+      - {{ net }}
+      {% endfor %}
+    {% else %}
+    networks:
+      - {{ docker_network | default('default') }}
+    {% endif %}
+
+{% if docker_network is mapping %}
+networks:
+  {% for name, net in docker_network.items() %}
+  {{ net }}:
+    external: true
+  {% endfor %}
+{% elif docker_network is iterable and docker_network is not string %}
+networks:
+  {% for net in docker_network %}
+  {{ net }}:
+    external: true
+  {% endfor %}
+{% elif docker_network %}
+networks:
+  {{ docker_network }}:
+    external: true
+{% endif %}