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

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. 

3 

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""" 

12 

13from __future__ import annotations 

14 

15import json 

16import logging 

17import re 

18import shutil 

19from dataclasses import dataclass 

20from pathlib import Path 

21from typing import Any, Dict, List, Optional 

22 

23from rich.console import Console 

24 

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 

28 

29console = Console() 

30 

31 

32@dataclass 

33class TemplateProcessorConfig: 

34 """Configuration for TemplateProcessor behavior.""" 

35 

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 

42 

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 

48 

49 # Performance configuration 

50 enable_caching: bool = True 

51 cache_size: int = 100 

52 async_operations: bool = False 

53 

54 # Error handling configuration 

55 graceful_degradation: bool = True 

56 verbose_logging: bool = False 

57 

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 ) 

88 

89 

90class TemplateProcessor: 

91 """Orchestrate template copying and backups with enhanced version handling and validation.""" 

92 

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 ] 

101 

102 # Paths excluded from backups 

103 BACKUP_EXCLUDE = PROTECTED_PATHS 

104 

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 } 

121 

122 def __init__( 

123 self, target_path: Path, config: Optional[TemplateProcessorConfig] = None 

124 ) -> None: 

125 """Initialize the processor with enhanced configuration. 

126 

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__) 

143 

144 if self.config.verbose_logging: 

145 self.logger.info( 

146 f"TemplateProcessor initialized with config: {self.config}" 

147 ) 

148 

149 def set_context(self, context: dict[str, str]) -> None: 

150 """Set variable substitution context with enhanced validation. 

151 

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() 

158 

159 if self.config.verbose_logging: 

160 self.logger.debug(f"Context set with {len(context)} variables") 

161 

162 # Validate template variables if enabled 

163 if self.config.validate_template_variables: 

164 self._validate_template_variables(context) 

165 

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"] 

169 

170 def _get_version_reader(self) -> VersionReader: 

171 """ 

172 Get or create version reader instance with enhanced configuration. 

173 

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) 

185 

186 if self.config.verbose_logging: 

187 self.logger.info("VersionReader created with enhanced configuration") 

188 return self._version_reader 

189 

190 def _validate_template_variables(self, context: Dict[str, str]) -> None: 

191 """ 

192 Validate template variables with comprehensive checking. 

193 

194 Args: 

195 context: Dictionary of template variables to validate 

196 """ 

197 import re 

198 

199 if not self.config.validate_template_variables: 

200 return 

201 

202 validation_errors: List[str] = [] 

203 warning_messages: List[str] = [] 

204 

205 # Check variable names against pattern 

206 variable_pattern = re.compile(self.config.allowed_variable_pattern) 

207 

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 

213 

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 ) 

219 

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 ) 

225 

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 ) 

231 

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) 

237 

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 ) 

242 

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 ) 

248 

249 if validation_errors and self.config.graceful_degradation: 

250 self.logger.warning( 

251 f"Template variable validation warnings: {validation_errors}" 

252 ) 

253 

254 if warning_messages and self.config.enable_substitution_warnings: 

255 self.logger.warning(f"Template variable warnings: {warning_messages}") 

256 

257 if self.config.verbose_logging: 

258 self.logger.debug( 

259 f"Template variables validated: {len(context)} variables checked" 

260 ) 

261 

262 def get_enhanced_version_context(self) -> dict[str, str]: 

263 """ 

264 Get enhanced version context with proper error handling and caching. 

265 

266 Returns comprehensive version information including multiple format options 

267 and debugging information. 

268 

269 Returns: 

270 Dictionary containing enhanced version-related template variables 

271 """ 

272 version_context = {} 

273 logger = logging.getLogger(__name__) 

274 

275 try: 

276 version_reader = self._get_version_reader() 

277 moai_version = version_reader.get_version() 

278 

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 ) 

287 

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 ) 

295 

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 ) 

303 

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" 

310 

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 ) 

317 

318 if self.config.verbose_logging: 

319 logger.debug(f"Enhanced version context generated: {version_context}") 

320 

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" 

344 

345 return version_context 

346 

347 def _is_valid_version_format(self, version: str) -> bool: 

348 """ 

349 Validate version format using configured regex pattern. 

350 

351 Args: 

352 version: Version string to validate 

353 

354 Returns: 

355 True if version format is valid 

356 """ 

357 import re 

358 

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)) 

366 

367 def _format_short_version(self, version: str) -> str: 

368 """ 

369 Format short version by removing 'v' prefix if present. 

370 

371 Args: 

372 version: Version string 

373 

374 Returns: 

375 Short version string 

376 """ 

377 return version[1:] if version.startswith("v") else version 

378 

379 def _format_display_version(self, version: str) -> str: 

380 """ 

381 Format display version with proper formatting. 

382 

383 Args: 

384 version: Version string 

385 

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}" 

395 

396 def _format_trimmed_version(self, version: str, max_length: int = 10) -> str: 

397 """ 

398 Format version with maximum length, suitable for UI displays. 

399 

400 Args: 

401 version: Version string 

402 max_length: Maximum allowed length for the version string 

403 

404 Returns: 

405 Trimmed version string 

406 """ 

407 if version == "unknown": 

408 return "unknown" 

409 

410 # Remove 'v' prefix for trimming 

411 clean_version = version[1:] if version.startswith("v") else version 

412 

413 # Trim if necessary 

414 if len(clean_version) > max_length: 

415 return clean_version[:max_length] 

416 return clean_version 

417 

418 def _format_semver_version(self, version: str) -> str: 

419 """ 

420 Format version as semantic version with major.minor.patch structure. 

421 

422 Args: 

423 version: Version string 

424 

425 Returns: 

426 Semantic version string 

427 """ 

428 if version == "unknown": 

429 return "0.0.0" 

430 

431 # Remove 'v' prefix and extract semantic version 

432 clean_version = version[1:] if version.startswith("v") else version 

433 

434 # Extract core semantic version (remove pre-release and build metadata) 

435 import re 

436 

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" 

441 

442 def _get_version_source(self, version_reader: VersionReader) -> str: 

443 """ 

444 Determine the source of the version information. 

445 

446 Args: 

447 version_reader: VersionReader instance 

448 

449 Returns: 

450 String indicating version source 

451 """ 

452 config = version_reader.get_config() 

453 cache_age = version_reader.get_cache_age_seconds() 

454 

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 

461 

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" 

468 

469 def _substitute_variables(self, content: str) -> tuple[str, list[str]]: 

470 """ 

471 Substitute template variables in content with enhanced validation and caching. 

472 

473 Args: 

474 content: Content to substitute variables in 

475 

476 Returns: 

477 Tuple of (substituted_content, warnings_list) 

478 """ 

479 warnings = [] 

480 logger = logging.getLogger(__name__) 

481 

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 

489 

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 

502 

503 safe_value = self._sanitize_value(value) 

504 content = content.replace(placeholder, safe_value) 

505 substitution_count += 1 

506 

507 if self.config.verbose_logging: 

508 logger.debug(f"Substituted {key}: {safe_value[:50]}...") 

509 

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)) 

514 

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 ) 

525 

526 warnings.append("Template variables not substituted:") 

527 warnings.extend(f"{part}" for part in warning_parts) 

528 

529 if self.config.enable_substitution_warnings: 

530 warnings.append( 

531 "💡 Run 'uv run moai-adk update' to fix template variables" 

532 ) 

533 

534 # Add performance information if verbose logging is enabled 

535 if self.config.verbose_logging: 

536 warnings.append(f" 📊 Substituted {substitution_count} variables") 

537 

538 # Cache the result if enabled 

539 if self.config.enable_caching: 

540 result = (content, warnings) 

541 self._substitution_cache[cache_key] = result 

542 

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") 

550 

551 return content, warnings 

552 

553 def _is_valid_template_variable(self, key: str, value: str) -> bool: 

554 """ 

555 Validate a template variable before substitution. 

556 

557 Args: 

558 key: Variable name 

559 value: Variable value 

560 

561 Returns: 

562 True if variable is valid 

563 """ 

564 import re 

565 

566 # Check variable name format 

567 if not re.match(self.config.allowed_variable_pattern, key): 

568 return False 

569 

570 # Check variable length 

571 if len(key) > self.config.max_variable_length: 

572 return False 

573 

574 # Check value length 

575 if len(value) > self.config.max_variable_length * 2: 

576 return False 

577 

578 # Note: {{ }} patterns are handled by sanitization, not validation 

579 

580 # Check for empty values 

581 if not value.strip(): 

582 return False 

583 

584 return True 

585 

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") 

591 

592 def get_cache_stats(self) -> Dict[str, Any]: 

593 """ 

594 Get cache statistics. 

595 

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 } 

605 

606 def _sanitize_value(self, value: str) -> str: 

607 """Sanitize value to prevent recursive substitution and control characters. 

608 

609 Args: 

610 value: Value to sanitize. 

611 

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 

620 

621 def _is_text_file(self, file_path: Path) -> bool: 

622 """Check if file is text-based (not binary). 

623 

624 Args: 

625 file_path: File path to check. 

626 

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 

645 

646 def _localize_yaml_description(self, content: str, language: str = "en") -> str: 

647 """Localize multilingual YAML description field. 

648 

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") 

655 

656 Args: 

657 content: File content. 

658 language: Target language code (en, ko, ja, zh). 

659 

660 Returns: 

661 Content with localized descriptions. 

662 """ 

663 import yaml 

664 

665 # Pattern to match YAML frontmatter 

666 frontmatter_pattern = r"^---\n(.*?)\n---" 

667 match = re.match(frontmatter_pattern, content, re.DOTALL) 

668 

669 if not match: 

670 return content 

671 

672 try: 

673 yaml_content = match.group(1) 

674 yaml_data = yaml.safe_load(yaml_content) 

675 

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", "")) 

681 

682 # Replace description with selected language 

683 yaml_data["description"] = selected_desc 

684 

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}" 

690 

691 except Exception: 

692 # If YAML parsing fails, return original content 

693 pass 

694 

695 return content 

696 

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. 

699 

700 Args: 

701 src: Source file path. 

702 dst: Destination file path. 

703 

704 Returns: 

705 List of warnings. 

706 """ 

707 import stat 

708 

709 warnings = [] 

710 

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) 

716 

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) 

723 

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) 

732 

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) 

738 

739 return warnings 

740 

741 def _copy_dir_with_substitution(self, src: Path, dst: Path) -> None: 

742 """Recursively copy directory with variable substitution for text files. 

743 

744 Args: 

745 src: Source directory path. 

746 dst: Destination directory path. 

747 """ 

748 dst.mkdir(parents=True, exist_ok=True) 

749 

750 for item in src.rglob("*"): 

751 rel_path = item.relative_to(src) 

752 dst_item = dst / rel_path 

753 

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) 

761 

762 def copy_templates(self, backup: bool = True, silent: bool = False) -> None: 

763 """Copy template files into the project. 

764 

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}") 

774 

775 # 2. Copy templates 

776 if not silent: 

777 console.print("📄 Copying templates...") 

778 

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) 

785 

786 if not silent: 

787 console.print("✅ Templates copied successfully") 

788 

789 def _has_existing_files(self) -> bool: 

790 """Determine whether project files exist (backup decision helper).""" 

791 return self.backup.has_existing_files() 

792 

793 def create_backup(self) -> Path: 

794 """Create a timestamped backup (delegated).""" 

795 return self.backup.create_backup() 

796 

797 def _copy_exclude_protected(self, src: Path, dst: Path) -> None: 

798 """Copy content while excluding protected paths. 

799 

800 Args: 

801 src: Source directory. 

802 dst: Destination directory. 

803 """ 

804 dst.mkdir(parents=True, exist_ok=True) 

805 

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 ] 

812 

813 for item in src.rglob("*"): 

814 rel_path = item.relative_to(src) 

815 rel_path_str = str(rel_path) 

816 

817 # Skip template copy for specs/ and reports/ 

818 if any(rel_path_str.startswith(p) for p in template_protected_paths): 

819 continue 

820 

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) 

831 

832 def _copy_claude(self, silent: bool = False) -> None: 

833 """.claude/ directory copy with variable substitution (selective with alfred folder overwrite). 

834 

835 

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" 

844 

845 if not src.exists(): 

846 if not silent: 

847 console.print("⚠️ .claude/ template not found") 

848 return 

849 

850 # Create .claude directory if not exists 

851 dst.mkdir(parents=True, exist_ok=True) 

852 

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 ] 

864 

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 

869 

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) 

874 

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") 

880 

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 

888 

889 for subdir in src_parent.iterdir(): 

890 if not subdir.is_dir(): 

891 continue 

892 

893 # Skip alfred subdirectories (already handled above) 

894 if subdir.name == "alfred": 

895 continue 

896 

897 rel_subdir = f"{parent_name}/{subdir.name}" 

898 dst_subdir = dst / parent_name / subdir.name 

899 

900 if dst_subdir.exists(): 

901 # For non-alfred directories, overwrite with merge if necessary 

902 shutil.rmtree(dst_subdir) 

903 

904 # Copy the subdirectory 

905 shutil.copytree(subdir, dst_subdir) 

906 if not silent: 

907 console.print(f" ✅ .claude/{rel_subdir}/ copied") 

908 

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 

914 

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 

923 

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) 

945 

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}") 

951 

952 if not silent: 

953 console.print(" ✅ .claude/ copy complete (variables substituted)") 

954 

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" 

959 

960 if not src.exists(): 

961 if not silent: 

962 console.print("⚠️ .moai/ template not found") 

963 return 

964 

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 ] 

971 

972 all_warnings = [] 

973 

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) 

978 

979 # Skip specs/ and reports/ 

980 if any(rel_path_str.startswith(p) for p in template_protected_paths): 

981 continue 

982 

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) 

992 

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}") 

998 

999 if not silent: 

1000 console.print(" ✅ .moai/ copy complete (variables substituted)") 

1001 

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" 

1006 

1007 if not src.exists(): 

1008 if not silent: 

1009 console.print("⚠️ .github/ template not found") 

1010 return 

1011 

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) 

1018 

1019 if not silent: 

1020 console.print( 

1021 " 🔄 .github/ merged (user workflows preserved, variables substituted)" 

1022 ) 

1023 

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" 

1028 

1029 if not src.exists(): 

1030 if not silent: 

1031 console.print("⚠️ CLAUDE.md template not found") 

1032 return 

1033 

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") 

1055 

1056 def _merge_claude_md(self, src: Path, dst: Path) -> None: 

1057 """Delegate the smart merge for CLAUDE.md. 

1058 

1059 Args: 

1060 src: Template CLAUDE.md. 

1061 dst: Project CLAUDE.md. 

1062 """ 

1063 self.merger.merge_claude_md(src, dst) 

1064 

1065 def _merge_github_workflows(self, src: Path, dst: Path) -> None: 

1066 """Delegate the smart merge for .github/workflows/. 

1067 

1068 Args: 

1069 src: Template .github directory. 

1070 dst: Project .github directory. 

1071 """ 

1072 self.merger.merge_github_workflows(src, dst) 

1073 

1074 def _merge_settings_json(self, src: Path, dst: Path) -> None: 

1075 """Delegate the smart merge for settings.json. 

1076 

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 

1089 

1090 self.merger.merge_settings_json(src, dst, backup_path) 

1091 

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" 

1096 

1097 if not src.exists(): 

1098 return 

1099 

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") 

1109 

1110 def _merge_gitignore(self, src: Path, dst: Path) -> None: 

1111 """Delegate the .gitignore merge. 

1112 

1113 Args: 

1114 src: Template .gitignore. 

1115 dst: Project .gitignore. 

1116 """ 

1117 self.merger.merge_gitignore(src, dst) 

1118 

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" 

1123 

1124 if not src.exists(): 

1125 return 

1126 

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") 

1136 

1137 def _merge_mcp_json(self, src: Path, dst: Path) -> None: 

1138 """Smart merge for .mcp.json (preserve user-added MCP servers). 

1139 

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")) 

1147 

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"]) 

1154 

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]") 

1159 

1160 def merge_config(self, detected_language: str | None = None) -> dict[str, str]: 

1161 """Delegate the smart merge for config.json. 

1162 

1163 Args: 

1164 detected_language: Detected language. 

1165 

1166 Returns: 

1167 Merged configuration dictionary. 

1168 """ 

1169 return self.merger.merge_config(detected_language)