Эх сурвалжийг харах

refactor: Remove VariableRegistry and make templates self-describing

Major refactoring to simplify the variable system:
- Removed VariableRegistry entirely - templates are now the single source of truth
- Created TemplateVariable dataclass to represent auto-detected variables
- Enhanced template parser to detect:
  - Enabler variables (used in {% if %} conditions)
  - Dotted notation (traefik.host, network.name)
  - Dict access patterns (service_port['http'])
  - Nested patterns (nginx_dashboard.port['dashboard'])
- Variables are now automatically analyzed from templates with type inference
- Simplified Module base class - no more _init_variables
- Updated all modules to remove variable registration
- Prompt handler now works with template-detected variables

This makes the system much more flexible - any variable used in a template
is automatically available without needing to be registered in code.
xcad 7 сар өмнө
parent
commit
238ad6b0b0

+ 11 - 43
cli/core/module.py

@@ -8,14 +8,13 @@ from .config import get_config
 from .exceptions import TemplateNotFoundError, TemplateValidationError
 from .library import LibraryManager
 from .prompt import SimplifiedPromptHandler
-from .variables import VariableRegistry
 
 logger = logging.getLogger('boilerplates')
 console = Console()  # Single shared console instance
 
 
 class Module(ABC):
-  """Streamlined base module with minimal redundancy."""
+  """Streamlined base module that auto-detects variables from templates."""
   
   # Required class attributes for subclasses
   name = None
@@ -29,11 +28,6 @@ class Module(ABC):
       )
     
     self.libraries = LibraryManager()
-    self.variables = VariableRegistry()
-    
-    # Allow subclasses to initialize variables if they override this
-    if hasattr(self, '_init_variables'):
-      self._init_variables()
 
 
   def list(self):
@@ -120,62 +114,36 @@ class Module(ABC):
   
   def _validate_template(self, template, template_id: str) -> None:
     """Validate template and raise error if validation fails."""
-    # Get registered variables for validation
-    registered_vars = set(self.variables.variables.keys())
-    
-    # Validate will raise TemplateValidationError for critical errors (syntax)
-    # and return a list of warnings for non-critical issues
-    warnings = template.validate(registered_vars)
+    # Template is now self-validating, no need for registered variables
+    warnings = template.validate()
     
     # If there are non-critical warnings, log them but don't fail
     if warnings:
       logger.warning(f"Template '{template_id}' has validation warnings: {warnings}")
-      # Optionally, you could still raise an error for strict validation:
-      # raise TemplateValidationError(template_id, warnings)
   
   def _process_variables(self, template) -> Dict[str, Any]:
     """Process template variables with prompting."""
-    # Get variables used in template that are registered
-    template_vars = self.variables.get_variables_for_template(list(template.vars))
-    if not template_vars:
+    # Use template's analyzed variables
+    if not template.variables:
       return {}
     
-    # Collect all defaults from variables and template
+    # Collect defaults from analyzed variables
     defaults = {}
-    for var_name, var in template_vars.items():
+    for var_name, var in template.variables.items():
       if var.default is not None:
         defaults[var_name] = var.default
     
-    # Handle dict variable defaults specially
-    # Auto-detect dict type from template usage
-    for var_name, var in template_vars.items():
-      # If template uses dict access, treat it as dict type regardless of registration
-      if var_name in template.var_dict_keys:
-        # This is a dict variable with dynamic keys
-        # Get defaults for each key from template
-        if var_name in template.var_defaults and isinstance(template.var_defaults[var_name], dict):
-          if var_name not in defaults:
-            defaults[var_name] = {}
-          defaults[var_name].update(template.var_defaults[var_name])
-    
-    # Also add template defaults for regular variables
-    for k, v in template.var_defaults.items():
-      if not isinstance(v, dict):  # Skip dict defaults, already handled
-        defaults[k] = v
-    
     # Use rich output if enabled
     if not get_config().use_rich_output:
       # Simple fallback - just prompt for missing values
       values = defaults.copy()
-      for var_name, var in template_vars.items():
+      for var_name, var in template.variables.items():
         if var_name not in values:
-          desc = f" ({var.description})" if var.description else ""
-          values[var_name] = input(f"Enter {var_name}{desc}: ")
+          values[var_name] = input(f"Enter {var_name}: ")
       return values
     
-    # Pass dict keys info to prompt handler
-    # Use the new simplified prompt handler with dict support
-    return SimplifiedPromptHandler(template_vars, defaults, template.var_dict_keys)()
+    # Use the new simplified prompt handler with template's analyzed variables
+    return SimplifiedPromptHandler(template.variables)()
   
   def register_cli(self, app: Typer):
     """Register module commands with the main app."""

+ 66 - 77
cli/core/prompt.py

@@ -1,28 +1,25 @@
-"""Simplified prompt handler for dotted notation variables."""
+"""Simplified prompt handler for template variables."""
 from typing import Dict, Any, List, Tuple, Optional
 from collections import OrderedDict
 from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
 import logging
+from .variables import TemplateVariable
 
 logger = logging.getLogger('boilerplates')
 console = Console()
 
 
 class SimplifiedPromptHandler:
-  """Clean prompt handler for dotted notation variables."""
+  """Prompt handler for template-detected variables."""
   
-  def __init__(self, variables: Dict[str, Any], defaults: Dict[str, Any], dict_keys: Dict[str, List[str]] = None):
-    """Initialize with template variables and defaults.
+  def __init__(self, variables: Dict[str, TemplateVariable]):
+    """Initialize with template variables.
     
     Args:
-      variables: Dict of variable name to Variable object
-      defaults: Dict of variable name to default value
-      dict_keys: Dict variables and their keys used in template
+      variables: Dict of variable name to TemplateVariable object
     """
     self.variables = variables
-    self.defaults = defaults
-    self.dict_keys = dict_keys or {}
     self.values = {}
     
   def __call__(self) -> Dict[str, Any]:
@@ -41,7 +38,7 @@ class SimplifiedPromptHandler:
     return self.values
   
   def _group_variables(self) -> Tuple[Dict[str, List[str]], List[str]]:
-    """Group variables by their prefix, preserving registration order.
+    """Group variables by their prefix or enabler status.
     
     Returns:
       (groups, standalone) where groups is {prefix: [var_names]}
@@ -49,21 +46,24 @@ class SimplifiedPromptHandler:
     groups = OrderedDict()
     standalone = []
     
-    # Process variables in registration order
-    for var_name in self.variables.keys():
-      if '.' in var_name:
-        # This is a child variable (e.g., 'traefik.host')
-        prefix = var_name.split('.')[0]
-        
-        # Create group if needed (preserves first encounter order)
-        if prefix not in groups:
-          groups[prefix] = []
-        
-        # Add to group
-        groups[prefix].append(var_name)
+    # First pass: identify all groups
+    for var_name, var in self.variables.items():
+      if var.group:
+        # This variable belongs to a group
+        if var.group not in groups:
+          groups[var.group] = []
+        groups[var.group].append(var_name)
       else:
-        # Standalone variable (no dots)
-        standalone.append(var_name)
+        # Check if this is an enabler for other variables
+        # An enabler is a variable that other variables use as their group
+        is_group_enabler = any(v.group == var_name for v in self.variables.values())
+        if is_group_enabler:
+          if var_name not in groups:
+            groups[var_name] = []
+          # The enabler itself is not added to the group list
+        else:
+          # Truly standalone variable
+          standalone.append(var_name)
     
     return groups, standalone
   
@@ -79,36 +79,38 @@ class SimplifiedPromptHandler:
     # Deduplicate variables
     var_names = list(dict.fromkeys(var_names))  # Preserves order while removing duplicates
     
-    # Check if this group has an enabler (standalone variable with same name as group)
+    # Check if this group has an enabler
     group_name = display_name.lower()
     enabler = None
     if is_group and group_name in self.variables:
-      enabler = group_name
-      # Ask about enabler first
-      console.print(f"\n[bold cyan]{display_name} Configuration[/bold cyan]")
-      var = self.variables[enabler]
-      enabled = Confirm.ask(
-        f"Enable {enabler}?", 
-        default=bool(self.defaults.get(enabler, False))
-      )
-      self.values[enabler] = enabled
-      
-      if not enabled:
-        # Skip all group variables
-        return
+      enabler_var = self.variables[group_name]
+      if enabler_var.is_enabler:
+        enabler = group_name
+        # Ask about enabler first
+        console.print(f"\n[bold cyan]{display_name} Configuration[/bold cyan]")
+        enabled = Confirm.ask(
+          f"Enable {enabler}?", 
+          default=bool(enabler_var.default)
+        )
+        self.values[enabler] = enabled
+        
+        if not enabled:
+          # Skip all group variables
+          return
     
     # Split into required and optional
     required = []
     optional = []
     for var_name in var_names:
-      if var_name in self.defaults:
-        optional.append(var_name)
-      else:
+      var = self.variables[var_name]
+      if var.is_required:
         required.append(var_name)
+      else:
+        optional.append(var_name)
     
     # Apply defaults
     for var_name in optional:
-      self.values[var_name] = self.defaults[var_name]
+      self.values[var_name] = self.variables[var_name].default
     
     # Process required variables
     if required:
@@ -116,12 +118,11 @@ class SimplifiedPromptHandler:
         console.print(f"\n[bold cyan]{display_name} - Required Configuration[/bold cyan]")
       for var_name in required:
         var = self.variables[var_name]
-        if var_name in self.dict_keys:
-          console.print(f"\n[cyan]{var.description or var_name}[/cyan]")
-          self.values[var_name] = self._prompt_dict_variable(var_name, var)
+        if var.is_dict:
+          console.print(f"\n[cyan]{var.name}[/cyan]")
+          self.values[var_name] = self._prompt_dict_variable(var)
         else:
-          display = var_name.replace('.', ' ') if is_group else var_name
-          self.values[var_name] = self._prompt_variable(display, var, required=True)
+          self.values[var_name] = self._prompt_variable(var, required=True)
     
     # Process optional variables
     if optional:
@@ -133,36 +134,30 @@ class SimplifiedPromptHandler:
       
       if Confirm.ask("Do you want to change any values?", default=False):
         for var_name in optional:
-          # Skip the enabler variable as it was already handled
-          if var_name == enabler:
-            continue
           var = self.variables[var_name]
-          if var_name in self.dict_keys:
-            console.print(f"\n[cyan]{var.description or var_name}[/cyan]")
+          if var.is_dict:
+            console.print(f"\n[cyan]{var.name}[/cyan]")
             self.values[var_name] = self._prompt_dict_variable(
-              var_name, var, current_values=self.values.get(var_name, {})
+              var, current_values=self.values.get(var_name, {})
             )
           else:
-            display = var_name.replace('.', ' ') if is_group else var_name
             self.values[var_name] = self._prompt_variable(
-              display, var, current_value=self.values[var_name]
+              var, current_value=self.values[var_name]
             )
   
   
-  def _prompt_dict_variable(self, var_name: str, var: Any, current_values: Dict[str, Any] = None) -> Dict[str, Any]:
+  def _prompt_dict_variable(self, var: TemplateVariable, current_values: Dict[str, Any] = None) -> Dict[str, Any]:
     """Prompt for a dict variable with dynamic keys."""
     result = {}
-    keys = self.dict_keys.get(var_name, [])
     current_values = current_values or {}
     
-    for key in keys:
+    for key in var.dict_keys:
       # Use current value if available, otherwise check for default
       current_value = current_values.get(key)
-      if current_value is None:
-        if var_name in self.defaults and isinstance(self.defaults[var_name], dict):
-          current_value = self.defaults[var_name].get(key)
+      if current_value is None and isinstance(var.default, dict):
+        current_value = var.default.get(key)
       
-      prompt_msg = f"Enter {var_name}['{key}']"
+      prompt_msg = f"Enter {var.name}['{key}']"
       if current_value is not None:
         prompt_msg += f" [dim]({current_value})[/dim]"
       else:
@@ -187,31 +182,25 @@ class SimplifiedPromptHandler:
   def _show_variables(self, var_names: List[str]):
     """Display current variable values."""
     for var_name in var_names:
-      value = self.values.get(var_name, self.defaults.get(var_name))
+      var = self.variables[var_name]
+      value = self.values.get(var_name, var.default)
       if value is not None:
-        display_name = var_name.replace('.', ' ')
         # Special formatting for dict values - show each key separately
         if isinstance(value, dict):
           for key, val in value.items():
-            console.print(f"  {display_name}['{key}']: [dim]{val}[/dim]")
+            console.print(f"  {var.display_name}['{key}']: [dim]{val}[/dim]")
         else:
-          console.print(f"  {display_name}: [dim]{value}[/dim]")
+          console.print(f"  {var.display_name}: [dim]{value}[/dim]")
   
   def _prompt_variable(
     self, 
-    name: str, 
-    var: Any, 
+    var: TemplateVariable, 
     required: bool = False,
     current_value: Any = None
   ) -> Any:
     """Prompt for a single variable value."""
-    var_type = var.type if hasattr(var, 'type') else 'string'
-    description = var.description if hasattr(var, 'description') else ''
-    
     # Build prompt message
-    parts = [f"Enter {name}"]
-    if description:
-      parts.append(f"({description})")
+    parts = [f"Enter {var.display_name}"]
     if current_value is not None:
       parts.append(f"[dim]({current_value})[/dim]")
     elif required:
@@ -220,11 +209,11 @@ class SimplifiedPromptHandler:
     prompt_msg = " ".join(parts)
     
     # Handle different types
-    if var_type == 'boolean':
+    if var.type == 'boolean':
       default = bool(current_value) if current_value is not None else None
       return Confirm.ask(prompt_msg, default=default)
     
-    elif var_type == 'integer':
+    elif var.type == 'integer':
       default = int(current_value) if current_value is not None else None
       while True:
         try:
@@ -232,7 +221,7 @@ class SimplifiedPromptHandler:
         except ValueError:
           console.print("[red]Please enter a valid integer[/red]")
     
-    elif var_type == 'float':
+    elif var.type == 'float':
       default = float(current_value) if current_value is not None else None
       while True:
         try:

+ 70 - 40
cli/core/template.py

@@ -6,6 +6,7 @@ import re
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
 import frontmatter
 from .exceptions import TemplateValidationError
+from .variables import TemplateVariable, analyze_template_variables
 
 
 @dataclass
@@ -36,6 +37,7 @@ class Template:
   vars: Set[str] = field(default_factory=set, init=False)
   var_defaults: Dict[str, Any] = field(default_factory=dict, init=False)
   var_dict_keys: Dict[str, List[str]] = field(default_factory=dict, init=False)  # Track dict access patterns
+  variables: Dict[str, TemplateVariable] = field(default_factory=dict, init=False)  # Analyzed variables
   
   def __post_init__(self):
     """Initialize computed properties after dataclass initialization."""
@@ -51,6 +53,10 @@ class Template:
     
     # Parse template variables
     self.vars, self.var_defaults, self.var_dict_keys = self._parse_template_variables(self.content)
+    # Analyze variables to create TemplateVariable objects
+    self.variables = analyze_template_variables(
+      self.vars, self.var_defaults, self.var_dict_keys, self.content
+    )
   
   @staticmethod
   def _create_jinja_env() -> Environment:
@@ -93,9 +99,11 @@ class Template:
   def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any], Dict[str, List[str]]]:
     """Parse Jinja2 template to extract variables and their defaults.
     
-    Two separate approaches:
-    - Dotted notation (traefik.host): Must be defined in module
-    - Dict notation (service_port['http']): Dynamic keys allowed
+    Handles multiple patterns:
+    - Simple variables: service_name
+    - Dotted notation: traefik.host
+    - Dict notation: service_port['http']
+    - Nested patterns: nginx_dashboard.port['dashboard']
     
     Returns:
         Tuple of (all_variable_names, variable_defaults, dict_access_patterns)
@@ -107,15 +115,36 @@ class Template:
       # Start with variables found by Jinja2's meta utility
       all_variables = meta.find_undeclared_variables(ast)
       
-      # Track dict access patterns (e.g., service_port['http'])
+      # Track dict access patterns
       dict_keys = {}  # var_name -> list of keys accessed
+      
+      # Handle all Getitem nodes (dict access)
       for node in ast.find_all(nodes.Getitem):
         if isinstance(node.arg, nodes.Const) and isinstance(node.arg.value, str):
-          # This is dict access with a string key
+          key = node.arg.value
           current = node.node
-          if isinstance(current, nodes.Name):
+          
+          # Handle nested patterns like nginx_dashboard.port['dashboard']
+          if isinstance(current, nodes.Getattr):
+            # Build the full dotted name
+            parts = [current.attr]
+            base = current.node
+            while isinstance(base, nodes.Getattr):
+              parts.insert(0, base.attr)
+              base = base.node
+            if isinstance(base, nodes.Name):
+              parts.insert(0, base.name)
+              var_name = '.'.join(parts)
+              # This is a dotted variable with dict access
+              all_variables.add(var_name)
+              if var_name not in dict_keys:
+                dict_keys[var_name] = []
+              if key not in dict_keys[var_name]:
+                dict_keys[var_name].append(key)
+          
+          # Handle simple dict access like service_port['http']
+          elif isinstance(current, nodes.Name):
             var_name = current.name
-            key = node.arg.value
             if var_name not in dict_keys:
               dict_keys[var_name] = []
             if key not in dict_keys[var_name]:
@@ -141,17 +170,39 @@ class Template:
           # 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) }}
+          
+          # Handle dict access defaults
           elif isinstance(node.node, nodes.Getitem):
-            if isinstance(node.node.arg, nodes.Const) and isinstance(node.node.node, nodes.Name):
-              var_name = node.node.node.name
+            if isinstance(node.node.arg, nodes.Const):
               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
-          # Handle dotted variable defaults: {{ port.http | default(8080) }}
+              
+              # Handle nested pattern defaults: {{ nginx_dashboard.port['dashboard'] | default(8081) }}
+              if isinstance(node.node.node, nodes.Getattr):
+                # Build the full dotted name
+                parts = []
+                current = node.node.node
+                while isinstance(current, nodes.Getattr):
+                  parts.insert(0, current.attr)
+                  current = current.node
+                if isinstance(current, nodes.Name):
+                  parts.insert(0, current.name)
+                  var_name = '.'.join(parts)
+                  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
+              
+              # Handle simple dict defaults: {{ var['key'] | default(value) }}
+              elif isinstance(node.node.node, nodes.Name):
+                var_name = node.node.node.name
+                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
+          
+          # Handle dotted variable defaults: {{ traefik.host | default('example.com') }}
           elif isinstance(node.node, nodes.Getattr):
             # Build the full dotted name
             current = node.node
@@ -169,13 +220,9 @@ class Template:
       logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
       return set(), {}, {}
 
-  def validate(self, registered_variables: Set[str]) -> List[str]:
+  def validate(self) -> List[str]:
     """Validate template integrity.
     
-    Args:
-        registered_variables: Set of variable names registered by the module.
-                             Required for proper variable validation.
-    
     Returns:
         List of validation error messages. Empty list if valid.
     
@@ -193,25 +240,8 @@ class Template:
     except Exception as e:
       raise TemplateValidationError(self.id, [f"Template parsing error: {str(e)}"])
     
-    # Check for undefined variables
-    # Dotted variables MUST be registered in the module (even if they have defaults)
-    # Dict variables only need the base name registered
-    undefined = []
-    for var in self.vars:
-      if '.' in var:
-        # Dotted variable MUST be registered (defaults don't excuse them)
-        if var not in registered_variables:
-          undefined.append(var)
-      else:
-        # Non-dotted variable
-        if var not in registered_variables and var not in self.var_defaults:
-          # Check if it's a dict variable with keys
-          if var not in self.var_dict_keys:
-            undefined.append(var)
-    
-    if undefined:
-      var_list = ", ".join(sorted(undefined))
-      errors.append(f"Undefined variables (dotted variables must be registered in module): {var_list}")
+    # All variables are now auto-detected, no need to check for undefined
+    # The template parser will have found all variables used
     
     # Check for missing required frontmatter fields
     if not self.name or self.name == self.file_path.parent.name:

+ 106 - 71
cli/core/variables.py

@@ -1,89 +1,124 @@
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Set
 from dataclasses import dataclass, field
-from collections import OrderedDict
 
 
 @dataclass
-class Variable:
-  """Variable with automatic grouping via dotted notation.
+class TemplateVariable:
+  """Variable detected from template analysis.
   
-  Variables are automatically grouped by their prefix:
-  - 'traefik' is a standalone boolean (enabler)
-  - 'traefik.host' is part of the traefik group
-  - 'port.http' is part of the port group
-  
-  Type can be:
-  - 'string': String value (default)
-  - 'integer': Integer value  
-  - 'float': Float value
-  - 'boolean': Boolean value
+  Represents a variable found in a template with all its properties:
+  - Simple variables: service_name, container_name
+  - Dotted variables: traefik.host, network.name
+  - Dict variables: service_port['http'], nginx_dashboard.port['dashboard']
+  - Enabler variables: Variables used in {% if var %} conditions
   """
   name: str
-  description: str = ""
   default: Any = None
-  type: str = "string"  # string, integer, float, boolean
+  type: str = "string"  # string, integer, float, boolean (inferred from default or usage)
+  
+  # Variable characteristics
+  is_enabler: bool = False  # Used in {% if %} conditions
+  is_dict: bool = False  # Has dict access patterns like var['key']
+  dict_keys: List[str] = field(default_factory=list)  # Keys accessed if is_dict
+  
+  # Grouping info (extracted from dotted notation)
+  group: Optional[str] = None  # e.g., 'traefik' for 'traefik.host'
   
-  def to_prompt_config(self) -> Dict[str, Any]:
-    """Convert to prompt configuration."""
-    return {
-      'name': self.name,
-      'description': self.description, 
-      'type': self.type,
-      'default': self.default
-    }
+  @property
+  def display_name(self) -> str:
+    """Get display name for prompts."""
+    if self.group:
+      # Remove group prefix for display
+      return self.name.replace(f"{self.group}.", "").replace(".", " ")
+    return self.name.replace(".", " ")
+  
+  @property 
+  def is_required(self) -> bool:
+    """Check if variable is required (no default value)."""
+    return self.default is None
 
 
-class VariableRegistry:
-  """Simplified variable registry with automatic grouping via dotted notation."""
+def analyze_template_variables(
+  vars_used: Set[str],
+  var_defaults: Dict[str, Any],
+  var_dict_keys: Dict[str, List[str]],
+  template_content: str
+) -> Dict[str, TemplateVariable]:
+  """Analyze template variables and create TemplateVariable objects.
   
-  def __init__(self):
-    self.variables: Dict[str, Variable] = OrderedDict()
+  Args:
+    vars_used: Set of all variable names used in template
+    var_defaults: Dict of variable defaults from template
+    var_dict_keys: Dict of variables with dict access patterns
+    template_content: The raw template content for additional analysis
   
-  def register(self, var: Variable) -> None:
-    """Register a variable."""
-    self.variables[var.name] = var
+  Returns:
+    Dict mapping variable name to TemplateVariable object
+  """
+  variables = {}
   
-  def get_variables_for_template(self, template_vars: List[str]) -> Dict[str, Variable]:
-    """Get variables that are used in the template.
-    
-    Returns a dict of variable name to Variable object for all
-    variables used in the template, preserving registration order.
-    """
-    result = OrderedDict()
-    # Iterate through registered variables to preserve registration order
-    for var_name in self.variables.keys():
-      if var_name in template_vars:
-        result[var_name] = self.variables[var_name]
-    return result
+  # Detect enabler variables (used in {% if %} conditions)
+  enablers = _detect_enablers(template_content)
   
-  def group_variables(self, variables: Dict[str, Variable]) -> Tuple[Dict[str, List[str]], List[str]]:
-    """Automatically group variables by their dotted notation prefix.
+  for var_name in vars_used:
+    var = TemplateVariable(
+      name=var_name,
+      default=var_defaults.get(var_name)
+    )
+    
+    # Detect if it's an enabler
+    var.is_enabler = var_name in enablers
+    
+    # Detect if it's a dict variable
+    if var_name in var_dict_keys:
+      var.is_dict = True
+      var.dict_keys = var_dict_keys[var_name]
+      # Dict variables might have dict defaults
+      if isinstance(var.default, dict):
+        var.type = "dict"
+    
+    # Infer type from default value
+    if var.default is not None and var.type == "string":
+      if isinstance(var.default, bool):
+        var.type = "boolean"
+      elif isinstance(var.default, int):
+        var.type = "integer"
+      elif isinstance(var.default, float):
+        var.type = "float"
     
-    Returns:
-      (groups, standalone) where:
-      - groups: Dict mapping group name to list of variable names in that group
-      - standalone: List of variable names that aren't in any group
-    """
-    groups = OrderedDict()
-    standalone = []
-    all_var_names = list(variables.keys())
+    # If it's an enabler without a default, assume boolean
+    if var.is_enabler and var.default is None:
+      var.type = "boolean"
+      var.default = False  # Default enablers to False
     
-    for var_name in all_var_names:
-      if '.' in var_name:
-        # This is a grouped variable like 'traefik.host'
-        prefix = var_name.split('.')[0]
-        if prefix not in groups:
-          groups[prefix] = []
-        groups[prefix].append(var_name)
-      else:
-        # Check if this is a group parent (has children)
-        is_group_parent = any(v.startswith(f"{var_name}.") for v in all_var_names)
-        if is_group_parent:
-          if var_name not in groups:
-            groups[var_name] = []
-          # The parent itself is not added to the group list, it's the enabler
-        else:
-          # Truly standalone variable
-          standalone.append(var_name)
+    # Detect group from dotted notation
+    if '.' in var_name:
+      var.group = var_name.split('.')[0]
     
-    return groups, standalone
+    variables[var_name] = var
+  
+  return variables
+
+
+def _detect_enablers(template_content: str) -> Set[str]:
+  """Detect variables used as enablers in {% if %} conditions.
+  
+  Args:
+    template_content: The raw template content
+  
+  Returns:
+    Set of variable names that are used as enablers
+  """
+  import re
+  enablers = set()
+  
+  # Find variables used in {% if var %} patterns
+  # This catches: {% if var %}, {% if not var %}, {% if var and ... %}
+  if_pattern = re.compile(r'{%\s*if\s+(not\s+)?(\w+)(?:\s|%)', re.MULTILINE)
+  for match in if_pattern.finditer(template_content):
+    var_name = match.group(2)
+    # Skip Jinja2 keywords
+    if var_name not in ['true', 'false', 'none', 'True', 'False', 'None']:
+      enablers.add(var_name)
+  
+  return enablers

+ 1 - 83
cli/modules/compose.py

@@ -1,94 +1,12 @@
 from ..core.module import Module
-from ..core.variables import Variable
 from ..core.registry import registry
 
 class ComposeModule(Module):
-  """Docker Compose module with variables."""
+  """Docker Compose module."""
   
   name = "compose"
   description = "Manage Docker Compose configurations"
   files = ["docker-compose.yml", "compose.yml", "compose.yaml"]
-  
-  def _init_variables(self):
-    """Initialize Compose-specific variables with dotted notation."""
-    # General standalone variables - register first
-    self.variables.register(Variable(
-      name="service_name",
-      description="Name of the service"
-    ))
-    
-    self.variables.register(Variable(
-      name="container_name",
-      description="Container name"
-    ))
-    
-    # Variable for dynamic port mappings (dict type auto-detected from template)
-    self.variables.register(Variable(
-      name="service_port",
-      description="Service port mappings"
-    ))
-    
-    # Network group - enabler controls whether to use network
-    self.variables.register(Variable(
-      name="network",
-      description="Enable custom network",
-      type="boolean",
-      default=False
-    ))
-    
-    self.variables.register(Variable(
-      name="network.name",
-      description="Docker network name",
-      default="bridge"
-    ))
-    
-    self.variables.register(Variable(
-      name="network.external",
-      description="Is network external",
-      type="boolean",
-      default=True
-    ))
-    
-    # Traefik group - enabler controls whether to use Traefik
-    self.variables.register(Variable(
-      name="traefik",
-      description="Enable Traefik reverse proxy",
-      type="boolean",
-      default=False
-    ))
-    
-    self.variables.register(Variable(
-      name="traefik.host",
-      description="Hostname for Traefik routing"
-    ))
-    
-    self.variables.register(Variable(
-      name="traefik.tls",
-      description="Enable TLS",
-      type="boolean",
-      default=True
-    ))
-
-    self.variables.register(Variable(
-      name="traefik.certresolver",
-      description="Certificate resolver name",
-      default="letsencrypt"
-    ))
-    
-    # Swarm group - enabler controls whether to use Swarm mode
-    self.variables.register(Variable(
-      name="swarm",
-      description="Enable Docker Swarm mode",
-      type="boolean",
-      default=False
-    ))
-    
-    self.variables.register(Variable(
-      name="swarm.replicas",
-      description="Number of replicas in swarm mode",
-      type="integer",
-      default=1
-    ))
 
 # Register the module
 registry.register(ComposeModule)

+ 1 - 7
cli/modules/terraform.py

@@ -1,18 +1,12 @@
 from ..core.module import Module
 from ..core.registry import registry
-from ..core.variables import Variable
 
 class TerraformModule(Module):
-  """Terraform module - clean and simple."""
+  """Terraform module."""
   
   name = "terraform"
   description = "Manage Terraform configurations"
   files = ["main.tf", "variables.tf", "outputs.tf", "versions.tf"]
-  
-  def _init_variables(self):
-    """Initialize Terraform-specific variables."""
-    # Only if module needs variables
-    pass
 
 # Register the module
 registry.register(TerraformModule)