xcad hai 5 meses
pai
achega
e14e90f0f7

+ 112 - 0
.github/copilot-instructions.md

@@ -0,0 +1,112 @@
+# copilot-instructions.md
+
+This file provides guidance to GitHub Copilot when working with code in this repository.
+
+## Project Overview
+
+This repository contains a sophisticated collection of templates (called boilerplates) for managing infrastructure across multiple technologies including Terraform, Docker, Ansible, Kubernetes, etc. The project also includes a Python CLI application that allows an easy management, creation, and deployment of boilerplates.
+
+## Repository Structure
+
+- `cli/` - Python CLI application source code
+  - `cli/core/` - Core functionality (app, config, commands, logging)
+  - `cli/modules/` - Technology-specific modules (terraform, docker, compose, etc.)
+- `library/` - Template collections organized by technology
+  - `library/terraform/` - OpenTofu/Terraform templates and examples
+  - `library/compose/` - Docker Compose configurations
+  - `library/proxmox/` - Packer templates for Proxmox
+  - `library/ansible/` - Ansible playbooks and configurations
+  - `library/kubernetes/` - Kubernetes deployments
+  - And more...
+
+## Development Setup
+
+### Installation and Dependencies
+
+```bash
+# Install in development mode
+pip install -e .
+
+# Install from requirements
+pip install -r requirements.txt
+```
+
+### Running the CLI
+
+```bash
+# Run via Python module
+python -m cli --help
+
+# Run via installed command
+boilerplate --help
+
+# Example module usage
+boilerplate terraform --help
+boilerplate compose config list
+```
+
+## Common Development Tasks
+
+### Testing and Validation
+
+```bash
+# Lint YAML files (used in CI)
+yamllint --strict -- $(git ls-files '*.yaml' '*.yml')
+
+# Run CLI with debug logging
+boilerplate --log-level DEBUG [command]
+```
+
+### Adding New Modules
+
+1. Create new module directory in `cli/modules/[module_name]/`
+2. Implement module class inheriting from `BaseModule` in `cli/core/command.py`
+3. Add module to imports in `cli/modules/__init__.py`
+4. Create corresponding template directory in `library/[module_name]/`
+
+## Architecture Notes
+
+### CLI Architecture
+
+- **Modular Design**: Each technology (terraform, docker, etc.) is implemented as a separate module
+- **Configuration Management**: Per-module configuration stored in `~/.boilerplates/[module].json`
+- **Template System**: Uses Jinja2 for template processing with frontmatter metadata
+- **Rich UI**: Uses Rich library for enhanced terminal output and tables
+
+### Key Components
+
+- `ConfigManager`: Handles module-specific configuration persistence
+- `BaseModule`: Abstract base class providing shared commands (config management)
+- Module Commands: Each module implements technology-specific operations
+- Template Library: Structured collection of boilerplates with metadata
+
+### Template Format
+
+Templates use YAML frontmatter for metadata:
+
+```yaml
+---
+name: "Template Name"
+description: "Template description"
+version: "0.0.1"
+date: "2023-10-01"
+author: "Christian Lempa"
+tags:
+  - tag1
+  - tag2
+---
+[Template content here]
+```
+
+## Important Rules and Conventions
+
+- **Docker Compose**: Default to `compose.yaml` filename (not `docker-compose.yml`)
+- **Logging Standards**: No emojis, avoid multi-lines, use proper log levels
+- **Comment Anchors**: Use for TODOs, FIXMEs, notes, and links in source code
+
+## Configuration
+
+- YAML linting configured with max 160 character line length
+- Python 3.9+ required
+- Rich markup mode enabled for enhanced CLI output
+- Logging configurable via `--log-level` flag

+ 112 - 0
WARP.md

@@ -0,0 +1,112 @@
+# WARP.md
+
+This file provides guidance to WARP (warp.dev) when working with code in this repository.
+
+## Project Overview
+
+This repository contains a sophisticated collection of templates (called boilerplates) for managing infrastructure across multiple technologies including Terraform, Docker, Ansible, Kubernetes, etc. The project also includes a Python CLI application that allows an easy management, creation, and deployment of boilerplates.
+
+## Repository Structure
+
+- `cli/` - Python CLI application source code
+  - `cli/core/` - Core functionality (app, config, commands, logging)
+  - `cli/modules/` - Technology-specific modules (terraform, docker, compose, etc.)
+- `library/` - Template collections organized by technology
+  - `library/terraform/` - OpenTofu/Terraform templates and examples
+  - `library/compose/` - Docker Compose configurations
+  - `library/proxmox/` - Packer templates for Proxmox
+  - `library/ansible/` - Ansible playbooks and configurations
+  - `library/kubernetes/` - Kubernetes deployments
+  - And more...
+
+## Development Setup
+
+### Installation and Dependencies
+
+```bash
+# Install in development mode
+pip install -e .
+
+# Install from requirements
+pip install -r requirements.txt
+```
+
+### Running the CLI
+
+```bash
+# Run via Python module
+python -m cli --help
+
+# Run via installed command
+boilerplate --help
+
+# Example module usage
+boilerplate terraform --help
+boilerplate compose config list
+```
+
+## Common Development Tasks
+
+### Testing and Validation
+
+```bash
+# Lint YAML files (used in CI)
+yamllint --strict -- $(git ls-files '*.yaml' '*.yml')
+
+# Run CLI with debug logging
+boilerplate --log-level DEBUG [command]
+```
+
+### Adding New Modules
+
+1. Create new module directory in `cli/modules/[module_name]/`
+2. Implement module class inheriting from `BaseModule` in `cli/core/command.py`
+3. Add module to imports in `cli/modules/__init__.py`
+4. Create corresponding template directory in `library/[module_name]/`
+
+## Architecture Notes
+
+### CLI Architecture
+
+- **Modular Design**: Each technology (terraform, docker, etc.) is implemented as a separate module
+- **Configuration Management**: Per-module configuration stored in `~/.boilerplates/[module].json`
+- **Template System**: Uses Jinja2 for template processing with frontmatter metadata
+- **Rich UI**: Uses Rich library for enhanced terminal output and tables
+
+### Key Components
+
+- `ConfigManager`: Handles module-specific configuration persistence
+- `BaseModule`: Abstract base class providing shared commands (config management)
+- Module Commands: Each module implements technology-specific operations
+- Template Library: Structured collection of boilerplates with metadata
+
+### Template Format
+
+Templates use YAML frontmatter for metadata:
+
+```yaml
+---
+name: "Template Name"
+description: "Template description"
+version: "0.0.1"
+date: "2023-10-01"
+author: "Christian Lempa"
+tags:
+  - tag1
+  - tag2
+---
+[Template content here]
+```
+
+## Important Rules and Conventions
+
+- **Docker Compose**: Default to `compose.yaml` filename (not `docker-compose.yml`)
+- **Logging Standards**: No emojis, avoid multi-lines, use proper log levels
+- **Comment Anchors**: Use for TODOs, FIXMEs, notes, and links in source code
+
+## Configuration
+
+- YAML linting configured with max 160 character line length
+- Python 3.9+ required
+- Rich markup mode enabled for enhanced CLI output
+- Logging configurable via `--log-level` flag

+ 0 - 11
cli/core/app.py

@@ -64,17 +64,6 @@ def create_app() -> typer.Typer:
             help="Set logging level",
             case_sensitive=False,
         ),
-        verbose: bool = typer.Option(
-            False,
-            "--verbose",
-            help="Enable verbose output"
-        ),
-        quiet: bool = typer.Option(
-            False,
-            "--quiet",
-            "-q",
-            help="Suppress output"
-        ),
     ):
         """
         🚀 Boilerplates CLI - Manage your infrastructure templates with ease!

+ 146 - 11
cli/core/command.py

@@ -4,13 +4,16 @@ Provides common functionality and patterns for all modules.
 """
 
 import logging
-from abc import ABC
-from typing import Optional, Set
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Optional, Set, Dict, Any, List, Tuple
 
-import typer
 from rich.console import Console
+import typer
 
 from .config import ConfigManager
+from .helpers import find_boilerplates
+from . import template, values, render
 
 
 
@@ -24,12 +27,37 @@ class BaseModule(ABC):
         self.console = Console()
         self.logger = logging.getLogger(f"boilerplates.module.{name}")
     
+    @property
+    def template_paths(self) -> List[str]:
+        """Return list of valid template file paths/patterns for this module.
+        Override this in modules that support template generation."""
+        return []
+        
+    @property
+    def library_path(self) -> Optional[Path]:
+        """Return the path to the template library for this module.
+        Override this in modules that support template generation."""
+        return None
+        
+    @property
+    def variable_handler_class(self) -> Any:
+        """Return the variable handler class for this module."""
+        return None
+        
     def get_valid_variables(self) -> Set[str]:
-        """
-        Get the set of valid variable names for this module.
-        Subclasses can override this to provide module-specific validation.
-        """
+        """Get the set of valid variable names for this module."""
+        if self.variable_handler_class:
+            handler = self.variable_handler_class()
+            return set(handler._declared.keys())
         return set()
+        
+    def process_template_content(self, content: str) -> str:
+        """Process template content before rendering. Override if needed."""
+        return content
+        
+    def get_template_syntax(self) -> str:
+        """Return the syntax highlighting to use for this template type."""
+        return "yaml"
     
     def get_app(self) -> typer.Typer:
         """
@@ -171,8 +199,115 @@ class BaseModule(ABC):
             self.console.print(table)
     
     def _add_module_commands(self, app: typer.Typer) -> None:
-        """
-        Override this method in subclasses to add module-specific commands.
-        This is called after the shared commands are added.
-        """
+        """Add module-specific commands to the app."""
+        # Only add generate command if module supports templates
+        if self.library_path is not None and self.template_paths:
+            self._add_generate_command(app)
+        self._add_custom_commands(app)
+    
+    def _add_custom_commands(self, app: typer.Typer) -> None:
+        """Override this method in subclasses to add module-specific commands."""
         pass
+    
+    def _add_generate_command(self, app: typer.Typer) -> None:
+        """Add the generate command to the app."""
+        
+        @app.command("generate", help="Generate from a template and write to --out")
+        def generate(
+            name: str,
+            out: Optional[Path] = typer.Option(None, "--out", "-o",
+                help="Output path to write rendered template (prints to stdout when omitted)"),
+            values_file: Optional[Path] = typer.Option(None, "--values-file", "-f",
+                help="Load values from YAML/JSON file"),
+            values: Optional[List[str]] = typer.Option(None, "--values",
+                help="Set values (format: key=value)")
+        ):
+            """Generate output from a template with optional value overrides."""
+            # Find and validate template
+            bps = find_boilerplates(self.library_path, self.template_paths)
+            bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
+            if not bp:
+                self.console.print(f"[red]Template '{name}' not found.[/red]")
+                raise typer.Exit(code=1)
+            
+            # Get variable handler if module provides one
+            var_handler = None
+            if self.variable_handler_class:
+                var_handler = self.variable_handler_class()
+            
+            # Clean and process template content
+            content = self.process_template_content(bp.content)
+            cleaned_content = template.clean_template_content(content)
+            
+            # Find variables if handler exists
+            used_vars = set()
+            if var_handler:
+                _, used_vars = var_handler.determine_variable_sets(cleaned_content)
+            
+            if not used_vars:
+                rendered = content
+            else:
+                # Validate template syntax
+                is_valid, error = template.validate_template(cleaned_content, bp.file_path)
+                if not is_valid:
+                    self.console.print(f"[red]{error}[/red]")
+                    raise typer.Exit(code=2)
+                
+                # Extract defaults and metadata if handler exists
+                template_defaults = {}
+                if var_handler:
+                    template_defaults = var_handler.extract_template_defaults(cleaned_content)
+                    try:
+                        meta_overrides = var_handler.extract_variable_meta_overrides(content)
+                        for var_name, overrides in meta_overrides.items():
+                            if var_name in var_handler._declared and isinstance(overrides, dict):
+                                existing = var_handler._declared[var_name][1]
+                                existing.update(overrides)
+                    except Exception:
+                        pass
+                
+                # Get subscript keys and load values from all sources
+                used_subscripts = set()
+                if var_handler:
+                    used_subscripts = var_handler.find_used_subscript_keys(content)
+                
+                # Load and merge values from all sources
+                try:
+                    merged_values = values.load_and_merge_values(
+                        values_file=values_file,
+                        cli_values=values,
+                        config_values=ConfigManager(self.name).list_all(),
+                        defaults=template_defaults
+                    )
+                except Exception as e:
+                    self.console.print(f"[red]{str(e)}[/red]")
+                    raise typer.Exit(code=1)
+                
+                # Collect final values and render template
+                values_dict = {}
+                if var_handler:
+                    values_dict = var_handler.collect_values(
+                        used_vars,
+                        merged_values,
+                        used_subscripts
+                    )
+                else:
+                    values_dict = merged_values
+                
+                success, rendered, error = template.render_template(
+                    cleaned_content,
+                    values_dict
+                )
+                
+                if not success:
+                    self.console.print(f"[red]{error}[/red]")
+                    raise typer.Exit(code=2)
+            
+            # Output the rendered content
+            output_handler = render.RenderOutput(self.console)
+            output_handler.output_rendered_content(
+                rendered,
+                out,
+                self.get_template_syntax(),
+                bp.name
+            )

+ 306 - 377
cli/core/prompt.py

@@ -57,99 +57,196 @@ class PromptHandler:
         # Iterate through declared variable_sets so the prompt order
         # matches the order variables were defined in each set.
         set_used_vars: Dict[str, List[str]] = {}
-        for set_name, set_def in self.variable_sets.items():
-            vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
-            if not isinstance(vars_map, dict):
-                continue
-            for var_name in vars_map.keys():
-                if var_name in used_vars and var_name in self._declared:
-                    if set_name not in set_used_vars:
-                        set_used_vars[set_name] = []
-                    set_used_vars[set_name].append(var_name)
+        if isinstance(self.variable_sets, dict):
+            for set_name, set_def in self.variable_sets.items():
+                vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
+                if not isinstance(vars_map, dict):
+                    continue
+                for var_name in vars_map.keys():
+                    if var_name in used_vars and var_name in self._declared:
+                        if set_name not in set_used_vars:
+                            set_used_vars[set_name] = []
+                        set_used_vars[set_name].append(var_name)
             
             # If the set name is used as a variable, include the set for prompting
-            if set_name in used_vars and set_name not in set_used_vars:
-                set_used_vars[set_name] = []
-
-        # Process each set
-        for set_name, vars_in_set in set_used_vars.items():
-            # Retrieve per-set definition to pick up the custom prompt if provided
-            set_def = self.variable_sets.get(set_name, {})
-            set_prompt = set_def.get("prompt") if isinstance(set_def, dict) else None
-            typer.secho(f"\n{set_name.title()} Settings", fg=typer.colors.BLUE, bold=True)
-
-            def _print_defaults_for_set(vars_list):
-                # Collect variables that have an effective default to print.
-                printable = []
-                for v in vars_list:
-                    meta_info = self._declared[v][1]
-                    display_name = meta_info.get("display_name", v.replace("_", " ").title())
-                    default = self._get_effective_default(v, template_defaults, values)
-                    # Skip variables that have no effective default (they must be provided by the user)
-                    if default is None:
-                        continue
-                    printable.append((v, display_name, default))
-
-                # If there are no defaults to show, don't print a header or blank line.
-                if not printable:
-                    return
+                if set_name in used_vars and set_name not in set_used_vars:
+                    set_used_vars[set_name] = []
+
+        # Process each set in order: 'always' sets first, then others
+        if set_used_vars:
+            # Sort sets by priority: 'always' sets first, then by name
+            def sort_sets(item):
+                set_name, vars_list = item
+                set_def = self.variable_sets.get(set_name, {})
+                is_always = bool(set_def.get('always', False))
+                return (0 if is_always else 1, set_name)
+            
+            sorted_sets = sorted(set_used_vars.items(), key=sort_sets)
+            
+            for set_name, vars_in_set in sorted_sets:
+                # Retrieve per-set definition to pick up the custom prompt if provided
+                set_def = self.variable_sets.get(set_name, {})
+                set_prompt = set_def.get("prompt") if isinstance(set_def, dict) else None
+                typer.secho(f"\n{set_name.title()} Settings", fg=typer.colors.BLUE, bold=True)
+
+                def _print_defaults_for_set(vars_list):
+                    # Collect variables that have an effective default to print.
+                    printable = []
+                    for v in vars_list:
+                        meta_info = self._declared[v][1]
+                        display_name = meta_info.get("display_name", v.replace("_", " ").title())
+                        default = self._get_effective_default(v, template_defaults, values)
+                        # Skip variables that have no effective default (they must be provided by the user)
+                        if default is not None:
+                            printable.append((v, display_name, default))
+
+                    # If there are no defaults to show, don't print a header or blank line.
+                    if not printable:
+                        return
+
+                    # Print a blank line and a consistent header for defaults so it matches
+                    # the 'Required ... Variables' section formatting.
+                    typer.secho("\nDefault %s Variables" % set_name.title(), fg=typer.colors.GREEN, bold=True)
+
+                    for v, display_name, default in printable:
+                        # If variable is accessed with subscripts, show '(multiple)'
+                        if used_subscripts and v in used_subscripts and used_subscripts[v]:
+                            typer.secho(f"{display_name}: ", fg=typer.colors.BRIGHT_BLACK, nl=False)
+                            typer.secho("(multiple)", fg=typer.colors.CYAN)
+                        else:
+                            typer.secho(f"{display_name}: ", fg=typer.colors.BRIGHT_BLACK, nl=False)
+                            typer.secho(f"{default}", fg=typer.colors.CYAN)
+
+                # Decide whether this set is enabled and whether it should be
+                # customized. Support three modes in the set definition:
+                # - 'always': True => the set is enabled and we skip the enable
+                #    question (but may still ask to customize values)
+                # - 'prompt_enable': str => ask this question first to enable the
+                #    set (stores values[set_name] boolean)
+                # - 'prompt' (existing): when provided, ask whether to customize
+                #    the values. We ask 'prompt_enable' first when present, then
+                #    'prompt' to decide whether to customize.
+
+                set_always = bool(set_def.get('always', False))
+                set_prompt_enable = set_def.get('prompt_enable')
+                set_customize_prompt = set_prompt or f"Do you want to change the {set_name.title()} settings?"
+
+                if set_always:
+                    enable_set = True
+                elif set_prompt_enable:
+                    enable_set = self.ask_bool(set_prompt_enable, default=False)
+                else:
+                    # No explicit enable prompt: fall back to asking the customize prompt
+                    # and treat that as enabling when answered Yes.
+                    enable_set = None
+
+                # If we have a definitive enable decision, store it into values
+                if enable_set is not None:
+                    values[set_name] = enable_set
+                    # If a declared variable exists with the same name, don't prompt it
+                    if set_name in vars_in_set:
+                        vars_in_set = [v for v in vars_in_set if v != set_name]
 
-                # Print a blank line and a consistent header for defaults so it matches
-                # the 'Required ... Variables' section formatting.
-                typer.secho("\nDefault %s Variables" % set_name.title(), fg=typer.colors.GREEN, bold=True)
+                # If we didn't ask prompt_enable, ask the customize prompt directly
+                if enable_set is None:
+                    # Check for undefined variables first, before asking if they want to enable
+                    undefined_vars_in_set = []
+                    for var in vars_in_set:
+                        effective_default = self._get_effective_default(var, template_defaults, values)
+                        if effective_default is None:
+                            undefined_vars_in_set.append(var)
+                    
+                    # If there are undefined variables, we must enable this set
+                    if undefined_vars_in_set:
+                        typer.secho(f"\n{set_name.title()} Settings", fg=typer.colors.BLUE, bold=True)
+                        typer.secho(f"Required {set_name.title()} Variables", fg=typer.colors.YELLOW, bold=True)
+                        for var in undefined_vars_in_set:
+                            meta_info = self._declared[var][1]
+                            display_name = meta_info.get("display_name", var.replace("_", " ").title())
+                            vtype = meta_info.get("type", "str")
+                            prompt = meta_info.get("prompt", f"Enter {display_name}")
+                            description = meta_info.get("description")
+                            
+                            # Handle subscripted variables
+                            subs = used_subscripts.get(var, set()) if used_subscripts else set()
+                            if subs:
+                                result_map = {}
+                                for k in subs:
+                                    # Required sub-key: enforce non-empty
+                                    kval = Prompt.ask(f"Value for {display_name}['{k}']:", default="", show_default=False)
+                                    if not (sys.stdin.isatty() and sys.stdout.isatty()):
+                                        # Non-interactive: empty value is an error
+                                        if kval is None or str(kval).strip() == "":
+                                            typer.secho(f"[red]Required value for {display_name}['{k}'] cannot be blank in non-interactive mode.[/red]")
+                                            raise typer.Exit(code=1)
+                                    else:
+                                        # Interactive: re-prompt until non-empty
+                                        while kval is None or str(kval).strip() == "":
+                                            typer.secho("Value cannot be blank. Please enter a value.", fg=typer.colors.YELLOW)
+                                            kval = Prompt.ask(f"Value for {display_name}['{k}']:", default="", show_default=False)
+                                    result_map[k] = self._guess_and_cast(kval)
+                                values[var] = result_map
+                                continue
 
-                for v, display_name, default in printable:
-                    # If variable is accessed with subscripts, show '(multiple)'
-                    if used_subscripts and v in used_subscripts and used_subscripts[v]:
-                        typer.secho(f"{display_name}: ", fg=typer.colors.BRIGHT_BLACK, nl=False)
-                        typer.secho("(multiple)", fg=typer.colors.CYAN)
+                            if vtype == "bool":
+                                val = self.ask_bool(prompt, default=False, description=description)
+                            elif vtype == "int":
+                                val = self.ask_int(prompt, default=None, description=description)
+                            else:
+                                val = self.ask_str(prompt, default=None, show_default=False, description=description)
+                            
+                            values[var] = self._cast_value_from_input(val, vtype)
+                        
+                        # Since we prompted for required variables, enable the set
+                        values[set_name] = True
+                        if set_name in vars_in_set:
+                            vars_in_set = [v for v in vars_in_set if v != set_name]
+                        
+                        # Print defaults and ask if they want to change others
+                        _print_defaults_for_set(vars_in_set)
+                        change_set = self.ask_bool(set_customize_prompt, default=False)
+                        if not change_set:
+                            # Use defaults for remaining variables
+                            for var in vars_in_set:
+                                if var not in values:  # Don't override variables we already prompted for
+                                    meta_info = self._declared[var][1]
+                                    default = self._get_effective_default(var, template_defaults, values)
+                                    values[var] = default
+                            continue
                     else:
-                        typer.secho(f"{display_name}: ", fg=typer.colors.BRIGHT_BLACK, nl=False)
-                        typer.secho(f"{default}", fg=typer.colors.CYAN)
-
-            # Decide whether this set is enabled and whether it should be
-            # customized. Support three modes in the set definition:
-            # - 'always': True => the set is enabled and we skip the enable
-            #    question (but may still ask to customize values)
-            # - 'prompt_enable': str => ask this question first to enable the
-            #    set (stores values[set_name] boolean)
-            # - 'prompt' (existing): when provided, ask whether to customize
-            #    the values. We ask 'prompt_enable' first when present, then
-            #    'prompt' to decide whether to customize.
-
-            set_always = bool(set_def.get('always', False))
-            set_prompt_enable = set_def.get('prompt_enable')
-            set_customize_prompt = set_prompt or f"Do you want to change the {set_name.title()} settings?"
-
-            if set_always:
-                enable_set = True
-            elif set_prompt_enable:
-                enable_set = self.ask_bool(set_prompt_enable, default=False)
-            else:
-                # No explicit enable prompt: fall back to asking the customize prompt
-                # and treat that as enabling when answered Yes.
-                enable_set = None
-
-            # If we have a definitive enable decision, store it into values
-            if enable_set is not None:
-                values[set_name] = enable_set
-                # If a declared variable exists with the same name, don't prompt it
-                if set_name in vars_in_set:
-                    vars_in_set = [v for v in vars_in_set if v != set_name]
-
-            # If we didn't ask prompt_enable, ask the customize prompt directly
-            if enable_set is None:
-                # Check for undefined variables first, before asking if they want to enable
+                        # No undefined variables, ask the customize prompt as normal
+                        change_set = self.ask_bool(set_customize_prompt, default=False)
+                        values[set_name] = change_set
+                        if set_name in vars_in_set:
+                            vars_in_set = [v for v in vars_in_set if v != set_name]
+                        if not change_set:
+                            # Use defaults for this set
+                            for var in vars_in_set:
+                                if var not in values:  # Don't override variables that might have been set
+                                    meta_info = self._declared[var][1]
+                                    default = self._get_effective_default(var, template_defaults, values)
+                                    values[var] = default
+                            continue
+
+                # If we had an enable_set (True/False) and it is False, skip customizing
+                if enable_set is not None and not enable_set:
+                    for var in vars_in_set:
+                        if var not in values:  # Don't override variables that might have been set
+                            meta_info = self._declared[var][1]
+                            default = self._get_effective_default(var, template_defaults, values)
+                            values[var] = default
+                    continue
+
+                # At this point the set is enabled. Check for undefined variables first.
                 undefined_vars_in_set = []
                 for var in vars_in_set:
                     effective_default = self._get_effective_default(var, template_defaults, values)
                     if effective_default is None:
                         undefined_vars_in_set.append(var)
                 
-                # If there are undefined variables, we must enable this set
+                # Prompt for undefined variables in this set
                 if undefined_vars_in_set:
-                    typer.secho(f"\n{set_name.title()} Settings", fg=typer.colors.BLUE, bold=True)
-                    typer.secho(f"Required {set_name.title()} Variables", fg=typer.colors.YELLOW, bold=True)
+                    typer.secho(f"\nRequired {set_name.title()} Variables", fg=typer.colors.YELLOW, bold=True)
                     for var in undefined_vars_in_set:
                         meta_info = self._declared[var][1]
                         display_name = meta_info.get("display_name", var.replace("_", " ").title())
@@ -165,12 +262,10 @@ class PromptHandler:
                                 # Required sub-key: enforce non-empty
                                 kval = Prompt.ask(f"Value for {display_name}['{k}']:", default="", show_default=False)
                                 if not (sys.stdin.isatty() and sys.stdout.isatty()):
-                                    # Non-interactive: empty value is an error
                                     if kval is None or str(kval).strip() == "":
                                         typer.secho(f"[red]Required value for {display_name}['{k}'] cannot be blank in non-interactive mode.[/red]")
                                         raise typer.Exit(code=1)
                                 else:
-                                    # Interactive: re-prompt until non-empty
                                     while kval is None or str(kval).strip() == "":
                                         typer.secho("Value cannot be blank. Please enter a value.", fg=typer.colors.YELLOW)
                                         kval = Prompt.ask(f"Value for {display_name}['{k}']:", default="", show_default=False)
@@ -184,33 +279,28 @@ class PromptHandler:
                             val = self.ask_int(prompt, default=None, description=description)
                         else:
                             val = self.ask_str(prompt, default=None, show_default=False, description=description)
-                        
+                            # Enforce non-empty for required scalar variables
+                            if not (sys.stdin.isatty() and sys.stdout.isatty()):
+                                if val is None or str(val).strip() == "":
+                                    typer.secho(f"[red]Required value for {display_name} cannot be blank in non-interactive mode.[/red]")
+                                    raise typer.Exit(code=1)
+                            else:
+                                while val is None or str(val).strip() == "":
+                                    typer.secho("Value cannot be blank. Please enter a value.", fg=typer.colors.YELLOW)
+                                    val = self.ask_str(prompt, default=None, show_default=False, description=description)
+
                         values[var] = self._cast_value_from_input(val, vtype)
-                    
-                    # Since we prompted for required variables, enable the set
-                    values[set_name] = True
-                    if set_name in vars_in_set:
-                        vars_in_set = [v for v in vars_in_set if v != set_name]
-                    
-                    # Print defaults and ask if they want to change others
-                    _print_defaults_for_set(vars_in_set)
-                    change_set = self.ask_bool(set_customize_prompt, default=False)
-                    if not change_set:
-                        # Use defaults for remaining variables
-                        for var in vars_in_set:
-                            if var not in values:  # Don't override variables we already prompted for
-                                meta_info = self._declared[var][1]
-                                default = self._get_effective_default(var, template_defaults, values)
-                                values[var] = default
-                        continue
-                else:
-                    # No undefined variables, ask the customize prompt as normal
+
+                # Print defaults now (only after enabling and prompting for required vars)
+                # so the user sees current values before customizing.
+                _print_defaults_for_set(vars_in_set)
+
+                # If we have asked prompt_enable earlier (and the set is enabled),
+                # now ask whether to customize. For 'always' sets we still ask the
+                # customize prompt.
+                if set_prompt_enable or set_always:
                     change_set = self.ask_bool(set_customize_prompt, default=False)
-                    values[set_name] = change_set
-                    if set_name in vars_in_set:
-                        vars_in_set = [v for v in vars_in_set if v != set_name]
                     if not change_set:
-                        # Use defaults for this set
                         for var in vars_in_set:
                             if var not in values:  # Don't override variables that might have been set
                                 meta_info = self._declared[var][1]
@@ -218,192 +308,142 @@ class PromptHandler:
                                 values[var] = default
                         continue
 
-            # If we had an enable_set (True/False) and it is False, skip customizing
-            if enable_set is not None and not enable_set:
+                # Prompt for each variable in the set
                 for var in vars_in_set:
-                    if var not in values:  # Don't override variables that might have been set
-                        meta_info = self._declared[var][1]
-                        default = self._get_effective_default(var, template_defaults, values)
-                        values[var] = default
-                continue
-
-            # At this point the set is enabled. Check for undefined variables first.
-            undefined_vars_in_set = []
-            for var in vars_in_set:
-                effective_default = self._get_effective_default(var, template_defaults, values)
-                if effective_default is None:
-                    undefined_vars_in_set.append(var)
-            
-            # Prompt for undefined variables in this set
-            if undefined_vars_in_set:
-                typer.secho(f"\nRequired {set_name.title()} Variables", fg=typer.colors.YELLOW, bold=True)
-                for var in undefined_vars_in_set:
+                    # Skip variables that have already been prompted for
+                    if var in values:
+                        continue
+                        
                     meta_info = self._declared[var][1]
                     display_name = meta_info.get("display_name", var.replace("_", " ").title())
                     vtype = meta_info.get("type", "str")
                     prompt = meta_info.get("prompt", f"Enter {display_name}")
                     description = meta_info.get("description")
-                    
-                    # Handle subscripted variables
+                    default = self._get_effective_default(var, template_defaults, values)
+
+                    # Build prompt text and rely on show_default to display the default value
+                    prompt_text = f"{prompt}"
+
+                    # If variable is accessed with subscripts in the template, always prompt for each key and store as dict
                     subs = used_subscripts.get(var, set()) if used_subscripts else set()
                     if subs:
+                        # Print all default values for subscripted keys before prompting
+                        for k in subs:
+                            key_default = None
+                            if isinstance(default, dict):
+                                key_default = default.get(k)
+                            elif default is not None:
+                                key_default = default
+                            typer.secho(f"{display_name}['{k}']: ", fg=typer.colors.BRIGHT_BLACK, nl=False)
+                            typer.secho(f"{key_default}", fg=typer.colors.CYAN)
                         result_map = {}
                         for k in subs:
-                            # Required sub-key: enforce non-empty
-                            kval = Prompt.ask(f"Value for {display_name}['{k}']:", default="", show_default=False)
-                            if not (sys.stdin.isatty() and sys.stdout.isatty()):
-                                if kval is None or str(kval).strip() == "":
-                                    typer.secho(f"[red]Required value for {display_name}['{k}'] cannot be blank in non-interactive mode.[/red]")
-                                    raise typer.Exit(code=1)
-                            else:
-                                while kval is None or str(kval).strip() == "":
-                                    typer.secho("Value cannot be blank. Please enter a value.", fg=typer.colors.YELLOW)
-                                    kval = Prompt.ask(f"Value for {display_name}['{k}']:", default="", show_default=False)
+                            kval = Prompt.ask(f"Value for {display_name}['{k}']:", default=str(default.get(k)) if isinstance(default, dict) and default.get(k) is not None else None, show_default=True)
                             result_map[k] = self._guess_and_cast(kval)
                         values[var] = result_map
                         continue
 
                     if vtype == "bool":
-                        val = self.ask_bool(prompt, default=False, description=description)
+                        # Normalize default to bool
+                        bool_default = False
+                        if isinstance(default, bool):
+                            bool_default = default
+                        elif isinstance(default, str):
+                            bool_default = default.lower() in ("true", "1", "yes")
+                        elif isinstance(default, int):
+                            bool_default = default != 0
+                        val = self.ask_bool(prompt_text, default=bool_default, description=description)
                     elif vtype == "int":
-                        val = self.ask_int(prompt, default=None, description=description)
+                        # Use IntPrompt to validate and parse integers; show default if present
+                        int_default = None
+                        if isinstance(default, int):
+                            int_default = default
+                        elif isinstance(default, str) and default.isdigit():
+                            int_default = int(default)
+                        val = self.ask_int(prompt_text, default=int_default, description=description)
                     else:
-                        val = self.ask_str(prompt, default=None, show_default=False, description=description)
-                        # Enforce non-empty for required scalar variables
-                        if not (sys.stdin.isatty() and sys.stdout.isatty()):
-                            if val is None or str(val).strip() == "":
-                                typer.secho(f"[red]Required value for {display_name} cannot be blank in non-interactive mode.[/red]")
-                                raise typer.Exit(code=1)
-                        else:
-                            while val is None or str(val).strip() == "":
-                                typer.secho("Value cannot be blank. Please enter a value.", fg=typer.colors.YELLOW)
-                                val = self.ask_str(prompt, default=None, show_default=False, description=description)
-
-                    values[var] = self._cast_value_from_input(val, vtype)
-
-            # Print defaults now (only after enabling and prompting for required vars)
-            # so the user sees current values before customizing.
-            _print_defaults_for_set(vars_in_set)
-
-            # If we have asked prompt_enable earlier (and the set is enabled),
-            # now ask whether to customize. For 'always' sets we still ask the
-            # customize prompt.
-            if set_prompt_enable or set_always:
-                change_set = self.ask_bool(set_customize_prompt, default=False)
-                if not change_set:
-                    for var in vars_in_set:
-                        if var not in values:  # Don't override variables that might have been set
-                            meta_info = self._declared[var][1]
-                            default = self._get_effective_default(var, template_defaults, values)
-                            values[var] = default
-                    continue
+                        # Use Prompt for string input and show default
+                        str_default = str(default) if default is not None else None
+                        val = self.ask_str(prompt_text, default=str_default, show_default=True, description=description)
 
-            # Prompt for each variable in the set
-            for var in vars_in_set:
-                # Skip variables that have already been prompted for
-                if var in values:
-                    continue
-                    
-                meta_info = self._declared[var][1]
-                display_name = meta_info.get("display_name", var.replace("_", " ").title())
-                vtype = meta_info.get("type", "str")
-                prompt = meta_info.get("prompt", f"Enter {display_name}")
-                description = meta_info.get("description")
-                default = self._get_effective_default(var, template_defaults, values)
-
-                # Build prompt text and rely on show_default to display the default value
-                prompt_text = f"{prompt}"
-
-                # If variable is accessed with subscripts in the template, always prompt for each key and store as dict
-                subs = used_subscripts.get(var, set()) if used_subscripts else set()
-                if subs:
-                    # Print all default values for subscripted keys before prompting
-                    for k in subs:
-                        key_default = None
-                        if isinstance(default, dict):
-                            key_default = default.get(k)
-                        elif default is not None:
-                            key_default = default
-                        typer.secho(f"{display_name}['{k}']: ", fg=typer.colors.BRIGHT_BLACK, nl=False)
-                        typer.secho(f"{key_default}", fg=typer.colors.CYAN)
-                    result_map = {}
-                    for k in subs:
-                        kval = Prompt.ask(f"Value for {display_name}['{k}']:", default=str(default.get(k)) if isinstance(default, dict) and default.get(k) is not None else None, show_default=True)
-                        result_map[k] = self._guess_and_cast(kval)
-                    values[var] = result_map
-                    continue
-
-                if vtype == "bool":
-                    # Normalize default to bool
-                    bool_default = False
-                    if isinstance(default, bool):
-                        bool_default = default
-                    elif isinstance(default, str):
-                        bool_default = default.lower() in ("true", "1", "yes")
-                    elif isinstance(default, int):
-                        bool_default = default != 0
-                    val = self.ask_bool(prompt_text, default=bool_default, description=description)
-                elif vtype == "int":
-                    # Use IntPrompt to validate and parse integers; show default if present
-                    int_default = None
-                    if isinstance(default, int):
-                        int_default = default
-                    elif isinstance(default, str) and default.isdigit():
-                        int_default = int(default)
-                    val = self.ask_int(prompt_text, default=int_default, description=description)
-                else:
-                    # Use Prompt for string input and show default
-                    str_default = str(default) if default is not None else None
-                    val = self.ask_str(prompt_text, default=str_default, show_default=True, description=description)
+                    # Handle collection types: arrays and maps
+                    if vtype in ("array", "list"):
+                        values[var] = self.prompt_array(var, meta_info, default)
+                        continue
 
-                # Handle collection types: arrays and maps
-                if vtype in ("array", "list"):
-                    values[var] = self.prompt_array(var, meta_info, default)
-                    continue
+                    if vtype in ("map", "dict"):
+                        # If the template indexes this variable with specific keys, prompt per-key
+                        subs = used_subscripts.get(var, set()) if used_subscripts else set()
+                        if subs:
+                            # Prompt for each accessed key; allow single scalar default to apply to all
+                            result_map = {}
+                            # If default is a scalar, ask whether to expand it to accessed keys
+                            if not isinstance(default, dict) and default is not None:
+                                use_single = self.ask_bool(f"Use single value {default} for all {display_name} keys?", default=True)
+                                if use_single:
+                                    for k in subs:
+                                        result_map[k] = default
+                                    values[var] = result_map
+                                    continue
+                            # Otherwise prompt per key or use metadata keys when present
+                            keys_meta = meta_info.get("keys")
+                            for k in subs:
+                                if isinstance(keys_meta, dict) and k in keys_meta:
+                                    # reuse metadata prompt
+                                    kmeta = keys_meta[k]
+                                    result_map[k] = self.prompt_scalar(k, kmeta, kmeta.get("default"))
+                                else:
+                                    # generic prompt
+                                    kval = self.ask_str(f"Value for {display_name}['{k}']:")
+                                    result_map[k] = self._guess_and_cast(kval)
+                            values[var] = result_map
+                            continue
 
-                if vtype in ("map", "dict"):
-                    # If the template indexes this variable with specific keys, prompt per-key
-                    subs = used_subscripts.get(var, set()) if used_subscripts else set()
-                    if subs:
-                        # Prompt for each accessed key; allow single scalar default to apply to all
-                        result_map = {}
-                        # If default is a scalar, ask whether to expand it to accessed keys
-                        if not isinstance(default, dict) and default is not None:
-                            use_single = self.ask_bool(f"Use single value {default} for all {display_name} keys?", default=True)
-                            if use_single:
-                                for k in subs:
-                                    result_map[k] = default
-                                values[var] = result_map
-                                continue
-                        # Otherwise prompt per key or use metadata keys when present
-                        keys_meta = meta_info.get("keys")
-                        for k in subs:
-                            if isinstance(keys_meta, dict) and k in keys_meta:
-                                # reuse metadata prompt
-                                kmeta = keys_meta[k]
-                                result_map[k] = self.prompt_scalar(k, kmeta, kmeta.get("default"))
-                            else:
-                                # generic prompt
-                                kval = self.ask_str(f"Value for {display_name}['{k}']:")
-                                result_map[k] = self._guess_and_cast(kval)
-                        values[var] = result_map
+                        # Fallback to full map prompting
+                        values[var] = self.prompt_map(var, meta_info, default)
                         continue
 
-                    # Fallback to full map prompting
-                    values[var] = self.prompt_map(var, meta_info, default)
-                    continue
-
-                # store scalar/canonicalized value
-                values[var] = self._cast_value_from_input(val, vtype)
+                    # store scalar/canonicalized value
+                    values[var] = self._cast_value_from_input(val, vtype)
 
-        # Handle unknown variables. If a variable was already set (for
-        # example by the set-level prompt mapping into `values[set_name]`),
-        # don't prompt for it again.
+        # Handle variables that belong to sets but weren't processed
+        all_set_vars = set()
+        if isinstance(self.variable_sets, dict):
+            for set_name, set_def in self.variable_sets.items():
+                vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
+                if isinstance(vars_map, dict):
+                    all_set_vars.update(vars_map.keys())
+        
+        # Handle unknown variables (variables that don't belong to any set)
+        # If a variable was already set (for example by the set-level prompt mapping
+        # into `values[set_name]`), don't prompt for it again.
         for var in used_vars:
-            if var not in self._declared and var not in values:
+            if var not in self._declared and var not in values and var not in all_set_vars:
                 prompt_text = f"Value for '{var}':"
                 val = Prompt.ask(prompt_text, default="", show_default=False)
                 values[var] = self._guess_and_cast(val)
+            elif var in self._declared and var not in values:
+                # This is a declared variable that wasn't processed in its set
+                # This shouldn't happen with proper logic, but let's handle it
+                meta_info = self._declared[var][1]
+                default = self._get_effective_default(var, template_defaults, values)
+                if default is not None:
+                    values[var] = default
+                else:
+                    # No default available, prompt generically
+                    display_name = meta_info.get("display_name", var.replace("_", " ").title())
+                    vtype = meta_info.get("type", "str")
+                    prompt = meta_info.get("prompt", f"Enter {display_name}")
+                    description = meta_info.get("description")
+                    
+                    if vtype == "bool":
+                        val = self.ask_bool(prompt, default=False, description=description)
+                    elif vtype == "int":
+                        val = self.ask_int(prompt, default=None, description=description)
+                    else:
+                        val = self.ask_str(prompt, default=None, show_default=False, description=description)
+                    
+                    values[var] = self._cast_value_from_input(val, vtype)
 
         return values
 
@@ -541,114 +581,3 @@ class PromptHandler:
             return PromptHandler._cast_value_from_input(s, "bool")
         return PromptHandler._cast_value_from_input(s, "str")
 
-    def prompt_scalar(self, var_name: str, meta_info: Dict[str, Any], default_val: Any) -> Any:
-        display_name = meta_info.get("display_name", var_name.replace("_", " ").title())
-        vtype = meta_info.get("type", "str")
-        prompt = meta_info.get("prompt", f"Enter {display_name}")
-        description = meta_info.get("description")
-        if vtype == "bool":
-            bool_default = False
-            if isinstance(default_val, bool):
-                bool_default = default_val
-            elif isinstance(default_val, str):
-                bool_default = default_val.lower() in ("true", "1", "yes")
-            elif isinstance(default_val, int):
-                bool_default = default_val != 0
-            return self.ask_bool(prompt, default=bool_default, description=description)
-        if vtype == "int":
-            int_default = None
-            if isinstance(default_val, int):
-                int_default = default_val
-            elif isinstance(default_val, str) and default_val.isdigit():
-                int_default = int(default_val)
-            return self.ask_int(prompt, default=int_default, description=description)
-        str_default = str(default_val) if default_val is not None else None
-        return self.ask_str(prompt, default=str_default, show_default=True, description=description)
-
-    def prompt_array(self, var_name: str, meta_info: Dict[str, Any], default_val: Any) -> Any:
-        display_name = meta_info.get("display_name", var_name.replace("_", " ").title())
-        item_type = meta_info.get("item_type", "str")
-        item_prompt = meta_info.get("item_prompt", f"Enter {display_name} item")
-        default_list = default_val if isinstance(default_val, list) else []
-        default_count = len(default_list) if default_list else 0
-        count = self.ask_int(f"How many entries for {display_name}?", default=default_count or 1)
-        arr = []
-        for i in range(count):
-            item_default = default_list[i] if i < len(default_list) else None
-            item_prompt_text = f"{item_prompt} [{i}]"
-            if item_type == "int":
-                int_d = item_default if isinstance(item_default, int) else (int(item_default) if isinstance(item_default, str) and str(item_default).isdigit() else None)
-                item_val = self.ask_int(item_prompt_text, default=int_d)
-            elif item_type == "bool":
-                item_bool_d = self._cast_str_to_bool(item_default)
-                item_val = self.ask_bool(item_prompt_text, default=item_bool_d)
-            else:
-                item_str_d = str(item_default) if item_default is not None else None
-                item_val = self.ask_str(item_prompt_text, default=item_str_d, show_default=True)
-            arr.append(self._cast_value_from_input(item_val, item_type))
-        return arr
-
-    def prompt_map(self, var_name: str, meta_info: Dict[str, Any], default_val: Any) -> Any:
-        display_name = meta_info.get("display_name", var_name.replace("_", " ").title())
-        keys_meta = meta_info.get("keys")
-        result_map = {}
-        if isinstance(keys_meta, dict):
-            for key_name, kmeta in keys_meta.items():
-                kdisplay = kmeta.get("display_name", f"{display_name}['{key_name}']")
-                ktype = kmeta.get("type", "str")
-                kdefault = kmeta.get("default") if "default" in kmeta else (default_val.get(key_name) if isinstance(default_val, dict) and key_name in default_val else None)
-                kprompt = kmeta.get("prompt", f"Enter value for {kdisplay}")
-                if ktype == "int":
-                    kd = kdefault if isinstance(kdefault, int) else (int(kdefault) if isinstance(kdefault, str) and str(kdefault).isdigit() else None)
-                    kval = self.ask_int(kprompt, default=kd)
-                elif ktype == "bool":
-                    kval = self.ask_bool(kprompt, default=self._cast_str_to_bool(kdefault))
-                else:
-                    kval = self.ask_str(kprompt, default=str(kdefault) if kdefault is not None else None, show_default=True)
-                result_map[key_name] = self._cast_value_from_input(kval, ktype)
-            return result_map
-        if isinstance(default_val, dict) and len(default_val) > 0:
-            for key_name, kdefault in default_val.items():
-                kprompt = f"Enter value for {display_name}['{key_name}']"
-                kval = self.ask_str(kprompt, default=str(kdefault) if kdefault is not None else None, show_default=True)
-                result_map[key_name] = self._guess_and_cast(kval)
-            return result_map
-        count = self.ask_int(f"How many named entries for {display_name}?", default=1)
-        for i in range(count):
-            key_name = self.ask_str(f"Key name [{i}]", default=None, show_default=False)
-            kval = self.ask_str(f"Value for {display_name}['{key_name}']:", default=None, show_default=False)
-            result_map[key_name] = self._guess_and_cast(kval)
-        return result_map
-
-    @staticmethod
-    def _cast_str_to_bool(s):
-        if isinstance(s, bool):
-            return s
-        if isinstance(s, int):
-            return s != 0
-        if isinstance(s, str):
-            return s.lower() in ("true", "1", "yes")
-        return False
-
-    @staticmethod
-    def _cast_value_from_input(raw, vtype):
-        if vtype == "int":
-            try:
-                return int(raw)
-            except Exception:
-                return raw
-        if vtype == "bool":
-            bool_val = PromptHandler._cast_str_to_bool(raw)
-            return "true" if bool_val else "false"
-        return raw
-
-    @staticmethod
-    def _guess_and_cast(raw):
-        s = raw if not isinstance(raw, str) else raw.strip()
-        if s == "":
-            return raw
-        if isinstance(s, str) and s.isdigit():
-            return PromptHandler._cast_value_from_input(s, "int")
-        if isinstance(s, str) and s.lower() in ("true", "false", "yes", "no", "1", "0", "t", "f"):
-            return PromptHandler._cast_value_from_input(s, "bool")
-        return PromptHandler._cast_value_from_input(s, "str")

+ 83 - 0
cli/core/render.py

@@ -0,0 +1,83 @@
+"""
+Core rendering functionality for handling template output and display.
+Provides consistent rendering and output handling across different module types.
+"""
+import logging
+from pathlib import Path
+from typing import Optional, Union
+
+from rich.console import Console
+from rich.syntax import Syntax
+
+logger = logging.getLogger(__name__)
+
+class RenderOutput:
+    """Handles the output of rendered templates."""
+    
+    def __init__(self, console: Optional[Console] = None):
+        self.console = console or Console()
+        
+    def write_to_file(self, content: str, output_path: Path) -> None:
+        """
+        Write rendered content to a file.
+        
+        Args:
+            content: Content to write
+            output_path: Path to write the content to
+            
+        Raises:
+            Exception: If writing fails
+        """
+        try:
+            # Ensure parent directory exists
+            output_parent = output_path.parent
+            if not output_parent.exists():
+                output_parent.mkdir(parents=True, exist_ok=True)
+                
+            output_path.write_text(content, encoding="utf-8")
+            self.console.print(f"[green]Rendered content written to {output_path}[/green]")
+        except Exception as e:
+            raise Exception(f"Failed to write output to {output_path}: {e}")
+            
+    def print_to_console(self, content: str, syntax: str = "yaml",
+                        template_name: Optional[str] = None) -> None:
+        """
+        Print rendered content to the console with syntax highlighting.
+        
+        Args:
+            content: Content to print
+            syntax: Syntax highlighting to use (default: yaml)
+            template_name: Optional template name to show in header
+        """
+        if template_name:
+            self.console.print(f"\n\nGenerated Content for [bold cyan]{template_name}[/bold cyan]\n")
+            
+        syntax_output = Syntax(
+            content,
+            syntax,
+            theme="monokai",
+            line_numbers=False,
+            word_wrap=True
+        )
+        self.console.print(syntax_output)
+        
+    def output_rendered_content(self, content: str, output_target: Optional[Union[str, Path]],
+                              syntax: str = "yaml", template_name: Optional[str] = None) -> None:
+        """
+        Output rendered content either to a file or console.
+        
+        Args:
+            content: Content to output
+            output_target: Path to output file or None for console output
+            syntax: Syntax highlighting to use for console output
+            template_name: Optional template name for console output header
+            
+        Raises:
+            Exception: If writing to file fails
+        """
+        if output_target:
+            if isinstance(output_target, str):
+                output_target = Path(output_target)
+            self.write_to_file(content, output_target)
+        else:
+            self.print_to_console(content, syntax, template_name)

+ 83 - 0
cli/core/template.py

@@ -0,0 +1,83 @@
+"""
+Core template utilities for processing and rendering boilerplate templates.
+Provides shared functionality for template cleaning, validation, and rendering
+across different module types (compose, ansible, etc.).
+"""
+import re
+import logging
+from pathlib import Path
+from typing import Optional, Tuple
+
+try:
+    import jinja2
+except ImportError:
+    jinja2 = None
+
+logger = logging.getLogger(__name__)
+
+def clean_template_content(content: str) -> str:
+    """
+    Remove template metadata blocks and prepare content for Jinja2 rendering.
+    
+    Args:
+        content: Raw template content
+        
+    Returns:
+        Cleaned template content with metadata blocks removed
+    """
+    # Remove variables block as it's not valid Jinja2 syntax
+    return re.sub(r"\{%\s*variables\s*%\}(.+?)\{%\s*endvariables\s*%\}\n?", "", content, flags=re.S)
+
+def validate_template(content: str, template_path: Optional[Path] = None) -> Tuple[bool, Optional[str]]:
+    """
+    Validate Jinja2 template syntax before rendering.
+    
+    Args:
+        content: Template content to validate
+        template_path: Optional path to template file for error messages
+        
+    Returns:
+        Tuple of (is_valid, error_message)
+    """
+    if not jinja2:
+        return False, "Jinja2 is required to render templates. Install it and retry."
+        
+    try:
+        env = jinja2.Environment(loader=jinja2.BaseLoader())
+        env.parse(content)
+        return True, None
+    except jinja2.exceptions.TemplateSyntaxError as e:
+        path_info = f" in '{template_path}'" if template_path else ""
+        return False, f"Template syntax error{path_info}: {e.message} (line {e.lineno})"
+    except Exception as e:
+        path_info = f" '{template_path}'" if template_path else ""
+        return False, f"Failed to parse template{path_info}: {e}"
+
+def render_template(content: str, values: dict) -> Tuple[bool, str, Optional[str]]:
+    """
+    Render a template with the provided values.
+    
+    Args:
+        content: Template content to render
+        values: Dictionary of values to use in rendering
+        
+    Returns:
+        Tuple of (success, rendered_content or empty string, error_message or None)
+    """
+    if not jinja2:
+        return False, "", "Jinja2 is required to render templates. Install it and retry."
+        
+    try:
+        # Enable whitespace control for cleaner output
+        env = jinja2.Environment(
+            loader=jinja2.BaseLoader(),
+            trim_blocks=True,
+            lstrip_blocks=True
+        )
+        template = env.from_string(content)
+        rendered = template.render(**values)
+        return True, rendered, None
+    except jinja2.exceptions.TemplateError as e:
+        return False, "", f"Template rendering error: {e}"
+    except Exception as e:
+        return False, "", f"Unexpected error while rendering: {e}"

+ 153 - 0
cli/core/values.py

@@ -0,0 +1,153 @@
+"""
+Core values loading functionality for handling template values from various sources.
+Provides consistent value loading from files and command line arguments.
+"""
+import json
+import logging
+from pathlib import Path
+from typing import Dict, List, Any, Optional
+
+try:
+    import yaml
+except ImportError:
+    yaml = None
+
+logger = logging.getLogger(__name__)
+
+class ValuesLoader:
+    """Handles loading and merging of template values from various sources."""
+
+    @staticmethod
+    def load_from_file(file_path: Path) -> Dict[str, Any]:
+        """
+        Load values from a YAML or JSON file.
+        
+        Args:
+            file_path: Path to the values file
+            
+        Returns:
+            Dictionary of loaded values
+            
+        Raises:
+            ValueError: If file format is unsupported or file doesn't exist
+            Exception: If file loading fails
+        """
+        if not file_path.exists():
+            raise ValueError(f"Values file '{file_path}' not found.")
+            
+        try:
+            with open(file_path, 'r', encoding='utf-8') as f:
+                if file_path.suffix.lower() in ['.yaml', '.yml']:
+                    if not yaml:
+                        raise ImportError("PyYAML is required to load YAML files. Install it and retry.")
+                    return yaml.safe_load(f) or {}
+                elif file_path.suffix.lower() == '.json':
+                    return json.load(f)
+                else:
+                    raise ValueError(
+                        f"Unsupported file format '{file_path.suffix}'. Use .yaml, .yml, or .json"
+                    )
+        except Exception as e:
+            raise Exception(f"Failed to load values from {file_path}: {e}")
+
+    @staticmethod
+    def parse_cli_values(values: List[str]) -> Dict[str, Any]:
+        """
+        Parse values provided via command line arguments.
+        
+        Args:
+            values: List of key=value strings
+            
+        Returns:
+            Dictionary of parsed values
+            
+        Raises:
+            ValueError: If value format is invalid
+        """
+        result = {}
+        
+        for value_pair in values:
+            if '=' not in value_pair:
+                raise ValueError(
+                    f"Invalid value format '{value_pair}'. Use key=value format."
+                )
+                
+            key, val = value_pair.split('=', 1)
+            
+            # Try to parse as JSON for complex values
+            try:
+                result[key] = json.loads(val)
+            except json.JSONDecodeError:
+                # If not valid JSON, use as string
+                result[key] = val
+                
+        return result
+
+    @staticmethod
+    def merge_values(*sources: Dict[str, Any]) -> Dict[str, Any]:
+        """
+        Merge multiple value sources with later sources taking precedence.
+        
+        Args:
+            *sources: Dictionaries of values to merge
+            
+        Returns:
+            Merged values dictionary
+        """
+        result = {}
+        
+        for source in sources:
+            result.update(source)
+            
+        return result
+
+def load_and_merge_values(
+    values_file: Optional[Path] = None,
+    cli_values: Optional[List[str]] = None,
+    config_values: Optional[Dict[str, Any]] = None,
+    defaults: Optional[Dict[str, Any]] = None
+) -> Dict[str, Any]:
+    """
+    Load and merge values from all available sources in order of precedence:
+    defaults <- config <- file <- CLI
+    
+    Args:
+        values_file: Optional path to values file
+        cli_values: Optional list of CLI key=value pairs
+        config_values: Optional values from configuration
+        defaults: Optional default values
+        
+    Returns:
+        Dictionary of merged values
+        
+    Raises:
+        Exception: If value loading fails
+    """
+    sources = []
+    
+    # Start with defaults if provided
+    if defaults:
+        sources.append(defaults)
+        
+    # Add config values if provided
+    if config_values:
+        sources.append(config_values)
+        
+    # Load from file if specified
+    if values_file:
+        try:
+            file_values = ValuesLoader.load_from_file(values_file)
+            sources.append(file_values)
+        except Exception as e:
+            raise Exception(f"Failed to load values file: {e}")
+            
+    # Parse CLI values if provided
+    if cli_values:
+        try:
+            parsed_cli_values = ValuesLoader.parse_cli_values(cli_values)
+            sources.append(parsed_cli_values)
+        except ValueError as e:
+            raise Exception(f"Failed to parse CLI values: {e}")
+            
+    # Merge all sources
+    return ValuesLoader.merge_values(*sources)

+ 16 - 9
cli/core/variables.py

@@ -31,12 +31,17 @@ class BaseVariables:
         # Support both legacy and new shapes. If the set value contains a
         # 'variables' key, use that mapping; otherwise assume the mapping is
         # directly the vars map (legacy).
-        for set_name, set_def in getattr(self, "variable_sets", {}).items():
-            vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
-            if not isinstance(vars_map, dict):
-                continue
-            for var_name, meta_info in vars_map.items():
-                self._declared[var_name] = (set_name, meta_info)
+        if not hasattr(self, "variable_sets"):
+            self.variable_sets = {}
+        # Ensure we can iterate over variable_sets
+        if not isinstance(self.variable_sets, dict):
+            self.variable_sets = {}
+        for set_name, set_def in self.variable_sets.items():
+                vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
+                if not isinstance(vars_map, dict):
+                    continue
+                for var_name, meta_info in vars_map.items():
+                    self._declared[var_name] = (set_name, meta_info)
 
     def find_used_variables(self, template_content: str) -> Set[str]:
         """Parse the Jinja2 template and return the set of variable names used."""
@@ -186,13 +191,15 @@ class BaseVariables:
         return {}
 
     def determine_variable_sets(self, template_content: str) -> Tuple[List[str], Set[str]]:
-        """Return a list of variable set names that contain any used variables.
-
+        """
         Also returns the raw set of used variable names.
         """
         used = self.find_used_variables(template_content)
         matched_sets: List[str] = []
-        for set_name, set_def in getattr(self, "variable_sets", {}).items():
+        variable_sets = getattr(self, "variable_sets", {})
+        if not isinstance(variable_sets, dict):
+            return [], used
+        for set_name, set_def in variable_sets.items():
             vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
             if not isinstance(vars_map, dict):
                 continue

+ 57 - 133
cli/modules/compose/commands.py

@@ -20,15 +20,24 @@ class ComposeModule(BaseModule):
     """Module for managing compose boilerplates."""
 
     compose_filenames = ["compose.yaml", "docker-compose.yaml", "compose.yml", "docker-compose.yml"]
-    library_path = Path(__file__).parent.parent.parent.parent / "library" / "compose"
+    _library_path = Path(__file__).parent.parent.parent.parent / "library" / "compose"
 
     def __init__(self):
         super().__init__(name="compose", icon="🐳", description="Manage Compose Templates and Configurations")
 
-    def get_valid_variables(self) -> Set[str]:
-        """Get the set of valid variable names for the compose module."""
-        variables = ComposeVariables()
-        return set(variables._declared.keys())
+    # Core BaseModule integration
+    @property
+    def template_paths(self) -> List[str]:
+        # Prefer compose.yaml as default per project rules
+        return self.compose_filenames
+
+    @property
+    def library_path(self) -> Path:
+        return self._library_path
+
+    @property
+    def variable_handler_class(self):
+        return ComposeVariables
     
     def _get_variable_details(self) -> Dict[str, Dict[str, Any]]:
         """Get detailed information about variables for display."""
@@ -44,8 +53,8 @@ class ComposeModule(BaseModule):
             }
         return details
 
-    def _add_module_commands(self, app: typer.Typer) -> None:
-        """Add Module-specific commands to the app."""
+    def _add_custom_commands(self, app: typer.Typer) -> None:
+        """Add compose-specific commands to the app."""
 
         @app.command("list", help="List all compose boilerplates")
         def list():
@@ -157,161 +166,76 @@ class ComposeModule(BaseModule):
             name: str, 
             out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output path to write rendered boilerplate (prints to stdout when omitted)"),
             values_file: Optional[Path] = typer.Option(None, "--values-file", "-f", help="Load values from YAML/JSON file"),
-            values: Optional[List[str]] = typer.Option(None, "--values", help="Set values (format: key=value)")
+            cli_values: Optional[List[str]] = typer.Option(None, "--values", help="Set values (format: key=value)")
         ):
             """Render a compose boilerplate interactively and write output to --out."""
+            from ...core import template, values as values_mod, render
+            from ...core.config import ConfigManager
+
+            # Find and validate boilerplate
             bps = find_boilerplates(self.library_path, self.compose_filenames)
-            # Match by directory name (parent folder of the compose file) instead of frontmatter 'name'
             bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
             if not bp:
                 self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
                 raise typer.Exit(code=1)
 
+            # Clean template content and find variables
             cv = ComposeVariables()
-            # Remove any in-template `{% variables %} ... {% endvariables %}` block
-            # before asking Jinja2 to parse/render the template. This block is
-            # used only to provide metadata overrides and is not valid Jinja2
-            # syntax for the default parser (unknown tag -> TemplateSyntaxError).
-            import re
-            cleaned_content = re.sub(r"\{%\s*variables\s*%\}(.+?)\{%\s*endvariables\s*%\}\n?", "", bp.content, flags=re.S)
+            cleaned_content = template.clean_template_content(bp.content)
             matched_sets, used_vars = cv.determine_variable_sets(cleaned_content)
 
-            # If there are no detected variable sets but there are used vars, we still
-            # need to prompt for the used variables. Lazy-import jinja2 only when
-            # rendering is required so module import doesn't fail when Jinja2 is missing.
+            # If no variables used, return original content
             if not used_vars:
                 rendered = bp.content
             else:
-                try:
-                    import jinja2
-                except Exception:
-                    typer.secho("Jinja2 is required to render templates. Install it and retry.", fg=typer.colors.RED)
+                # Validate template syntax
+                is_valid, error = template.validate_template(cleaned_content, bp.file_path)
+                if not is_valid:
+                    self.console.print(f"[red]{error}[/red]")
                     raise typer.Exit(code=2)
 
-                # Use the cleaned content for defaults and rendering, but extract
-                # overrides from the original content (which may contain the
-                # variables block).
+                # Extract defaults and variable metadata
                 template_defaults = cv.extract_template_defaults(cleaned_content)
-
-                # Validate Jinja2 template syntax before proceeding. Parsing the
-                # template will surface syntax errors (unclosed blocks, invalid
-                # tags, etc.) early and allow us to abort with a helpful message.
-                try:
-                    env_for_validation = jinja2.Environment(loader=jinja2.BaseLoader())
-                    env_for_validation.parse(cleaned_content)
-                except jinja2.exceptions.TemplateSyntaxError as e:
-                    # Show file path (if available) and error details, then exit.
-                    self.console.print(f"[red]Template syntax error in '{bp.file_path}': {e.message} (line {e.lineno})[/red]")
-                    raise typer.Exit(code=2)
-                except Exception as e:
-                    # Generic parse failure
-                    self.console.print(f"[red]Failed to parse template '{bp.file_path}': {e}[/red]")
-                    raise typer.Exit(code=2)
-                # Extract variable metadata overrides from a {% variables %} block
                 try:
                     meta_overrides = cv.extract_variable_meta_overrides(bp.content)
-                    # Merge overrides into declared metadata so PromptHandler will pick them up
+                    # Merge overrides into declared metadata
                     for var_name, overrides in meta_overrides.items():
                         if var_name in cv._declared and isinstance(overrides, dict):
                             existing = cv._declared[var_name][1]
-                            # shallow merge
                             existing.update(overrides)
                 except Exception:
                     meta_overrides = {}
+
+                # Get subscript keys and load values from all sources
                 used_subscripts = cv.find_used_subscript_keys(bp.content)
-                
-                # Load values from file if specified
-                file_values = {}
-                if values_file:
-                    if not values_file.exists():
-                        self.console.print(f"[red]Values file '{values_file}' not found.[/red]")
-                        raise typer.Exit(code=1)
-                    
-                    try:
-                        import yaml
-                        with open(values_file, 'r', encoding='utf-8') as f:
-                            if values_file.suffix.lower() in ['.yaml', '.yml']:
-                                file_values = yaml.safe_load(f) or {}
-                            elif values_file.suffix.lower() == '.json':
-                                import json
-                                file_values = json.load(f)
-                            else:
-                                self.console.print(f"[red]Unsupported file format '{values_file.suffix}'. Use .yaml, .yml, or .json[/red]")
-                                raise typer.Exit(code=1)
-                        self.console.print(f"[dim]Loaded values from {values_file}[/dim]")
-                    except Exception as e:
-                        self.console.print(f"[red]Failed to load values from {values_file}: {e}[/red]")
-                        raise typer.Exit(code=1)
-                
-                # Parse command-line values
-                cli_values = {}
-                if values:
-                    for value_pair in values:
-                        if '=' not in value_pair:
-                            self.console.print(f"[red]Invalid value format '{value_pair}'. Use key=value format.[/red]")
-                            raise typer.Exit(code=1)
-                        key, val = value_pair.split('=', 1)
-                        # Try to parse as JSON for complex values
-                        try:
-                            import json
-                            cli_values[key] = json.loads(val)
-                        except json.JSONDecodeError:
-                            cli_values[key] = val
-                        except Exception:
-                            cli_values[key] = val
-                
-                # Override template defaults with configured values
-                from ...core.config import ConfigManager
                 config_manager = ConfigManager(self.name)
-                config_values = config_manager.list_all()
-                
-                # Merge values in order of precedence: template defaults <- config <- file <- CLI
-                for key, config_value in config_values.items():
-                    template_defaults[key] = config_value
-                
-                for key, file_value in file_values.items():
-                    template_defaults[key] = file_value
-                
-                for key, cli_value in cli_values.items():
-                    template_defaults[key] = cli_value
-                
-                values_dict = cv.collect_values(used_vars, template_defaults, used_subscripts)
-
-                # Enable Jinja2 whitespace control so that block tags like
-                # {% if %} don't leave an extra newline in the rendered result.
-                env = jinja2.Environment(loader=jinja2.BaseLoader(), trim_blocks=True, lstrip_blocks=True)
                 try:
-                    template = env.from_string(cleaned_content)
-                except jinja2.exceptions.TemplateSyntaxError as e:
-                    self.console.print(f"[red]Template syntax error in '{bp.file_path}': {e.message} (line {e.lineno})[/red]")
-                    raise typer.Exit(code=2)
+                    merged_values = values_mod.load_and_merge_values(
+                        values_file=values_file,
+                        cli_values=cli_values,
+                        config_values=config_manager.list_all(),
+                        defaults=template_defaults
+                    )
                 except Exception as e:
-                    self.console.print(f"[red]Failed to compile template '{bp.file_path}': {e}[/red]")
-                    raise typer.Exit(code=2)
+                    self.console.print(f"[red]{str(e)}[/red]")
+                    raise typer.Exit(code=1)
+
+                # Collect final values and render template
+                values_dict = cv.collect_values(used_vars, merged_values, used_subscripts)
+                success, rendered, error = template.render_template(
+                    cleaned_content,
+                    values_dict
+                )
 
-                try:
-                    rendered = template.render(**values_dict)
-                except jinja2.exceptions.TemplateError as e:
-                    # Catch runtime/template errors (undefined variables, etc.)
-                    self.console.print(f"[red]Template rendering error for '{bp.file_path}': {e}[/red]")
+                if not success:
+                    self.console.print(f"[red]{error}[/red]")
                     raise typer.Exit(code=2)
-                except Exception as e:
-                    self.console.print(f"[red]Unexpected error while rendering '{bp.file_path}': {e}[/red]")
-                    raise typer.Exit(code=2)
-
-            # If --out not provided, print to console; else write to file
-
-            if out is None:
-                # Print a subtle rule and a small header, then the highlighted YAML
-                self.console.print(f"\n\nGenerated Boilerplate for [bold cyan]{bp.name}[/bold cyan]\n")
-                syntax = Syntax(rendered, "yaml", theme="monokai", line_numbers=False, word_wrap=True)
-                self.console.print(syntax)
-
-            else:
-                # Ensure parent directory exists
-                out_parent = out.parent
-                if not out_parent.exists():
-                    out_parent.mkdir(parents=True, exist_ok=True)
 
-                out.write_text(rendered, encoding="utf-8")
-                self.console.print(f"[green]Rendered boilerplate written to {out}[/green]")
+            # Output the rendered content
+            output_handler = render.RenderOutput(self.console)
+            output_handler.output_rendered_content(
+                rendered,
+                out,
+                "yaml",
+                bp.name
+            )

+ 3 - 1
cli/modules/compose/variables.py

@@ -10,7 +10,8 @@ class ComposeVariables(BaseVariables):
     the individual variable definitions.
     """
 
-    variable_sets: Dict[str, Dict[str, Any]] = {
+    def __init__(self) -> None:
+        self.variable_sets: Dict[str, Dict[str, Any]] = {
         "general": {
             "always": True,
             "prompt": "Do you want to change the general settings?",
@@ -43,3 +44,4 @@ class ComposeVariables(BaseVariables):
             },
         },
     }
+        super().__init__()

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

@@ -23,7 +23,7 @@ services:
         - traefik.enable={{ traefik_enable | default(true) }}
         - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
         - traefik.http.routers.{{ container_name }}.entrypoints=websecure
-        - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik_host | default('example.com') }}`)
+        - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik_host }}`)
         - traefik.http.routers.{{ container_name }}.tls={{ traefik_tls | default(true) }}
         - traefik.http.routers.{{ container_name }}.tls.certresolver={{ traefik_certresolver }}
         - traefik.http.routers.{{ container_name }}.service={{ container_name }}
@@ -42,7 +42,7 @@ services:
       - traefik.enable={{ traefik_enable | default(true) }}
       - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
       - traefik.http.routers.{{ container_name }}.entrypoints=websecure
-      - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik_host | default('example.com') }}`)
+      - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik_host }}`)
       - traefik.http.routers.{{ container_name }}.tls={{ traefik_tls | default(true) }}
       - traefik.http.routers.{{ container_name }}.tls.certresolver={{ traefik_certresolver }}
       - traefik.http.routers.{{ container_name }}.service={{ container_name }}