Coverage for .claude/hooks/moai/lib/config_manager.py: 97.35%
113 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-19 08:00 +0900
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-19 08:00 +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
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 = 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 = 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 if subcategory in default_messages[category]:
196 message = default_messages[category][subcategory].get(key)
198 if message is None:
199 # Fallback to English default
200 fallback = default_messages["timeout"].get(subcategory, {}).get(key)
201 message = fallback or f"Message not found: {category}.{subcategory}.{key}"
203 return message
205 def get_cache_config(self) -> Dict[str, Any]:
206 """Get cache configuration.
208 Returns:
209 Cache configuration dictionary
210 """
211 return self.get("hooks.cache", {})
213 def get_project_search_config(self) -> Dict[str, Any]:
214 """Get project search configuration.
216 Returns:
217 Project search configuration dictionary
218 """
219 return self.get("hooks.project_search", {})
221 def get_network_config(self) -> Dict[str, Any]:
222 """Get network configuration.
224 Returns:
225 Network configuration dictionary
226 """
227 return self.get("hooks.network", {})
229 def get_git_config(self) -> Dict[str, Any]:
230 """Get git configuration.
232 Returns:
233 Git configuration dictionary
234 """
235 return self.get("hooks.git", {})
238 def get_exit_code(self, exit_type: str) -> int:
239 """Get exit code for specific exit type.
241 Args:
242 exit_type: Type of exit (success, error, critical_error, config_error)
244 Returns:
245 Exit code
246 """
247 return self.get("hooks.exit_codes", {}).get(exit_type, 0)
249 def update_config(self, updates: Dict[str, Any]) -> bool:
250 """Update configuration with new values.
252 Args:
253 updates: Dictionary with configuration updates
255 Returns:
256 True if update was successful, False otherwise
257 """
258 try:
259 current_config = self.load_config()
260 updated_config = self._merge_configs(current_config, updates)
262 # Ensure parent directory exists
263 self.config_path.parent.mkdir(parents=True, exist_ok=True)
265 with open(self.config_path, 'w', encoding='utf-8') as f:
266 json.dump(updated_config, f, indent=2, ensure_ascii=False)
268 self._config = updated_config
269 return True
270 except (IOError, OSError, json.JSONDecodeError):
271 return False
273 def validate_config(self) -> bool:
274 """Validate current configuration.
276 Returns:
277 True if configuration is valid, False otherwise
278 """
279 try:
280 config = self.load_config()
282 # Check required top-level keys
283 required_keys = ["hooks"]
284 for key in required_keys:
285 if key not in config:
286 return False
288 # Check hooks structure
289 hooks = config.get("hooks", {})
290 if not isinstance(hooks, dict):
291 return False
293 return True
294 except Exception:
295 return False
297 def _merge_configs(self, base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
298 """Recursively merge configuration dictionaries.
300 Args:
301 base: Base configuration dictionary
302 updates: Updates to apply
304 Returns:
305 Merged configuration dictionary
306 """
307 result = base.copy()
309 for key, value in updates.items():
310 if key in result and isinstance(result[key], dict) and isinstance(value, dict):
311 result[key] = self._merge_configs(result[key], value)
312 else:
313 result[key] = value
315 return result
317 def get_language_config(self) -> Dict[str, Any]:
318 """Get language configuration.
320 Returns:
321 Language configuration dictionary
322 """
323 return self.get("language", {"conversation_language": "en"})
326# Global configuration manager instance
327_config_manager = None
330def get_config_manager(config_path: Optional[Path] = None) -> ConfigManager:
331 """Get global configuration manager instance.
333 Args:
334 config_path: Path to configuration file
336 Returns:
337 Configuration manager instance
338 """
339 global _config_manager
340 if _config_manager is None or config_path is not None:
341 _config_manager = ConfigManager(config_path)
342 return _config_manager
345def get_config(key_path: str, default: Any = None) -> Any:
346 """Get configuration value using dot notation.
348 Args:
349 key_path: Dot-separated path to configuration value
350 default: Default value if key not found
352 Returns:
353 Configuration value or default
354 """
355 return get_config_manager().get(key_path, default)
358# Convenience functions for common configuration values
359def get_timeout_seconds(hook_type: str = "default") -> int:
360 """Get timeout seconds for a specific hook type."""
361 return get_config_manager().get_timeout_seconds(hook_type)
364def get_graceful_degradation() -> bool:
365 """Get graceful degradation setting."""
366 return get_config_manager().get_graceful_degradation()
369def get_exit_code(exit_type: str) -> int:
370 """Get exit code for specific exit type."""
371 return get_config_manager().get_exit_code(exit_type)