Coverage for src / moai_adk / core / project / initializer.py: 15.38%
104 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# SPEC: SPEC-CORE-PROJECT-001.md, SPEC-INIT-003.md
2# TEST: tests/unit/test_project_initializer.py, tests/unit/test_init_reinit.py
3"""Project Initialization Module
5Phase-based 5-step initialization process:
61. Preparation: Backup and validation
72. Directory: Create directory structure
83. Resource: Copy template resources
94. Configuration: Generate configuration files
105. Validation: Verification and finalization
11"""
12# type: ignore
14import json
15import stat
16import time
17from datetime import datetime
18from pathlib import Path
20from moai_adk.core.project.phase_executor import PhaseExecutor, ProgressCallback # type: ignore
21from moai_adk.core.project.validator import ProjectValidator
24class InstallationResult:
25 """Installation result"""
27 def __init__(
28 self,
29 success: bool,
30 project_path: str,
31 language: str,
32 mode: str,
33 locale: str,
34 duration: int,
35 created_files: list[str],
36 errors: list[str] | None = None,
37 ) -> None:
38 self.success = success
39 self.project_path = project_path
40 self.language = language
41 self.mode = mode
42 self.locale = locale
43 self.duration = duration
44 self.created_files = created_files
45 self.errors = errors or []
48class ProjectInitializer:
49 """Project initializer (Phase-based)"""
51 def __init__(self, path: str | Path = ".") -> None:
52 """Initialize
54 Args:
55 path: Project root directory
56 """
57 self.path = Path(path).resolve()
58 self.validator = ProjectValidator()
59 self.executor = PhaseExecutor(self.validator)
61 def _create_memory_files(self) -> list[str]:
62 """Create runtime session and memory files (auto-generated per user/session)
64 Returns:
65 List of created memory files
67 """
68 memory_dir = self.path / ".moai" / "memory"
69 memory_dir.mkdir(parents=True, exist_ok=True)
70 created_files = []
72 # 1. project-notes.json - Project tracking notes (empty on init)
73 project_notes = {
74 "tech_debt": [],
75 "performance_bottlenecks": [],
76 "recent_patterns": {
77 "frequent_file_edits": [],
78 "test_failures": [],
79 "git_operations": "daily commits, feature branches",
80 },
81 "next_priorities": [],
82 }
83 project_notes_file = memory_dir / "project-notes.json"
84 project_notes_file.write_text(json.dumps(project_notes, indent=2))
85 created_files.append(str(project_notes_file))
87 # 2. session-hint.json - Last session state
88 session_hint = {
89 "last_command": None,
90 "command_timestamp": None,
91 "hours_ago": None,
92 "active_spec": None,
93 "current_branch": "main",
94 }
95 session_hint_file = memory_dir / "session-hint.json"
96 session_hint_file.write_text(json.dumps(session_hint, indent=2))
97 created_files.append(str(session_hint_file))
99 # 3. user-patterns.json - User preferences and expertise
100 user_patterns = {
101 "tech_preferences": {},
102 "expertise_signals": {
103 "ask_question_skip_rate": 0.0,
104 "custom_workflows": 0,
105 "estimated_level": "beginner",
106 },
107 "skip_questions": [],
108 "last_updated": datetime.now().isoformat() + "Z",
109 }
110 user_patterns_file = memory_dir / "user-patterns.json"
111 user_patterns_file.write_text(json.dumps(user_patterns, indent=2))
112 created_files.append(str(user_patterns_file))
114 return created_files
116 def _create_user_settings(self) -> list[str]:
117 """Create user-specific settings files (.claude/settings.local.json)
119 Returns:
120 List of created settings files
121 """
122 created_files = []
123 claude_dir = self.path / ".claude"
124 claude_dir.mkdir(parents=True, exist_ok=True)
126 # Create settings.local.json
127 settings_local = {
128 "_meta": {
129 "description": "User-specific Claude Code settings (gitignored - never commit)",
130 "created_at": datetime.now().isoformat() + "Z",
131 "note": "Edit this file to customize your local development environment",
132 },
133 "enabledMcpjsonServers": ["context7"], # context7 is mandatory
134 }
136 # Add companyAnnouncements in user's selected language
137 try:
138 import sys
139 utils_dir = Path(__file__).parent.parent.parent / "templates" / ".claude" / "hooks" / "moai" / "shared" / "utils"
141 if utils_dir.exists():
142 sys.path.insert(0, str(utils_dir))
143 try:
144 from announcement_translator import get_language_from_config, translate_announcements
145 language = get_language_from_config(self.path)
146 announcements = translate_announcements(language, self.path)
147 settings_local["companyAnnouncements"] = announcements
148 except Exception as e:
149 print(f"[ProjectInitializer] Warning: Failed to add announcements: {e}")
150 finally:
151 sys.path.remove(str(utils_dir))
153 except Exception as e:
154 print(f"[ProjectInitializer] Warning: Announcement module not available: {e}")
156 settings_local_file = claude_dir / "settings.local.json"
157 settings_local_file.write_text(json.dumps(settings_local, indent=2, ensure_ascii=False))
158 created_files.append(str(settings_local_file))
160 return created_files
162 def initialize(
163 self,
164 mode: str = "personal",
165 locale: str = "en", # Changed from "ko" to "en" (will be configurable in /alfred:0-project)
166 language: str | None = None,
167 custom_language: str | None = None,
168 backup_enabled: bool = True,
169 progress_callback: ProgressCallback | None = None,
170 reinit: bool = False,
171 ) -> InstallationResult:
172 """Execute project initialization (5-phase process)
174 Args:
175 mode: Project mode (personal/team) - Default: personal (configurable in /alfred:0-project)
176 locale: Locale (ko/en/ja/zh/other) - Default: en (configurable in /alfred:0-project)
177 language: Force language specification (auto-detect if None) - Will be detected in /alfred:0-project
178 custom_language: Custom language name when locale="other" (user input)
179 backup_enabled: Whether to enable backup
180 progress_callback: Progress callback
181 reinit: Reinitialization mode (v0.3.0, SPEC-INIT-003)
183 Returns:
184 InstallationResult object
186 Raises:
187 FileExistsError: If project is already initialized (when reinit=False)
188 """
189 start_time = time.time()
191 try:
192 # Prevent duplicate initialization (only when not in reinit mode)
193 if self.is_initialized() and not reinit:
194 raise FileExistsError(
195 f"Project already initialized at {self.path}/.moai/\n"
196 f"Use 'python -m moai_adk status' to check the current configuration."
197 )
199 # Use provided language or default to generic
200 # Language detection now happens in /alfred:0-project via project-manager
201 detected_language = language or "generic"
203 # Phase 1: Preparation (backup and validation)
204 self.executor.execute_preparation_phase(
205 self.path, backup_enabled, progress_callback
206 )
208 # Phase 2: Directory (create directories)
209 self.executor.execute_directory_phase(self.path, progress_callback)
211 # Prepare config for template variable substitution (Phase 3)
212 config = {
213 "name": self.path.name,
214 "mode": mode,
215 "locale": locale,
216 "language": detected_language,
217 "description": "",
218 "version": "0.1.0",
219 "author": "@user",
220 }
222 # Phase 3: Resource (copy templates with variable substitution)
223 resource_files = self.executor.execute_resource_phase(
224 self.path, config, progress_callback
225 )
227 # Post-Phase 3: Fix shell script permissions
228 # git may not preserve file permissions, so explicitly set them
229 scripts_dir = self.path / ".moai" / "scripts"
230 if scripts_dir.exists():
231 for script_file in scripts_dir.glob("*.sh"):
232 try:
233 current_mode = script_file.stat().st_mode
234 new_mode = current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
235 # On Windows, chmod has limited effect, but we try anyway
236 # or check os.name != 'nt' if strict behavior is needed.
237 # For now, we just apply it and ignore errors if it fails.
238 script_file.chmod(new_mode)
239 except Exception:
240 pass # Silently ignore permission errors
242 # Phase 4: Configuration (generate config.json)
243 # Handle language configuration
244 language_config = {}
245 if locale == "other" and custom_language:
246 language_config = {
247 "conversation_language": "other",
248 "conversation_language_name": custom_language,
249 }
250 elif locale in ["ko", "en", "ja", "zh"]:
251 language_names = {
252 "ko": "Korean",
253 "en": "English",
254 "ja": "Japanese",
255 "zh": "Chinese",
256 }
257 language_config = {
258 "conversation_language": locale,
259 "conversation_language_name": language_names.get(locale, "English"),
260 }
261 else:
262 # Default fallback
263 language_config = {
264 "conversation_language": locale,
265 "conversation_language_name": "English",
266 }
268 config_data: dict[str, str | bool | dict] = {
269 "project": {
270 "name": self.path.name,
271 "mode": mode,
272 "locale": locale,
273 "language": detected_language,
274 # Language detection metadata (will be updated by project-manager via /alfred:0-project)
275 "language_detection": {
276 "detected_language": detected_language,
277 "detection_method": "cli_default", # Will be "context_aware" after /alfred:0-project
278 "confidence": None, # Will be calculated by project-manager
279 "markers": [], # Will be populated by project-manager
280 "confirmed_by": None, # Will be "user" after project-manager confirmation
281 },
282 },
283 "language": language_config,
284 "constitution": {
285 "enforce_tdd": True,
286 "principles": {
287 "simplicity": {
288 "max_projects": 5,
289 "notes": "Default recommendation. Adjust in .moai/config.json or via SPEC/ADR with documented rationale based on project size.",
290 }
291 },
292 "test_coverage_target": 90,
293 },
294 "git_strategy": {
295 "personal": {
296 "auto_checkpoint": "disabled",
297 "checkpoint_events": ["delete", "refactor", "merge", "script", "critical-file"],
298 "checkpoint_type": "local-branch",
299 "max_checkpoints": 10,
300 "cleanup_days": 7,
301 "push_to_remote": False,
302 "auto_commit": True,
303 "branch_prefix": "feature/SPEC-",
304 "develop_branch": "develop",
305 "main_branch": "main",
306 "prevent_branch_creation": False,
307 "work_on_main": False,
308 },
309 "team": {
310 "auto_pr": False,
311 "develop_branch": "develop",
312 "draft_pr": False,
313 "feature_prefix": "feature/SPEC-",
314 "main_branch": "main",
315 "use_gitflow": True,
316 "default_pr_base": "develop",
317 "prevent_main_direct_merge": False,
318 },
319 },
320 "session": {
321 "suppress_setup_messages": False,
322 "notes": "suppress_setup_messages: false enables SessionStart output. Set to true to suppress messages (show again after 7 days)",
323 },
324 }
325 config_files = self.executor.execute_configuration_phase(
326 self.path, config_data, progress_callback
327 )
329 # Phase 5: Validation (verify and finalize)
330 self.executor.execute_validation_phase(self.path, mode, progress_callback)
332 # Phase 6: Create runtime memory files (auto-generated per user/session)
333 memory_files = self._create_memory_files()
335 # Phase 7: Create user settings (gitignored, user-specific)
336 user_settings_files = self._create_user_settings()
338 # Generate result
339 duration = int((time.time() - start_time) * 1000) # ms
340 return InstallationResult(
341 success=True,
342 project_path=str(self.path),
343 language=detected_language,
344 mode=mode,
345 locale=locale,
346 duration=duration,
347 created_files=resource_files + config_files + memory_files + user_settings_files,
348 )
350 except Exception as e:
351 duration = int((time.time() - start_time) * 1000)
352 return InstallationResult(
353 success=False,
354 project_path=str(self.path),
355 language=language or "unknown",
356 mode=mode,
357 locale=locale,
358 duration=duration,
359 created_files=[],
360 errors=[str(e)],
361 )
363 def is_initialized(self) -> bool:
364 """Check if .moai/ directory exists
366 Returns:
367 Whether initialized
368 """
369 return (self.path / ".moai").exists()
372def initialize_project(
373 project_path: Path, progress_callback: ProgressCallback | None = None
374) -> InstallationResult:
375 """Initialize project (for CLI command)
377 Args:
378 project_path: Project directory path
379 progress_callback: Progress callback
381 Returns:
382 InstallationResult object
383 """
384 initializer = ProjectInitializer(project_path)
385 return initializer.initialize(progress_callback=progress_callback)