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
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""Update command
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)
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
15## Skill Invocation Guide (English-Only)
16# mypy: disable-error-code=return-value
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
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
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
332. **Before updating**:
34 - Create backup with `python -m moai_adk backup`
363. **If update fails**:
37 - Use backup to restore previous state
38 - Debug with `python -m moai_adk doctor --verbose`
39"""
41# type: ignore
43from __future__ import annotations
45import json
46import logging
47import subprocess
48from datetime import datetime
49from pathlib import Path
50from typing import Any, cast
52import click
53from packaging import version
54from rich.console import Console
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
62console = Console()
63logger = logging.getLogger(__name__)
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"]
72# Custom exceptions for better error handling
73class UpdateError(Exception):
74 """Base exception for update operations."""
76 pass
79class InstallerNotFoundError(UpdateError):
80 """Raised when no package installer detected."""
82 pass
85class NetworkError(UpdateError):
86 """Raised when network operation fails."""
88 pass
91class UpgradeError(UpdateError):
92 """Raised when package upgrade fails."""
94 pass
97class TemplateSyncError(UpdateError):
98 """Raised when template sync fails."""
100 pass
103def _is_installed_via_uv_tool() -> bool:
104 """Check if moai-adk installed via uv tool.
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
122def _is_installed_via_pipx() -> bool:
123 """Check if moai-adk installed via pipx.
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
141def _is_installed_via_pip() -> bool:
142 """Check if moai-adk installed via pip.
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
160def _detect_tool_installer() -> list[str] | None:
161 """Detect which tool installed moai-adk.
163 Checks in priority order:
164 1. uv tool (most likely for MoAI-ADK users)
165 2. pipx
166 3. pip (fallback)
168 Returns:
169 Command list [tool, ...args] ready for subprocess.run()
170 or None if detection fails
172 Examples:
173 >>> # If uv tool is detected:
174 >>> _detect_tool_installer()
175 ['uv', 'tool', 'upgrade', 'moai-adk']
177 >>> # If pipx is detected:
178 >>> _detect_tool_installer()
179 ['pipx', 'upgrade', 'moai-adk']
181 >>> # If only pip is available:
182 >>> _detect_tool_installer()
183 ['pip', 'install', '--upgrade', 'moai-adk']
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
199def _get_current_version() -> str:
200 """Get currently installed moai-adk version.
202 Returns:
203 Version string (e.g., "0.6.1")
205 Raises:
206 RuntimeError: If version cannot be determined
207 """
208 return __version__
211def _get_latest_version() -> str:
212 """Fetch latest moai-adk version from PyPI.
214 Returns:
215 Version string (e.g., "0.6.2")
217 Raises:
218 RuntimeError: If PyPI API unavailable or parsing fails
219 """
220 try:
221 import urllib.error
222 import urllib.request
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
234def _compare_versions(current: str, latest: str) -> int:
235 """Compare semantic versions.
237 Args:
238 current: Current version string
239 latest: Latest version string
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)
249 if current_v < latest_v:
250 return -1
251 elif current_v == latest_v:
252 return 0
253 else:
254 return 1
257def _get_package_config_version() -> str:
258 """Get the current package template version.
260 This returns the version of the currently installed moai-adk package,
261 which is the version of templates that this package provides.
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__
271def _get_project_config_version(project_path: Path) -> str:
272 """Get current project config.json template version.
274 This reads the project's .moai/config/config.json to determine the current
275 template version that the project is configured with.
277 Args:
278 project_path: Project directory path (absolute)
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)
284 Raises:
285 ValueError: If config.json exists but cannot be parsed
286 """
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 )
294 config_path = project_path / ".moai" / "config" / "config.json"
296 if not config_path.exists():
297 # No config yet, treat as version 0.0.0 (needs initial sync)
298 return "0.0.0"
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
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
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
318def _ask_merge_strategy(yes: bool = False) -> str:
319 """
320 Ask user to choose merge strategy via CLI prompt.
322 Args:
323 yes: If True, auto-select "auto" (for --yes flag)
325 Returns:
326 "auto" or "manual"
327 """
328 if yes:
329 return "auto"
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 )
341 response = click.prompt("Select [1 or 2]", default="1")
342 if response == "2":
343 return "manual"
344 return "auto"
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.
353 Args:
354 backup_path: Path to backup directory
355 template_path: Path to template directory
356 project_path: Project root path
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)
364 guide_path = guide_dir / "merge-guide.md"
366 # Find changed files
367 changed_files = []
368 backup_claude = backup_path / ".claude"
369 backup_moai = backup_path / ".moai"
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)")
385 # Generate guide
386 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
387 guide_content = f"""# Merge Guide - Manual Merge Mode
389**Generated**: {timestamp}
390**Backup Location**: `{backup_path.relative_to(project_path)}/`
392## Summary
394During this update, the following files were changed:
396{chr(10).join(changed_files) if changed_files else " (No changes detected)"}
398## How to Merge
400### Option 1: Using diff (Terminal)
402```bash
403# Compare specific files
404diff {backup_path.name}/.claude/settings.json .claude/settings.json
406# View all differences
407diff -r {backup_path.name}/ .
408```
410### Option 2: Using Visual Merge Tool
412```bash
413# macOS/Linux - Using meld
414meld {backup_path.relative_to(project_path)}/ .
416# Using VSCode
417code --diff {backup_path.relative_to(project_path)}/.claude/settings.json .claude/settings.json
418```
420### Option 3: Manual Line-by-Line
4221. Open backup file in your editor
4232. Open current file side-by-side
4243. Manually copy your customizations
426## Key Files to Review
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
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
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/
443## Migration Checklist
445- [ ] Compare `.claude/settings.json`
446 - [ ] Restore custom MCP servers
447 - [ ] Restore environment variables
448 - [ ] Verify hooks are properly configured
450- [ ] Review `.moai/config/config.json`
451 - [ ] Check version was updated
452 - [ ] Verify user settings preserved
454- [ ] Restore custom scripts
455 - [ ] Any custom commands outside /moai/
456 - [ ] Any custom agents outside /moai/
457 - [ ] Any custom hooks outside /moai/
459- [ ] Run tests
460 ```bash
461 uv run pytest
462 moai-adk validate
463 ```
465- [ ] Commit changes
466 ```bash
467 git add .
468 git commit -m "merge: Update templates with manual merge"
469 ```
471## Rollback if Needed
473If you want to cancel and restore the backup:
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 .
481# Or restore specific files
482cp {backup_path.relative_to(project_path)}/.claude/settings.json .claude/
483```
485## Questions?
487If you encounter merge conflicts or issues:
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
493---
495**Backup**: `{backup_path}/`
496**Generated**: {timestamp}
497"""
499 guide_path.write_text(guide_content, encoding="utf-8")
500 logger.info(f"✅ Merge guide created: {guide_path}")
501 return guide_path
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.
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
516 Uses packaging.version.parse() for robust semantic version comparison that
517 handles pre-releases, dev versions, and other PEP 440 version formats correctly.
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")
524 Returns:
525 True if cache is stale (output shows "Nothing to upgrade" but current < latest),
526 False otherwise
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
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
551def _clear_uv_package_cache(package_name: str = "moai-adk") -> bool:
552 """
553 Clear uv cache for specific package.
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.).
559 Args:
560 package_name: Package name to clear cache for (default: "moai-adk")
562 Returns:
563 True if cache cleared successfully, False otherwise
565 Exceptions:
566 - subprocess.TimeoutExpired: Logged as warning, returns False
567 - FileNotFoundError: Logged as warning, returns False
568 - Exception: Logged as warning, returns False
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 )
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
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
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.
607 Implements a robust 7-stage upgrade flow that handles PyPI cache staleness:
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)
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
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
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")
632 Returns:
633 True if upgrade succeeded (either first attempt or after retry),
634 False otherwise
636 Examples:
637 >>> # First attempt succeeds
638 >>> _execute_upgrade_with_retry(["uv", "tool", "upgrade", "moai-adk"])
639 True
641 >>> # First attempt stale, retry succeeds
642 >>> _execute_upgrade_with_retry(["uv", "tool", "upgrade", "moai-adk"])
643 True # After cache clear and retry
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
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
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
670 if _detect_stale_cache(result.stdout, current_version, latest_version):
671 # Stage 4: User feedback
672 console.print("[yellow]⚠️ Cache outdated, refreshing...[/yellow]")
674 # Stage 5: Clear cache
675 if _clear_uv_package_cache(package_name):
676 console.print("[cyan]♻️ Cache cleared, retrying upgrade...[/cyan]")
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 )
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
703 # Stage 7: Cache is not stale, return original result
704 return result.returncode == 0
707def _execute_upgrade(installer_cmd: list[str]) -> bool:
708 """Execute package upgrade using detected installer.
710 Args:
711 installer_cmd: Command list from _detect_tool_installer()
712 e.g., ["uv", "tool", "upgrade", "moai-adk"]
714 Returns:
715 True if upgrade succeeded, False otherwise
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
731def _preserve_user_settings(project_path: Path) -> dict[str, Path | None]:
732 """Back up user-specific settings files before template sync.
734 Args:
735 project_path: Project directory path
737 Returns:
738 Dictionary with backup paths of preserved files
739 """
740 preserved = {}
741 claude_dir = project_path / ".claude"
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
759 return preserved
762def _restore_user_settings(project_path: Path, preserved: dict[str, Path | None]) -> bool:
763 """Restore user-specific settings files after template sync.
765 Args:
766 project_path: Project directory path
767 preserved: Dictionary of backup paths from _preserve_user_settings()
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)
775 success = True
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
789 return success
792def _sync_templates(project_path: Path, force: bool = False) -> bool:
793 """Sync templates to project with rollback mechanism.
795 Args:
796 project_path: Project path (absolute)
797 force: Force update without backup
799 Returns:
800 True if sync succeeded, False otherwise
801 """
802 from moai_adk.core.template.backup import TemplateBackup
804 backup_path = None
805 try:
806 processor = TemplateProcessor(project_path)
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}")
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 )
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)
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 )
843 # Load existing config
844 existing_config = _load_existing_config(project_path)
846 # Build context
847 context = _build_template_context(project_path, existing_config, __version__)
848 if context:
849 processor.set_context(context)
851 # Copy templates (including moai folder)
852 processor.copy_templates(backup=False, silent=True)
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
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
892 # Preserve metadata
893 _preserve_project_metadata(project_path, context, existing_config, __version__)
894 _apply_context_to_file(processor, project_path / "CLAUDE.md")
896 # Set optimized=false
897 set_optimized_false(project_path)
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"
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))
916 except Exception as e:
917 console.print(f"[yellow]⚠️ Announcement module not available: {e}[/yellow]")
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
935def get_latest_version() -> str | None:
936 """Get the latest version from PyPI.
938 DEPRECATED: Use _get_latest_version() for new code.
939 This function is kept for backward compatibility.
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
951def set_optimized_false(project_path: Path) -> None:
952 """Set config.json's optimized field to false.
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
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
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 {}
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 )
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
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 {}
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
1026 project_section = _extract_project_section(existing_config)
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 )
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 )
1062 # Extract language configuration
1063 language_config = existing_config.get("language", {})
1064 if not isinstance(language_config, dict):
1065 language_config = {}
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 }
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.
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
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
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"]
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"])
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
1122 language = _coalesce(
1123 existing_project.get("language"), existing_config.get("language")
1124 )
1125 if language:
1126 project_data["language"] = language
1128 config_data.setdefault("moai", {})
1129 config_data["moai"]["version"] = version_for_config
1131 # This allows Stage 2 to compare package vs project template versions
1132 project_data["template_version"] = version_for_config
1134 config_path.write_text(
1135 json.dumps(config_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
1136 )
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
1144 try:
1145 content = target_path.read_text(encoding="utf-8")
1146 except UnicodeDecodeError:
1147 return
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}")
1157 target_path.write_text(substituted, encoding="utf-8")
1160def _validate_template_substitution(project_path: Path) -> None:
1161 """Validate that all template variables have been properly substituted."""
1162 import re
1164 # Files to check for unsubstituted variables
1165 files_to_check = [
1166 project_path / ".claude" / "settings.json",
1167 project_path / "CLAUDE.md",
1168 ]
1170 issues_found = []
1172 for file_path in files_to_check:
1173 if not file_path.exists():
1174 continue
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 )
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]")
1201def _validate_template_substitution_with_rollback(
1202 project_path: Path, backup_path: Path | None
1203) -> bool:
1204 """Validate template substitution with rollback capability.
1206 Returns:
1207 True if validation passed, False if failed (rollback handled by caller)
1208 """
1209 import re
1211 # Files to check for unsubstituted variables
1212 files_to_check = [
1213 project_path / ".claude" / "settings.json",
1214 project_path / "CLAUDE.md",
1215 ]
1217 issues_found = []
1219 for file_path in files_to_check:
1220 if not file_path.exists():
1221 continue
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 )
1237 if issues_found:
1238 console.print("[red]✗ Template substitution validation failed:[/red]")
1239 for issue in issues_found:
1240 console.print(f" {issue}")
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]")
1252 return False
1253 else:
1254 console.print("[green]✅ Template substitution validation passed[/green]")
1255 return True
1258def _show_version_info(current: str, latest: str) -> None:
1259 """Display version information.
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}")
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]")
1284def _show_upgrade_failure_help(installer_cmd: list[str]) -> None:
1285 """Show help when upgrade fails.
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")
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 )
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")
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]")
1327def _execute_migration_if_needed(project_path: Path, yes: bool = False) -> bool:
1328 """Check and execute migration if needed.
1330 Args:
1331 project_path: Project directory path
1332 yes: Auto-confirm without prompting
1334 Returns:
1335 True if no migration needed or migration succeeded, False if migration failed
1336 """
1337 try:
1338 migrator = VersionMigrator(project_path)
1340 # Check if migration is needed
1341 if not migrator.needs_migration():
1342 return True
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()
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
1373 # Execute migration
1374 console.print("[cyan]🚀 Starting migration...[/cyan]")
1375 success = migrator.migrate_to_v024(dry_run=False, cleanup=True)
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
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
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+).
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
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)
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
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)
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
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()
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()
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__
1492 _show_version_info(current, latest)
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
1510 # Step 2: Handle --templates-only (skip upgrade, go straight to sync)
1511 if templates_only:
1512 console.print("[cyan]📄 Syncing templates only...[/cyan]")
1514 # Preserve user-specific settings before sync
1515 console.print(" [cyan]💾 Preserving user settings...[/cyan]")
1516 preserved_settings = _preserve_user_settings(project_path)
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()
1530 # Restore user-specific settings after sync
1531 _restore_user_settings(project_path, preserved_settings)
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
1542 # Compare versions
1543 comparison = _compare_versions(current, latest)
1545 # Stage 1: Package Upgrade (if current < latest)
1546 if comparison < 0:
1547 console.print(f"\n[cyan]📦 Upgrading: {current} → {latest}[/cyan]")
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
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()
1564 # Display upgrade command
1565 console.print(f"Running: {' '.join(installer_cmd)}")
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()
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
1588 # Stage 1.5: Migration Check (NEW in v0.24.0)
1589 console.print(f"✓ Package already up to date ({current})")
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 )
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"
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}")
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
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
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 )
1640 # Determine merge strategy (default: auto-merge)
1641 final_merge_strategy = merge_strategy or "auto"
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]")
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
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 )
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 )
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 )
1695 except Exception as e:
1696 console.print(f"[red]Error: Manual merge setup failed - {e}[/red]")
1697 raise click.Abort()
1699 return
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)
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]")
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()
1734 # Restore user-specific settings after sync
1735 _restore_user_settings(project_path, preserved_settings)
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 )
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 )
1752 except Exception as e:
1753 console.print(f"[red]✗ Update failed: {e}[/red]")
1754 raise click.ClickException(str(e)) from e