xcad 9 ماه پیش
والد
کامیت
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 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:
     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:
     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:
     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:
     Args:
-        module_name: Name of the module
+        name: Name of the library to remove
         
         
     Returns:
     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:
     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 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:
 class Library:
   """Represents a single library with a specific path."""
   """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.name = name
     self.path = path
     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.
     Find a template by its ID in this library.
     
     
@@ -33,7 +36,7 @@ class Library:
         return template
         return template
     return None
     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."""
     """Find templates in this library for a specific module."""
     from .template import Template  # Import here to avoid circular import
     from .template import Template  # Import here to avoid circular import
     
     
@@ -57,21 +60,247 @@ class Library:
     return templates
     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:
 class LibraryManager:
-  """Manager for multiple libraries."""
+  """Manager for multiple libraries with priority-based ordering."""
   
   
   def __init__(self):
   def __init__(self):
     self.libraries = []
     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):
   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.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."""
     """Find templates across all libraries for a specific module."""
     all_templates = []
     all_templates = []
     
     
@@ -84,7 +313,7 @@ class LibraryManager:
 
 
     return all_templates
     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.
     Find a template by its ID across all libraries.
     
     
@@ -103,11 +332,13 @@ class LibraryManager:
         Template object if found across any library, None otherwise.
         Template object if found across any library, None otherwise.
         
         
     Note:
     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)
       template = library.find_by_id(module_name, files, template_id)
       if template:
       if template:
+        logger.debug(f"Found template '{template_id}' in library '{library.name}' (priority: {library.priority})")
         return template
         return template
     return None
     return None

+ 136 - 87
cli/core/module.py

@@ -1,136 +1,185 @@
 from abc import ABC
 from abc import ABC
 from pathlib import Path
 from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, Dict, Any
 import logging
 import logging
 from typer import Typer, Option, Argument
 from typer import Typer, Option, Argument
 from rich.console import Console
 from rich.console import Console
+from .config import get_config
+from .exceptions import TemplateNotFoundError, TemplateValidationError
 from .library import LibraryManager
 from .library import LibraryManager
+from .prompt import PromptHandler
 from .variables import VariableRegistry
 from .variables import VariableRegistry
-from .processor import VariableProcessor
 
 
 logger = logging.getLogger('boilerplates')
 logger = logging.getLogger('boilerplates')
+console = Console()  # Single shared console instance
 
 
 
 
 class Module(ABC):
 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):
   def __init__(self):
-    # Validate required attributes
     if not all([self.name, self.description, self.files]):
     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.libraries = LibraryManager()
     self.variables = VariableRegistry()
     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):
   def list(self):
     """List all templates."""
     """List all templates."""
     templates = self.libraries.find(self.name, self.files, sorted=True)
     templates = self.libraries.find(self.name, self.files, sorted=True)
     for template in templates:
     for template in templates:
-      print(f"{template.id} - {template.name}")
+      console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
     return templates
     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:
     if not template:
-      logger.error(f"Template with ID '{id}' not found")
-      print(f"Template with ID '{id}' not found.")
       return
       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:
     if template.content:
       console.print(f"\n{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."""
     """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:
     if out:
       out.parent.mkdir(parents=True, exist_ok=True)
       out.parent.mkdir(parents=True, exist_ok=True)
       out.write_text(content)
       out.write_text(content)
-      print(f"✅ Generated to {out}")
+      console.print(f"[green]✅ Generated to {out}[/green]")
     else:
     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):
   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
 import logging
 from rich.console import Console
 from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
 from rich.table import Table
 from rich.table import Table
-from rich.panel import Panel
-from rich.text import Text
-from rich.markdown import Markdown
 from rich import box
 from rich import box
-import re
 
 
 logger = logging.getLogger('boilerplates')
 logger = logging.getLogger('boilerplates')
 
 
 class PromptHandler:
 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):
   def __init__(self, variable_groups: Dict[str, Any], resolved_defaults: Dict[str, Any] = None):
     """Initialize the prompt handler.
     """Initialize the prompt handler.
@@ -26,293 +22,394 @@ class PromptHandler:
     self.console = Console()
     self.console = Console()
     self.final_values = {}
     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]:
   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._process_variable_group(group_name, group_data)
     
     
     self._show_summary()
     self._show_summary()
     return self.final_values
     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', {})
     variables = group_data.get('vars', {})
     if not variables:
     if not variables:
       return
       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
       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', '')
     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:
     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]?",
         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):
     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
       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
       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:
   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')
     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:
     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:
     except KeyboardInterrupt:
-      # Let KeyboardInterrupt propagate up to be handled at module level
       raise
       raise
     except Exception as e:
     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)
       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:
   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:
     while True:
       try:
       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():
         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
           continue
           
           
         return value.strip()
         return value.strip()
       except (EOFError, KeyboardInterrupt):
       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:
   def _prompt_boolean(self, prompt_message: str, current_value: Any = None) -> bool:
     """Prompt for boolean input."""
     """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:
     try:
-      return Confirm.ask(prompt_message, default=default_val)
+      return Confirm.ask(prompt_message, default=default)
     except (EOFError, KeyboardInterrupt):
     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:
   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:
     while True:
       try:
       try:
-        return IntPrompt.ask(prompt_message, default=default_val)
+        return IntPrompt.ask(prompt_message, default=default)
       except ValueError:
       except ValueError:
         self.console.print("[red]Please enter a valid integer[/red]")
         self.console.print("[red]Please enter a valid integer[/red]")
       except (EOFError, KeyboardInterrupt):
       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:
   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:
     while True:
       try:
       try:
-        return FloatPrompt.ask(prompt_message, default=default_val)
+        return FloatPrompt.ask(prompt_message, default=default)
       except ValueError:
       except ValueError:
         self.console.print("[red]Please enter a valid number[/red]")
         self.console.print("[red]Please enter a valid number[/red]")
       except (EOFError, KeyboardInterrupt):
       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):
     for i, option in enumerate(options, 1):
       marker = "→" if option == current_value else " "
       marker = "→" if option == current_value else " "
       self.console.print(f"  {marker} {i}. {option}")
       self.console.print(f"  {marker} {i}. {option}")
@@ -321,138 +418,63 @@ class PromptHandler:
       try:
       try:
         choice = Prompt.ask(f"{prompt_message} (1-{len(options)})")
         choice = Prompt.ask(f"{prompt_message} (1-{len(options)})")
         
         
+        # Try numeric selection
         try:
         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:
         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):
       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]:
   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:
     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):
     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):
   def _show_summary(self):
-    """Display a compact summary of all configured variables."""
+    """Display summary of configured variables."""
     if not self.final_values:
     if not self.final_values:
       return
       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 = Table(box=box.SIMPLE)
       table.add_column("Variable", style="cyan")
       table.add_column("Variable", style="cyan")
       table.add_column("Value", style="green")
       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)
       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()
     self.console.print()
     
     
-    # Ask user if they want to proceed with template generation
+    # Confirm generation
     if not Confirm.ask("Proceed with generation?", default=True):
     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."""
 """Module registry system."""
-from typing import Type, Dict, List
 
 
 
 
 class ModuleRegistry:
 class ModuleRegistry:
   """Simple module registry without magic."""
   """Simple module registry without magic."""
   
   
   def __init__(self):
   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."""
     """Register a module class."""
     # Module class defines its own name attribute
     # Module class defines its own name attribute
     self._modules[module_class.name] = module_class
     self._modules[module_class.name] = module_class
   
   
-  def create_instances(self) -> List:
+  def create_instances(self):
     """Create instances of all registered modules."""
     """Create instances of all registered modules."""
     instances = []
     instances = []
     for name in sorted(self._modules.keys()):
     for name in sorted(self._modules.keys()):

+ 143 - 23
cli/core/template.py

@@ -1,9 +1,10 @@
 from pathlib import Path
 from pathlib import Path
-from typing import Any, Dict, Set, Tuple
+from typing import Any, Dict, Set, Tuple, List
 import logging
 import logging
 import re
 import re
-from jinja2 import Environment, BaseLoader, meta, nodes
+from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
 import frontmatter
 import frontmatter
+from .exceptions import TemplateValidationError
 
 
 
 
 class Template:
 class Template:
@@ -16,7 +17,7 @@ class Template:
       loader=BaseLoader(),
       loader=BaseLoader(),
       trim_blocks=True,           # Remove first newline after block tags
       trim_blocks=True,           # Remove first newline after block tags
       lstrip_blocks=True,         # Strip leading whitespace from 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):
   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
     # Extract variables and defaults from the template content
     # vars: Set[str] - All Jinja2 variable names found in template (e.g., {'app_name', 'port', 'debug'})
     # 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})
     # 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
   @classmethod
   def from_file(cls, file_path: Path) -> "Template":
   def from_file(cls, file_path: Path) -> "Template":
@@ -65,37 +67,152 @@ class Template:
       post = frontmatter.load(f)
       post = frontmatter.load(f)
     return post.metadata, post.content
     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:
     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:
     Returns:
-        Tuple of (all_variable_names, variable_defaults)
+        Tuple of (all_variable_names, variable_defaults, variable_usage_patterns)
     """
     """
     try:
     try:
       env = self._create_jinja_env()
       env = self._create_jinja_env()
       ast = env.parse(template_content)
       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)
       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
       # 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]:
   def to_dict(self) -> Dict[str, Any]:
     """Convert to dictionary for display."""
     """Convert to dictionary for display."""
@@ -123,7 +240,10 @@ class Template:
     try:
     try:
       env = self._create_jinja_env()
       env = self._create_jinja_env()
       jinja_template = env.from_string(self.content)
       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
       # Clean up excessive blank lines and whitespace
       rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)
       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 = ""
   description: str = ""
   default: Any = None
   default: Any = None
   type: str = "string"
   type: str = "string"
-  options: List[Any] = field(default_factory=list)
+  options: List[Any] = field(default_factory=list)  # FIXME: not needed
   group: str = "general"
   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]:
   def to_prompt_config(self) -> Dict[str, Any]:
     """Convert to prompt configuration."""
     """Convert to prompt configuration."""
@@ -22,7 +23,8 @@ class Variable:
       'type': self.type,
       'type': self.type,
       'options': self.options,
       'options': self.options,
       'required': self.required,
       '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",
       "general", "General Settings",
       "Basic configuration for Docker Compose services"
       "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(
     self.variables.register_group(
       "traefik", "Traefik Configuration", 
       "traefik", "Traefik Configuration", 
-      "Reverse proxy settings", icon="󰞉", enabler="traefik"
+      "Reverse proxy settings", icon="󰞉 ", enabler="traefik"
     )
     )
     
     
     # Register variables
     # Register variables
     self.variables.register_variable(Variable(
     self.variables.register_variable(Variable(
       name="service_name",
       name="service_name",
       description="Name of the service",
       description="Name of the service",
-      group="general",
-      required=True
+      group="general"
     ))
     ))
     
     
     self.variables.register_variable(Variable(
     self.variables.register_variable(Variable(
@@ -35,7 +39,23 @@ class ComposeModule(Module):
       description="Container name",
       description="Container name",
       group="general"
       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(
     self.variables.register_variable(Variable(
       name="traefik",
       name="traefik",
       description="Enable Traefik",
       description="Enable Traefik",
@@ -47,9 +67,23 @@ class ComposeModule(Module):
     self.variables.register_variable(Variable(
     self.variables.register_variable(Variable(
       name="traefik_host",
       name="traefik_host",
       description="Traefik hostname",
       description="Traefik hostname",
-      default=None,
       group="traefik"
       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
 # Register the module
 registry.register(ComposeModule)
 registry.register(ComposeModule)

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

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