Coverage for src / moai_adk / cli / commands / update.py: 10.92%

696 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1"""Update command 

2 

3Update MoAI-ADK to the latest version available on PyPI with 3-stage workflow: 

4- Stage 1: Package version check (PyPI vs current) 

5- Stage 2: Config version comparison (template_version in config.json) 

6- Stage 3: Template sync (only if versions differ) 

7 

8Includes: 

9- Automatic installer detection (uv tool, pipx, pip) 

10- Package upgrade with intelligent re-run prompts 

11- Template and configuration updates with performance optimization 

12- Backward compatibility validation 

13- 70-80% performance improvement for up-to-date projects 

14 

15## Skill Invocation Guide (English-Only) 

16# mypy: disable-error-code=return-value 

17 

18### Related Skills 

19- **moai-foundation-trust**: For post-update validation 

20 - Trigger: After updating MoAI-ADK version 

21 - Invocation: `Skill("moai-foundation-trust")` to verify all toolchains still work 

22 

23- **moai-foundation-langs**: For language detection after update 

24 - Trigger: After updating, confirm language stack is intact 

25 - Invocation: `Skill("moai-foundation-langs")` to re-detect and validate language configuration 

26 

27### When to Invoke Skills in Related Workflows 

281. **After successful update**: 

29 - Run `Skill("moai-foundation-trust")` to validate all TRUST 4 gates 

30 - Run `Skill("moai-foundation-langs")` to confirm language toolchain still works 

31 - Run project doctor command for full system validation 

32 

332. **Before updating**: 

34 - Create backup with `python -m moai_adk backup` 

35 

363. **If update fails**: 

37 - Use backup to restore previous state 

38 - Debug with `python -m moai_adk doctor --verbose` 

39""" 

40 

41# type: ignore 

42 

43from __future__ import annotations 

44 

45import json 

46import logging 

47import subprocess 

48from datetime import datetime 

49from pathlib import Path 

50from typing import Any, cast 

51 

52import click 

53from packaging import version 

54from rich.console import Console 

55 

56from moai_adk import __version__ 

57from moai_adk.core.merge import MergeAnalyzer 

58from moai_adk.core.migration import VersionMigrator 

59from moai_adk.core.migration.alfred_to_moai_migrator import AlfredToMoaiMigrator 

60from moai_adk.core.template.processor import TemplateProcessor 

61 

62console = Console() 

63logger = logging.getLogger(__name__) 

64 

65# Constants for tool detection 

66TOOL_DETECTION_TIMEOUT = 5 # seconds 

67UV_TOOL_COMMAND = ["uv", "tool", "upgrade", "moai-adk"] 

68PIPX_COMMAND = ["pipx", "upgrade", "moai-adk"] 

69PIP_COMMAND = ["pip", "install", "--upgrade", "moai-adk"] 

70 

71 

72# Custom exceptions for better error handling 

73class UpdateError(Exception): 

74 """Base exception for update operations.""" 

75 

76 pass 

77 

78 

79class InstallerNotFoundError(UpdateError): 

80 """Raised when no package installer detected.""" 

81 

82 pass 

83 

84 

85class NetworkError(UpdateError): 

86 """Raised when network operation fails.""" 

87 

88 pass 

89 

90 

91class UpgradeError(UpdateError): 

92 """Raised when package upgrade fails.""" 

93 

94 pass 

95 

96 

97class TemplateSyncError(UpdateError): 

98 """Raised when template sync fails.""" 

99 

100 pass 

101 

102 

103def _is_installed_via_uv_tool() -> bool: 

104 """Check if moai-adk installed via uv tool. 

105 

106 Returns: 

107 True if uv tool list shows moai-adk, False otherwise 

108 """ 

109 try: 

110 result = subprocess.run( 

111 ["uv", "tool", "list"], 

112 capture_output=True, 

113 text=True, 

114 timeout=TOOL_DETECTION_TIMEOUT, 

115 check=False, 

116 ) 

117 return result.returncode == 0 and "moai-adk" in result.stdout 

118 except (FileNotFoundError, subprocess.TimeoutExpired, OSError): 

119 return False 

120 

121 

122def _is_installed_via_pipx() -> bool: 

123 """Check if moai-adk installed via pipx. 

124 

125 Returns: 

126 True if pipx list shows moai-adk, False otherwise 

127 """ 

128 try: 

129 result = subprocess.run( 

130 ["pipx", "list"], 

131 capture_output=True, 

132 text=True, 

133 timeout=TOOL_DETECTION_TIMEOUT, 

134 check=False, 

135 ) 

136 return result.returncode == 0 and "moai-adk" in result.stdout 

137 except (FileNotFoundError, subprocess.TimeoutExpired, OSError): 

138 return False 

139 

140 

141def _is_installed_via_pip() -> bool: 

142 """Check if moai-adk installed via pip. 

143 

144 Returns: 

145 True if pip show finds moai-adk, False otherwise 

146 """ 

147 try: 

148 result = subprocess.run( 

149 ["pip", "show", "moai-adk"], 

150 capture_output=True, 

151 text=True, 

152 timeout=TOOL_DETECTION_TIMEOUT, 

153 check=False, 

154 ) 

155 return result.returncode == 0 

156 except (FileNotFoundError, subprocess.TimeoutExpired, OSError): 

157 return False 

158 

159 

160def _detect_tool_installer() -> list[str] | None: 

161 """Detect which tool installed moai-adk. 

162 

163 Checks in priority order: 

164 1. uv tool (most likely for MoAI-ADK users) 

165 2. pipx 

166 3. pip (fallback) 

167 

168 Returns: 

169 Command list [tool, ...args] ready for subprocess.run() 

170 or None if detection fails 

171 

172 Examples: 

173 >>> # If uv tool is detected: 

174 >>> _detect_tool_installer() 

175 ['uv', 'tool', 'upgrade', 'moai-adk'] 

176 

177 >>> # If pipx is detected: 

178 >>> _detect_tool_installer() 

179 ['pipx', 'upgrade', 'moai-adk'] 

180 

181 >>> # If only pip is available: 

182 >>> _detect_tool_installer() 

183 ['pip', 'install', '--upgrade', 'moai-adk'] 

184 

185 >>> # If none are detected: 

186 >>> _detect_tool_installer() 

187 None 

188 """ 

189 if _is_installed_via_uv_tool(): 

190 return UV_TOOL_COMMAND 

191 elif _is_installed_via_pipx(): 

192 return PIPX_COMMAND 

193 elif _is_installed_via_pip(): 

194 return PIP_COMMAND 

195 else: 

196 return None 

197 

198 

199def _get_current_version() -> str: 

200 """Get currently installed moai-adk version. 

201 

202 Returns: 

203 Version string (e.g., "0.6.1") 

204 

205 Raises: 

206 RuntimeError: If version cannot be determined 

207 """ 

208 return __version__ 

209 

210 

211def _get_latest_version() -> str: 

212 """Fetch latest moai-adk version from PyPI. 

213 

214 Returns: 

215 Version string (e.g., "0.6.2") 

216 

217 Raises: 

218 RuntimeError: If PyPI API unavailable or parsing fails 

219 """ 

220 try: 

221 import urllib.error 

222 import urllib.request 

223 

224 url = "https://pypi.org/pypi/moai-adk/json" 

225 with urllib.request.urlopen( 

226 url, timeout=5 

227 ) as response: # nosec B310 - URL is hardcoded HTTPS to PyPI API, no user input 

228 data = json.loads(response.read().decode("utf-8")) 

229 return cast(str, data["info"]["version"]) 

230 except (urllib.error.URLError, json.JSONDecodeError, KeyError, TimeoutError) as e: 

231 raise RuntimeError(f"Failed to fetch latest version from PyPI: {e}") from e 

232 

233 

234def _compare_versions(current: str, latest: str) -> int: 

235 """Compare semantic versions. 

236 

237 Args: 

238 current: Current version string 

239 latest: Latest version string 

240 

241 Returns: 

242 -1 if current < latest (upgrade needed) 

243 0 if current == latest (up to date) 

244 1 if current > latest (unusual, already newer) 

245 """ 

246 current_v = version.parse(current) 

247 latest_v = version.parse(latest) 

248 

249 if current_v < latest_v: 

250 return -1 

251 elif current_v == latest_v: 

252 return 0 

253 else: 

254 return 1 

255 

256 

257def _get_package_config_version() -> str: 

258 """Get the current package template version. 

259 

260 This returns the version of the currently installed moai-adk package, 

261 which is the version of templates that this package provides. 

262 

263 Returns: 

264 Version string of the installed package (e.g., "0.6.1") 

265 """ 

266 # Package template version = current installed package version 

267 # This is simple and reliable since templates are versioned with the package 

268 return __version__ 

269 

270 

271def _get_project_config_version(project_path: Path) -> str: 

272 """Get current project config.json template version. 

273 

274 This reads the project's .moai/config/config.json to determine the current 

275 template version that the project is configured with. 

276 

277 Args: 

278 project_path: Project directory path (absolute) 

279 

280 Returns: 

281 Version string from project's config.json (e.g., "0.6.1") 

282 Returns "0.0.0" if template_version field not found (indicates no prior sync) 

283 

284 Raises: 

285 ValueError: If config.json exists but cannot be parsed 

286 """ 

287 

288 def _is_placeholder(value: str) -> bool: 

289 """Check if value contains unsubstituted template placeholders.""" 

290 return ( 

291 isinstance(value, str) and value.startswith("{{") and value.endswith("}}") 

292 ) 

293 

294 config_path = project_path / ".moai" / "config" / "config.json" 

295 

296 if not config_path.exists(): 

297 # No config yet, treat as version 0.0.0 (needs initial sync) 

298 return "0.0.0" 

299 

300 try: 

301 config_data = json.loads(config_path.read_text(encoding="utf-8")) 

302 # Check for template_version in project section 

303 template_version = config_data.get("project", {}).get("template_version") 

304 if template_version and not _is_placeholder(template_version): 

305 return template_version 

306 

307 # Fallback to moai version if no template_version exists 

308 moai_version = config_data.get("moai", {}).get("version") 

309 if moai_version and not _is_placeholder(moai_version): 

310 return moai_version 

311 

312 # If values are placeholders or don't exist, treat as uninitialized (0.0.0 triggers sync) 

313 return "0.0.0" 

314 except json.JSONDecodeError as e: 

315 raise ValueError(f"Failed to parse project config.json: {e}") from e 

316 

317 

318def _ask_merge_strategy(yes: bool = False) -> str: 

319 """ 

320 Ask user to choose merge strategy via CLI prompt. 

321 

322 Args: 

323 yes: If True, auto-select "auto" (for --yes flag) 

324 

325 Returns: 

326 "auto" or "manual" 

327 """ 

328 if yes: 

329 return "auto" 

330 

331 console.print("\n[cyan]🔀 Choose merge strategy:[/cyan]") 

332 console.print("[cyan] [1] Auto-merge (default)[/cyan]") 

333 console.print( 

334 "[dim] → Template installs fresh + user changes preserved + minimal conflicts[/dim]" 

335 ) 

336 console.print("[cyan] [2] Manual merge[/cyan]") 

337 console.print( 

338 "[dim] → Backup preserved + merge guide generated + you control merging[/dim]" 

339 ) 

340 

341 response = click.prompt("Select [1 or 2]", default="1") 

342 if response == "2": 

343 return "manual" 

344 return "auto" 

345 

346 

347def _generate_manual_merge_guide( 

348 backup_path: Path, template_path: Path, project_path: Path 

349) -> Path: 

350 """ 

351 Generate comprehensive merge guide for manual merging. 

352 

353 Args: 

354 backup_path: Path to backup directory 

355 template_path: Path to template directory 

356 project_path: Project root path 

357 

358 Returns: 

359 Path to generated merge guide 

360 """ 

361 guide_dir = project_path / ".moai" / "guides" 

362 guide_dir.mkdir(parents=True, exist_ok=True) 

363 

364 guide_path = guide_dir / "merge-guide.md" 

365 

366 # Find changed files 

367 changed_files = [] 

368 backup_claude = backup_path / ".claude" 

369 backup_moai = backup_path / ".moai" 

370 

371 # Compare .claude/ 

372 if backup_claude.exists(): 

373 for file in backup_claude.rglob("*"): 

374 if file.is_file(): 

375 rel_path = file.relative_to(backup_path) 

376 current_file = project_path / rel_path 

377 if current_file.exists(): 

378 if file.read_text(encoding="utf-8", errors="ignore") != current_file.read_text( 

379 encoding="utf-8", errors="ignore" 

380 ): 

381 changed_files.append(f" - {rel_path}") 

382 else: 

383 changed_files.append(f" - {rel_path} (new)") 

384 

385 # Generate guide 

386 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 

387 guide_content = f"""# Merge Guide - Manual Merge Mode 

388 

389**Generated**: {timestamp} 

390**Backup Location**: `{backup_path.relative_to(project_path)}/` 

391 

392## Summary 

393 

394During this update, the following files were changed: 

395 

396{chr(10).join(changed_files) if changed_files else " (No changes detected)"} 

397 

398## How to Merge 

399 

400### Option 1: Using diff (Terminal) 

401 

402```bash 

403# Compare specific files 

404diff {backup_path.name}/.claude/settings.json .claude/settings.json 

405 

406# View all differences 

407diff -r {backup_path.name}/ . 

408``` 

409 

410### Option 2: Using Visual Merge Tool 

411 

412```bash 

413# macOS/Linux - Using meld 

414meld {backup_path.relative_to(project_path)}/ . 

415 

416# Using VSCode 

417code --diff {backup_path.relative_to(project_path)}/.claude/settings.json .claude/settings.json 

418``` 

419 

420### Option 3: Manual Line-by-Line 

421 

4221. Open backup file in your editor 

4232. Open current file side-by-side 

4243. Manually copy your customizations 

425 

426## Key Files to Review 

427 

428### .claude/settings.json 

429- Contains MCP servers, hooks, environment variables 

430- **Action**: Restore any custom MCP servers and environment variables 

431- **Location**: {backup_path.relative_to(project_path)}/.claude/settings.json 

432 

433### .moai/config/config.json 

434- Contains project configuration and metadata 

435- **Action**: Verify user-specific settings are preserved 

436- **Location**: {backup_path.relative_to(project_path)}/.moai/config/config.json 

437 

438### .claude/commands/, .claude/agents/, .claude/hooks/ 

439- Contains custom scripts and automation 

440- **Action**: Restore any custom scripts outside of /moai/ folders 

441- **Location**: {backup_path.relative_to(project_path)}/.claude/ 

442 

443## Migration Checklist 

444 

445- [ ] Compare `.claude/settings.json` 

446 - [ ] Restore custom MCP servers 

447 - [ ] Restore environment variables 

448 - [ ] Verify hooks are properly configured 

449 

450- [ ] Review `.moai/config/config.json` 

451 - [ ] Check version was updated 

452 - [ ] Verify user settings preserved 

453 

454- [ ] Restore custom scripts 

455 - [ ] Any custom commands outside /moai/ 

456 - [ ] Any custom agents outside /moai/ 

457 - [ ] Any custom hooks outside /moai/ 

458 

459- [ ] Run tests 

460 ```bash 

461 uv run pytest 

462 moai-adk validate 

463 ``` 

464 

465- [ ] Commit changes 

466 ```bash 

467 git add . 

468 git commit -m "merge: Update templates with manual merge" 

469 ``` 

470 

471## Rollback if Needed 

472 

473If you want to cancel and restore the backup: 

474 

475```bash 

476# Restore everything from backup 

477cp -r {backup_path.relative_to(project_path)}/.claude . 

478cp -r {backup_path.relative_to(project_path)}/.moai . 

479cp {backup_path.relative_to(project_path)}/CLAUDE.md . 

480 

481# Or restore specific files 

482cp {backup_path.relative_to(project_path)}/.claude/settings.json .claude/ 

483``` 

484 

485## Questions? 

486 

487If you encounter merge conflicts or issues: 

488 

4891. Check the backup folder for original files 

4902. Compare line-by-line using diff tools 

4913. Consult documentation: https://adk.mo.ai.kr/update-merge 

492 

493--- 

494 

495**Backup**: `{backup_path}/` 

496**Generated**: {timestamp} 

497""" 

498 

499 guide_path.write_text(guide_content, encoding="utf-8") 

500 logger.info(f"✅ Merge guide created: {guide_path}") 

501 return guide_path 

502 

503 

504def _detect_stale_cache( 

505 upgrade_output: str, current_version: str, latest_version: str 

506) -> bool: 

507 """ 

508 Detect if uv cache is stale by comparing versions. 

509 

510 A stale cache occurs when PyPI metadata is outdated, causing uv to incorrectly 

511 report "Nothing to upgrade" even though a newer version exists. This function 

512 detects this condition by: 

513 1. Checking if upgrade output contains "Nothing to upgrade" 

514 2. Verifying that latest version is actually newer than current version 

515 

516 Uses packaging.version.parse() for robust semantic version comparison that 

517 handles pre-releases, dev versions, and other PEP 440 version formats correctly. 

518 

519 Args: 

520 upgrade_output: Output from uv tool upgrade command 

521 current_version: Currently installed version (string, e.g., "0.8.3") 

522 latest_version: Latest version available on PyPI (string, e.g., "0.9.0") 

523 

524 Returns: 

525 True if cache is stale (output shows "Nothing to upgrade" but current < latest), 

526 False otherwise 

527 

528 Examples: 

529 >>> _detect_stale_cache("Nothing to upgrade", "0.8.3", "0.9.0") 

530 True 

531 >>> _detect_stale_cache("Updated moai-adk", "0.8.3", "0.9.0") 

532 False 

533 >>> _detect_stale_cache("Nothing to upgrade", "0.9.0", "0.9.0") 

534 False 

535 """ 

536 # Check if output indicates no upgrade needed 

537 if not upgrade_output or "Nothing to upgrade" not in upgrade_output: 

538 return False 

539 

540 # Compare versions using packaging.version 

541 try: 

542 current_v = version.parse(current_version) 

543 latest_v = version.parse(latest_version) 

544 return current_v < latest_v 

545 except (version.InvalidVersion, TypeError) as e: 

546 # Graceful degradation: if version parsing fails, assume cache is not stale 

547 logger.debug(f"Version parsing failed: {e}") 

548 return False 

549 

550 

551def _clear_uv_package_cache(package_name: str = "moai-adk") -> bool: 

552 """ 

553 Clear uv cache for specific package. 

554 

555 Executes `uv cache clean <package>` with 10-second timeout to prevent 

556 hanging on network issues. Provides user-friendly error handling for 

557 various failure scenarios (timeout, missing uv, etc.). 

558 

559 Args: 

560 package_name: Package name to clear cache for (default: "moai-adk") 

561 

562 Returns: 

563 True if cache cleared successfully, False otherwise 

564 

565 Exceptions: 

566 - subprocess.TimeoutExpired: Logged as warning, returns False 

567 - FileNotFoundError: Logged as warning, returns False 

568 - Exception: Logged as warning, returns False 

569 

570 Examples: 

571 >>> _clear_uv_package_cache("moai-adk") 

572 True # If uv cache clean succeeds 

573 """ 

574 try: 

575 result = subprocess.run( 

576 ["uv", "cache", "clean", package_name], 

577 capture_output=True, 

578 text=True, 

579 timeout=10, # 10 second timeout 

580 check=False, 

581 ) 

582 

583 if result.returncode == 0: 

584 logger.debug(f"UV cache cleared for {package_name}") 

585 return True 

586 else: 

587 logger.warning(f"Failed to clear UV cache: {result.stderr}") 

588 return False 

589 

590 except subprocess.TimeoutExpired: 

591 logger.warning(f"UV cache clean timed out for {package_name}") 

592 return False 

593 except FileNotFoundError: 

594 logger.warning("UV command not found. Is uv installed?") 

595 return False 

596 except Exception as e: 

597 logger.warning(f"Unexpected error clearing cache: {e}") 

598 return False 

599 

600 

601def _execute_upgrade_with_retry( 

602 installer_cmd: list[str], package_name: str = "moai-adk" 

603) -> bool: 

604 """ 

605 Execute upgrade with automatic cache retry on stale detection. 

606 

607 Implements a robust 7-stage upgrade flow that handles PyPI cache staleness: 

608 

609 Stage 1: First upgrade attempt (up to 60 seconds) 

610 Stage 2: Check success condition (returncode=0 AND no "Nothing to upgrade") 

611 Stage 3: Detect stale cache using _detect_stale_cache() 

612 Stage 4: Show user feedback if stale cache detected 

613 Stage 5: Clear cache using _clear_uv_package_cache() 

614 Stage 6: Retry upgrade with same command 

615 Stage 7: Return final result (success or failure) 

616 

617 Retry Logic: 

618 - Only ONE retry is performed to prevent infinite loops 

619 - Retry only happens if stale cache is detected AND cache clear succeeds 

620 - Cache clear failures are reported to user with manual workaround 

621 

622 User Feedback: 

623 - Shows emoji-based status messages for each stage 

624 - Clear guidance on manual workaround if automatic retry fails 

625 - All errors logged at WARNING level for debugging 

626 

627 Args: 

628 installer_cmd: Command list from _detect_tool_installer() 

629 e.g., ["uv", "tool", "upgrade", "moai-adk"] 

630 package_name: Package name for cache clearing (default: "moai-adk") 

631 

632 Returns: 

633 True if upgrade succeeded (either first attempt or after retry), 

634 False otherwise 

635 

636 Examples: 

637 >>> # First attempt succeeds 

638 >>> _execute_upgrade_with_retry(["uv", "tool", "upgrade", "moai-adk"]) 

639 True 

640 

641 >>> # First attempt stale, retry succeeds 

642 >>> _execute_upgrade_with_retry(["uv", "tool", "upgrade", "moai-adk"]) 

643 True # After cache clear and retry 

644 

645 Raises: 

646 subprocess.TimeoutExpired: Re-raised if upgrade command times out 

647 """ 

648 # Stage 1: First upgrade attempt 

649 try: 

650 result = subprocess.run( 

651 installer_cmd, capture_output=True, text=True, timeout=60, check=False 

652 ) 

653 except subprocess.TimeoutExpired: 

654 raise # Re-raise timeout for caller to handle 

655 except Exception: 

656 return False 

657 

658 # Stage 2: Check if upgrade succeeded without stale cache 

659 if result.returncode == 0 and "Nothing to upgrade" not in result.stdout: 

660 return True 

661 

662 # Stage 3: Detect stale cache 

663 try: 

664 current_version = _get_current_version() 

665 latest_version = _get_latest_version() 

666 except RuntimeError: 

667 # If version check fails, return original result 

668 return result.returncode == 0 

669 

670 if _detect_stale_cache(result.stdout, current_version, latest_version): 

671 # Stage 4: User feedback 

672 console.print("[yellow]⚠️ Cache outdated, refreshing...[/yellow]") 

673 

674 # Stage 5: Clear cache 

675 if _clear_uv_package_cache(package_name): 

676 console.print("[cyan]♻️ Cache cleared, retrying upgrade...[/cyan]") 

677 

678 # Stage 6: Retry upgrade 

679 try: 

680 result = subprocess.run( 

681 installer_cmd, 

682 capture_output=True, 

683 text=True, 

684 timeout=60, 

685 check=False, 

686 ) 

687 

688 if result.returncode == 0: 

689 return True 

690 else: 

691 console.print("[red]✗ Upgrade failed after retry[/red]") 

692 return False 

693 except subprocess.TimeoutExpired: 

694 raise # Re-raise timeout 

695 except Exception: 

696 return False 

697 else: 

698 # Cache clear failed 

699 console.print("[red]✗ Cache clear failed. Manual workaround:[/red]") 

700 console.print(" [cyan]uv cache clean moai-adk && moai-adk update[/cyan]") 

701 return False 

702 

703 # Stage 7: Cache is not stale, return original result 

704 return result.returncode == 0 

705 

706 

707def _execute_upgrade(installer_cmd: list[str]) -> bool: 

708 """Execute package upgrade using detected installer. 

709 

710 Args: 

711 installer_cmd: Command list from _detect_tool_installer() 

712 e.g., ["uv", "tool", "upgrade", "moai-adk"] 

713 

714 Returns: 

715 True if upgrade succeeded, False otherwise 

716 

717 Raises: 

718 subprocess.TimeoutExpired: If upgrade times out 

719 """ 

720 try: 

721 result = subprocess.run( 

722 installer_cmd, capture_output=True, text=True, timeout=60, check=False 

723 ) 

724 return result.returncode == 0 

725 except subprocess.TimeoutExpired: 

726 raise # Re-raise timeout for caller to handle 

727 except Exception: 

728 return False 

729 

730 

731def _preserve_user_settings(project_path: Path) -> dict[str, Path | None]: 

732 """Back up user-specific settings files before template sync. 

733 

734 Args: 

735 project_path: Project directory path 

736 

737 Returns: 

738 Dictionary with backup paths of preserved files 

739 """ 

740 preserved = {} 

741 claude_dir = project_path / ".claude" 

742 

743 # Preserve settings.local.json (user MCP and GLM configuration) 

744 settings_local = claude_dir / "settings.local.json" 

745 if settings_local.exists(): 

746 try: 

747 backup_dir = project_path / ".moai-backups" / "settings-backup" 

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

749 backup_path = backup_dir / "settings.local.json" 

750 backup_path.write_text(settings_local.read_text(encoding="utf-8")) 

751 preserved["settings.local.json"] = backup_path 

752 console.print(" [cyan]💾 Backed up user settings[/cyan]") 

753 except Exception as e: 

754 logger.warning(f"Failed to backup settings.local.json: {e}") 

755 preserved["settings.local.json"] = None 

756 else: 

757 preserved["settings.local.json"] = None 

758 

759 return preserved 

760 

761 

762def _restore_user_settings(project_path: Path, preserved: dict[str, Path | None]) -> bool: 

763 """Restore user-specific settings files after template sync. 

764 

765 Args: 

766 project_path: Project directory path 

767 preserved: Dictionary of backup paths from _preserve_user_settings() 

768 

769 Returns: 

770 True if restoration succeeded, False otherwise 

771 """ 

772 claude_dir = project_path / ".claude" 

773 claude_dir.mkdir(parents=True, exist_ok=True) 

774 

775 success = True 

776 

777 # Restore settings.local.json 

778 if preserved.get("settings.local.json"): 

779 try: 

780 backup_path = preserved["settings.local.json"] 

781 settings_local = claude_dir / "settings.local.json" 

782 settings_local.write_text(backup_path.read_text(encoding="utf-8")) 

783 console.print(" [cyan]✓ Restored user settings[/cyan]") 

784 except Exception as e: 

785 console.print(f" [yellow]⚠️ Failed to restore settings.local.json: {e}[/yellow]") 

786 logger.warning(f"Failed to restore settings.local.json: {e}") 

787 success = False 

788 

789 return success 

790 

791 

792def _sync_templates(project_path: Path, force: bool = False) -> bool: 

793 """Sync templates to project with rollback mechanism. 

794 

795 Args: 

796 project_path: Project path (absolute) 

797 force: Force update without backup 

798 

799 Returns: 

800 True if sync succeeded, False otherwise 

801 """ 

802 from moai_adk.core.template.backup import TemplateBackup 

803 

804 backup_path = None 

805 try: 

806 processor = TemplateProcessor(project_path) 

807 

808 # Create pre-sync backup for rollback 

809 if not force: 

810 backup = TemplateBackup(project_path) 

811 if backup.has_existing_files(): 

812 backup_path = backup.create_backup() 

813 console.print(f"💾 Created backup: {backup_path.name}") 

814 

815 # Merge analysis using Claude Code headless mode 

816 try: 

817 analyzer = MergeAnalyzer(project_path) 

818 # Template source path from installed package 

819 template_path = ( 

820 Path(__file__).parent.parent.parent / "templates" 

821 ) 

822 

823 console.print("\n[cyan]🔍 Starting merge analysis (max 2 mins)...[/cyan]") 

824 console.print("[dim] Analyzing intelligent merge with Claude Code.[/dim]") 

825 console.print("[dim] Please wait...[/dim]\n") 

826 analysis = analyzer.analyze_merge(backup_path, template_path) 

827 

828 # Ask user confirmation 

829 if not analyzer.ask_user_confirmation(analysis): 

830 console.print( 

831 "[yellow]⚠️ User cancelled the update.[/yellow]" 

832 ) 

833 backup.restore_backup(backup_path) 

834 return False 

835 except Exception as e: 

836 console.print( 

837 f"[yellow]⚠️ Merge analysis failed: {e}[/yellow]" 

838 ) 

839 console.print( 

840 "[yellow]Proceeding with automatic merge.[/yellow]" 

841 ) 

842 

843 # Load existing config 

844 existing_config = _load_existing_config(project_path) 

845 

846 # Build context 

847 context = _build_template_context(project_path, existing_config, __version__) 

848 if context: 

849 processor.set_context(context) 

850 

851 # Copy templates (including moai folder) 

852 processor.copy_templates(backup=False, silent=True) 

853 

854 # Stage 1.5: Alfred → Moai migration (AFTER template sync) 

855 # Execute migration after template copy (moai folders must exist first) 

856 migrator = AlfredToMoaiMigrator(project_path) 

857 if migrator.needs_migration(): 

858 console.print("\n[cyan]🔄 Migrating folder structure: Alfred → Moai[/cyan]") 

859 try: 

860 if not migrator.execute_migration(backup_path): 

861 console.print( 

862 "[red]❌ Alfred → Moai migration failed[/red]" 

863 ) 

864 if backup_path: 

865 console.print( 

866 "[yellow]🔄 Restoring from backup...[/yellow]" 

867 ) 

868 backup = TemplateBackup(project_path) 

869 backup.restore_backup(backup_path) 

870 return False 

871 except Exception as e: 

872 console.print( 

873 f"[red]❌ Error during migration: {e}[/red]" 

874 ) 

875 if backup_path: 

876 backup = TemplateBackup(project_path) 

877 backup.restore_backup(backup_path) 

878 return False 

879 

880 # Validate template substitution 

881 validation_passed = _validate_template_substitution_with_rollback( 

882 project_path, backup_path 

883 ) 

884 if not validation_passed: 

885 if backup_path: 

886 console.print( 

887 f"[yellow]🔄 Rolling back to backup: {backup_path.name}[/yellow]" 

888 ) 

889 backup.restore_backup(backup_path) 

890 return False 

891 

892 # Preserve metadata 

893 _preserve_project_metadata(project_path, context, existing_config, __version__) 

894 _apply_context_to_file(processor, project_path / "CLAUDE.md") 

895 

896 # Set optimized=false 

897 set_optimized_false(project_path) 

898 

899 # Update companyAnnouncements in settings.local.json 

900 try: 

901 import sys 

902 utils_dir = Path(__file__).parent.parent.parent / "templates" / ".claude" / "hooks" / "moai" / "shared" / "utils" 

903 

904 if utils_dir.exists(): 

905 sys.path.insert(0, str(utils_dir)) 

906 try: 

907 from announcement_translator import auto_translate_and_update 

908 console.print("[cyan]Updating announcements...[/cyan]") 

909 auto_translate_and_update(project_path) 

910 console.print("[green]✓ Announcements updated[/green]") 

911 except Exception as e: 

912 console.print(f"[yellow]⚠️ Announcement update failed: {e}[/yellow]") 

913 finally: 

914 sys.path.remove(str(utils_dir)) 

915 

916 except Exception as e: 

917 console.print(f"[yellow]⚠️ Announcement module not available: {e}[/yellow]") 

918 

919 return True 

920 except Exception as e: 

921 console.print(f"[red]✗ Template sync failed: {e}[/red]") 

922 if backup_path: 

923 console.print( 

924 f"[yellow]🔄 Rolling back to backup: {backup_path.name}[/yellow]" 

925 ) 

926 try: 

927 backup = TemplateBackup(project_path) 

928 backup.restore_backup(backup_path) 

929 console.print("[green]✅ Rollback completed[/green]") 

930 except Exception as rollback_error: 

931 console.print(f"[red]✗ Rollback failed: {rollback_error}[/red]") 

932 return False 

933 

934 

935def get_latest_version() -> str | None: 

936 """Get the latest version from PyPI. 

937 

938 DEPRECATED: Use _get_latest_version() for new code. 

939 This function is kept for backward compatibility. 

940 

941 Returns: 

942 Latest version string, or None if fetch fails. 

943 """ 

944 try: 

945 return _get_latest_version() 

946 except RuntimeError: 

947 # Return None if PyPI check fails (backward compatibility) 

948 return None 

949 

950 

951def set_optimized_false(project_path: Path) -> None: 

952 """Set config.json's optimized field to false. 

953 

954 Args: 

955 project_path: Project path (absolute). 

956 """ 

957 config_path = project_path / ".moai" / "config" / "config.json" 

958 if not config_path.exists(): 

959 return 

960 

961 try: 

962 config_data = json.loads(config_path.read_text(encoding="utf-8")) 

963 config_data.setdefault("project", {})["optimized"] = False 

964 config_path.write_text( 

965 json.dumps(config_data, indent=2, ensure_ascii=False) + "\n", 

966 encoding="utf-8", 

967 ) 

968 except (json.JSONDecodeError, KeyError): 

969 # Ignore errors if config.json is invalid 

970 pass 

971 

972 

973def _load_existing_config(project_path: Path) -> dict[str, Any]: 

974 """Load existing config.json if available.""" 

975 config_path = project_path / ".moai" / "config" / "config.json" 

976 if config_path.exists(): 

977 try: 

978 return json.loads(config_path.read_text(encoding="utf-8")) 

979 except json.JSONDecodeError: 

980 console.print( 

981 "[yellow]⚠ Existing config.json could not be parsed. Proceeding with defaults.[/yellow]" 

982 ) 

983 return {} 

984 

985 

986def _is_placeholder(value: Any) -> bool: 

987 """Check if a string value is an unsubstituted template placeholder.""" 

988 return ( 

989 isinstance(value, str) 

990 and value.strip().startswith("{{") 

991 and value.strip().endswith("}}") 

992 ) 

993 

994 

995def _coalesce(*values: Any, default: str = "") -> str: 

996 """Return the first non-empty, non-placeholder string value.""" 

997 for value in values: 

998 if isinstance(value, str): 

999 if not value.strip(): 

1000 continue 

1001 if _is_placeholder(value): 

1002 continue 

1003 return value 

1004 for value in values: 

1005 if value is not None and not isinstance(value, str): 

1006 return str(value) 

1007 return default 

1008 

1009 

1010def _extract_project_section(config: dict[str, Any]) -> dict[str, Any]: 

1011 """Return the nested project section if present.""" 

1012 project_section = config.get("project") 

1013 if isinstance(project_section, dict): 

1014 return project_section 

1015 return {} 

1016 

1017 

1018def _build_template_context( 

1019 project_path: Path, 

1020 existing_config: dict[str, Any], 

1021 version_for_config: str, 

1022) -> dict[str, str]: 

1023 """Build substitution context for template files.""" 

1024 import platform 

1025 

1026 project_section = _extract_project_section(existing_config) 

1027 

1028 project_name = _coalesce( 

1029 project_section.get("name"), 

1030 existing_config.get("projectName"), # Legacy fallback 

1031 project_path.name, 

1032 ) 

1033 project_mode = _coalesce( 

1034 project_section.get("mode"), 

1035 existing_config.get("mode"), # Legacy fallback 

1036 default="personal", 

1037 ) 

1038 project_description = _coalesce( 

1039 project_section.get("description"), 

1040 existing_config.get("projectDescription"), # Legacy fallback 

1041 existing_config.get("description"), # Legacy fallback 

1042 ) 

1043 project_version = _coalesce( 

1044 project_section.get("version"), 

1045 existing_config.get("projectVersion"), 

1046 existing_config.get("version"), 

1047 default="0.1.0", 

1048 ) 

1049 created_at = _coalesce( 

1050 project_section.get("created_at"), 

1051 existing_config.get("created_at"), 

1052 default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 

1053 ) 

1054 

1055 # Detect OS for cross-platform Hook path configuration 

1056 hook_project_dir = ( 

1057 "%CLAUDE_PROJECT_DIR%" 

1058 if platform.system() == "Windows" 

1059 else "$CLAUDE_PROJECT_DIR" 

1060 ) 

1061 

1062 # Extract language configuration 

1063 language_config = existing_config.get("language", {}) 

1064 if not isinstance(language_config, dict): 

1065 language_config = {} 

1066 

1067 return { 

1068 "MOAI_VERSION": version_for_config, 

1069 "PROJECT_NAME": project_name, 

1070 "PROJECT_MODE": project_mode, 

1071 "PROJECT_DESCRIPTION": project_description, 

1072 "PROJECT_VERSION": project_version, 

1073 "CREATION_TIMESTAMP": created_at, 

1074 "PROJECT_DIR": hook_project_dir, 

1075 "CONVERSATION_LANGUAGE": language_config.get("conversation_language", "en"), 

1076 "CONVERSATION_LANGUAGE_NAME": language_config.get( 

1077 "conversation_language_name", "English" 

1078 ), 

1079 "CODEBASE_LANGUAGE": project_section.get("language", "generic"), 

1080 "PROJECT_OWNER": project_section.get("author", "@user"), 

1081 "AUTHOR": project_section.get("author", "@user"), 

1082 } 

1083 

1084 

1085def _preserve_project_metadata( 

1086 project_path: Path, 

1087 context: dict[str, str], 

1088 existing_config: dict[str, Any], 

1089 version_for_config: str, 

1090) -> None: 

1091 """Restore project-specific metadata in the new config.json. 

1092 

1093 Also updates template_version to track which template version is synchronized. 

1094 """ 

1095 config_path = project_path / ".moai" / "config" / "config.json" 

1096 if not config_path.exists(): 

1097 return 

1098 

1099 try: 

1100 config_data = json.loads(config_path.read_text(encoding="utf-8")) 

1101 except json.JSONDecodeError: 

1102 console.print("[red]✗ Failed to parse config.json after template copy[/red]") 

1103 return 

1104 

1105 project_data = config_data.setdefault("project", {}) 

1106 project_data["name"] = context["PROJECT_NAME"] 

1107 project_data["mode"] = context["PROJECT_MODE"] 

1108 project_data["description"] = context["PROJECT_DESCRIPTION"] 

1109 project_data["created_at"] = context["CREATION_TIMESTAMP"] 

1110 

1111 if "optimized" not in project_data and isinstance(existing_config, dict): 

1112 existing_project = _extract_project_section(existing_config) 

1113 if isinstance(existing_project, dict) and "optimized" in existing_project: 

1114 project_data["optimized"] = bool(existing_project["optimized"]) 

1115 

1116 # Preserve locale and language preferences when possible 

1117 existing_project = _extract_project_section(existing_config) 

1118 locale = _coalesce(existing_project.get("locale"), existing_config.get("locale")) 

1119 if locale: 

1120 project_data["locale"] = locale 

1121 

1122 language = _coalesce( 

1123 existing_project.get("language"), existing_config.get("language") 

1124 ) 

1125 if language: 

1126 project_data["language"] = language 

1127 

1128 config_data.setdefault("moai", {}) 

1129 config_data["moai"]["version"] = version_for_config 

1130 

1131 # This allows Stage 2 to compare package vs project template versions 

1132 project_data["template_version"] = version_for_config 

1133 

1134 config_path.write_text( 

1135 json.dumps(config_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" 

1136 ) 

1137 

1138 

1139def _apply_context_to_file(processor: TemplateProcessor, target_path: Path) -> None: 

1140 """Apply the processor context to an existing file (post-merge pass).""" 

1141 if not processor.context or not target_path.exists(): 

1142 return 

1143 

1144 try: 

1145 content = target_path.read_text(encoding="utf-8") 

1146 except UnicodeDecodeError: 

1147 return 

1148 

1149 substituted, warnings = processor._substitute_variables( 

1150 content 

1151 ) # pylint: disable=protected-access 

1152 if warnings: 

1153 console.print("[yellow]⚠ Template warnings:[/yellow]") 

1154 for warning in warnings: 

1155 console.print(f" {warning}") 

1156 

1157 target_path.write_text(substituted, encoding="utf-8") 

1158 

1159 

1160def _validate_template_substitution(project_path: Path) -> None: 

1161 """Validate that all template variables have been properly substituted.""" 

1162 import re 

1163 

1164 # Files to check for unsubstituted variables 

1165 files_to_check = [ 

1166 project_path / ".claude" / "settings.json", 

1167 project_path / "CLAUDE.md", 

1168 ] 

1169 

1170 issues_found = [] 

1171 

1172 for file_path in files_to_check: 

1173 if not file_path.exists(): 

1174 continue 

1175 

1176 try: 

1177 content = file_path.read_text(encoding="utf-8") 

1178 # Look for unsubstituted template variables 

1179 unsubstituted = re.findall(r"\{\{([A-Z_]+)\}\}", content) 

1180 if unsubstituted: 

1181 unique_vars = sorted(set(unsubstituted)) 

1182 issues_found.append( 

1183 f"{file_path.relative_to(project_path)}: {', '.join(unique_vars)}" 

1184 ) 

1185 except Exception as e: 

1186 console.print( 

1187 f"[yellow]⚠️ Could not validate {file_path.relative_to(project_path)}: {e}[/yellow]" 

1188 ) 

1189 

1190 if issues_found: 

1191 console.print("[red]✗ Template substitution validation failed:[/red]") 

1192 for issue in issues_found: 

1193 console.print(f" {issue}") 

1194 console.print( 

1195 "[yellow]💡 Run '/alfred:0-project' to fix template variables[/yellow]" 

1196 ) 

1197 else: 

1198 console.print("[green]✅ Template substitution validation passed[/green]") 

1199 

1200 

1201def _validate_template_substitution_with_rollback( 

1202 project_path: Path, backup_path: Path | None 

1203) -> bool: 

1204 """Validate template substitution with rollback capability. 

1205 

1206 Returns: 

1207 True if validation passed, False if failed (rollback handled by caller) 

1208 """ 

1209 import re 

1210 

1211 # Files to check for unsubstituted variables 

1212 files_to_check = [ 

1213 project_path / ".claude" / "settings.json", 

1214 project_path / "CLAUDE.md", 

1215 ] 

1216 

1217 issues_found = [] 

1218 

1219 for file_path in files_to_check: 

1220 if not file_path.exists(): 

1221 continue 

1222 

1223 try: 

1224 content = file_path.read_text(encoding="utf-8") 

1225 # Look for unsubstituted template variables 

1226 unsubstituted = re.findall(r"\{\{([A-Z_]+)\}\}", content) 

1227 if unsubstituted: 

1228 unique_vars = sorted(set(unsubstituted)) 

1229 issues_found.append( 

1230 f"{file_path.relative_to(project_path)}: {', '.join(unique_vars)}" 

1231 ) 

1232 except Exception as e: 

1233 console.print( 

1234 f"[yellow]⚠️ Could not validate {file_path.relative_to(project_path)}: {e}[/yellow]" 

1235 ) 

1236 

1237 if issues_found: 

1238 console.print("[red]✗ Template substitution validation failed:[/red]") 

1239 for issue in issues_found: 

1240 console.print(f" {issue}") 

1241 

1242 if backup_path: 

1243 console.print( 

1244 "[yellow]🔄 Rolling back due to validation failure...[/yellow]" 

1245 ) 

1246 else: 

1247 console.print( 

1248 "[yellow]💡 Run '/alfred:0-project' to fix template variables[/yellow]" 

1249 ) 

1250 console.print("[red]⚠️ No backup available - manual fix required[/red]") 

1251 

1252 return False 

1253 else: 

1254 console.print("[green]✅ Template substitution validation passed[/green]") 

1255 return True 

1256 

1257 

1258def _show_version_info(current: str, latest: str) -> None: 

1259 """Display version information. 

1260 

1261 Args: 

1262 current: Current installed version 

1263 latest: Latest available version 

1264 """ 

1265 console.print("[cyan]🔍 Checking versions...[/cyan]") 

1266 console.print(f" Current version: {current}") 

1267 console.print(f" Latest version: {latest}") 

1268 

1269 

1270def _show_installer_not_found_help() -> None: 

1271 """Show help when installer not found.""" 

1272 console.print("[red]❌ Cannot detect package installer[/red]\n") 

1273 console.print("Installation method not detected. To update manually:\n") 

1274 console.print(" • If installed via uv tool:") 

1275 console.print(" [cyan]uv tool upgrade moai-adk[/cyan]\n") 

1276 console.print(" • If installed via pipx:") 

1277 console.print(" [cyan]pipx upgrade moai-adk[/cyan]\n") 

1278 console.print(" • If installed via pip:") 

1279 console.print(" [cyan]pip install --upgrade moai-adk[/cyan]\n") 

1280 console.print("Then run:") 

1281 console.print(" [cyan]moai-adk update --templates-only[/cyan]") 

1282 

1283 

1284def _show_upgrade_failure_help(installer_cmd: list[str]) -> None: 

1285 """Show help when upgrade fails. 

1286 

1287 Args: 

1288 installer_cmd: The installer command that failed 

1289 """ 

1290 console.print("[red]❌ Upgrade failed[/red]\n") 

1291 console.print("Troubleshooting:") 

1292 console.print(" 1. Check network connection") 

1293 console.print(f" 2. Clear cache: {installer_cmd[0]} cache clean") 

1294 console.print(f" 3. Try manually: {' '.join(installer_cmd)}") 

1295 console.print(" 4. Report issue: https://github.com/modu-ai/moai-adk/issues") 

1296 

1297 

1298def _show_network_error_help() -> None: 

1299 """Show help for network errors.""" 

1300 console.print("[yellow]⚠️ Cannot reach PyPI to check latest version[/yellow]\n") 

1301 console.print("Options:") 

1302 console.print(" 1. Check network connection") 

1303 console.print(" 2. Try again with: [cyan]moai-adk update --force[/cyan]") 

1304 console.print( 

1305 " 3. Skip version check: [cyan]moai-adk update --templates-only[/cyan]" 

1306 ) 

1307 

1308 

1309def _show_template_sync_failure_help() -> None: 

1310 """Show help when template sync fails.""" 

1311 console.print("[yellow]⚠️ Template sync failed[/yellow]\n") 

1312 console.print("Rollback options:") 

1313 console.print( 

1314 " 1. Restore from backup: [cyan]cp -r .moai-backups/TIMESTAMP .moai/[/cyan]" 

1315 ) 

1316 console.print(" 2. Skip backup and retry: [cyan]moai-adk update --force[/cyan]") 

1317 console.print(" 3. Report issue: https://github.com/modu-ai/moai-adk/issues") 

1318 

1319 

1320def _show_timeout_error_help() -> None: 

1321 """Show help for timeout errors.""" 

1322 console.print("[red]❌ Error: Operation timed out[/red]\n") 

1323 console.print("Try again with:") 

1324 console.print(" [cyan]moai-adk update --yes --force[/cyan]") 

1325 

1326 

1327def _execute_migration_if_needed(project_path: Path, yes: bool = False) -> bool: 

1328 """Check and execute migration if needed. 

1329 

1330 Args: 

1331 project_path: Project directory path 

1332 yes: Auto-confirm without prompting 

1333 

1334 Returns: 

1335 True if no migration needed or migration succeeded, False if migration failed 

1336 """ 

1337 try: 

1338 migrator = VersionMigrator(project_path) 

1339 

1340 # Check if migration is needed 

1341 if not migrator.needs_migration(): 

1342 return True 

1343 

1344 # Get migration info 

1345 info = migrator.get_migration_info() 

1346 console.print("\n[cyan]🔄 Migration Required[/cyan]") 

1347 console.print(f" Current version: {info['current_version']}") 

1348 console.print(f" Target version: {info['target_version']}") 

1349 console.print(f" Files to migrate: {info['file_count']}") 

1350 console.print() 

1351 console.print(" This will migrate configuration files to new locations:") 

1352 console.print(" • .moai/config.json → .moai/config/config.json") 

1353 console.print( 

1354 " • .claude/statusline-config.yaml → .moai/config/statusline-config.yaml" 

1355 ) 

1356 console.print() 

1357 console.print(" A backup will be created automatically.") 

1358 console.print() 

1359 

1360 # Confirm with user (unless --yes) 

1361 if not yes: 

1362 if not click.confirm( 

1363 "Do you want to proceed with migration?", default=True 

1364 ): 

1365 console.print( 

1366 "[yellow]⚠️ Migration skipped. Some features may not work correctly.[/yellow]" 

1367 ) 

1368 console.print( 

1369 "[cyan]💡 Run 'moai-adk migrate' manually when ready[/cyan]" 

1370 ) 

1371 return False 

1372 

1373 # Execute migration 

1374 console.print("[cyan]🚀 Starting migration...[/cyan]") 

1375 success = migrator.migrate_to_v024(dry_run=False, cleanup=True) 

1376 

1377 if success: 

1378 console.print("[green]✅ Migration completed successfully![/green]") 

1379 return True 

1380 else: 

1381 console.print("[red]❌ Migration failed[/red]") 

1382 console.print( 

1383 "[cyan]💡 Use 'moai-adk migrate --rollback' to restore from backup[/cyan]" 

1384 ) 

1385 return False 

1386 

1387 except Exception as e: 

1388 console.print(f"[red]❌ Migration error: {e}[/red]") 

1389 logger.error(f"Migration failed: {e}", exc_info=True) 

1390 return False 

1391 

1392 

1393@click.command() 

1394@click.option( 

1395 "--path", 

1396 type=click.Path(exists=True), 

1397 default=".", 

1398 help="Project path (default: current directory)", 

1399) 

1400@click.option("--force", is_flag=True, help="Skip backup and force the update") 

1401@click.option("--check", is_flag=True, help="Only check version (do not update)") 

1402@click.option( 

1403 "--templates-only", is_flag=True, help="Skip package upgrade, sync templates only" 

1404) 

1405@click.option("--yes", is_flag=True, help="Auto-confirm all prompts (CI/CD mode)") 

1406@click.option( 

1407 "--merge", 

1408 "merge_strategy", 

1409 flag_value="auto", 

1410 help="Auto-merge: Apply template + preserve user changes", 

1411) 

1412@click.option( 

1413 "--manual", 

1414 "merge_strategy", 

1415 flag_value="manual", 

1416 help="Manual merge: Preserve backup, generate merge guide", 

1417) 

1418def update( 

1419 path: str, 

1420 force: bool, 

1421 check: bool, 

1422 templates_only: bool, 

1423 yes: bool, 

1424 merge_strategy: str | None, 

1425) -> None: 

1426 """Update command with 3-stage workflow + merge strategy selection (v0.26.0+). 

1427 

1428 Stage 1 (Package Version Check): 

1429 - Fetches current and latest versions from PyPI 

1430 - If current < latest: detects installer (uv tool, pipx, pip) and upgrades package 

1431 - Prompts user to re-run after upgrade completes 

1432 

1433 Stage 2 (Config Version Comparison - NEW in v0.6.3): 

1434 - Compares package template_version with project config.json template_version 

1435 - If versions match: skips Stage 3 (already up-to-date) 

1436 - Performance improvement: 70-80% faster for unchanged projects (3-4s vs 12-18s) 

1437 

1438 Stage 3 (Template Sync with Merge Strategy - NEW in v0.26.0): 

1439 - Syncs templates only if versions differ 

1440 - User chooses merge strategy: 

1441 * Auto-merge (default): Template + preserved user changes 

1442 * Manual merge: Backup + comprehensive merge guide (full control) 

1443 - Updates .claude/, .moai/, CLAUDE.md, config.json 

1444 - Preserves specs and reports 

1445 - Saves new template_version to config.json 

1446 

1447 Examples: 

1448 python -m moai_adk update # interactive merge strategy selection 

1449 python -m moai_adk update --merge # auto-merge (template + user changes) 

1450 python -m moai_adk update --manual # manual merge (backup + guide) 

1451 python -m moai_adk update --force # force template sync (no backup) 

1452 python -m moai_adk update --check # check version only 

1453 python -m moai_adk update --templates-only # skip package upgrade 

1454 python -m moai_adk update --yes # CI/CD mode (auto-confirm + auto-merge) 

1455 

1456 Merge Strategies: 

1457 --merge: Auto-merge applies template + preserves your changes (default) 

1458 Generated files: backup, merge report 

1459 --manual: Manual merge preserves backup + generates comprehensive guide 

1460 Generated files: backup, merge guide 

1461 

1462 Generated Files: 

1463 - Backup: .moai-backups/pre-update-backup_{timestamp}/ 

1464 - Report: .moai/reports/merge-report.md (auto-merge only) 

1465 - Guide: .moai/guides/merge-guide.md (manual merge only) 

1466 """ 

1467 try: 

1468 project_path = Path(path).resolve() 

1469 

1470 # Verify the project is initialized 

1471 if not (project_path / ".moai").exists(): 

1472 console.print("[yellow]⚠ Project not initialized[/yellow]") 

1473 raise click.Abort() 

1474 

1475 # Get versions (needed for --check and normal workflow, but not for --templates-only alone) 

1476 # Note: If --check is used, always fetch versions even if --templates-only is also present 

1477 if check or not templates_only: 

1478 try: 

1479 current = _get_current_version() 

1480 latest = _get_latest_version() 

1481 except RuntimeError as e: 

1482 console.print(f"[red]Error: {e}[/red]") 

1483 if not force: 

1484 console.print( 

1485 "[yellow]⚠ Cannot check for updates. Use --force to update anyway.[/yellow]" 

1486 ) 

1487 raise click.Abort() 

1488 # With --force, proceed to Stage 2 even if version check fails 

1489 current = __version__ 

1490 latest = __version__ 

1491 

1492 _show_version_info(current, latest) 

1493 

1494 # Step 1: Handle --check (preview mode, no changes) - takes priority 

1495 if check: 

1496 comparison = _compare_versions(current, latest) 

1497 if comparison < 0: 

1498 console.print( 

1499 f"\n[yellow]📦 Update available: {current}{latest}[/yellow]" 

1500 ) 

1501 console.print(" Run 'moai-adk update' to upgrade") 

1502 elif comparison == 0: 

1503 console.print(f"[green]✓ Already up to date ({current})[/green]") 

1504 else: 

1505 console.print( 

1506 f"[cyan]ℹ️ Dev version: {current} (latest: {latest})[/cyan]" 

1507 ) 

1508 return 

1509 

1510 # Step 2: Handle --templates-only (skip upgrade, go straight to sync) 

1511 if templates_only: 

1512 console.print("[cyan]📄 Syncing templates only...[/cyan]") 

1513 

1514 # Preserve user-specific settings before sync 

1515 console.print(" [cyan]💾 Preserving user settings...[/cyan]") 

1516 preserved_settings = _preserve_user_settings(project_path) 

1517 

1518 try: 

1519 if not _sync_templates(project_path, force): 

1520 raise TemplateSyncError("Template sync returned False") 

1521 except TemplateSyncError: 

1522 console.print("[red]Error: Template sync failed[/red]") 

1523 _show_template_sync_failure_help() 

1524 raise click.Abort() 

1525 except Exception as e: 

1526 console.print(f"[red]Error: Template sync failed - {e}[/red]") 

1527 _show_template_sync_failure_help() 

1528 raise click.Abort() 

1529 

1530 # Restore user-specific settings after sync 

1531 _restore_user_settings(project_path, preserved_settings) 

1532 

1533 console.print(" [green]✅ .claude/ update complete[/green]") 

1534 console.print( 

1535 " [green]✅ .moai/ update complete (specs/reports preserved)[/green]" 

1536 ) 

1537 console.print(" [green]🔄 CLAUDE.md merge complete[/green]") 

1538 console.print(" [green]🔄 config.json merge complete[/green]") 

1539 console.print("\n[green]✓ Template sync complete![/green]") 

1540 return 

1541 

1542 # Compare versions 

1543 comparison = _compare_versions(current, latest) 

1544 

1545 # Stage 1: Package Upgrade (if current < latest) 

1546 if comparison < 0: 

1547 console.print(f"\n[cyan]📦 Upgrading: {current}{latest}[/cyan]") 

1548 

1549 # Confirm upgrade (unless --yes) 

1550 if not yes: 

1551 if not click.confirm(f"Upgrade {current}{latest}?", default=True): 

1552 console.print("Cancelled") 

1553 return 

1554 

1555 # Detect installer 

1556 try: 

1557 installer_cmd = _detect_tool_installer() 

1558 if not installer_cmd: 

1559 raise InstallerNotFoundError("No package installer detected") 

1560 except InstallerNotFoundError: 

1561 _show_installer_not_found_help() 

1562 raise click.Abort() 

1563 

1564 # Display upgrade command 

1565 console.print(f"Running: {' '.join(installer_cmd)}") 

1566 

1567 # Execute upgrade with timeout handling 

1568 try: 

1569 upgrade_result = _execute_upgrade(installer_cmd) 

1570 if not upgrade_result: 

1571 raise UpgradeError( 

1572 f"Upgrade command failed: {' '.join(installer_cmd)}" 

1573 ) 

1574 except subprocess.TimeoutExpired: 

1575 _show_timeout_error_help() 

1576 raise click.Abort() 

1577 except UpgradeError: 

1578 _show_upgrade_failure_help(installer_cmd) 

1579 raise click.Abort() 

1580 

1581 # Prompt re-run 

1582 console.print("\n[green]✓ Upgrade complete![/green]") 

1583 console.print( 

1584 "[cyan]📢 Run 'moai-adk update' again to sync templates[/cyan]" 

1585 ) 

1586 return 

1587 

1588 # Stage 1.5: Migration Check (NEW in v0.24.0) 

1589 console.print(f"✓ Package already up to date ({current})") 

1590 

1591 # Execute migration if needed 

1592 if not _execute_migration_if_needed(project_path, yes): 

1593 console.print("[yellow]⚠️ Update continuing without migration[/yellow]") 

1594 console.print( 

1595 "[cyan]💡 Some features may require migration to work correctly[/cyan]" 

1596 ) 

1597 

1598 # Stage 2: Config Version Comparison 

1599 try: 

1600 package_config_version = _get_package_config_version() 

1601 project_config_version = _get_project_config_version(project_path) 

1602 except ValueError as e: 

1603 console.print(f"[yellow]⚠ Warning: {e}[/yellow]") 

1604 # On version detection error, proceed with template sync (safer choice) 

1605 package_config_version = __version__ 

1606 project_config_version = "0.0.0" 

1607 

1608 console.print("\n[cyan]🔍 Comparing config versions...[/cyan]") 

1609 console.print(f" Package template: {package_config_version}") 

1610 console.print(f" Project config: {project_config_version}") 

1611 

1612 try: 

1613 config_comparison = _compare_versions( 

1614 package_config_version, project_config_version 

1615 ) 

1616 except version.InvalidVersion as e: 

1617 # Handle invalid version strings (e.g., unsubstituted template placeholders, corrupted configs) 

1618 console.print(f"[yellow]⚠ Invalid version format in config: {e}[/yellow]") 

1619 console.print( 

1620 "[cyan]ℹ️ Forcing template sync to repair configuration...[/cyan]" 

1621 ) 

1622 # Force template sync by treating project version as outdated 

1623 config_comparison = 1 # package_config_version > project_config_version 

1624 

1625 # If versions are equal, no sync needed 

1626 if config_comparison <= 0: 

1627 console.print( 

1628 f"\n[green]✓ Project already has latest template version ({project_config_version})[/green]" 

1629 ) 

1630 console.print( 

1631 "[cyan]ℹ️ Templates are up to date! No changes needed.[/cyan]" 

1632 ) 

1633 return 

1634 

1635 # Stage 3: Template Sync (Only if package_config_version > project_config_version) 

1636 console.print( 

1637 f"\n[cyan]📄 Syncing templates ({project_config_version}{package_config_version})...[/cyan]" 

1638 ) 

1639 

1640 # Determine merge strategy (default: auto-merge) 

1641 final_merge_strategy = merge_strategy or "auto" 

1642 

1643 # Handle merge strategy 

1644 if final_merge_strategy == "manual": 

1645 # Manual merge mode: Create full backup + generate guide, no template sync 

1646 console.print("\n[cyan]🔀 Manual merge mode selected[/cyan]") 

1647 

1648 # Create full project backup 

1649 console.print(" [cyan]💾 Creating full project backup...[/cyan]") 

1650 try: 

1651 from moai_adk.core.migration.backup_manager import BackupManager 

1652 

1653 backup_manager = BackupManager(project_path) 

1654 full_backup_path = backup_manager.create_full_project_backup( 

1655 description="pre-update-backup" 

1656 ) 

1657 console.print( 

1658 f" [green]✓ Backup: {full_backup_path.relative_to(project_path)}/[/green]" 

1659 ) 

1660 

1661 # Generate merge guide 

1662 console.print(" [cyan]📋 Generating merge guide...[/cyan]") 

1663 template_path = Path(__file__).parent.parent.parent / "templates" 

1664 guide_path = _generate_manual_merge_guide( 

1665 full_backup_path, template_path, project_path 

1666 ) 

1667 console.print( 

1668 f" [green]✓ Guide: {guide_path.relative_to(project_path)}[/green]" 

1669 ) 

1670 

1671 # Summary 

1672 console.print("\n[green]✓ Manual merge setup complete![/green]") 

1673 console.print( 

1674 f"[cyan]📍 Backup location: {full_backup_path.relative_to(project_path)}/[/cyan]" 

1675 ) 

1676 console.print( 

1677 f"[cyan]📋 Merge guide: {guide_path.relative_to(project_path)}[/cyan]" 

1678 ) 

1679 console.print( 

1680 "\n[yellow]⚠️ Next steps:[/yellow]" 

1681 ) 

1682 console.print( 

1683 "[yellow] 1. Review the merge guide[/yellow]" 

1684 ) 

1685 console.print( 

1686 "[yellow] 2. Compare files using diff or visual tools[/yellow]" 

1687 ) 

1688 console.print( 

1689 "[yellow] 3. Manually merge your customizations[/yellow]" 

1690 ) 

1691 console.print( 

1692 "[yellow] 4. Test and commit changes[/yellow]" 

1693 ) 

1694 

1695 except Exception as e: 

1696 console.print(f"[red]Error: Manual merge setup failed - {e}[/red]") 

1697 raise click.Abort() 

1698 

1699 return 

1700 

1701 # Auto merge mode: Preserve user-specific settings before sync 

1702 console.print("\n[cyan]🔀 Auto-merge mode selected[/cyan]") 

1703 console.print(" [cyan]💾 Preserving user settings...[/cyan]") 

1704 preserved_settings = _preserve_user_settings(project_path) 

1705 

1706 # Create backup unless --force 

1707 if not force: 

1708 console.print(" [cyan]💾 Creating backup...[/cyan]") 

1709 try: 

1710 processor = TemplateProcessor(project_path) 

1711 backup_path = processor.create_backup() 

1712 console.print( 

1713 f" [green]✓ Backup: {backup_path.relative_to(project_path)}/[/green]" 

1714 ) 

1715 except Exception as e: 

1716 console.print(f" [yellow]⚠ Backup failed: {e}[/yellow]") 

1717 console.print(" [yellow]⚠ Continuing without backup...[/yellow]") 

1718 else: 

1719 console.print(" [yellow]⚠ Skipping backup (--force)[/yellow]") 

1720 

1721 # Sync templates 

1722 try: 

1723 if not _sync_templates(project_path, force): 

1724 raise TemplateSyncError("Template sync returned False") 

1725 except TemplateSyncError: 

1726 console.print("[red]Error: Template sync failed[/red]") 

1727 _show_template_sync_failure_help() 

1728 raise click.Abort() 

1729 except Exception as e: 

1730 console.print(f"[red]Error: Template sync failed - {e}[/red]") 

1731 _show_template_sync_failure_help() 

1732 raise click.Abort() 

1733 

1734 # Restore user-specific settings after sync 

1735 _restore_user_settings(project_path, preserved_settings) 

1736 

1737 console.print(" [green]✅ .claude/ update complete[/green]") 

1738 console.print( 

1739 " [green]✅ .moai/ update complete (specs/reports preserved)[/green]" 

1740 ) 

1741 console.print(" [green]🔄 CLAUDE.md merge complete[/green]") 

1742 console.print(" [green]🔄 config.json merge complete[/green]") 

1743 console.print( 

1744 " [yellow]⚙️ Set optimized=false (optimization needed)[/yellow]" 

1745 ) 

1746 

1747 console.print("\n[green]✓ Update complete![/green]") 

1748 console.print( 

1749 "[cyan]ℹ️ Next step: Run /alfred:0-project update to optimize template changes[/cyan]" 

1750 ) 

1751 

1752 except Exception as e: 

1753 console.print(f"[red]✗ Update failed: {e}[/red]") 

1754 raise click.ClickException(str(e)) from e