Coverage for src / moai_adk / core / template / processor.py: 14.26%
505 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1# # REMOVED_ORPHAN_CODE:TEMPLATE-001 | SPEC: SPEC-INIT-003/spec.md | Chain: TEMPLATE-001
2"""Enhanced Template copy and backup processor with improved version handling and validation.
4SPEC-INIT-003 v0.3.0: preserve user content
5Enhanced with:
6- Comprehensive version field management
7- Template substitution validation
8- Performance optimization
9- Error handling improvements
10- Configuration-driven behavior
11"""
13from __future__ import annotations
15import json
16import logging
17import re
18import shutil
19from dataclasses import dataclass
20from pathlib import Path
21from typing import Any, Dict, List, Optional
23from rich.console import Console
25from moai_adk.core.template.backup import TemplateBackup
26from moai_adk.core.template.merger import TemplateMerger
27from moai_adk.statusline.version_reader import VersionConfig, VersionReader
29console = Console()
32@dataclass
33class TemplateProcessorConfig:
34 """Configuration for TemplateProcessor behavior."""
36 # Version handling configuration
37 version_cache_ttl_seconds: int = 120
38 version_fallback: str = "unknown"
39 version_format_regex: str = r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$"
40 enable_version_validation: bool = True
41 preserve_user_version: bool = True
43 # Template substitution configuration
44 validate_template_variables: bool = True
45 max_variable_length: int = 50
46 allowed_variable_pattern: str = r"^[A-Z_]+$"
47 enable_substitution_warnings: bool = True
49 # Performance configuration
50 enable_caching: bool = True
51 cache_size: int = 100
52 async_operations: bool = False
54 # Error handling configuration
55 graceful_degradation: bool = True
56 verbose_logging: bool = False
58 @classmethod
59 def from_dict(cls, config_dict: Dict[str, Any]) -> "TemplateProcessorConfig":
60 """Create config from dictionary."""
61 config_dict = config_dict or {}
62 return cls(
63 version_cache_ttl_seconds=config_dict.get("version_cache_ttl_seconds", 120),
64 version_fallback=config_dict.get("version_fallback", "unknown"),
65 version_format_regex=config_dict.get(
66 "version_format_regex", r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$"
67 ),
68 enable_version_validation=config_dict.get(
69 "enable_version_validation", True
70 ),
71 preserve_user_version=config_dict.get("preserve_user_version", True),
72 validate_template_variables=config_dict.get(
73 "validate_template_variables", True
74 ),
75 max_variable_length=config_dict.get("max_variable_length", 50),
76 allowed_variable_pattern=config_dict.get(
77 "allowed_variable_pattern", r"^[A-Z_]+$"
78 ),
79 enable_substitution_warnings=config_dict.get(
80 "enable_substitution_warnings", True
81 ),
82 enable_caching=config_dict.get("enable_caching", True),
83 cache_size=config_dict.get("cache_size", 100),
84 async_operations=config_dict.get("async_operations", False),
85 graceful_degradation=config_dict.get("graceful_degradation", True),
86 verbose_logging=config_dict.get("verbose_logging", False),
87 )
90class TemplateProcessor:
91 """Orchestrate template copying and backups with enhanced version handling and validation."""
93 # User data protection paths (never touch) - SPEC-INIT-003 v0.3.0
94 PROTECTED_PATHS = [
95 ".moai/specs/", # User SPEC documents
96 ".moai/reports/", # User reports
97 ".moai/project/", # User project documents (product/structure/tech.md)
98 # config.json is now FORCE OVERWRITTEN (backup in .moai-backups/)
99 # Merge via /alfred:0-project when optimized=false
100 ]
102 # Paths excluded from backups
103 BACKUP_EXCLUDE = PROTECTED_PATHS
105 # Common template variables with validation hints
106 COMMON_TEMPLATE_VARIABLES = {
107 "PROJECT_DIR": "Cross-platform project path (run /alfred:0-project to set)",
108 "PROJECT_NAME": "Project name (run /alfred:0-project to set)",
109 "AUTHOR": "Project author (run /alfred:0-project to set)",
110 "CONVERSATION_LANGUAGE": "Interface language (run /alfred:0-project to set)",
111 "MOAI_VERSION": "MoAI-ADK version (should be set automatically)",
112 "MOAI_VERSION_SHORT": "Short MoAI-ADK version (without 'v' prefix)",
113 "MOAI_VERSION_DISPLAY": "Display version with proper formatting",
114 "MOAI_VERSION_TRIMMED": "Trimmed version for UI displays",
115 "MOAI_VERSION_SEMVER": "Semantic version format (major.minor.patch)",
116 "MOAI_VERSION_VALID": "Version validation status",
117 "MOAI_VERSION_SOURCE": "Version source information",
118 "MOAI_VERSION_CACHE_AGE": "Cache age for debugging",
119 "CREATION_TIMESTAMP": "Project creation timestamp",
120 }
122 def __init__(
123 self, target_path: Path, config: Optional[TemplateProcessorConfig] = None
124 ) -> None:
125 """Initialize the processor with enhanced configuration.
127 Args:
128 target_path: Project path.
129 config: Optional configuration for processor behavior.
130 """
131 self.target_path = target_path.resolve()
132 self.template_root = self._get_template_root()
133 self.backup = TemplateBackup(self.target_path)
134 self.merger = TemplateMerger(self.target_path)
135 self.context: dict[str, str] = {} # Template variable substitution context
136 self._version_reader: VersionReader | None = None
137 self.config = config or TemplateProcessorConfig()
138 self._substitution_cache: Dict[str, str] = {} # Cache for substitution results
139 self._variable_validation_cache: Dict[str, bool] = (
140 {}
141 ) # Cache for variable validation
142 self.logger = logging.getLogger(__name__)
144 if self.config.verbose_logging:
145 self.logger.info(
146 f"TemplateProcessor initialized with config: {self.config}"
147 )
149 def set_context(self, context: dict[str, str]) -> None:
150 """Set variable substitution context with enhanced validation.
152 Args:
153 context: Dictionary of template variables.
154 """
155 self.context = context
156 self._substitution_cache.clear() # Clear cache when context changes
157 self._variable_validation_cache.clear()
159 if self.config.verbose_logging:
160 self.logger.debug(f"Context set with {len(context)} variables")
162 # Validate template variables if enabled
163 if self.config.validate_template_variables:
164 self._validate_template_variables(context)
166 # Add deprecation mapping for HOOK_PROJECT_DIR
167 if "PROJECT_DIR" in self.context and "HOOK_PROJECT_DIR" not in self.context:
168 self.context["HOOK_PROJECT_DIR"] = self.context["PROJECT_DIR"]
170 def _get_version_reader(self) -> VersionReader:
171 """
172 Get or create version reader instance with enhanced configuration.
174 Returns:
175 VersionReader instance
176 """
177 if self._version_reader is None:
178 version_config = VersionConfig(
179 cache_ttl_seconds=self.config.version_cache_ttl_seconds,
180 fallback_version=self.config.version_fallback,
181 version_format_regex=self.config.version_format_regex,
182 debug_mode=self.config.verbose_logging,
183 )
184 self._version_reader = VersionReader(version_config)
186 if self.config.verbose_logging:
187 self.logger.info("VersionReader created with enhanced configuration")
188 return self._version_reader
190 def _validate_template_variables(self, context: Dict[str, str]) -> None:
191 """
192 Validate template variables with comprehensive checking.
194 Args:
195 context: Dictionary of template variables to validate
196 """
197 import re
199 if not self.config.validate_template_variables:
200 return
202 validation_errors: List[str] = []
203 warning_messages: List[str] = []
205 # Check variable names against pattern
206 variable_pattern = re.compile(self.config.allowed_variable_pattern)
208 for var_name, var_value in context.items():
209 # Check variable name format
210 if not variable_pattern.match(var_name):
211 validation_errors.append(f"Invalid variable name format: '{var_name}'")
212 continue
214 # Check variable length
215 if len(var_name) > self.config.max_variable_length:
216 warning_messages.append(
217 f"Variable name '{var_name}' exceeds maximum length"
218 )
220 # Check variable value length
221 if len(var_value) > self.config.max_variable_length * 2:
222 warning_messages.append(
223 f"Variable value '{var_value[:20]}...' is very long"
224 )
226 # Check for potentially dangerous values
227 if "{{" in var_value or "}}" in var_value:
228 warning_messages.append(
229 f"Variable '{var_name}' contains placeholder patterns"
230 )
232 # Check for common variables that should be present
233 missing_common_vars = []
234 for common_var in self.COMMON_TEMPLATE_VARIABLES:
235 if common_var not in context:
236 missing_common_vars.append(common_var)
238 if missing_common_vars and self.config.enable_substitution_warnings:
239 warning_messages.append(
240 f"Common variables missing: {', '.join(missing_common_vars[:3])}"
241 )
243 # Report validation results
244 if validation_errors and not self.config.graceful_degradation:
245 raise ValueError(
246 f"Template variable validation failed: {validation_errors}"
247 )
249 if validation_errors and self.config.graceful_degradation:
250 self.logger.warning(
251 f"Template variable validation warnings: {validation_errors}"
252 )
254 if warning_messages and self.config.enable_substitution_warnings:
255 self.logger.warning(f"Template variable warnings: {warning_messages}")
257 if self.config.verbose_logging:
258 self.logger.debug(
259 f"Template variables validated: {len(context)} variables checked"
260 )
262 def get_enhanced_version_context(self) -> dict[str, str]:
263 """
264 Get enhanced version context with proper error handling and caching.
266 Returns comprehensive version information including multiple format options
267 and debugging information.
269 Returns:
270 Dictionary containing enhanced version-related template variables
271 """
272 version_context = {}
273 logger = logging.getLogger(__name__)
275 try:
276 version_reader = self._get_version_reader()
277 moai_version = version_reader.get_version()
279 # Basic version information
280 version_context["MOAI_VERSION"] = moai_version
281 version_context["MOAI_VERSION_SHORT"] = self._format_short_version(
282 moai_version
283 )
284 version_context["MOAI_VERSION_DISPLAY"] = self._format_display_version(
285 moai_version
286 )
288 # Enhanced formatting options
289 version_context["MOAI_VERSION_TRIMMED"] = self._format_trimmed_version(
290 moai_version, max_length=10
291 )
292 version_context["MOAI_VERSION_SEMVER"] = self._format_semver_version(
293 moai_version
294 )
296 # Validation and source information
297 version_context["MOAI_VERSION_VALID"] = (
298 "true" if moai_version != "unknown" else "false"
299 )
300 version_context["MOAI_VERSION_SOURCE"] = self._get_version_source(
301 version_reader
302 )
304 # Performance metrics
305 cache_age = version_reader.get_cache_age_seconds()
306 if cache_age is not None:
307 version_context["MOAI_VERSION_CACHE_AGE"] = f"{cache_age:.2f}s"
308 else:
309 version_context["MOAI_VERSION_CACHE_AGE"] = "uncached"
311 # Additional metadata
312 if self.config.enable_version_validation:
313 is_valid = self._is_valid_version_format(moai_version)
314 version_context["MOAI_VERSION_FORMAT_VALID"] = (
315 "true" if is_valid else "false"
316 )
318 if self.config.verbose_logging:
319 logger.debug(f"Enhanced version context generated: {version_context}")
321 except Exception as e:
322 logger.warning(f"Failed to read version for template context: {e}")
323 # Use fallback version with comprehensive formatting
324 fallback_version = self.config.version_fallback
325 version_context["MOAI_VERSION"] = fallback_version
326 version_context["MOAI_VERSION_SHORT"] = self._format_short_version(
327 fallback_version
328 )
329 version_context["MOAI_VERSION_DISPLAY"] = self._format_display_version(
330 fallback_version
331 )
332 version_context["MOAI_VERSION_TRIMMED"] = self._format_trimmed_version(
333 fallback_version, max_length=10
334 )
335 version_context["MOAI_VERSION_SEMVER"] = self._format_semver_version(
336 fallback_version
337 )
338 version_context["MOAI_VERSION_VALID"] = (
339 "false" if fallback_version == "unknown" else "true"
340 )
341 version_context["MOAI_VERSION_SOURCE"] = "fallback_config"
342 version_context["MOAI_VERSION_CACHE_AGE"] = "unavailable"
343 version_context["MOAI_VERSION_FORMAT_VALID"] = "false"
345 return version_context
347 def _is_valid_version_format(self, version: str) -> bool:
348 """
349 Validate version format using configured regex pattern.
351 Args:
352 version: Version string to validate
354 Returns:
355 True if version format is valid
356 """
357 import re
359 try:
360 pattern = re.compile(self.config.version_format_regex)
361 return bool(pattern.match(version))
362 except re.error:
363 # Fallback to default pattern if custom one is invalid
364 default_pattern = re.compile(r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$")
365 return bool(default_pattern.match(version))
367 def _format_short_version(self, version: str) -> str:
368 """
369 Format short version by removing 'v' prefix if present.
371 Args:
372 version: Version string
374 Returns:
375 Short version string
376 """
377 return version[1:] if version.startswith("v") else version
379 def _format_display_version(self, version: str) -> str:
380 """
381 Format display version with proper formatting.
383 Args:
384 version: Version string
386 Returns:
387 Display version string
388 """
389 if version == "unknown":
390 return "MoAI-ADK unknown version"
391 elif version.startswith("v"):
392 return f"MoAI-ADK {version}"
393 else:
394 return f"MoAI-ADK v{version}"
396 def _format_trimmed_version(self, version: str, max_length: int = 10) -> str:
397 """
398 Format version with maximum length, suitable for UI displays.
400 Args:
401 version: Version string
402 max_length: Maximum allowed length for the version string
404 Returns:
405 Trimmed version string
406 """
407 if version == "unknown":
408 return "unknown"
410 # Remove 'v' prefix for trimming
411 clean_version = version[1:] if version.startswith("v") else version
413 # Trim if necessary
414 if len(clean_version) > max_length:
415 return clean_version[:max_length]
416 return clean_version
418 def _format_semver_version(self, version: str) -> str:
419 """
420 Format version as semantic version with major.minor.patch structure.
422 Args:
423 version: Version string
425 Returns:
426 Semantic version string
427 """
428 if version == "unknown":
429 return "0.0.0"
431 # Remove 'v' prefix and extract semantic version
432 clean_version = version[1:] if version.startswith("v") else version
434 # Extract core semantic version (remove pre-release and build metadata)
435 import re
437 semver_match = re.match(r"^(\d+\.\d+\.\d+)", clean_version)
438 if semver_match:
439 return semver_match.group(1)
440 return "0.0.0"
442 def _get_version_source(self, version_reader: VersionReader) -> str:
443 """
444 Determine the source of the version information.
446 Args:
447 version_reader: VersionReader instance
449 Returns:
450 String indicating version source
451 """
452 config = version_reader.get_config()
453 cache_age = version_reader.get_cache_age_seconds()
455 if cache_age is not None and cache_age < config.cache_ttl_seconds:
456 return "config_cached"
457 elif cache_age is not None:
458 return "config_stale"
459 else:
460 return config.fallback_source.value
462 def _get_template_root(self) -> Path:
463 """Return the template root path."""
464 # src/moai_adk/core/template/processor.py → src/moai_adk/templates/
465 current_file = Path(__file__).resolve()
466 package_root = current_file.parent.parent.parent
467 return package_root / "templates"
469 def _substitute_variables(self, content: str) -> tuple[str, list[str]]:
470 """
471 Substitute template variables in content with enhanced validation and caching.
473 Args:
474 content: Content to substitute variables in
476 Returns:
477 Tuple of (substituted_content, warnings_list)
478 """
479 warnings = []
480 logger = logging.getLogger(__name__)
482 # Check cache first if enabled
483 cache_key = hash((frozenset(self.context.items()), content[:1000]))
484 if self.config.enable_caching and cache_key in self._substitution_cache:
485 cached_result = self._substitution_cache[cache_key]
486 if self.config.verbose_logging:
487 logger.debug("Using cached substitution result")
488 return cached_result
490 # Enhanced variable substitution with validation
491 substitution_count = 0
492 for key, value in self.context.items():
493 placeholder = f"{{{{{key}}}}}" # {{KEY}}
494 if placeholder in content:
495 if self.config.validate_template_variables:
496 # Validate variable before substitution
497 if not self._is_valid_template_variable(key, value):
498 warnings.append(
499 f"Invalid variable {key} - skipped substitution"
500 )
501 continue
503 safe_value = self._sanitize_value(value)
504 content = content.replace(placeholder, safe_value)
505 substitution_count += 1
507 if self.config.verbose_logging:
508 logger.debug(f"Substituted {key}: {safe_value[:50]}...")
510 # Detect unsubstituted variables with enhanced error messages
511 remaining = re.findall(r"\{\{([A-Z_]+)\}\}", content)
512 if remaining:
513 unique_remaining = sorted(set(remaining))
515 # Build detailed warning message with enhanced suggestions
516 warning_parts = []
517 for var in unique_remaining:
518 if var in self.COMMON_TEMPLATE_VARIABLES:
519 suggestion = self.COMMON_TEMPLATE_VARIABLES[var]
520 warning_parts.append(f"{{{{{var}}}}} → {suggestion}")
521 else:
522 warning_parts.append(
523 f"{{{{{var}}}}} → Unknown variable (check template)"
524 )
526 warnings.append("Template variables not substituted:")
527 warnings.extend(f" • {part}" for part in warning_parts)
529 if self.config.enable_substitution_warnings:
530 warnings.append(
531 "💡 Run 'uv run moai-adk update' to fix template variables"
532 )
534 # Add performance information if verbose logging is enabled
535 if self.config.verbose_logging:
536 warnings.append(f" 📊 Substituted {substitution_count} variables")
538 # Cache the result if enabled
539 if self.config.enable_caching:
540 result = (content, warnings)
541 self._substitution_cache[cache_key] = result
543 # Manage cache size
544 if len(self._substitution_cache) > self.config.cache_size:
545 # Remove oldest entry (simple FIFO)
546 oldest_key = next(iter(self._substitution_cache))
547 del self._substitution_cache[oldest_key]
548 if self.config.verbose_logging:
549 logger.debug("Cache size limit reached, removed oldest entry")
551 return content, warnings
553 def _is_valid_template_variable(self, key: str, value: str) -> bool:
554 """
555 Validate a template variable before substitution.
557 Args:
558 key: Variable name
559 value: Variable value
561 Returns:
562 True if variable is valid
563 """
564 import re
566 # Check variable name format
567 if not re.match(self.config.allowed_variable_pattern, key):
568 return False
570 # Check variable length
571 if len(key) > self.config.max_variable_length:
572 return False
574 # Check value length
575 if len(value) > self.config.max_variable_length * 2:
576 return False
578 # Note: {{ }} patterns are handled by sanitization, not validation
580 # Check for empty values
581 if not value.strip():
582 return False
584 return True
586 def clear_substitution_cache(self) -> None:
587 """Clear the substitution cache."""
588 self._substitution_cache.clear()
589 if self.config.verbose_logging:
590 self.logger.debug("Substitution cache cleared")
592 def get_cache_stats(self) -> Dict[str, Any]:
593 """
594 Get cache statistics.
596 Returns:
597 Dictionary containing cache statistics
598 """
599 return {
600 "cache_size": len(self._substitution_cache),
601 "max_cache_size": self.config.cache_size,
602 "cache_enabled": self.config.enable_caching,
603 "cache_hit_ratio": 0.0, # Would need to track hits to implement this
604 }
606 def _sanitize_value(self, value: str) -> str:
607 """Sanitize value to prevent recursive substitution and control characters.
609 Args:
610 value: Value to sanitize.
612 Returns:
613 Sanitized value.
614 """
615 # Remove control characters (keep printable and whitespace)
616 value = "".join(c for c in value if c.isprintable() or c in "\n\r\t")
617 # Prevent recursive substitution by removing placeholder patterns
618 value = value.replace("{{", "").replace("}}", "")
619 return value
621 def _is_text_file(self, file_path: Path) -> bool:
622 """Check if file is text-based (not binary).
624 Args:
625 file_path: File path to check.
627 Returns:
628 True if file is text-based.
629 """
630 text_extensions = {
631 ".md",
632 ".json",
633 ".txt",
634 ".py",
635 ".ts",
636 ".js",
637 ".yaml",
638 ".yml",
639 ".toml",
640 ".xml",
641 ".sh",
642 ".bash",
643 }
644 return file_path.suffix.lower() in text_extensions
646 def _localize_yaml_description(self, content: str, language: str = "en") -> str:
647 """Localize multilingual YAML description field.
649 Converts multilingual description maps to single-language strings:
650 description:
651 en: "English text"
652 ko: "Korean text"
653 →
654 description: "Korean text" (if language="ko")
656 Args:
657 content: File content.
658 language: Target language code (en, ko, ja, zh).
660 Returns:
661 Content with localized descriptions.
662 """
663 import yaml
665 # Pattern to match YAML frontmatter
666 frontmatter_pattern = r"^---\n(.*?)\n---"
667 match = re.match(frontmatter_pattern, content, re.DOTALL)
669 if not match:
670 return content
672 try:
673 yaml_content = match.group(1)
674 yaml_data = yaml.safe_load(yaml_content)
676 # Check if description is a dict (multilingual)
677 if isinstance(yaml_data.get("description"), dict):
678 # Select language (fallback to English)
679 descriptions = yaml_data["description"]
680 selected_desc = descriptions.get(language, descriptions.get("en", ""))
682 # Replace description with selected language
683 yaml_data["description"] = selected_desc
685 # Reconstruct frontmatter
686 new_yaml = yaml.dump(yaml_data, allow_unicode=True, sort_keys=False)
687 # Preserve the rest of the content
688 rest_content = content[match.end() :]
689 return f"---\n{new_yaml}---{rest_content}"
691 except Exception:
692 # If YAML parsing fails, return original content
693 pass
695 return content
697 def _copy_file_with_substitution(self, src: Path, dst: Path) -> list[str]:
698 """Copy file with variable substitution and description localization for text files.
700 Args:
701 src: Source file path.
702 dst: Destination file path.
704 Returns:
705 List of warnings.
706 """
707 import stat
709 warnings = []
711 # Text files: read, substitute, write
712 if self._is_text_file(src) and self.context:
713 try:
714 content = src.read_text(encoding="utf-8")
715 content, file_warnings = self._substitute_variables(content)
717 # Apply description localization for command/output-style files
718 if src.suffix == ".md" and (
719 "commands/alfred" in str(src) or "output-styles/alfred" in str(src)
720 ):
721 lang = self.context.get("CONVERSATION_LANGUAGE", "en")
722 content = self._localize_yaml_description(content, lang)
724 dst.write_text(content, encoding="utf-8")
725 warnings.extend(file_warnings)
726 except UnicodeDecodeError:
727 # Binary file fallback
728 shutil.copy2(src, dst)
729 else:
730 # Binary file or no context: simple copy
731 shutil.copy2(src, dst)
733 # Ensure executable permission for shell scripts
734 if src.suffix == ".sh":
735 # Always make shell scripts executable regardless of source permissions
736 dst_mode = dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
737 dst.chmod(dst_mode)
739 return warnings
741 def _copy_dir_with_substitution(self, src: Path, dst: Path) -> None:
742 """Recursively copy directory with variable substitution for text files.
744 Args:
745 src: Source directory path.
746 dst: Destination directory path.
747 """
748 dst.mkdir(parents=True, exist_ok=True)
750 for item in src.rglob("*"):
751 rel_path = item.relative_to(src)
752 dst_item = dst / rel_path
754 if item.is_file():
755 # Create parent directory if needed
756 dst_item.parent.mkdir(parents=True, exist_ok=True)
757 # Copy with variable substitution
758 self._copy_file_with_substitution(item, dst_item)
759 elif item.is_dir():
760 dst_item.mkdir(parents=True, exist_ok=True)
762 def copy_templates(self, backup: bool = True, silent: bool = False) -> None:
763 """Copy template files into the project.
765 Args:
766 backup: Whether to create a backup.
767 silent: Reduce log output when True.
768 """
769 # 1. Create a backup when existing files are present
770 if backup and self._has_existing_files():
771 backup_path = self.create_backup()
772 if not silent:
773 console.print(f"💾 Backup created: {backup_path.name}")
775 # 2. Copy templates
776 if not silent:
777 console.print("📄 Copying templates...")
779 self._copy_claude(silent)
780 self._copy_moai(silent)
781 self._copy_github(silent)
782 self._copy_claude_md(silent)
783 self._copy_gitignore(silent)
784 self._copy_mcp_json(silent)
786 if not silent:
787 console.print("✅ Templates copied successfully")
789 def _has_existing_files(self) -> bool:
790 """Determine whether project files exist (backup decision helper)."""
791 return self.backup.has_existing_files()
793 def create_backup(self) -> Path:
794 """Create a timestamped backup (delegated)."""
795 return self.backup.create_backup()
797 def _copy_exclude_protected(self, src: Path, dst: Path) -> None:
798 """Copy content while excluding protected paths.
800 Args:
801 src: Source directory.
802 dst: Destination directory.
803 """
804 dst.mkdir(parents=True, exist_ok=True)
806 # PROTECTED_PATHS: only specs/ and reports/ are excluded during copying
807 # project/ and config.json are preserved only when they already exist
808 template_protected_paths = [
809 "specs",
810 "reports",
811 ]
813 for item in src.rglob("*"):
814 rel_path = item.relative_to(src)
815 rel_path_str = str(rel_path)
817 # Skip template copy for specs/ and reports/
818 if any(rel_path_str.startswith(p) for p in template_protected_paths):
819 continue
821 dst_item = dst / rel_path
822 if item.is_file():
823 # Preserve user content by skipping existing files (v0.3.0)
824 # This automatically protects project/ and config.json
825 if dst_item.exists():
826 continue
827 dst_item.parent.mkdir(parents=True, exist_ok=True)
828 shutil.copy2(item, dst_item)
829 elif item.is_dir():
830 dst_item.mkdir(parents=True, exist_ok=True)
832 def _copy_claude(self, silent: bool = False) -> None:
833 """.claude/ directory copy with variable substitution (selective with alfred folder overwrite).
836 Strategy:
837 - Alfred folders (commands/agents/hooks/output-styles/alfred) → copy wholesale (delete & overwrite)
838 * Creates individual backup before deletion for safety
839 * Commands: 0-project.md, 1-plan.md, 2-run.md, 3-sync.md
840 - Other files/folders → copy individually (preserve existing)
841 """
842 src = self.template_root / ".claude"
843 dst = self.target_path / ".claude"
845 if not src.exists():
846 if not silent:
847 console.print("⚠️ .claude/ template not found")
848 return
850 # Create .claude directory if not exists
851 dst.mkdir(parents=True, exist_ok=True)
853 # Alfred and Moai folders to copy wholesale (overwrite)
854 # Including both legacy alfred/ and new moai/ structure
855 alfred_moai_folders = [
856 "hooks/alfred",
857 "hooks/moai",
858 "commands/alfred", # Contains 0-project.md, 1-plan.md, 2-run.md, 3-sync.md
859 "commands/moai",
860 "output-styles/moai",
861 "agents/alfred",
862 "agents/moai",
863 ]
865 # 1. Copy Alfred and Moai folders wholesale (backup before delete & overwrite)
866 for folder in alfred_moai_folders:
867 src_folder = src / folder
868 dst_folder = dst / folder
870 if src_folder.exists():
871 # Remove existing folder (backup is already handled by create_backup() in update.py)
872 if dst_folder.exists():
873 shutil.rmtree(dst_folder)
875 # Create parent directory if needed
876 dst_folder.parent.mkdir(parents=True, exist_ok=True)
877 shutil.copytree(src_folder, dst_folder)
878 if not silent:
879 console.print(f" ✅ .claude/{folder}/ overwritten")
881 # 1.5 Copy other subdirectories in parent folders (e.g., output-styles/moai, hooks/shared)
882 # This ensures non-alfred subdirectories are also copied
883 parent_folders_with_subdirs = ["output-styles", "hooks", "commands", "agents"]
884 for parent_name in parent_folders_with_subdirs:
885 src_parent = src / parent_name
886 if not src_parent.exists():
887 continue
889 for subdir in src_parent.iterdir():
890 if not subdir.is_dir():
891 continue
893 # Skip alfred subdirectories (already handled above)
894 if subdir.name == "alfred":
895 continue
897 rel_subdir = f"{parent_name}/{subdir.name}"
898 dst_subdir = dst / parent_name / subdir.name
900 if dst_subdir.exists():
901 # For non-alfred directories, overwrite with merge if necessary
902 shutil.rmtree(dst_subdir)
904 # Copy the subdirectory
905 shutil.copytree(subdir, dst_subdir)
906 if not silent:
907 console.print(f" ✅ .claude/{rel_subdir}/ copied")
909 # 2. Copy other files/folders individually (smart merge for settings.json)
910 all_warnings = []
911 for item in src.iterdir():
912 rel_path = item.relative_to(src)
913 dst_item = dst / rel_path
915 # Skip Alfred parent folders (already handled above)
916 if item.is_dir() and item.name in [
917 "hooks",
918 "commands",
919 "output-styles",
920 "agents",
921 ]:
922 continue
924 if item.is_file():
925 # Smart merge for settings.json
926 if item.name == "settings.json":
927 self._merge_settings_json(item, dst_item)
928 # Apply variable substitution to merged settings.json (for cross-platform Hook paths)
929 if self.context:
930 content = dst_item.read_text(encoding="utf-8")
931 content, file_warnings = self._substitute_variables(content)
932 dst_item.write_text(content, encoding="utf-8")
933 all_warnings.extend(file_warnings)
934 if not silent:
935 console.print(
936 " 🔄 settings.json merged (Hook paths configured for your OS)"
937 )
938 else:
939 # FORCE OVERWRITE: Always copy other files (no skip)
940 warnings = self._copy_file_with_substitution(item, dst_item)
941 all_warnings.extend(warnings)
942 elif item.is_dir():
943 # FORCE OVERWRITE: Always copy directories (no skip)
944 self._copy_dir_with_substitution(item, dst_item)
946 # Print warnings if any
947 if all_warnings and not silent:
948 console.print("[yellow]⚠️ Template warnings:[/yellow]")
949 for warning in set(all_warnings): # Deduplicate
950 console.print(f" {warning}")
952 if not silent:
953 console.print(" ✅ .claude/ copy complete (variables substituted)")
955 def _copy_moai(self, silent: bool = False) -> None:
956 """.moai/ directory copy with variable substitution (excludes protected paths)."""
957 src = self.template_root / ".moai"
958 dst = self.target_path / ".moai"
960 if not src.exists():
961 if not silent:
962 console.print("⚠️ .moai/ template not found")
963 return
965 # Paths excluded from template copying (specs/, reports/, .moai/config/config.json)
966 template_protected_paths = [
967 "specs",
968 "reports",
969 ".moai/config/config.json",
970 ]
972 all_warnings = []
974 # Copy while skipping protected paths
975 for item in src.rglob("*"):
976 rel_path = item.relative_to(src)
977 rel_path_str = str(rel_path)
979 # Skip specs/ and reports/
980 if any(rel_path_str.startswith(p) for p in template_protected_paths):
981 continue
983 dst_item = dst / rel_path
984 if item.is_file():
985 # FORCE OVERWRITE: Always copy files (no skip)
986 dst_item.parent.mkdir(parents=True, exist_ok=True)
987 # Copy with variable substitution
988 warnings = self._copy_file_with_substitution(item, dst_item)
989 all_warnings.extend(warnings)
990 elif item.is_dir():
991 dst_item.mkdir(parents=True, exist_ok=True)
993 # Print warnings if any
994 if all_warnings and not silent:
995 console.print("[yellow]⚠️ Template warnings:[/yellow]")
996 for warning in set(all_warnings): # Deduplicate
997 console.print(f" {warning}")
999 if not silent:
1000 console.print(" ✅ .moai/ copy complete (variables substituted)")
1002 def _copy_github(self, silent: bool = False) -> None:
1003 """.github/ directory copy with smart merge (preserves user workflows)."""
1004 src = self.template_root / ".github"
1005 dst = self.target_path / ".github"
1007 if not src.exists():
1008 if not silent:
1009 console.print("⚠️ .github/ template not found")
1010 return
1012 # Smart merge: preserve existing user workflows
1013 if dst.exists():
1014 self._merge_github_workflows(src, dst)
1015 else:
1016 # First time: just copy
1017 self._copy_dir_with_substitution(src, dst)
1019 if not silent:
1020 console.print(
1021 " 🔄 .github/ merged (user workflows preserved, variables substituted)"
1022 )
1024 def _copy_claude_md(self, silent: bool = False) -> None:
1025 """Copy CLAUDE.md with smart merge (preserves \"## Project Information\" section)."""
1026 src = self.template_root / "CLAUDE.md"
1027 dst = self.target_path / "CLAUDE.md"
1029 if not src.exists():
1030 if not silent:
1031 console.print("⚠️ CLAUDE.md template not found")
1032 return
1034 # Smart merge: preserve existing "## Project Information" section
1035 if dst.exists():
1036 self._merge_claude_md(src, dst)
1037 # Substitute variables in the merged content
1038 if self.context:
1039 content = dst.read_text(encoding="utf-8")
1040 content, warnings = self._substitute_variables(content)
1041 dst.write_text(content, encoding="utf-8")
1042 if warnings and not silent:
1043 console.print("[yellow]⚠️ Template warnings:[/yellow]")
1044 for warning in set(warnings):
1045 console.print(f" {warning}")
1046 if not silent:
1047 console.print(
1048 " 🔄 CLAUDE.md merged (project information preserved, variables substituted)"
1049 )
1050 else:
1051 # First time: just copy
1052 self._copy_file_with_substitution(src, dst)
1053 if not silent:
1054 console.print(" ✅ CLAUDE.md created")
1056 def _merge_claude_md(self, src: Path, dst: Path) -> None:
1057 """Delegate the smart merge for CLAUDE.md.
1059 Args:
1060 src: Template CLAUDE.md.
1061 dst: Project CLAUDE.md.
1062 """
1063 self.merger.merge_claude_md(src, dst)
1065 def _merge_github_workflows(self, src: Path, dst: Path) -> None:
1066 """Delegate the smart merge for .github/workflows/.
1068 Args:
1069 src: Template .github directory.
1070 dst: Project .github directory.
1071 """
1072 self.merger.merge_github_workflows(src, dst)
1074 def _merge_settings_json(self, src: Path, dst: Path) -> None:
1075 """Delegate the smart merge for settings.json.
1077 Args:
1078 src: Template settings.json.
1079 dst: Project settings.json.
1080 """
1081 # Find the latest backup for user settings extraction
1082 backup_path = None
1083 if self.backup.backup_dir.exists():
1084 backups = sorted(self.backup.backup_dir.iterdir(), reverse=True)
1085 if backups:
1086 backup_settings = backups[0] / ".claude" / "settings.json"
1087 if backup_settings.exists():
1088 backup_path = backup_settings
1090 self.merger.merge_settings_json(src, dst, backup_path)
1092 def _copy_gitignore(self, silent: bool = False) -> None:
1093 """.gitignore copy (optional)."""
1094 src = self.template_root / ".gitignore"
1095 dst = self.target_path / ".gitignore"
1097 if not src.exists():
1098 return
1100 # Merge with the existing .gitignore when present
1101 if dst.exists():
1102 self._merge_gitignore(src, dst)
1103 if not silent:
1104 console.print(" 🔄 .gitignore merged")
1105 else:
1106 shutil.copy2(src, dst)
1107 if not silent:
1108 console.print(" ✅ .gitignore copy complete")
1110 def _merge_gitignore(self, src: Path, dst: Path) -> None:
1111 """Delegate the .gitignore merge.
1113 Args:
1114 src: Template .gitignore.
1115 dst: Project .gitignore.
1116 """
1117 self.merger.merge_gitignore(src, dst)
1119 def _copy_mcp_json(self, silent: bool = False) -> None:
1120 """.mcp.json copy (smart merge with existing MCP server configuration)."""
1121 src = self.template_root / ".mcp.json"
1122 dst = self.target_path / ".mcp.json"
1124 if not src.exists():
1125 return
1127 # Merge with existing .mcp.json when present (preserve user-added MCP servers)
1128 if dst.exists():
1129 self._merge_mcp_json(src, dst)
1130 if not silent:
1131 console.print(" 🔄 .mcp.json merged (user MCP servers preserved)")
1132 else:
1133 shutil.copy2(src, dst)
1134 if not silent:
1135 console.print(" ✅ .mcp.json copy complete")
1137 def _merge_mcp_json(self, src: Path, dst: Path) -> None:
1138 """Smart merge for .mcp.json (preserve user-added MCP servers).
1140 Args:
1141 src: Template .mcp.json.
1142 dst: Project .mcp.json.
1143 """
1144 try:
1145 src_data = json.loads(src.read_text(encoding="utf-8"))
1146 dst_data = json.loads(dst.read_text(encoding="utf-8"))
1148 # Merge mcpServers: preserve user servers, update template servers
1149 if "mcpServers" in src_data:
1150 if "mcpServers" not in dst_data:
1151 dst_data["mcpServers"] = {}
1152 # Update with template servers (preserves existing user servers)
1153 dst_data["mcpServers"].update(src_data["mcpServers"])
1155 # Write merged result back
1156 dst.write_text(json.dumps(dst_data, indent=2, ensure_ascii=False), encoding="utf-8")
1157 except json.JSONDecodeError as e:
1158 console.print(f"[yellow]⚠️ Failed to merge .mcp.json: {e}[/yellow]")
1160 def merge_config(self, detected_language: str | None = None) -> dict[str, str]:
1161 """Delegate the smart merge for config.json.
1163 Args:
1164 detected_language: Detected language.
1166 Returns:
1167 Merged configuration dictionary.
1168 """
1169 return self.merger.merge_config(detected_language)