Coverage for src/moai_adk/templates/.claude/hooks/moai/lib/config_manager.py: 23.77%
122 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-20 09:47 +0900
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-20 09:47 +0900
1#!/usr/bin/env python3
2"""Configuration Manager for Alfred Hooks
4Provides centralized configuration management with fallbacks and validation.
5"""
7import json
8from pathlib import Path
9from typing import Any, Dict, Optional, cast
11# Default configuration
12DEFAULT_CONFIG = {
13 "hooks": {
14 "timeout_seconds": 5,
15 "timeout_ms": 5000,
16 "minimum_timeout_seconds": 1,
17 "graceful_degradation": True,
18 "exit_codes": {
19 "success": 0,
20 "error": 1,
21 "critical_error": 2,
22 "config_error": 3
23 },
24 "messages": {
25 "timeout": {
26 "post_tool_use": "⚠️ PostToolUse timeout - continuing",
27 "session_end": "⚠️ SessionEnd cleanup timeout - session ending anyway",
28 "session_start": "⚠️ Session start timeout - continuing without project info"
29 },
30 "stderr": {
31 "timeout": {
32 "post_tool_use": "PostToolUse hook timeout after 5 seconds",
33 "session_end": "SessionEnd hook timeout after 5 seconds",
34 "session_start": "SessionStart hook timeout after 5 seconds"
35 }
36 },
37 "config": {
38 "missing": "❌ Project configuration not found - run /alfred:0-project",
39 "missing_fields": "⚠️ Missing configuration:"
40 }
41 },
42 "cache": {
43 "directory": ".moai/cache",
44 "version_ttl_seconds": 1800,
45 "git_ttl_seconds": 10
46 },
47 "project_search": {
48 "max_depth": 10
49 },
50 "network": {
51 "test_host": "8.8.8.8",
52 "test_port": 53,
53 "timeout_seconds": 0.1
54 },
55 "version_check": {
56 "pypi_url": "https://pypi.org/pypi/moai-adk/json",
57 "timeout_seconds": 1
58 },
59 "git": {
60 "timeout_seconds": 2
61 },
62 "defaults": {
63 "timeout_ms": 5000,
64 "graceful_degradation": True
65 }
66 }
67}
70class ConfigManager:
71 """Configuration manager for Alfred hooks with validation and fallbacks."""
73 def __init__(self, config_path: Optional[Path] = None):
74 """Initialize configuration manager.
76 Args:
77 config_path: Path to configuration file (defaults to .moai/config/config.json)
78 """
79 self.config_path = config_path or Path.cwd() / ".moai" / "config.json"
80 self._config: Optional[Dict[str, Any]] = None
82 def load_config(self) -> Dict[str, Any]:
83 """Load configuration from file with fallback to defaults.
85 Returns:
86 Merged configuration dictionary
87 """
88 if self._config is not None:
89 return self._config
91 # Load from file if exists
92 config = {}
93 if self.config_path.exists():
94 try:
95 with open(self.config_path, 'r', encoding='utf-8') as f:
96 file_config = json.load(f)
97 config = self._merge_configs(DEFAULT_CONFIG, file_config)
98 except (json.JSONDecodeError, IOError, OSError):
99 # Use defaults if file is corrupted or unreadable
100 config = DEFAULT_CONFIG.copy()
101 else:
102 # Use defaults if file doesn't exist
103 config = DEFAULT_CONFIG.copy()
105 self._config = config
106 return config
108 def get(self, key_path: str, default: Any = None) -> Any:
109 """Get configuration value using dot notation.
111 Args:
112 key_path: Dot-separated path to configuration value
113 default: Default value if key not found
115 Returns:
116 Configuration value or default
117 """
118 config = self.load_config()
119 keys = key_path.split('.')
120 current = config
122 for key in keys:
123 if isinstance(current, dict) and key in current:
124 current = current[key]
125 else:
126 return default
128 return current
130 def get_hooks_config(self) -> Dict[str, Any]:
131 """Get hooks-specific configuration.
133 Returns:
134 Hooks configuration dictionary
135 """
136 return self.get("hooks", {})
138 def get_timeout_seconds(self, hook_type: str = "default") -> int:
139 """Get timeout seconds for a specific hook type.
141 Args:
142 hook_type: Type of hook (default, git, network, version_check)
144 Returns:
145 Timeout seconds
146 """
147 if hook_type == "git":
148 return self.get("hooks.git.timeout_seconds", 2)
149 elif hook_type == "network":
150 return self.get("hooks.network.timeout_seconds", 0.1)
151 elif hook_type == "version_check":
152 return self.get("hooks.version_check.timeout_seconds", 1)
153 else:
154 return self.get("hooks.timeout_seconds", 5)
156 def get_timeout_ms(self) -> int:
157 """Get timeout milliseconds for hooks.
159 Returns:
160 Timeout milliseconds
161 """
162 return self.get("hooks.timeout_ms", 5000)
164 def get_minimum_timeout_seconds(self) -> int:
165 """Get minimum allowed timeout seconds.
167 Returns:
168 Minimum timeout seconds
169 """
170 return self.get("hooks.minimum_timeout_seconds", 1)
172 def get_graceful_degradation(self) -> bool:
173 """Get graceful degradation setting.
175 Returns:
176 Whether graceful degradation is enabled
177 """
178 return self.get("hooks.graceful_degradation", True)
180 def get_message(self, category: str, subcategory: str, key: str) -> str:
181 """Get localized message from configuration.
183 Args:
184 category: Message category (timeout, stderr, config)
185 subcategory: Subcategory within category
186 key: Message key
188 Returns:
189 Localized message
190 """
191 default_messages = cast(Dict[str, Any], DEFAULT_CONFIG["hooks"]["messages"])
192 message = self.get(f"hooks.messages.{category}.{subcategory}.{key}")
194 if message is None and category in default_messages:
195 category_messages = default_messages[category]
196 if isinstance(category_messages, dict) and subcategory in category_messages:
197 subcategory_messages = category_messages[subcategory]
198 if isinstance(subcategory_messages, dict):
199 message = subcategory_messages.get(key)
201 if message is None:
202 # Fallback to English default
203 timeout_messages = default_messages.get("timeout", {})
204 if isinstance(timeout_messages, dict):
205 subcategory_messages = timeout_messages.get(subcategory, {})
206 if isinstance(subcategory_messages, dict):
207 fallback = subcategory_messages.get(key)
208 message = fallback or f"Message not found: {category}.{subcategory}.{key}"
209 else:
210 message = f"Message not found: {category}.{subcategory}.{key}"
211 else:
212 message = f"Message not found: {category}.{subcategory}.{key}"
214 return message
216 def get_cache_config(self) -> Dict[str, Any]:
217 """Get cache configuration.
219 Returns:
220 Cache configuration dictionary
221 """
222 return self.get("hooks.cache", {})
224 def get_project_search_config(self) -> Dict[str, Any]:
225 """Get project search configuration.
227 Returns:
228 Project search configuration dictionary
229 """
230 return self.get("hooks.project_search", {})
232 def get_network_config(self) -> Dict[str, Any]:
233 """Get network configuration.
235 Returns:
236 Network configuration dictionary
237 """
238 return self.get("hooks.network", {})
240 def get_git_config(self) -> Dict[str, Any]:
241 """Get git configuration.
243 Returns:
244 Git configuration dictionary
245 """
246 return self.get("hooks.git", {})
249 def get_exit_code(self, exit_type: str) -> int:
250 """Get exit code for specific exit type.
252 Args:
253 exit_type: Type of exit (success, error, critical_error, config_error)
255 Returns:
256 Exit code
257 """
258 return self.get("hooks.exit_codes", {}).get(exit_type, 0)
260 def update_config(self, updates: Dict[str, Any]) -> bool:
261 """Update configuration with new values.
263 Args:
264 updates: Dictionary with configuration updates
266 Returns:
267 True if update was successful, False otherwise
268 """
269 try:
270 current_config = self.load_config()
271 updated_config = self._merge_configs(current_config, updates)
273 # Ensure parent directory exists
274 self.config_path.parent.mkdir(parents=True, exist_ok=True)
276 with open(self.config_path, 'w', encoding='utf-8') as f:
277 json.dump(updated_config, f, indent=2, ensure_ascii=False)
279 self._config = updated_config
280 return True
281 except (IOError, OSError, json.JSONDecodeError):
282 return False
284 def validate_config(self) -> bool:
285 """Validate current configuration.
287 Returns:
288 True if configuration is valid, False otherwise
289 """
290 try:
291 config = self.load_config()
293 # Check required top-level keys
294 required_keys = ["hooks"]
295 for key in required_keys:
296 if key not in config:
297 return False
299 # Check hooks structure
300 hooks = config.get("hooks", {})
301 if not isinstance(hooks, dict):
302 return False
304 return True
305 except Exception:
306 return False
308 def _merge_configs(self, base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
309 """Recursively merge configuration dictionaries.
311 Args:
312 base: Base configuration dictionary
313 updates: Updates to apply
315 Returns:
316 Merged configuration dictionary
317 """
318 result = base.copy()
320 for key, value in updates.items():
321 if key in result and isinstance(result[key], dict) and isinstance(value, dict):
322 result[key] = self._merge_configs(result[key], value)
323 else:
324 result[key] = value
326 return result
328 def get_language_config(self) -> Dict[str, Any]:
329 """Get language configuration.
331 Returns:
332 Language configuration dictionary
333 """
334 return self.get("language", {"conversation_language": "en"})
337# Global configuration manager instance
338_config_manager = None
341def get_config_manager(config_path: Optional[Path] = None) -> ConfigManager:
342 """Get global configuration manager instance.
344 Args:
345 config_path: Path to configuration file
347 Returns:
348 Configuration manager instance
349 """
350 global _config_manager
351 if _config_manager is None or config_path is not None:
352 _config_manager = ConfigManager(config_path)
353 return _config_manager
356def get_config(key_path: str, default: Any = None) -> Any:
357 """Get configuration value using dot notation.
359 Args:
360 key_path: Dot-separated path to configuration value
361 default: Default value if key not found
363 Returns:
364 Configuration value or default
365 """
366 return get_config_manager().get(key_path, default)
369# Convenience functions for common configuration values
370def get_timeout_seconds(hook_type: str = "default") -> int:
371 """Get timeout seconds for a specific hook type."""
372 return get_config_manager().get_timeout_seconds(hook_type)
375def get_graceful_degradation() -> bool:
376 """Get graceful degradation setting."""
377 return get_config_manager().get_graceful_degradation()
380def get_exit_code(exit_type: str) -> int:
381 """Get exit code for specific exit type."""
382 return get_config_manager().get_exit_code(exit_type)