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

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, cast 

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: Optional[Dict[str, Any]] = 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 = cast(Dict[str, Any], 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 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) 

200 

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

213 

214 return message 

215 

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

217 """Get cache configuration. 

218 

219 Returns: 

220 Cache configuration dictionary 

221 """ 

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

223 

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

225 """Get project search configuration. 

226 

227 Returns: 

228 Project search configuration dictionary 

229 """ 

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

231 

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

233 """Get network configuration. 

234 

235 Returns: 

236 Network configuration dictionary 

237 """ 

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

239 

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

241 """Get git configuration. 

242 

243 Returns: 

244 Git configuration dictionary 

245 """ 

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

247 

248 

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

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

251 

252 Args: 

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

254 

255 Returns: 

256 Exit code 

257 """ 

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

259 

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

261 """Update configuration with new values. 

262 

263 Args: 

264 updates: Dictionary with configuration updates 

265 

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) 

272 

273 # Ensure parent directory exists 

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

275 

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

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

278 

279 self._config = updated_config 

280 return True 

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

282 return False 

283 

284 def validate_config(self) -> bool: 

285 """Validate current configuration. 

286 

287 Returns: 

288 True if configuration is valid, False otherwise 

289 """ 

290 try: 

291 config = self.load_config() 

292 

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 

298 

299 # Check hooks structure 

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

301 if not isinstance(hooks, dict): 

302 return False 

303 

304 return True 

305 except Exception: 

306 return False 

307 

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

309 """Recursively merge configuration dictionaries. 

310 

311 Args: 

312 base: Base configuration dictionary 

313 updates: Updates to apply 

314 

315 Returns: 

316 Merged configuration dictionary 

317 """ 

318 result = base.copy() 

319 

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 

325 

326 return result 

327 

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

329 """Get language configuration. 

330 

331 Returns: 

332 Language configuration dictionary 

333 """ 

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

335 

336 

337# Global configuration manager instance 

338_config_manager = None 

339 

340 

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

342 """Get global configuration manager instance. 

343 

344 Args: 

345 config_path: Path to configuration file 

346 

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 

354 

355 

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

357 """Get configuration value using dot notation. 

358 

359 Args: 

360 key_path: Dot-separated path to configuration value 

361 default: Default value if key not found 

362 

363 Returns: 

364 Configuration value or default 

365 """ 

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

367 

368 

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) 

373 

374 

375def get_graceful_degradation() -> bool: 

376 """Get graceful degradation setting.""" 

377 return get_config_manager().get_graceful_degradation() 

378 

379 

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)