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

1#!/usr/bin/env python3 

2"""Configuration Manager for Alfred Hooks 

3 

4Provides centralized configuration management with fallbacks and validation. 

5""" 

6 

7import json 

8from pathlib import Path 

9from typing import Any, Dict, Optional 

10 

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} 

68 

69 

70class ConfigManager: 

71 """Configuration manager for Alfred hooks with validation and fallbacks.""" 

72 

73 def __init__(self, config_path: Optional[Path] = None): 

74 """Initialize configuration manager. 

75 

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 

81 

82 def load_config(self) -> Dict[str, Any]: 

83 """Load configuration from file with fallback to defaults. 

84 

85 Returns: 

86 Merged configuration dictionary 

87 """ 

88 if self._config is not None: 

89 return self._config 

90 

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() 

104 

105 self._config = config 

106 return config 

107 

108 def get(self, key_path: str, default: Any = None) -> Any: 

109 """Get configuration value using dot notation. 

110 

111 Args: 

112 key_path: Dot-separated path to configuration value 

113 default: Default value if key not found 

114 

115 Returns: 

116 Configuration value or default 

117 """ 

118 config = self.load_config() 

119 keys = key_path.split('.') 

120 current = config 

121 

122 for key in keys: 

123 if isinstance(current, dict) and key in current: 

124 current = current[key] 

125 else: 

126 return default 

127 

128 return current 

129 

130 def get_hooks_config(self) -> Dict[str, Any]: 

131 """Get hooks-specific configuration. 

132 

133 Returns: 

134 Hooks configuration dictionary 

135 """ 

136 return self.get("hooks", {}) 

137 

138 def get_timeout_seconds(self, hook_type: str = "default") -> int: 

139 """Get timeout seconds for a specific hook type. 

140 

141 Args: 

142 hook_type: Type of hook (default, git, network, version_check) 

143 

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) 

155 

156 def get_timeout_ms(self) -> int: 

157 """Get timeout milliseconds for hooks. 

158 

159 Returns: 

160 Timeout milliseconds 

161 """ 

162 return self.get("hooks.timeout_ms", 5000) 

163 

164 def get_minimum_timeout_seconds(self) -> int: 

165 """Get minimum allowed timeout seconds. 

166 

167 Returns: 

168 Minimum timeout seconds 

169 """ 

170 return self.get("hooks.minimum_timeout_seconds", 1) 

171 

172 def get_graceful_degradation(self) -> bool: 

173 """Get graceful degradation setting. 

174 

175 Returns: 

176 Whether graceful degradation is enabled 

177 """ 

178 return self.get("hooks.graceful_degradation", True) 

179 

180 def get_message(self, category: str, subcategory: str, key: str) -> str: 

181 """Get localized message from configuration. 

182 

183 Args: 

184 category: Message category (timeout, stderr, config) 

185 subcategory: Subcategory within category 

186 key: Message key 

187 

188 Returns: 

189 Localized message 

190 """ 

191 default_messages = DEFAULT_CONFIG["hooks"]["messages"] 

192 message = self.get(f"hooks.messages.{category}.{subcategory}.{key}") 

193 

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) 

197 

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}" 

202 

203 return message 

204 

205 def get_cache_config(self) -> Dict[str, Any]: 

206 """Get cache configuration. 

207 

208 Returns: 

209 Cache configuration dictionary 

210 """ 

211 return self.get("hooks.cache", {}) 

212 

213 def get_project_search_config(self) -> Dict[str, Any]: 

214 """Get project search configuration. 

215 

216 Returns: 

217 Project search configuration dictionary 

218 """ 

219 return self.get("hooks.project_search", {}) 

220 

221 def get_network_config(self) -> Dict[str, Any]: 

222 """Get network configuration. 

223 

224 Returns: 

225 Network configuration dictionary 

226 """ 

227 return self.get("hooks.network", {}) 

228 

229 def get_git_config(self) -> Dict[str, Any]: 

230 """Get git configuration. 

231 

232 Returns: 

233 Git configuration dictionary 

234 """ 

235 return self.get("hooks.git", {}) 

236 

237 

238 def get_exit_code(self, exit_type: str) -> int: 

239 """Get exit code for specific exit type. 

240 

241 Args: 

242 exit_type: Type of exit (success, error, critical_error, config_error) 

243 

244 Returns: 

245 Exit code 

246 """ 

247 return self.get("hooks.exit_codes", {}).get(exit_type, 0) 

248 

249 def update_config(self, updates: Dict[str, Any]) -> bool: 

250 """Update configuration with new values. 

251 

252 Args: 

253 updates: Dictionary with configuration updates 

254 

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) 

261 

262 # Ensure parent directory exists 

263 self.config_path.parent.mkdir(parents=True, exist_ok=True) 

264 

265 with open(self.config_path, 'w', encoding='utf-8') as f: 

266 json.dump(updated_config, f, indent=2, ensure_ascii=False) 

267 

268 self._config = updated_config 

269 return True 

270 except (IOError, OSError, json.JSONDecodeError): 

271 return False 

272 

273 def validate_config(self) -> bool: 

274 """Validate current configuration. 

275 

276 Returns: 

277 True if configuration is valid, False otherwise 

278 """ 

279 try: 

280 config = self.load_config() 

281 

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 

287 

288 # Check hooks structure 

289 hooks = config.get("hooks", {}) 

290 if not isinstance(hooks, dict): 

291 return False 

292 

293 return True 

294 except Exception: 

295 return False 

296 

297 def _merge_configs(self, base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: 

298 """Recursively merge configuration dictionaries. 

299 

300 Args: 

301 base: Base configuration dictionary 

302 updates: Updates to apply 

303 

304 Returns: 

305 Merged configuration dictionary 

306 """ 

307 result = base.copy() 

308 

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 

314 

315 return result 

316 

317 def get_language_config(self) -> Dict[str, Any]: 

318 """Get language configuration. 

319 

320 Returns: 

321 Language configuration dictionary 

322 """ 

323 return self.get("language", {"conversation_language": "en"}) 

324 

325 

326# Global configuration manager instance 

327_config_manager = None 

328 

329 

330def get_config_manager(config_path: Optional[Path] = None) -> ConfigManager: 

331 """Get global configuration manager instance. 

332 

333 Args: 

334 config_path: Path to configuration file 

335 

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 

343 

344 

345def get_config(key_path: str, default: Any = None) -> Any: 

346 """Get configuration value using dot notation. 

347 

348 Args: 

349 key_path: Dot-separated path to configuration value 

350 default: Default value if key not found 

351 

352 Returns: 

353 Configuration value or default 

354 """ 

355 return get_config_manager().get(key_path, default) 

356 

357 

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) 

362 

363 

364def get_graceful_degradation() -> bool: 

365 """Get graceful degradation setting.""" 

366 return get_config_manager().get_graceful_degradation() 

367 

368 

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)