Pārlūkot izejas kodu

feat(library): support multiple libraries with git and static types (#1314)

- Add support for multiple library sources (git-based and static/local)
- Implement library priority system (first in config = highest priority)
- Add duplicate template handling with qualified IDs (e.g., alloy.default, alloy.local)
- Enhance LibraryManager with sparse-checkout for git libraries
- Update AGENTS.md documentation with library architecture details
- Improve error handling and validation for library configurations
- Add DisplayManager methods for library-related output
- Extend RepoManager to handle multiple git repositories

Closes #1304
Christian Lempa 4 mēneši atpakaļ
vecāks
revīzija
ef6a7a68d1
8 mainītis faili ar 558 papildinājumiem un 129 dzēšanām
  1. 100 11
      AGENTS.md
  2. 123 44
      cli/core/config.py
  3. 30 5
      cli/core/display.py
  4. 12 0
      cli/core/exceptions.py
  5. 122 35
      cli/core/library.py
  6. 63 5
      cli/core/module.py
  7. 84 25
      cli/core/repo.py
  8. 24 4
      cli/core/template.py

+ 100 - 11
AGENTS.md

@@ -60,25 +60,69 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 - `cli/core/prompt.py` - Interactive CLI prompts using rich library
 - `cli/core/registry.py` - Central registry for module classes (auto-discovers modules)
 - `cli/core/repo.py` - Repository management for syncing git-based template libraries
-- `cli/core/sections.py` - Dataclass for VariableSection (stores section metadata and variables)
+- `cli/core/section.py` - Dataclass for VariableSection (stores section metadata and variables)
 - `cli/core/template.py` - Template Class for parsing, managing and rendering templates
-- `cli/core/variables.py` - Dataclass for Variable (stores variable metadata and values)
+- `cli/core/variable.py` - Dataclass for Variable (stores variable metadata and values)
+- `cli/core/validators.py` - Semantic validators for template content (Docker Compose, YAML, etc.)
 
 ### Modules
 
-- `cli/modules/compose.py` - Docker Compose-specific functionality
-**(Work in Progress)**
-- `cli/modules/terraform.py` - Terraform-specific functionality
-- `cli/modules/docker.py` - Docker-specific functionality
-- `cli/modules/ansible.py` - Ansible-specific functionality
-- `cli/modules/kubernetes.py` - Kubernetes-specific functionality
-- `cli/modules/packer.py` - Packer-specific functionality
+**Creating Modules:**
+- Subclass `Module` from `cli/core/module.py`
+- Define `name` and `description` class attributes
+- Optional: Define module-wide `spec` for default variables (common across all templates)
+- Call `registry.register(YourModule)` at module bottom
+- Auto-discovered and registered at CLI startup
+
+**Module Spec:**
+Optional class attribute for module-wide variable defaults. Example:
+```python
+spec = VariableCollection.from_dict({
+  "general": {"vars": {"common_var": {"type": "str", "default": "value"}}},
+  "networking": {"title": "Network", "toggle": "net_enabled", "vars": {...}}
+})
+```
+
+**Existing Modules:**
+- `cli/modules/compose.py` - Docker Compose (defines extensive module spec with traefik, database, email, authentik sections)
+
+**(Work in Progress):** terraform, docker, ansible, kubernetes, packer modules
 
 ### LibraryManager
 
 - Loads libraries from config file
 - Stores Git Libraries under: `~/.config/boilerplates/libraries/{name}/`
 - Uses sparse-checkout to clone only template directories for git-based libraries (avoiding unnecessary files)
+- Supports two library types: **git** (synced from repos) and **static** (local directories)
+- Priority determined by config order (first = highest)
+
+**Library Types:**
+- `git`: Requires `url`, `branch`, `directory` fields
+- `static`: Requires `path` field (absolute or relative to config)
+
+**Duplicate Handling:**
+- Within same library: Raises `DuplicateTemplateError`
+- Across libraries: Uses qualified IDs (e.g., `alloy.default`, `alloy.local`)
+- Simple IDs use priority: `compose show alloy` loads from first library
+- Qualified IDs target specific library: `compose show alloy.local`
+
+**Config Example:**
+```yaml
+libraries:
+  - name: default       # Highest priority (checked first)
+    type: git
+    url: https://github.com/user/templates.git
+    branch: main
+    directory: library
+  - name: local         # Lower priority
+    type: static
+    path: ~/my-templates
+    url: ''             # Backward compatibility fields
+    branch: main
+    directory: .
+```
+
+**Note:** Static libraries include dummy `url`/`branch`/`directory` fields for backward compatibility with older CLI versions.
 
 ### ConfigManager
 
@@ -137,7 +181,23 @@ spec:
 3. User `config.yaml` (overrides template and module defaults)
 4. CLI `--var` (highest priority)
 
-**Key Features:**
+**Variable Types:**
+- `str` (default), `int`, `float`, `bool`
+- `email` - Email validation with regex
+- `url` - URL validation (requires scheme and host)
+- `hostname` - Hostname/domain validation
+- `enum` - Choice from `options` list
+
+**Variable Properties:**
+- `sensitive: true` - Masked in prompts/display (e.g., passwords)
+- `autogenerated: true` - Auto-generates value if empty (shows `*auto` placeholder)
+- `default` - Default value
+- `description` - Variable description
+- `prompt` - Custom prompt text (overrides description)
+- `extra` - Additional help text
+- `options` - List of valid values (for enum type)
+
+**Section Features:**
 - **Required Sections**: Mark with `required: true` (general is implicit). Users must provide all values.
 - **Toggle Settings**: Conditional sections via `toggle: "bool_var_name"`. If false, section is skipped.
 - **Dependencies**: Use `needs: "section_name"` or `needs: ["sec1", "sec2"]`. Dependent sections only shown when dependencies are enabled. Auto-validated (detects circular/missing/self dependencies). Topologically sorted.
@@ -171,13 +231,42 @@ spec:
         default: myresolver
 ```
 
+## Validation
+
+**Jinja2 Validation:**
+- Templates validated for Jinja2 syntax errors during load
+- Checks for undefined variables (variables used but not declared in spec)
+- Built into Template class
+
+**Semantic Validation:**
+- Validator registry system in `cli/core/validators.py`
+- Extensible: `ContentValidator` abstract base class
+- Built-in validators: `DockerComposeValidator`, `YAMLValidator`
+- Validates rendered output (YAML structure, Docker Compose schema, etc.)
+- Triggered via `compose validate` command with `--semantic` flag (enabled by default)
+
 ## Prompt
 
 Uses `rich` library for interactive prompts. Supports:
 - Text input
-- Password input (masked)
+- Password input (masked, for `sensitive: true` variables)
 - Selection from list (single/multiple)
 - Confirmation (yes/no)
 - Default values
+- Autogenerated variables (show `*auto` placeholder, generate on render)
 
 To skip the prompt use the `--no-interactive` flag, which will use defaults or empty values.
+
+## Commands
+
+**Standard Module Commands** (auto-registered for all modules):
+- `list` - List all templates
+- `search <query>` - Search templates by ID
+- `show <id>` - Show template details
+- `generate <id> [directory]` - Generate from template (supports `--dry-run`, `--var`, `--no-interactive`)
+- `validate [id]` - Validate templates (Jinja2 + semantic)
+- `defaults` - Manage config defaults (`get`, `set`, `rm`, `clear`, `list`)
+
+**Core Commands:**
+- `repo sync` - Sync git-based libraries
+- `repo list` - List configured libraries

+ 123 - 44
cli/core/config.py

@@ -66,6 +66,7 @@ class ConfigManager:
             "libraries": [
                 {
                     "name": "default",
+                    "type": "git",
                     "url": "https://github.com/christianlempa/boilerplates.git",
                     "branch": "main",
                     "directory": "library",
@@ -77,7 +78,7 @@ class ConfigManager:
         logger.info(f"Created default configuration at {self.config_path}")
     
     def _migrate_config_if_needed(self) -> None:
-        """Migrate existing config to add missing sections like libraries."""
+        """Migrate existing config to add missing sections and library types."""
         try:
             config = self._read_config()
             needs_migration = False
@@ -88,6 +89,7 @@ class ConfigManager:
                 config["libraries"] = [
                     {
                         "name": "default",
+                        "type": "git",
                         "url": "https://github.com/christianlempa/boilerplates.git",
                         "branch": "refactor/boilerplates-v2",
                         "directory": "library",
@@ -95,11 +97,20 @@ class ConfigManager:
                     }
                 ]
                 needs_migration = True
+            else:
+                # Migrate existing libraries to add 'type' field if missing
+                # For backward compatibility, assume all old libraries without 'type' are git libraries
+                libraries = config.get("libraries", [])
+                for library in libraries:
+                    if "type" not in library:
+                        logger.info(f"Migrating library '{library.get('name', 'unknown')}': adding type: git")
+                        library["type"] = "git"
+                        needs_migration = True
             
             # Write back if migration was needed
             if needs_migration:
                 self._write_config(config)
-                logger.info("Config migration completed")
+                logger.info("Config migration completed successfully")
         except Exception as e:
             logger.warning(f"Config migration failed: {e}")
     
@@ -354,24 +365,48 @@ class ConfigManager:
                 if not isinstance(library, dict):
                     raise ConfigValidationError(f"Library at index {i} must be a dictionary")
                 
-                # Validate required fields
-                required_fields = ["name", "url", "directory"]
-                for field in required_fields:
-                    if field not in library:
-                        raise ConfigValidationError(f"Library at index {i} missing required field '{field}'")
-                    
-                    if not isinstance(library[field], str):
-                        raise ConfigValidationError(f"Library '{field}' at index {i} must be a string")
+                # Validate name field (required for all library types)
+                if "name" not in library:
+                    raise ConfigValidationError(f"Library at index {i} missing required field 'name'")
+                if not isinstance(library["name"], str):
+                    raise ConfigValidationError(f"Library 'name' at index {i} must be a string")
+                self._validate_string_length(library["name"], f"Library 'name' at index {i}", max_length=500)
+                
+                # Validate type field (default to "git" for backward compatibility)
+                lib_type = library.get("type", "git")
+                if lib_type not in ("git", "static"):
+                    raise ConfigValidationError(f"Library type at index {i} must be 'git' or 'static', got '{lib_type}'")
+                
+                # Type-specific validation
+                if lib_type == "git":
+                    # Git libraries require: url, directory
+                    required_fields = ["url", "directory"]
+                    for field in required_fields:
+                        if field not in library:
+                            raise ConfigValidationError(f"Git library at index {i} missing required field '{field}'")
+                        
+                        if not isinstance(library[field], str):
+                            raise ConfigValidationError(f"Library '{field}' at index {i} must be a string")
+                        
+                        self._validate_string_length(library[field], f"Library '{field}' at index {i}", max_length=500)
                     
-                    self._validate_string_length(library[field], f"Library '{field}' at index {i}", max_length=500)
+                    # Validate optional branch field
+                    if "branch" in library:
+                        if not isinstance(library["branch"], str):
+                            raise ConfigValidationError(f"Library 'branch' at index {i} must be a string")
+                        self._validate_string_length(library["branch"], f"Library 'branch' at index {i}", max_length=200)
                 
-                # Validate optional branch field
-                if "branch" in library:
-                    if not isinstance(library["branch"], str):
-                        raise ConfigValidationError(f"Library 'branch' at index {i} must be a string")
-                    self._validate_string_length(library["branch"], f"Library 'branch' at index {i}", max_length=200)
+                elif lib_type == "static":
+                    # Static libraries require: path
+                    if "path" not in library:
+                        raise ConfigValidationError(f"Static library at index {i} missing required field 'path'")
+                    
+                    if not isinstance(library["path"], str):
+                        raise ConfigValidationError(f"Library 'path' at index {i} must be a string")
+                    
+                    self._validate_path_string(library["path"], f"Library 'path' at index {i}")
                 
-                # Validate optional enabled field
+                # Validate optional enabled field (applies to all types)
                 if "enabled" in library and not isinstance(library["enabled"], bool):
                     raise ConfigValidationError(f"Library 'enabled' at index {i} must be a boolean")
     
@@ -630,59 +665,103 @@ class ConfigManager:
                 return library
         return None
     
-    def add_library(self, name: str, url: str, directory: str = "library", branch: str = "main", enabled: bool = True) -> None:
+    def add_library(
+        self,
+        name: str,
+        library_type: str = "git",
+        url: Optional[str] = None,
+        directory: Optional[str] = None,
+        branch: str = "main",
+        path: Optional[str] = None,
+        enabled: bool = True
+    ) -> None:
         """Add a new library to the configuration.
         
         Args:
             name: Unique name for the library
-            url: Git repository URL
-            directory: Directory within the repo containing templates
-            branch: Git branch to use
+            library_type: Type of library ("git" or "static")
+            url: Git repository URL (required for git type)
+            directory: Directory within repo (required for git type)
+            branch: Git branch (for git type)
+            path: Local path to templates (required for static type)
             enabled: Whether the library is enabled
             
         Raises:
             ConfigValidationError: If library with the same name already exists or validation fails
         """
-        # Validate inputs
+        # Validate name
         if not isinstance(name, str) or not name:
             raise ConfigValidationError("Library name must be a non-empty string")
         
         self._validate_string_length(name, "Library name", max_length=100)
         
-        if not isinstance(url, str) or not url:
-            raise ConfigValidationError("Library URL must be a non-empty string")
-        
-        self._validate_string_length(url, "Library URL", max_length=500)
-        
-        if not isinstance(directory, str) or not directory:
-            raise ConfigValidationError("Library directory must be a non-empty string")
-        
-        self._validate_string_length(directory, "Library directory", max_length=200)
-        
-        if not isinstance(branch, str) or not branch:
-            raise ConfigValidationError("Library branch must be a non-empty string")
-        
-        self._validate_string_length(branch, "Library branch", max_length=200)
+        # Validate type
+        if library_type not in ("git", "static"):
+            raise ConfigValidationError(f"Library type must be 'git' or 'static', got '{library_type}'")
         
         # Check if library already exists
         if self.get_library_by_name(name):
             raise ConfigValidationError(f"Library '{name}' already exists")
         
+        # Type-specific validation and config building
+        if library_type == "git":
+            if not url:
+                raise ConfigValidationError("Git libraries require 'url' parameter")
+            if not directory:
+                raise ConfigValidationError("Git libraries require 'directory' parameter")
+            
+            # Validate git-specific fields
+            if not isinstance(url, str) or not url:
+                raise ConfigValidationError("Library URL must be a non-empty string")
+            self._validate_string_length(url, "Library URL", max_length=500)
+            
+            if not isinstance(directory, str) or not directory:
+                raise ConfigValidationError("Library directory must be a non-empty string")
+            self._validate_string_length(directory, "Library directory", max_length=200)
+            
+            if not isinstance(branch, str) or not branch:
+                raise ConfigValidationError("Library branch must be a non-empty string")
+            self._validate_string_length(branch, "Library branch", max_length=200)
+            
+            library_config = {
+                "name": name,
+                "type": "git",
+                "url": url,
+                "branch": branch,
+                "directory": directory,
+                "enabled": enabled
+            }
+        
+        else:  # static
+            if not path:
+                raise ConfigValidationError("Static libraries require 'path' parameter")
+            
+            # Validate static-specific fields
+            if not isinstance(path, str) or not path:
+                raise ConfigValidationError("Library path must be a non-empty string")
+            self._validate_path_string(path, "Library path")
+            
+            # For backward compatibility with older CLI versions,
+            # add dummy values for git-specific fields
+            library_config = {
+                "name": name,
+                "type": "static",
+                "url": "",  # Empty string for backward compatibility
+                "branch": "main",  # Default value for backward compatibility
+                "directory": ".",  # Default value for backward compatibility
+                "path": path,
+                "enabled": enabled
+            }
+        
         config = self._read_config()
         
         if "libraries" not in config:
             config["libraries"] = []
         
-        config["libraries"].append({
-            "name": name,
-            "url": url,
-            "branch": branch,
-            "directory": directory,
-            "enabled": enabled
-        })
+        config["libraries"].append(library_config)
         
         self._write_config(config)
-        logger.info(f"Added library '{name}'")
+        logger.info(f"Added {library_type} library '{name}'")
     
     def remove_library(self, name: str) -> None:
         """Remove a library from the configuration.

+ 30 - 5
cli/core/display.py

@@ -54,6 +54,8 @@ class IconManager:
     UI_SETTINGS = "\uf013"          # 
     UI_ARROW_RIGHT = "\uf061"       #  (arrow-right)
     UI_BULLET = "\uf111"            #  (circle)
+    UI_LIBRARY_GIT = "\uf418"       #  (git icon)
+    UI_LIBRARY_STATIC = "\uf07c"    #  (folder icon)
     
     @classmethod
     def get_file_icon(cls, file_path: str | Path) -> str:
@@ -173,7 +175,7 @@ class DisplayManager:
     def display_templates_table(
         self, templates: list, module_name: str, title: str
     ) -> None:
-        """Display a table of templates.
+        """Display a table of templates with library type indicators.
         
         Args:
             templates: List of Template objects
@@ -197,9 +199,22 @@ class DisplayManager:
             tags_list = template.metadata.tags or []
             tags = ", ".join(tags_list) if tags_list else "-"
             version = str(template.metadata.version) if template.metadata.version else ""
-            library = template.metadata.library or ""
+            
+            # Show library with type indicator and color
+            library_name = template.metadata.library or ""
+            library_type = template.metadata.library_type or "git"
+            
+            if library_type == "static":
+                # Static libraries: yellow/amber color with folder icon
+                library_display = f"[yellow]{IconManager.UI_LIBRARY_STATIC} {library_name}[/yellow]"
+            else:
+                # Git libraries: blue color with git icon
+                library_display = f"[blue]{IconManager.UI_LIBRARY_GIT} {library_name}[/blue]"
+            
+            # Display qualified ID if present (e.g., "alloy.default")
+            display_id = template.id
 
-            table.add_row(template.id, name, tags, version, library)
+            table.add_row(display_id, name, tags, version, library_display)
 
         console.print(table)
 
@@ -275,13 +290,23 @@ class DisplayManager:
         self.display_message('info', message, context)
 
     def _display_template_header(self, template: Template, template_id: str) -> None:
-        """Display the header for a template."""
+        """Display the header for a template with library information."""
         template_name = template.metadata.name or "Unnamed Template"
         version = str(template.metadata.version) if template.metadata.version else "Not specified"
         description = template.metadata.description or "No description available"
+        
+        # Get library information
+        library_name = template.metadata.library or ""
+        library_type = template.metadata.library_type or "git"
+        
+        # Format library display with icon and color
+        if library_type == "static":
+            library_display = f"[yellow]{IconManager.UI_LIBRARY_STATIC} {library_name}[/yellow]"
+        else:
+            library_display = f"[blue]{IconManager.UI_LIBRARY_GIT} {library_name}[/blue]"
 
         console.print(
-            f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan])[/bold blue]"
+            f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan]) {library_display}[/bold blue]"
         )
         console.print(description)
 

+ 12 - 0
cli/core/exceptions.py

@@ -39,6 +39,18 @@ class TemplateNotFoundError(TemplateError):
         super().__init__(msg)
 
 
+class DuplicateTemplateError(TemplateError):
+    """Raised when duplicate template IDs are found within the same library."""
+    
+    def __init__(self, template_id: str, library_name: str):
+        self.template_id = template_id
+        self.library_name = library_name
+        super().__init__(
+            f"Duplicate template ID '{template_id}' found in library '{library_name}'. "
+            f"Each template within a library must have a unique ID."
+        )
+
+
 class TemplateLoadError(TemplateError):
     """Raised when a template fails to load."""
     pass

+ 122 - 35
cli/core/library.py

@@ -5,7 +5,7 @@ import logging
 from typing import Optional
 import yaml
 
-from .exceptions import LibraryError, TemplateNotFoundError, YAMLParseError
+from .exceptions import LibraryError, TemplateNotFoundError, YAMLParseError, DuplicateTemplateError
 
 logger = logging.getLogger(__name__)
 
@@ -13,17 +13,22 @@ logger = logging.getLogger(__name__)
 class Library:
   """Represents a single library with a specific path."""
   
-  def __init__(self, name: str, path: Path, priority: int = 0) -> None:
+  def __init__(self, name: str, path: Path, priority: int = 0, library_type: str = "git") -> None:
     """Initialize a library instance.
     
     Args:
       name: Display name for the library
       path: Path to the library directory
       priority: Priority for library lookup (higher = checked first)
+      library_type: Type of library ("git" or "static")
     """
+    if library_type not in ("git", "static"):
+      raise ValueError(f"Invalid library type: {library_type}. Must be 'git' or 'static'.")
+    
     self.name = name
     self.path = path
     self.priority = priority  # Higher priority = checked first
+    self.library_type = library_type
   
   def _is_template_draft(self, template_path: Path) -> bool:
     """Check if a template is marked as draft."""
@@ -97,12 +102,20 @@ class Library:
     if not module_path.is_dir():
       raise LibraryError(f"Module '{module_name}' not found in library '{self.name}'")
     
-    # Get non-draft templates
+    # Track seen IDs to detect duplicates within this library
+    seen_ids = {}
     template_dirs = []
     try:
       for item in module_path.iterdir():
         has_template = item.is_dir() and any((item / f).exists() for f in ("template.yaml", "template.yml"))
         if has_template and not self._is_template_draft(item):
+          template_id = item.name
+          
+          # Check for duplicate within same library
+          if template_id in seen_ids:
+            raise DuplicateTemplateError(template_id, self.name)
+          
+          seen_ids[template_id] = True
           template_dirs.append((item, self.name))
         elif has_template:
           logger.debug(f"Skipping draft template: {item.name}")
@@ -145,28 +158,55 @@ class LibraryManager:
         continue
       
       name = lib_config.get("name")
-      directory = lib_config.get("directory", ".")
+      lib_type = lib_config.get("type", "git")  # Default to "git" for backward compat
+      
+      # Handle library type-specific path resolution
+      if lib_type == "git":
+        # Existing git logic
+        directory = lib_config.get("directory", ".")
+        
+        # Build path to library: ~/.config/boilerplates/libraries/{name}/{directory}/
+        # For sparse-checkout, files remain in the specified directory
+        library_base = libraries_path / name
+        if directory and directory != ".":
+          library_path = library_base / directory
+        else:
+          library_path = library_base
+      
+      elif lib_type == "static":
+        # New static logic - use path directly
+        path_str = lib_config.get("path")
+        if not path_str:
+          logger.warning(f"Static library '{name}' has no path configured")
+          continue
+        
+        # Expand ~ and resolve relative paths
+        library_path = Path(path_str).expanduser()
+        if not library_path.is_absolute():
+          # Resolve relative to config directory
+          library_path = (self.config.config_path.parent / library_path).resolve()
       
-      # Build path to library: ~/.config/boilerplates/libraries/{name}/{directory}/
-      # For sparse-checkout, files remain in the specified directory
-      library_base = libraries_path / name
-      if directory and directory != ".":
-        library_path = library_base / directory
       else:
-        library_path = library_base
+        logger.warning(f"Unknown library type '{lib_type}' for library '{name}'")
+        continue
       
       # Check if library path exists
       if not library_path.exists():
-        logger.warning(
-          f"Library '{name}' not found at {library_path}. "
-          f"Run 'repo update' to sync libraries."
-        )
+        if lib_type == "git":
+          logger.warning(
+            f"Library '{name}' not found at {library_path}. "
+            f"Run 'repo update' to sync libraries."
+          )
+        else:
+          logger.warning(f"Static library '{name}' not found at {library_path}")
         continue
       
-      # Create Library instance with priority based on order (first = highest priority)
+      # Create Library instance with type and priority based on order (first = highest priority)
       priority = len(library_configs) - i
-      libraries.append(Library(name=name, path=library_path, priority=priority))
-      logger.debug(f"Loaded library '{name}' from {library_path} with priority {priority}")
+      libraries.append(
+        Library(name=name, path=library_path, priority=priority, library_type=lib_type)
+      )
+      logger.debug(f"Loaded {lib_type} library '{name}' from {library_path} with priority {priority}")
     
     if not libraries:
       logger.warning("No libraries loaded. Run 'repo update' to sync libraries.")
@@ -176,15 +216,39 @@ class LibraryManager:
   def find_by_id(self, module_name: str, template_id: str) -> Optional[tuple[Path, str]]:
     """Find a template by its ID across all libraries.
     
+    Supports both simple IDs and qualified IDs (template.library format).
+    
     Args:
         module_name: The module name (e.g., 'compose', 'terraform')
-        template_id: The template ID to find
+        template_id: The template ID to find (simple or qualified)
     
     Returns:
-        Path to the template directory if found, None otherwise
+        Tuple of (template_path, library_name) if found, None otherwise
     """
     logger.debug(f"Searching for template '{template_id}' in module '{module_name}' across all libraries")
     
+    # Check if this is a qualified ID (contains '.')
+    if '.' in template_id:
+      parts = template_id.rsplit('.', 1)
+      if len(parts) == 2:
+        base_id, requested_lib = parts
+        logger.debug(f"Parsing qualified ID: base='{base_id}', library='{requested_lib}'")
+        
+        # Try to find in the specific library
+        for library in self.libraries:
+          if library.name == requested_lib:
+            try:
+              template_path, lib_name = library.find_by_id(module_name, base_id)
+              logger.debug(f"Found template '{base_id}' in library '{requested_lib}'")
+              return template_path, lib_name
+            except TemplateNotFoundError:
+              logger.debug(f"Template '{base_id}' not found in library '{requested_lib}'")
+              return None
+        
+        logger.debug(f"Library '{requested_lib}' not found")
+        return None
+    
+    # Simple ID - search by priority
     for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
       try:
         template_path, lib_name = library.find_by_id(module_name, template_id)
@@ -197,42 +261,65 @@ class LibraryManager:
     logger.debug(f"Template '{template_id}' not found in any library")
     return None
   
-  def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str]]:
+  def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str, bool]]:
     """Find templates across all libraries for a specific module.
     
+    Handles duplicates by qualifying IDs with library names when needed.
+    
     Args:
         module_name: The module name (e.g., 'compose', 'terraform')
         sort_results: Whether to return results sorted alphabetically
     
     Returns:
-        List of Path objects representing template directories from all libraries
+        List of tuples (template_path, library_name, needs_qualification)
+        where needs_qualification is True if the template ID appears in multiple libraries
     """
     logger.debug(f"Searching for templates in module '{module_name}' across all libraries")
     
     all_templates = []
     
+    # Collect templates from all libraries
     for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
       try:
-        templates = library.find(module_name, sort_results=False)  # Sort at the end
+        templates = library.find(module_name, sort_results=False)
         all_templates.extend(templates)
         logger.debug(f"Found {len(templates)} templates in library '{library.name}'")
-      except LibraryError:
-        # Module not found in this library, continue with next
+      except (LibraryError, DuplicateTemplateError) as e:
+        # DuplicateTemplateError from library.find() should propagate up
+        if isinstance(e, DuplicateTemplateError):
+          raise
         logger.debug(f"Module '{module_name}' not found in library '{library.name}'")
         continue
     
-    # Remove duplicates based on template name (directory name)
-    seen_names = set()
-    unique_templates = []
-    for template in all_templates:
-      name, library_name = template
-      if name.name not in seen_names:
-        unique_templates.append((name, library_name))
-        seen_names.add(name.name)
+    # Track template IDs and their libraries to detect cross-library duplicates
+    id_to_occurrences = {}
+    for template_path, library_name in all_templates:
+      template_id = template_path.name
+      if template_id not in id_to_occurrences:
+        id_to_occurrences[template_id] = []
+      id_to_occurrences[template_id].append((template_path, library_name))
+    
+    # Build result with qualification markers for duplicates
+    result = []
+    for template_id, occurrences in id_to_occurrences.items():
+      if len(occurrences) > 1:
+        # Duplicate across libraries - mark for qualified IDs
+        lib_names = ', '.join(lib for _, lib in occurrences)
+        logger.info(
+          f"Template '{template_id}' found in multiple libraries: {lib_names}. "
+          f"Using qualified IDs."
+        )
+        for template_path, library_name in occurrences:
+          # Mark that this ID needs qualification
+          result.append((template_path, library_name, True))
+      else:
+        # Unique template - no qualification needed
+        template_path, library_name = occurrences[0]
+        result.append((template_path, library_name, False))
     
     # Sort if requested
     if sort_results:
-      unique_templates.sort(key=lambda x: x[0].name.lower())
+      result.sort(key=lambda x: x[0].name.lower())
     
-    logger.debug(f"Found {len(unique_templates)} unique templates total")
-    return unique_templates
+    logger.debug(f"Found {len(result)} templates total")
+    return result

+ 63 - 5
cli/core/module.py

@@ -79,9 +79,23 @@ class Module(ABC):
     templates = []
 
     entries = self.libraries.find(self.name, sort_results=True)
-    for template_dir, library_name in entries:
+    for entry in entries:
+      # Unpack entry - now returns (path, library_name, needs_qualification)
+      template_dir = entry[0]
+      library_name = entry[1]
+      needs_qualification = entry[2] if len(entry) > 2 else False
+      
       try:
-        template = Template(template_dir, library_name=library_name)
+        # Get library object to determine type
+        library = next((lib for lib in self.libraries.libraries if lib.name == library_name), None)
+        library_type = library.library_type if library else "git"
+        
+        template = Template(template_dir, library_name=library_name, library_type=library_type)
+        
+        # If template ID needs qualification, set qualified ID
+        if needs_qualification:
+          template.set_qualified_id()
+        
         templates.append(template)
       except Exception as exc:
         logger.error(f"Failed to load template from {template_dir}: {exc}")
@@ -121,9 +135,23 @@ class Module(ABC):
     templates = []
 
     entries = self.libraries.find(self.name, sort_results=True)
-    for template_dir, library_name in entries:
+    for entry in entries:
+      # Unpack entry - now returns (path, library_name, needs_qualification)
+      template_dir = entry[0]
+      library_name = entry[1]
+      needs_qualification = entry[2] if len(entry) > 2 else False
+      
       try:
-        template = Template(template_dir, library_name=library_name)
+        # Get library object to determine type
+        library = next((lib for lib in self.libraries.libraries if lib.name == library_name), None)
+        library_type = library.library_type if library else "git"
+        
+        template = Template(template_dir, library_name=library_name, library_type=library_type)
+        
+        # If template ID needs qualification, set qualified ID
+        if needs_qualification:
+          template.set_qualified_id()
+        
         templates.append(template)
       except Exception as exc:
         logger.error(f"Failed to load template from {template_dir}: {exc}")
@@ -949,13 +977,43 @@ class Module(ABC):
     logger.info(f"Module '{cls.name}' CLI commands registered")
 
   def _load_template_by_id(self, id: str) -> Template:
+    """Load a template by its ID, supporting qualified IDs.
+    
+    Supports both formats:
+    - Simple: "alloy" (uses priority system)
+    - Qualified: "alloy.default" (loads from specific library)
+    
+    Args:
+        id: Template ID (simple or qualified)
+    
+    Returns:
+        Template instance
+    
+    Raises:
+        FileNotFoundError: If template is not found
+    """
+    logger.debug(f"Loading template with ID '{id}' from module '{self.name}'")
+    
+    # find_by_id now handles both simple and qualified IDs
     result = self.libraries.find_by_id(self.name, id)
+    
     if not result:
       raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
     
     template_dir, library_name = result
+    
+    # Get library type
+    library = next((lib for lib in self.libraries.libraries if lib.name == library_name), None)
+    library_type = library.library_type if library else "git"
+    
     try:
-      return Template(template_dir, library_name=library_name)
+      template = Template(template_dir, library_name=library_name, library_type=library_type)
+      
+      # If the original ID was qualified, preserve it
+      if '.' in id:
+        template.id = id
+      
+      return template
     except Exception as exc:
       logger.error(f"Failed to load template '{id}': {exc}")
       raise FileNotFoundError(f"Template '{id}' could not be loaded: {exc}") from exc

+ 84 - 25
cli/core/repo.py

@@ -214,9 +214,7 @@ def update(
     ) as progress:
         for lib in libraries:
             name = lib.get("name")
-            url = lib.get("url")
-            branch = lib.get("branch")
-            directory = lib.get("directory", "library")
+            lib_type = lib.get("type", "git")
             enabled = lib.get("enabled", True)
             
             if not enabled:
@@ -225,6 +223,18 @@ def update(
                 results.append((name, "Skipped (disabled)", False))
                 continue
             
+            # Skip static libraries (no sync needed)
+            if lib_type == "static":
+                if verbose:
+                    console.print(f"[dim]Skipping static library: {name} (no sync needed)[/dim]")
+                results.append((name, "N/A (static)", True))
+                continue
+            
+            # Handle git libraries
+            url = lib.get("url")
+            branch = lib.get("branch")
+            directory = lib.get("directory", "library")
+            
             task = progress.add_task(f"Updating {name}...", total=None)
             
             # Target path: ~/.config/boilerplates/libraries/{name}/
@@ -274,39 +284,64 @@ def list() -> None:
     
     table = Table(title="Configured Libraries", show_header=True)
     table.add_column("Name", style="cyan", no_wrap=True)
-    table.add_column("URL", style="blue")
+    table.add_column("URL/Path", style="blue")
     table.add_column("Branch", style="yellow")
     table.add_column("Directory", style="magenta")
+    table.add_column("Type", style="cyan")
     table.add_column("Status", style="green")
     
     libraries_path = config.get_libraries_path()
     
     for lib in libraries:
         name = lib.get("name", "")
-        url = lib.get("url", "")
-        branch = lib.get("branch", "main")
-        directory = lib.get("directory", "library")
+        lib_type = lib.get("type", "git")
         enabled = lib.get("enabled", True)
         
-        # Check if library exists locally
-        library_base = libraries_path / name
-        if directory and directory != ".":
-            library_path = library_base / directory
+        if lib_type == "git":
+            url_or_path = lib.get("url", "")
+            branch = lib.get("branch", "main")
+            directory = lib.get("directory", "library")
+            
+            # Check if library exists locally
+            library_base = libraries_path / name
+            if directory and directory != ".":
+                library_path = library_base / directory
+            else:
+                library_path = library_base
+            exists = library_path.exists()
+        
+        elif lib_type == "static":
+            url_or_path = lib.get("path", "")
+            branch = "-"
+            directory = "-"
+            
+            # Check if static path exists
+            from pathlib import Path
+            library_path = Path(url_or_path).expanduser()
+            if not library_path.is_absolute():
+                library_path = (config.config_path.parent / library_path).resolve()
+            exists = library_path.exists()
+        
         else:
-            library_path = library_base
-        exists = library_path.exists()
+            # Unknown type
+            url_or_path = "<unknown type>"
+            branch = "-"
+            directory = "-"
+            exists = False
+        
+        type_display = lib_type
         
         status_parts = []
         if not enabled:
             status_parts.append("[dim]disabled[/dim]")
         elif exists:
-            status_parts.append("[green]synced[/green]")
+            status_parts.append("[green]available[/green]")
         else:
-            status_parts.append("[yellow]not synced[/yellow]")
+            status_parts.append("[yellow]not found[/yellow]")
         
         status = " ".join(status_parts)
         
-        table.add_row(name, url, branch, directory, status)
+        table.add_row(name, url_or_path, branch, directory, type_display, status)
     
     console.print(table)
 
@@ -314,23 +349,47 @@ def list() -> None:
 @app.command()
 def add(
     name: str = Argument(..., help="Unique name for the library"),
-    url: str = Argument(..., help="Git repository URL"),
-    branch: str = Option("main", "--branch", "-b", help="Git branch to use"),
-    directory: str = Option("library", "--directory", "-d", help="Directory within repo containing templates (metadata only)"),
+    library_type: str = Option("git", "--type", "-t", help="Library type (git or static)"),
+    url: Optional[str] = Option(None, "--url", "-u", help="Git repository URL (for git type)"),
+    branch: str = Option("main", "--branch", "-b", help="Git branch (for git type)"),
+    directory: str = Option("library", "--directory", "-d", help="Directory in repo (for git type)"),
+    path: Optional[str] = Option(None, "--path", "-p", help="Local path (for static type)"),
     enabled: bool = Option(True, "--enabled/--disabled", help="Enable or disable the library"),
-    sync: bool = Option(True, "--sync/--no-sync", help="Sync the library after adding")
+    sync: bool = Option(True, "--sync/--no-sync", help="Sync after adding (git only)")
 ) -> None:
-    """Add a new library to the configuration."""
+    """Add a new library to the configuration.
+    
+    Examples:
+      # Add a git library
+      repo add mylib --type git --url https://github.com/user/templates.git
+      
+      # Add a static library
+      repo add local --type static --path ~/my-templates
+    """
     config = ConfigManager()
     
     try:
-        config.add_library(name, url, directory, branch, enabled)
-        display.display_success(f"Added library '{name}'")
+        if library_type == "git":
+            if not url:
+                display.display_error("--url is required for git libraries")
+                return
+            config.add_library(name, library_type="git", url=url, branch=branch, directory=directory, enabled=enabled)
+        elif library_type == "static":
+            if not path:
+                display.display_error("--path is required for static libraries")
+                return
+            config.add_library(name, library_type="static", path=path, enabled=enabled)
+        else:
+            display.display_error(f"Invalid library type: {library_type}. Must be 'git' or 'static'.")
+            return
+        
+        display.display_success(f"Added {library_type} library '{name}'")
         
-        if sync and enabled:
+        if library_type == "git" and sync and enabled:
             console.print(f"\nSyncing library '{name}'...")
-            # Call update for this specific library
             update(library_name=name, verbose=True)
+        elif library_type == "static":
+            display.display_info(f"Static library points to: {path}")
     except ConfigError as e:
         display.display_error(str(e))
 

+ 24 - 4
cli/core/template.py

@@ -202,10 +202,11 @@ class TemplateMetadata:
   module: str = ""
   tags: List[str] = field(default_factory=list)
   library: str = "unknown"
+  library_type: str = "git"  # Type of library ("git" or "static")
   next_steps: str = ""
   draft: bool = False
 
-  def __init__(self, template_data: dict, library_name: str | None = None) -> None:
+  def __init__(self, template_data: dict, library_name: str | None = None, library_type: str = "git") -> None:
     """Initialize TemplateMetadata from parsed YAML template data.
     
     Args:
@@ -233,6 +234,7 @@ class TemplateMetadata:
     self.module = metadata_section.get("module", "")
     self.tags = metadata_section.get("tags", []) or []
     self.library = library_name or "unknown"
+    self.library_type = library_type
     self.draft = metadata_section.get("draft", False)
     
     # Extract next_steps (optional)
@@ -268,12 +270,20 @@ class TemplateMetadata:
 class Template:
   """Represents a template directory."""
 
-  def __init__(self, template_dir: Path, library_name: str) -> None:
-    """Create a Template instance from a directory path."""
+  def __init__(self, template_dir: Path, library_name: str, library_type: str = "git") -> None:
+    """Create a Template instance from a directory path.
+    
+    Args:
+        template_dir: Path to the template directory
+        library_name: Name of the library this template belongs to
+        library_type: Type of library ("git" or "static"), defaults to "git"
+    """
     logger.debug(f"Loading template from directory: {template_dir}")
     self.template_dir = template_dir
     self.id = template_dir.name
+    self.original_id = template_dir.name  # Store the original ID
     self.library_name = library_name
+    self.library_type = library_type
 
     # Initialize caches for lazy loading
     self.__module_specs: Optional[dict] = None
@@ -306,7 +316,7 @@ class Template:
         raise ValueError("Template file must contain a valid YAML dictionary")
 
       # Load metadata (always needed)
-      self.metadata = TemplateMetadata(self._template_data, library_name)
+      self.metadata = TemplateMetadata(self._template_data, library_name, library_type)
       logger.debug(f"Loaded metadata: {self.metadata}")
 
       # Validate 'kind' field (always needed)
@@ -327,6 +337,16 @@ class Template:
       logger.error(f"File I/O error loading template {template_dir}: {e}")
       raise TemplateLoadError(f"File I/O error loading template from {template_dir}: {e}")
 
+  def set_qualified_id(self, library_name: str | None = None) -> None:
+    """Set a qualified ID for this template (used when duplicates exist across libraries).
+    
+    Args:
+        library_name: Name of the library to qualify with. If None, uses self.library_name
+    """
+    lib_name = library_name or self.library_name
+    self.id = f"{self.original_id}.{lib_name}"
+    logger.debug(f"Template ID qualified: {self.original_id} -> {self.id}")
+
   def _find_main_template_file(self) -> Path:
     """Find the main template file (template.yaml or template.yml)."""
     for filename in ["template.yaml", "template.yml"]: