Parcourir la source

more features to generate templates

xcad il y a 5 mois
Parent
commit
04c530f33a

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@
 # Docker Secrets, Environment Files
 **/secret.*
 **/.env
+**/.envrc
 
 # Ignore Ansible
 **/.ansible

+ 1 - 11
cli/core/app.py

@@ -16,17 +16,7 @@ from cli import __version__
 from ..modules import get_all_modules
 
 
-def setup_logging(log_level: str = "WARNING") -> logging.Logger:
-    """Setup basic logging configuration."""
-    # Configure root logger
-    logging.basicConfig(
-        level=getattr(logging, log_level.upper()),
-        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
-        datefmt='%Y-%m-%d %H:%M:%S',
-        handlers=[logging.StreamHandler(sys.stderr)]
-    )
-    
-    return logging.getLogger("boilerplates")
+from .logging import setup_logging
 
 
 def version_callback(value: bool):

+ 133 - 1
cli/core/command.py

@@ -5,11 +5,13 @@ Provides common functionality and patterns for all modules.
 
 import logging
 from abc import ABC
-from typing import Optional
+from typing import Optional, Set
 
 import typer
 from rich.console import Console
 
+from .config import ConfigManager
+
 
 
 class BaseModule(ABC):
@@ -22,6 +24,13 @@ class BaseModule(ABC):
         self.console = Console()
         self.logger = logging.getLogger(f"boilerplates.module.{name}")
     
+    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.
+        """
+        return set()
+    
     def get_app(self) -> typer.Typer:
         """
         Create and return the Typer app with shared commands.
@@ -33,11 +42,134 @@ class BaseModule(ABC):
             rich_markup_mode="rich"
         )
         
+        # Add shared config commands
+        self._add_config_commands(app)
+        
         # Add module-specific commands
         self._add_module_commands(app)
         
         return app
     
+    def _add_config_commands(self, app: typer.Typer) -> None:
+        """
+        Add shared configuration commands to the app.
+        These commands are available for all modules.
+        """
+        config_app = typer.Typer(name="config", help="Manage module configuration")
+        app.add_typer(config_app, name="config")
+        
+        @config_app.command("set", help="Set a configuration value")
+        def set_config(
+            key: str = typer.Argument(..., help="Configuration key"),
+            value: str = typer.Argument(..., help="Configuration value")
+        ):
+            """Set a configuration value for this module."""
+            # Validate that the key is a valid variable for this module
+            valid_vars = self.get_valid_variables()
+            if valid_vars and key not in valid_vars:
+                self.console.print(f"[red]✗[/red] Invalid config key '{key}'. Valid keys are: {', '.join(sorted(valid_vars))}")
+                raise typer.Exit(code=1)
+            
+            config_manager = ConfigManager(self.name)
+            try:
+                # Try to parse as JSON for complex values
+                import json
+                try:
+                    parsed_value = json.loads(value)
+                except json.JSONDecodeError:
+                    parsed_value = value
+                
+                config_manager.set(key, parsed_value)
+                self.console.print(f"[green]✓[/green] Set {self.name} config '{key}' = {parsed_value}")
+            except Exception as e:
+                self.console.print(f"[red]✗[/red] Failed to set config: {e}")
+        
+        @config_app.command("get", help="Get a configuration value")
+        def get_config(
+            key: str = typer.Argument(..., help="Configuration key"),
+            default: Optional[str] = typer.Option(None, "--default", "-d", help="Default value if key not found")
+        ):
+            """Get a configuration value for this module."""
+            config_manager = ConfigManager(self.name)
+            value = config_manager.get(key, default)
+            if value is None:
+                self.console.print(f"[yellow]⚠[/yellow] Config key '{key}' not found")
+                return
+            
+            import json
+            if isinstance(value, (dict, list)):
+                self.console.print(json.dumps(value, indent=2))
+            else:
+                self.console.print(f"{key}: {value}")
+        
+        @config_app.command("list", help="List all configuration values")
+        def list_config():
+            """List all configuration values for this module."""
+            config_manager = ConfigManager(self.name)
+            config = config_manager.list_all()
+            if not config:
+                self.console.print(f"[yellow]No configuration found for {self.name}[/yellow]")
+                return
+            
+            from rich.table import Table
+            table = Table(title=f"⚙️  {self.name.title()} Configuration", title_style="bold blue")
+            table.add_column("Key", style="cyan", no_wrap=True)
+            table.add_column("Value", style="green")
+            
+            import json
+            for key, value in config.items():
+                if isinstance(value, (dict, list)):
+                    value_str = json.dumps(value, indent=2)
+                else:
+                    value_str = str(value)
+                table.add_row(key, value_str)
+            
+            self.console.print(table)
+        
+        @config_app.command("delete", help="Delete a configuration value")
+        def delete_config(key: str = typer.Argument(..., help="Configuration key")):
+            """Delete a configuration value for this module."""
+            config_manager = ConfigManager(self.name)
+            if config_manager.delete(key):
+                self.console.print(f"[green]✓[/green] Deleted config key '{key}'")
+            else:
+                self.console.print(f"[yellow]⚠[/yellow] Config key '{key}' not found")
+        
+        @config_app.command("variables", help="List valid configuration variables for this module")
+        def list_variables():
+            """List all valid configuration variables for this module."""
+            valid_vars = self.get_valid_variables()
+            if not valid_vars:
+                self.console.print(f"[yellow]No variables defined for {self.name} module yet.[/yellow]")
+                return
+            
+            from rich.table import Table
+            table = Table(title=f"🔧 Valid {self.name.title()} Variables", title_style="bold blue")
+            table.add_column("Variable Name", style="cyan", no_wrap=True)
+            table.add_column("Set", style="magenta")
+            table.add_column("Type", style="green")
+            table.add_column("Description", style="dim")
+            
+            # Get detailed variable information
+            if hasattr(self, '_get_variable_details'):
+                var_details = self._get_variable_details()
+                for var_name in sorted(valid_vars):
+                    if var_name in var_details:
+                        detail = var_details[var_name]
+                        table.add_row(
+                            var_name,
+                            detail.get('set', 'unknown'),
+                            detail.get('type', 'str'),
+                            detail.get('display_name', '')
+                        )
+                    else:
+                        table.add_row(var_name, 'unknown', 'str', '')
+            else:
+                for var_name in sorted(valid_vars):
+                    table.add_row(var_name, 'unknown', 'str', '')
+            
+            self.console.print(table)
+    
     def _add_module_commands(self, app: typer.Typer) -> None:
         """
         Override this method in subclasses to add module-specific commands.

+ 75 - 0
cli/core/config.py

@@ -0,0 +1,75 @@
+"""
+Configuration management for the Boilerplates CLI.
+Handles module-specific configuration stored in config.json files.
+"""
+
+import json
+import os
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from .logging import setup_logging
+
+
+class ConfigManager:
+    """Manages configuration for CLI modules."""
+
+    def __init__(self, module_name: str):
+        self.module_name = module_name
+        self.config_dir = Path.home() / ".boilerplates"
+        self.config_file = self.config_dir / f"{module_name}.json"
+        self.logger = setup_logging()
+
+    def _ensure_config_dir(self) -> None:
+        """Ensure the configuration directory exists."""
+        self.config_dir.mkdir(parents=True, exist_ok=True)
+
+    def _load_config(self) -> Dict[str, Any]:
+        """Load configuration from file."""
+        if not self.config_file.exists():
+            return {}
+
+        try:
+            with open(self.config_file, 'r', encoding='utf-8') as f:
+                return json.load(f)
+        except (json.JSONDecodeError, IOError) as e:
+            self.logger.warning(f"Failed to load config for {self.module_name}: {e}")
+            return {}
+
+    def _save_config(self, config: Dict[str, Any]) -> None:
+        """Save configuration to file."""
+        self._ensure_config_dir()
+        try:
+            with open(self.config_file, 'w', encoding='utf-8') as f:
+                json.dump(config, f, indent=2, ensure_ascii=False)
+        except IOError as e:
+            self.logger.error(f"Failed to save config for {self.module_name}: {e}")
+            raise
+
+    def get(self, key: str, default: Any = None) -> Any:
+        """Get a configuration value."""
+        config = self._load_config()
+        return config.get(key, default)
+
+    def set(self, key: str, value: Any) -> None:
+        """Set a configuration value."""
+        config = self._load_config()
+        config[key] = value
+        self._save_config(config)
+
+    def delete(self, key: str) -> bool:
+        """Delete a configuration value."""
+        config = self._load_config()
+        if key in config:
+            del config[key]
+            self._save_config(config)
+            return True
+        return False
+
+    def list_all(self) -> Dict[str, Any]:
+        """List all configuration values."""
+        return self._load_config()
+
+    def get_config_path(self) -> Path:
+        """Get the path to the configuration file."""
+        return self.config_file

+ 19 - 0
cli/core/logging.py

@@ -0,0 +1,19 @@
+"""
+Logging utilities for the Boilerplates CLI.
+"""
+
+import logging
+import sys
+
+
+def setup_logging(log_level: str = "WARNING") -> logging.Logger:
+    """Setup basic logging configuration."""
+    # Configure root logger
+    logging.basicConfig(
+        level=getattr(logging, log_level.upper()),
+        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S',
+        handlers=[logging.StreamHandler(sys.stderr)]
+    )
+
+    return logging.getLogger("boilerplates")

+ 501 - 0
cli/core/prompt.py

@@ -0,0 +1,501 @@
+from typing import Any, Dict, Optional, List, Set, Tuple
+from rich.prompt import Prompt, IntPrompt, Confirm
+import typer
+import sys
+
+class PromptHandler:
+    def __init__(self, declared_variables: Dict[str, Tuple[str, Dict[str, Any]]], variable_sets: Dict[str, Dict[str, Any]]):
+        self._declared = declared_variables
+        self.variable_sets = variable_sets
+
+    @staticmethod
+    def ask_bool(prompt_text: str, default: bool = False) -> bool:
+        """Ask a yes/no question, render default in cyan when in a TTY, and
+        fall back to typer.confirm when not attached to a TTY.
+        """
+        if not (sys.stdin.isatty() and sys.stdout.isatty()):
+            return typer.confirm(prompt_text, default=default)
+
+        if default:
+            indicator = "[cyan]Y[/cyan]/n"
+        else:
+            indicator = "y/[cyan]N[/cyan]"
+
+        prompt_full = f"{prompt_text} [{indicator}]"
+        resp = Prompt.ask(prompt_full, default="", show_default=False)
+        if resp is None or str(resp).strip() == "":
+            return bool(default)
+        r = str(resp).strip().lower()
+        return r[0] in ("y", "1", "t")
+
+    @staticmethod
+    def ask_int(prompt_text: str, default: Optional[int] = None) -> int:
+        return IntPrompt.ask(prompt_text, default=default, show_default=True)
+
+    @staticmethod
+    def ask_str(prompt_text: str, default: Optional[str] = None, show_default: bool = True) -> str:
+        return Prompt.ask(prompt_text, default=default, show_default=show_default)
+
+    def collect_values(self, used_vars: Set[str], template_defaults: Dict[str, Any] = None, used_subscripts: Dict[str, Set[str]] = None) -> Dict[str, Any]:
+        """Interactively prompt for values for the variables that appear in the template.
+
+        For variables that were declared in `variable_sets` we use their metadata.
+        For unknown variables, we fall back to a generic prompt.
+        """
+        if template_defaults is None:
+            template_defaults = {}
+        values: Dict[str, Any] = {}
+
+        # Group used vars by their set.
+        # 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 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):
+                # Print each variable and its default value (field name: grey, value: cyan)
+                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)
+                    # 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]
+
+            # If we didn't ask prompt_enable, ask the customize prompt directly
+            if enable_set is None:
+                # In this mode we treat the customize prompt as the enable decision.
+                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:
+                        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:
+                    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. Print defaults now (only after
+            # enabling) 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:
+                        meta_info = self._declared[var][1]
+                        default = self._get_effective_default(var, template_defaults, values)
+                        values[var] = default
+                    continue
+
+            # Prompt for each variable in the set
+            for var in 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}")
+                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)
+                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 = IntPrompt.ask(prompt_text, default=int_default, show_default=True)
+                else:
+                    # Use Prompt for string input and show default
+                    str_default = str(default) if default is not None else None
+                    val = Prompt.ask(prompt_text, default=str_default, show_default=True)
+
+                # 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
+
+                    # 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)
+
+        # 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.
+        for var in used_vars:
+            if var not in self._declared and var not in values:
+                prompt_text = f"Value for '{var}':"
+                val = Prompt.ask(prompt_text, default="", show_default=False)
+                values[var] = self._guess_and_cast(val)
+
+        return values
+
+    def _get_effective_default(self, var_name: str, template_defaults: Dict[str, Any], current_values: Dict[str, Any]):
+        # Prefer template-provided default, else declared metadata default
+        meta_info = self._declared.get(var_name, ({}, {}))[1] if var_name in self._declared else {}
+        candidate = None
+        if template_defaults and var_name in template_defaults:
+            candidate = template_defaults[var_name]
+        else:
+            candidate = meta_info.get("default") if isinstance(meta_info, dict) else None
+
+        # If candidate names another variable and that variable has already
+        # been provided by the user, use that value.
+        if isinstance(candidate, str) and candidate in current_values:
+            return current_values[candidate]
+
+        # Otherwise, try to resolve identifier references to declared defaults
+        if isinstance(candidate, str) and candidate in self._declared:
+            decl_def = self._declared[candidate][1].get("default")
+            if decl_def is not None:
+                return decl_def
+
+        return candidate
+
+    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}")
+        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)
+        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)
+        str_default = str(default_val) if default_val is not None else None
+        return self.ask_str(prompt, default=str_default, show_default=True)
+
+    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":
+            return PromptHandler._cast_str_to_bool(raw)
+        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")
+
+    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}")
+        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)
+        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)
+        str_default = str(default_val) if default_val is not None else None
+        return self.ask_str(prompt, default=str_default, show_default=True)
+
+    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":
+            return PromptHandler._cast_str_to_bool(raw)
+        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")

+ 167 - 0
cli/core/variables.py

@@ -0,0 +1,167 @@
+"""
+Core variables support for interactive collection and detection.
+
+Provides a BaseVariables class that can detect which variable sets are used
+in a Jinja2 template and interactively collect values from the user.
+"""
+from typing import Dict, List, Tuple, Set, Any
+import jinja2
+from jinja2 import meta
+import typer
+from .prompt import PromptHandler
+
+
+class BaseVariables:
+    """Base implementation for variable sets and interactive prompting.
+
+     Subclasses should set `variable_sets` to one of two shapes:
+
+     1) Legacy shape (mapping of set-name -> { var_name: { ... } })
+         { "general": { "foo": { ... }, ... } }
+
+     2) New shape (mapping of set-name -> { "prompt": str, "variables": { var_name: { ... } } })
+         { "general": { "prompt": "...", "variables": { "foo": { ... } } } }
+    """
+
+    variable_sets: Dict[str, Dict[str, Any]] = {}
+
+    def __init__(self) -> None:
+        # Flattened list of all declared variable names -> (set_name, meta)
+        self._declared: Dict[str, Tuple[str, Dict[str, Any]]] = {}
+        # 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)
+
+    def find_used_variables(self, template_content: str) -> Set[str]:
+        """Parse the Jinja2 template and return the set of variable names used."""
+        env = jinja2.Environment()
+        try:
+            ast = env.parse(template_content)
+            used = meta.find_undeclared_variables(ast)
+            return set(used)
+        except Exception:
+            # If parsing fails, fallback to an empty set (safe behavior)
+            return set()
+
+    def find_used_subscript_keys(self, template_content: str) -> Dict[str, Set[str]]:
+        """Return mapping of variable name -> set of string keys accessed via subscripting
+
+        Example: for template using service_port['http'] and service_port['https']
+        this returns { 'service_port': {'http', 'https'} }.
+        """
+        try:
+            env = jinja2.Environment()
+            ast = env.parse(template_content)
+            # Walk AST and collect Subscript nodes
+            from jinja2 import nodes
+
+            subs: Dict[str, Set[str]] = {}
+
+            for node in ast.find_all(nodes.Getitem):
+                # Getitem node structure: node.node (value), node.arg (index)
+                try:
+                    if isinstance(node.node, nodes.Name):
+                        var_name = node.node.name
+                        # index can be Const (string) or Name/other; handle Const
+                        idx = node.arg
+                        if isinstance(idx, nodes.Const) and isinstance(idx.value, str):
+                            subs.setdefault(var_name, set()).add(idx.value)
+                except Exception:
+                    continue
+
+            return subs
+        except Exception:
+            return {}
+
+    def extract_template_defaults(self, template_content: str) -> Dict[str, Any]:
+        """Extract default values from Jinja2 expressions like {{ var | default(value) }}."""
+        import re
+
+        def _parse_literal(s: str):
+            s = s.strip()
+            if s.startswith("'") and s.endswith("'"):
+                return s[1:-1]
+            if s.startswith('"') and s.endswith('"'):
+                return s[1:-1]
+            if s.isdigit():
+                return int(s)
+            return s
+
+        defaults: Dict[str, Any] = {}
+
+
+        # Match {{ var['key'] | default(value) }} and {{ var | default(value) }}
+        pattern_subscript = r'\{\{\s*(\w+)\s*\[\s*["\']([^"\']+)["\']\s*\]\s*\|\s*default\(([^)]+)\)\s*\}\}'
+        for var, key, default_str in re.findall(pattern_subscript, template_content):
+            if var not in defaults or not isinstance(defaults[var], dict):
+                defaults[var] = {}
+            defaults[var][key] = _parse_literal(default_str)
+
+        pattern_scalar = r'\{\{\s*(\w+)\s*\|\s*default\(([^)]+)\)\s*\}\}'
+        for var, default_str in re.findall(pattern_scalar, template_content):
+            # Only set scalar default if not already set as a dict
+            if var not in defaults:
+                defaults[var] = _parse_literal(default_str)
+
+        # Handle simple {% set name = other | default('val') %} patterns
+        set_pattern = r"\{%\s*set\s+(\w+)\s*=\s*([^%]+?)\s*%}"
+        for set_var, expr in re.findall(set_pattern, template_content):
+            m = re.match(r"(\w+)\s*\|\s*default\(([^)]+)\)", expr.strip())
+            if m:
+                src_var, src_default = m.groups()
+                if src_var in defaults:
+                    defaults[set_var] = defaults[src_var]
+                else:
+                    defaults[set_var] = _parse_literal(src_default)
+
+        # Resolve transitive references: if a default is an identifier that
+        # points to another default, follow it; if it points to a declared
+        # variable with a metadata default, use that.
+        def _resolve_ref(value, seen: Set[str]):
+            if not isinstance(value, str):
+                return value
+            if value in seen:
+                return value
+            seen.add(value)
+            if value in defaults:
+                return _resolve_ref(defaults[value], seen)
+            if value in self._declared:
+                declared_def = self._declared[value][1].get("default")
+                if declared_def is not None:
+                    return declared_def
+            return value
+
+        for k in list(defaults.keys()):
+            defaults[k] = _resolve_ref(defaults[k], set([k]))
+
+        return defaults
+
+    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():
+            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
+            if any(var in used for var in vars_map.keys()):
+                matched_sets.append(set_name)
+        return matched_sets, used
+
+    def collect_values(self, used_vars: Set[str], template_defaults: Dict[str, Any] = None, used_subscripts: Dict[str, Set[str]] = None) -> Dict[str, Any]:
+        """Interactively prompt for values for the variables that appear in the template.
+
+        For variables that were declared in `variable_sets` we use their metadata.
+        For unknown variables, we fall back to a generic prompt.
+        """
+        prompt_handler = PromptHandler(self._declared, getattr(self, "variable_sets", {}))
+        return prompt_handler.collect_values(used_vars, template_defaults, used_subscripts)

+ 172 - 23
cli/modules/compose/commands.py

@@ -7,70 +7,219 @@ import typer
 from pathlib import Path
 from rich.console import Console
 from rich.table import Table
+from typing import List, Optional, Set, Dict, Any
 
 from ...core.command import BaseModule
 from ...core.helpers import find_boilerplates
+from .variables import ComposeVariables
 
 
 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"
+
     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())
+    
+    def _get_variable_details(self) -> Dict[str, Dict[str, Any]]:
+        """Get detailed information about variables for display."""
+        variables = ComposeVariables()
+        details = {}
+        for var_name, (set_name, var_meta) in variables._declared.items():
+            details[var_name] = {
+                'set': set_name,
+                'type': var_meta.get('type', 'str'),
+                'display_name': var_meta.get('display_name', var_name),
+                'default': var_meta.get('default'),
+                'prompt': var_meta.get('prompt', '')
+            }
+        return details
+
     def _add_module_commands(self, app: typer.Typer) -> None:
         """Add Module-specific commands to the app."""
 
         @app.command("list", help="List all compose boilerplates")
         def list():
             """List all compose boilerplates from library/compose directory."""
-            # Get the library/compose path
-            library_path = Path(__file__).parent.parent.parent.parent / "library" / "compose"
-            
-            # Define the compose file names to search for
-            compose_filenames = ["compose.yaml", "docker-compose.yaml", "compose.yml", "docker-compose.yml"]
-            
-            # Find all boilerplates
-            bps = find_boilerplates(library_path, compose_filenames)
-            
+            bps = find_boilerplates(self.library_path, self.compose_filenames)
             if not bps:
-                console = Console()
-                console.print("[yellow]No compose boilerplates found.[/yellow]")
+                self.console.print("[yellow]No compose boilerplates found.[/yellow]")
                 return
-            
-            # Create a rich table
-            console = Console()
             table = Table(title="🐳 Available Compose Boilerplates", title_style="bold blue")
-            
             table.add_column("Name", style="cyan", no_wrap=True)
             table.add_column("Module", style="magenta")
             table.add_column("Path", style="green")
             table.add_column("Size", justify="right", style="yellow")
             table.add_column("Description", style="dim")
-            
             for bp in bps:
-                # Format file size
                 if bp.size < 1024:
                     size_str = f"{bp.size} B"
                 elif bp.size < 1024 * 1024:
                     size_str = f"{bp.size // 1024} KB"
                 else:
                     size_str = f"{bp.size // (1024 * 1024)} MB"
-                
                 table.add_row(
                     bp.name,
                     bp.module,
-                    str(bp.file_path.relative_to(library_path)),
+                    str(bp.file_path.relative_to(self.library_path)),
                     size_str,
                     bp.description[:50] + "..." if len(bp.description) > 50 else bp.description
                 )
-            
-            console.print(table)
+            self.console.print(table)
 
         @app.command("show", help="Show details about a compose boilerplate")
-        def show(name: str):
-            pass
+        def show(name: str, raw: bool = typer.Option(False, "--raw", help="Output only the raw boilerplate content")):
+            """Show details about a compose boilerplate by name."""
+            bps = find_boilerplates(self.library_path, self.compose_filenames)
+            bp = next((b for b in bps if b.name.lower() == name.lower()), None)
+            if not bp:
+                self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
+                return
+            if raw:
+                # Output only the raw boilerplate content
+                print(bp.content)
+                return
+            # Print frontmatter info in a clever way
+            table = Table(title=f"🐳 Boilerplate: {bp.name}", title_style="bold blue")
+            table.add_column("Field", style="cyan", no_wrap=True)
+            table.add_column("Value", style="green")
+            info = bp.to_dict()
+            for key, value in info.items():
+                if isinstance(value, List):
+                    value = ", ".join(str(v) for v in value)
+                table.add_row(key.title(), str(value))
+            self.console.print(table)
+
+            # Show the content of the boilerplate file in a cleaner form
+            from rich.panel import Panel
+            from rich.syntax import Syntax
+            self.console.print()  # Add spacing
+
+            # Use syntax highlighting for YAML files
+            syntax = Syntax(bp.content, "yaml", theme="monokai", line_numbers=True, word_wrap=True)
+            panel = Panel(syntax, title=f"{bp.file_path.name}", border_style="blue", padding=(1,2))
+            self.console.print(panel)
 
         @app.command("search", help="Search compose boilerplates")
         def search(query: str):
             pass
+
+        @app.command("generate", help="Generate a compose file from a boilerplate and write to --out")
+        def generate(
+            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)")
+        ):
+            """Render a compose boilerplate interactively and write output to --out."""
+            bps = find_boilerplates(self.library_path, self.compose_filenames)
+            bp = next((b for b in bps if b.name.lower() == name.lower()), None)
+            if not bp:
+                self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
+                raise typer.Exit(code=1)
+
+            cv = ComposeVariables()
+            matched_sets, used_vars = cv.determine_variable_sets(bp.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 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)
+                    raise typer.Exit(code=2)
+
+                template_defaults = cv.extract_template_defaults(bp.content)
+                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)
+                template = env.from_string(bp.content)
+                rendered = template.render(**values_dict)
+
+            # If --out not provided, print to console; else write to file
+
+            if out is None:
+                from rich.panel import Panel
+                from rich.syntax import Syntax
+
+                syntax = Syntax(rendered, "yaml", theme="monokai", line_numbers=False, word_wrap=True)
+                panel = Panel(syntax, title=f"{bp.name}", border_style="green", padding=(1,2))
+                self.console.print(panel)
+            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]")

+ 50 - 0
cli/modules/compose/variables.py

@@ -0,0 +1,50 @@
+from typing import Dict, Any
+from ...core.variables import BaseVariables
+
+
+class ComposeVariables(BaseVariables):
+    """Compose-specific variable sets declaration.
+
+    Each entry in `variable_sets` is now a mapping with a `prompt` to ask
+    whether the set should be applied and a `variables` mapping containing
+    the individual variable definitions.
+    """
+
+    variable_sets: Dict[str, Dict[str, Any]] = {
+        "general": {
+            "always": True,
+            "prompt": "Do you want to change the general settings?",
+            "variables": {
+                    "service_name": {"display_name": "Service name", "default": None, "type": "str", "prompt": "Enter service name"},
+                    "service_port": {
+                        "display_name": "Service port",
+                        "type": "int",
+                        "prompt": "Enter service port(s)",
+                    },
+                    "container_name": {"display_name": "Container name", "default": "", "type": "str", "prompt": "Enter container name"},
+                    "docker_network": {"display_name": "Docker network", "default": "bridge", "type": "str", "prompt": "Enter Docker network name"},
+            },
+        },
+        "swarm": {
+            "prompt_enable": "Do you want to enable swarm mode?",
+            "prompt": "Do you want to change the Swarm settings?",
+            "variables": {
+                "swarm_replicas": {"display_name": "Number of replicas", "default": 1, "type": "int", "prompt": "Enter number of replicas"},
+            },
+        },
+        "traefik": {
+            "prompt_enable": "Do you want to add Traefik labels?",
+            "prompt": "Do you want to change the Traefik labels?",
+            "variables": {
+                "traefik_enable": {"display_name": "Enable Traefik", "default": True, "type": "bool", "prompt": "Enable Traefik routing for this service?"},
+                "traefik_router_name": {"display_name": "Router name", "default": "", "type": "str", "prompt": "Enter router name (leave empty to use service name)"},
+                "traefik_entrypoints": {"display_name": "Entrypoints", "default": "websecure", "type": "str", "prompt": "Enter entrypoints (comma-separated, e.g., websecure)"},
+                "traefik_rule": {"display_name": "Routing rule", "default": "", "type": "str", "prompt": "Enter routing rule (e.g., Host(`example.com`))"},
+                "traefik_tls": {"display_name": "Enable TLS", "default": True, "type": "bool", "prompt": "Enable TLS for this router?"},
+                "traefik_cert_resolver": {"display_name": "Certificate resolver", "default": "cloudflare", "type": "str", "prompt": "Enter certificate resolver name"},
+                "traefik_service_port": {"display_name": "Service port", "default": 80, "type": "int", "prompt": "Enter the internal port the service listens on"},
+                "traefik_middlewares": {"display_name": "Middlewares", "default": "", "type": "str", "prompt": "Enter middlewares (comma-separated, leave empty for none)"},
+                "traefik_priority": {"display_name": "Router priority", "default": "", "type": "str", "prompt": "Enter router priority (leave empty for default)"},
+            },
+        },
+    }

+ 20 - 6
library/compose/nginx/compose.yaml

@@ -1,14 +1,25 @@
 ---
 services:
-  nginx:
+  {{ service_name | default('nginx') }}:
     image: docker.io/library/nginx:1.28.0-alpine
-    container_name: nginx
+    {% if not swarm %}
+    container_name: {{ container_name | default('nginx') }}
+    {% endif %}
+    {% if swarm %}
+    deploy:
+      replicas: {{ swarm_replicas | default(1) }}
+      restart_policy:
+        condition: on-failure
+    {% endif %}
+    {% if not traefik %}
     ports:
-      - 80:80
-      - 443:443
+      - "{{ service_port['http'] | default(8080) }}:80"
+      - "{{ service_port['https'] | default(8443) }}:443"
+    {% endif %}
     volumes:
       - ./config/default.conf:/etc/nginx/conf.d/default.conf:ro
       - ./data:/usr/share/nginx/html:ro
+    {% if traefik %}
     labels:
       - traefik.enable=true
       - traefik.http.services.nginx.loadbalancer.server.port=80
@@ -17,10 +28,13 @@ services:
       - traefik.http.routers.nginx.tls=true
       - traefik.http.routers.nginx.tls.certresolver=cloudflare
       - traefik.http.routers.nginx.service=nginx
+    {% endif %}
     networks:
-      - frontend
+      - {{ docker_network | default('bridge') }}
+    {% if not swarm %}
     restart: unless-stopped
+    {% endif %}
 
 networks:
-  frontend:
+  {{ docker_network | default('bridge') }}:
     external: true

+ 11 - 3
library/compose/whoami/compose.yaml

@@ -1,5 +1,5 @@
 ---
-name: "Whoami Service"
+name: "Whoami"
 description: "Simple HTTP service that returns information about the request"
 version: "1.0.0"
 author: "Your Name"
@@ -11,8 +11,16 @@ tags:
 category: "utilities"
 ---
 services:
-  {{ service_name }}:
+  {{ service_name | default('whoami') }}:
     image: traefik/whoami
+    container_name: {{ container_name | default('whoami') }}
+    {% if swarm %}
+    deploy:
+      replicas: {{ swarm_replicas | default(1) }}
+      restart_policy:
+        condition: on-failure
+    {% endif %}
     ports:
-      - "8080:80"
+      - "{{ service_port['http'] | default(8080) }}:80"
+      - "{{ service_port['https'] | default(8443) }}:443"
     restart: unless-stopped

+ 1 - 0
requirements.txt

@@ -2,3 +2,4 @@ typer[all]>=0.9.0
 rich>=13.0.0
 PyYAML>=6.0
 python-frontmatter>=1.0.0
+Jinja2>=3.0