omnimancer-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. omnimancer/__init__.py +42 -0
  2. omnimancer/__main__.py +9 -0
  3. omnimancer/cli/__init__.py +8 -0
  4. omnimancer/cli/agent_manager.py +826 -0
  5. omnimancer/cli/agent_wizard.py +944 -0
  6. omnimancer/cli/approval_formatter.py +734 -0
  7. omnimancer/cli/approval_integration.py +734 -0
  8. omnimancer/cli/approval_prompt.py +1286 -0
  9. omnimancer/cli/batch_approval_display.py +617 -0
  10. omnimancer/cli/batch_approval_filters.py +729 -0
  11. omnimancer/cli/batch_operation_monitor.py +775 -0
  12. omnimancer/cli/commands/performance_cmd.py +624 -0
  13. omnimancer/cli/commands.py +757 -0
  14. omnimancer/cli/completion_handler.py +281 -0
  15. omnimancer/cli/config_setup_wizard.py +783 -0
  16. omnimancer/cli/dynamic_commands.py +371 -0
  17. omnimancer/cli/handlers/__init__.py +18 -0
  18. omnimancer/cli/handlers/agent_handler.py +865 -0
  19. omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
  20. omnimancer/cli/handlers/custom_agent_handler.py +352 -0
  21. omnimancer/cli/handlers/permissions_handler.py +1609 -0
  22. omnimancer/cli/interface.py +6695 -0
  23. omnimancer/cli/performance_dashboard.py +612 -0
  24. omnimancer/core/__init__.py +34 -0
  25. omnimancer/core/agent/__init__.py +256 -0
  26. omnimancer/core/agent/agent_config.py +715 -0
  27. omnimancer/core/agent/agent_switcher.py +668 -0
  28. omnimancer/core/agent/approval_context.py +460 -0
  29. omnimancer/core/agent/approval_dialog.py +902 -0
  30. omnimancer/core/agent/approval_interface.py +628 -0
  31. omnimancer/core/agent/approval_manager.py +760 -0
  32. omnimancer/core/agent/cli_integration.py +470 -0
  33. omnimancer/core/agent/config.py +845 -0
  34. omnimancer/core/agent/custom_agent_integration.py +654 -0
  35. omnimancer/core/agent/diff_renderer.py +706 -0
  36. omnimancer/core/agent/file_content_display.py +503 -0
  37. omnimancer/core/agent/file_modification_workflow.py +658 -0
  38. omnimancer/core/agent/file_system_manager.py +1790 -0
  39. omnimancer/core/agent/input_handler.py +614 -0
  40. omnimancer/core/agent/metrics_collector.py +771 -0
  41. omnimancer/core/agent/optimization_engine.py +729 -0
  42. omnimancer/core/agent/performance_monitor.py +717 -0
  43. omnimancer/core/agent/persona.py +870 -0
  44. omnimancer/core/agent/persona_validator.py +672 -0
  45. omnimancer/core/agent/program_executor.py +748 -0
  46. omnimancer/core/agent/proposed_changes_integration.py +663 -0
  47. omnimancer/core/agent/read_before_write_errors.py +492 -0
  48. omnimancer/core/agent/read_before_write_ui.py +647 -0
  49. omnimancer/core/agent/rich_renderer.py +763 -0
  50. omnimancer/core/agent/status_config.py +238 -0
  51. omnimancer/core/agent/status_core.py +446 -0
  52. omnimancer/core/agent/status_integrated_engine.py +540 -0
  53. omnimancer/core/agent/status_manager.py +878 -0
  54. omnimancer/core/agent/status_ui_components.py +535 -0
  55. omnimancer/core/agent/template_applicator.py +548 -0
  56. omnimancer/core/agent/terminal_status_listener.py +394 -0
  57. omnimancer/core/agent/token_tracker.py +679 -0
  58. omnimancer/core/agent/types.py +60 -0
  59. omnimancer/core/agent/web_client.py +745 -0
  60. omnimancer/core/agent/workflow_orchestrator.py +826 -0
  61. omnimancer/core/agent_engine.py +1625 -0
  62. omnimancer/core/agent_mode_manager.py +706 -0
  63. omnimancer/core/agent_progress_ui.py +545 -0
  64. omnimancer/core/chat_manager.py +141 -0
  65. omnimancer/core/config_manager.py +2199 -0
  66. omnimancer/core/config_migration.py +772 -0
  67. omnimancer/core/config_provider.py +1974 -0
  68. omnimancer/core/config_security_validator.py +393 -0
  69. omnimancer/core/config_validator.py +768 -0
  70. omnimancer/core/conversation_manager.py +352 -0
  71. omnimancer/core/engine.py +787 -0
  72. omnimancer/core/enhanced_input.py +523 -0
  73. omnimancer/core/env_loader.py +83 -0
  74. omnimancer/core/fallback_manager.py +568 -0
  75. omnimancer/core/health_monitor.py +462 -0
  76. omnimancer/core/history_manager.py +462 -0
  77. omnimancer/core/mcp_integration_layer.py +878 -0
  78. omnimancer/core/models.py +1889 -0
  79. omnimancer/core/provider_capabilities.py +403 -0
  80. omnimancer/core/provider_initializer.py +450 -0
  81. omnimancer/core/provider_registry.py +432 -0
  82. omnimancer/core/security/__init__.py +16 -0
  83. omnimancer/core/security/approval_workflow.py +406 -0
  84. omnimancer/core/security/audit_logger.py +511 -0
  85. omnimancer/core/security/permission_controller.py +511 -0
  86. omnimancer/core/security/sandbox_manager.py +379 -0
  87. omnimancer/core/security/security_manager.py +530 -0
  88. omnimancer/core/setup_wizard.py +345 -0
  89. omnimancer/core/setup_wizard_core.py +449 -0
  90. omnimancer/core/setup_wizard_guide_generator.py +376 -0
  91. omnimancer/core/setup_wizard_provider_setup.py +454 -0
  92. omnimancer/core/setup_wizard_ui.py +317 -0
  93. omnimancer/core/setup_wizard_validation.py +371 -0
  94. omnimancer/core/signal_handler.py +174 -0
  95. omnimancer/mcp/__init__.py +11 -0
  96. omnimancer/mcp/client.py +725 -0
  97. omnimancer/mcp/manager.py +649 -0
  98. omnimancer/providers/__init__.py +24 -0
  99. omnimancer/providers/azure.py +608 -0
  100. omnimancer/providers/base.py +262 -0
  101. omnimancer/providers/bedrock.py +711 -0
  102. omnimancer/providers/claude.py +426 -0
  103. omnimancer/providers/claude_code.py +406 -0
  104. omnimancer/providers/cohere.py +412 -0
  105. omnimancer/providers/factory.py +988 -0
  106. omnimancer/providers/gemini.py +708 -0
  107. omnimancer/providers/mistral.py +554 -0
  108. omnimancer/providers/ollama.py +510 -0
  109. omnimancer/providers/openai.py +362 -0
  110. omnimancer/providers/openrouter.py +709 -0
  111. omnimancer/providers/perplexity.py +476 -0
  112. omnimancer/providers/vertex.py +617 -0
  113. omnimancer/providers/xai.py +484 -0
  114. omnimancer/ui/__init__.py +6 -0
  115. omnimancer/ui/cancellation_handler.py +235 -0
  116. omnimancer/ui/progress_indicator.py +318 -0
  117. omnimancer/utils/__init__.py +22 -0
  118. omnimancer/utils/error_handler.py +589 -0
  119. omnimancer/utils/errors.py +667 -0
  120. omnimancer/utils/retry.py +238 -0
  121. omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
  122. omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
  123. omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
  124. omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
  125. omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  126. omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2199 @@
1
+ """
2
+ Configuration manager for Omnimancer.
3
+
4
+ This module handles loading, saving, and managing configuration
5
+ including provider settings, API keys, and user preferences.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from cryptography.fernet import Fernet
15
+
16
+ from ..utils.errors import ConfigurationError, MCPConfigurationError
17
+ from .config_migration import ConfigMigration, ConfigValidator
18
+ from .models import (
19
+ Config,
20
+ ConfigProfile,
21
+ MCPConfig,
22
+ MCPServerConfig,
23
+ ProviderConfig,
24
+ )
25
+
26
+
27
+ class ConfigManager:
28
+ """
29
+ Manages Omnimancer configuration.
30
+
31
+ This class handles loading and saving configuration files,
32
+ managing API keys, and providing access to settings.
33
+ """
34
+
35
+ def __init__(self, config_path: Optional[str] = None):
36
+ """
37
+ Initialize the configuration manager.
38
+
39
+ Args:
40
+ config_path: Path to configuration file (optional)
41
+ """
42
+ if config_path:
43
+ self.config_path = Path(config_path)
44
+ else:
45
+ self.config_path = Path.home() / ".omnimancer" / "config.json"
46
+
47
+ self.config: Optional[Config] = None
48
+ self._key_file = self.config_path.parent / ".key"
49
+ self._ensure_config_dir()
50
+ self._cipher = self._get_cipher()
51
+
52
+ def _ensure_config_dir(self) -> None:
53
+ """Ensure the configuration directory exists."""
54
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ def _get_cipher(self) -> Fernet:
57
+ """
58
+ Get or create encryption cipher.
59
+
60
+ Returns:
61
+ Fernet cipher instance
62
+ """
63
+ if self._key_file.exists():
64
+ with open(self._key_file, "rb") as f:
65
+ key = f.read()
66
+ else:
67
+ # Generate new key
68
+ key = Fernet.generate_key()
69
+ with open(self._key_file, "wb") as f:
70
+ f.write(key)
71
+ # Set restrictive permissions on key file
72
+ os.chmod(self._key_file, 0o600)
73
+
74
+ return Fernet(key)
75
+
76
+ def _encrypt_api_key(self, api_key: str) -> str:
77
+ """
78
+ Encrypt an API key.
79
+
80
+ Args:
81
+ api_key: Plain text API key
82
+
83
+ Returns:
84
+ Encrypted API key as base64 string
85
+ """
86
+ encrypted = self._cipher.encrypt(api_key.encode())
87
+ return base64.b64encode(encrypted).decode()
88
+
89
+ def _decrypt_api_key(self, encrypted_key: str) -> str:
90
+ """
91
+ Decrypt an API key.
92
+
93
+ Args:
94
+ encrypted_key: Encrypted API key as base64 string
95
+
96
+ Returns:
97
+ Plain text API key
98
+
99
+ Raises:
100
+ ConfigurationError: If decryption fails
101
+ """
102
+ try:
103
+ encrypted_bytes = base64.b64decode(encrypted_key.encode())
104
+ decrypted = self._cipher.decrypt(encrypted_bytes)
105
+ return decrypted.decode()
106
+ except Exception as e:
107
+ raise ConfigurationError(f"Failed to decrypt API key: {e}")
108
+
109
+ def load_config(self) -> Config:
110
+ """
111
+ Load configuration from file.
112
+
113
+ Returns:
114
+ Loaded Config object
115
+
116
+ Raises:
117
+ ConfigurationError: If config file is invalid or missing
118
+ """
119
+ if not self.config_path.exists() or self.config_path.stat().st_size == 0:
120
+ # Create default config for missing or empty files
121
+ self.config = self._create_default_config()
122
+ self.save_config()
123
+ return self.config
124
+
125
+ # Check if migration is needed
126
+ migration = ConfigMigration(self.config_path)
127
+ if migration.needs_migration():
128
+ success, messages = migration.migrate_config()
129
+ if not success:
130
+ raise ConfigurationError(
131
+ f"Configuration migration failed: {'; '.join(messages)}"
132
+ )
133
+
134
+ try:
135
+ with open(self.config_path, "r") as f:
136
+ content = f.read().strip()
137
+ if not content:
138
+ # Handle empty file
139
+ self.config = self._create_default_config()
140
+ self.save_config()
141
+ return self.config
142
+
143
+ config_data = json.loads(content)
144
+
145
+ self.config = Config(**config_data)
146
+ return self.config
147
+
148
+ except (json.JSONDecodeError, ValueError):
149
+ # If JSON is invalid, create default config
150
+ self.config = self._create_default_config()
151
+ self.save_config()
152
+ return self.config
153
+ except Exception as e:
154
+ raise ConfigurationError(
155
+ f"Failed to load configuration: {e}", details=str(e)
156
+ )
157
+
158
+ def save_config(self, config: Optional[Config] = None) -> None:
159
+ """
160
+ Save configuration to file.
161
+
162
+ Args:
163
+ config: Configuration to save (optional, uses current config if not provided)
164
+
165
+ Raises:
166
+ ConfigurationError: If saving fails
167
+ """
168
+ config_to_save = config or self.config
169
+ if not config_to_save:
170
+ raise ConfigurationError("No configuration to save")
171
+
172
+ # Update current config if a new one was provided
173
+ if config:
174
+ self.config = config
175
+
176
+ try:
177
+ with open(self.config_path, "w") as f:
178
+ # Use model_dump with mode='json' to properly serialize datetime objects
179
+ config_dict = config_to_save.model_dump(mode="json")
180
+ json.dump(config_dict, f, indent=2)
181
+ except Exception as e:
182
+ raise ConfigurationError(
183
+ f"Failed to save configuration: {e}", details=str(e)
184
+ )
185
+
186
+ def get_config(self) -> Config:
187
+ """
188
+ Get the current configuration.
189
+
190
+ Returns:
191
+ Current Config object
192
+ """
193
+ if not self.config:
194
+ self.config = self.load_config()
195
+ return self.config
196
+
197
+ def get_provider_config(self, provider_name: str) -> Optional[ProviderConfig]:
198
+ """
199
+ Get configuration for a specific provider.
200
+
201
+ Args:
202
+ provider_name: Name of the provider
203
+
204
+ Returns:
205
+ ProviderConfig or None if not found
206
+ """
207
+ config = self.get_config()
208
+ return config.providers.get(provider_name)
209
+
210
+ def set_provider_config(
211
+ self, provider_name: str, provider_config: ProviderConfig
212
+ ) -> None:
213
+ """
214
+ Set configuration for a provider.
215
+
216
+ Args:
217
+ provider_name: Name of the provider
218
+ provider_config: Provider configuration
219
+ """
220
+ config = self.get_config()
221
+ config.providers[provider_name] = provider_config
222
+ self.save_config()
223
+
224
+ def set_default_provider(self, provider_name: str) -> None:
225
+ """
226
+ Set the default provider.
227
+
228
+ Args:
229
+ provider_name: Name of the provider
230
+
231
+ Raises:
232
+ ConfigurationError: If provider is not configured
233
+ """
234
+ config = self.get_config()
235
+ if provider_name not in config.providers:
236
+ raise ConfigurationError(f"Provider '{provider_name}' is not configured")
237
+
238
+ config.default_provider = provider_name
239
+ self.save_config()
240
+
241
+ def get_api_key(self, provider_name: str) -> Optional[str]:
242
+ """
243
+ Get API key for a provider.
244
+
245
+ Args:
246
+ provider_name: Name of the provider
247
+
248
+ Returns:
249
+ Decrypted API key or None if not found
250
+ """
251
+ provider_config = self.get_provider_config(provider_name)
252
+ if provider_config and provider_config.api_key:
253
+ try:
254
+ return self._decrypt_api_key(provider_config.api_key)
255
+ except ConfigurationError:
256
+ # If decryption fails, assume it's a plain text key (for backward compatibility)
257
+ return provider_config.api_key
258
+ return None
259
+
260
+ def set_api_key(self, provider_name: str, api_key: str) -> None:
261
+ """
262
+ Set API key for a provider.
263
+
264
+ Args:
265
+ provider_name: Name of the provider
266
+ api_key: API key to set (will be encrypted before storage)
267
+ """
268
+ config = self.get_config()
269
+ encrypted_key = self._encrypt_api_key(api_key)
270
+
271
+ if provider_name in config.providers:
272
+ config.providers[provider_name].api_key = encrypted_key
273
+ else:
274
+ # Create new provider config with minimal settings
275
+ config.providers[provider_name] = ProviderConfig(
276
+ api_key=encrypted_key,
277
+ model="default", # Will be updated when provider is initialized
278
+ )
279
+
280
+ self.save_config()
281
+
282
+ def add_custom_model(self, model_info: "EnhancedModelInfo") -> None:
283
+ """
284
+ Add a custom model to the configuration.
285
+
286
+ Args:
287
+ model_info: Enhanced model information
288
+ """
289
+ config = self.get_config()
290
+
291
+ # Check if model already exists (by name and provider)
292
+ existing_model = None
293
+ for i, model in enumerate(config.custom_models):
294
+ if model.name == model_info.name and model.provider == model_info.provider:
295
+ existing_model = i
296
+ break
297
+
298
+ if existing_model is not None:
299
+ # Update existing model
300
+ config.custom_models[existing_model] = model_info
301
+ else:
302
+ # Add new model
303
+ config.custom_models.append(model_info)
304
+
305
+ self.save_config()
306
+
307
+ def remove_custom_model(self, model_name: str, provider: str) -> bool:
308
+ """
309
+ Remove a custom model from the configuration.
310
+
311
+ Args:
312
+ model_name: Name of the model to remove
313
+ provider: Provider of the model
314
+
315
+ Returns:
316
+ True if model was removed, False if not found
317
+ """
318
+ config = self.get_config()
319
+
320
+ for i, model in enumerate(config.custom_models):
321
+ if model.name == model_name and model.provider == provider:
322
+ config.custom_models.pop(i)
323
+ self.save_config()
324
+ return True
325
+
326
+ return False
327
+
328
+ def get_custom_models(self) -> List["EnhancedModelInfo"]:
329
+ """
330
+ Get list of custom models from configuration.
331
+
332
+ Returns:
333
+ List of custom model information
334
+ """
335
+ config = self.get_config()
336
+ return config.custom_models.copy()
337
+
338
+ def list_custom_models(self) -> List[Dict[str, Any]]:
339
+ """
340
+ Get custom models as a list of dictionaries for display.
341
+
342
+ Returns:
343
+ List of model dictionaries
344
+ """
345
+ custom_models = self.get_custom_models()
346
+ return [
347
+ {
348
+ "name": model.name,
349
+ "provider": model.provider,
350
+ "description": model.description,
351
+ "max_tokens": model.max_tokens,
352
+ "cost_per_million_input": model.cost_per_million_input,
353
+ "cost_per_million_output": model.cost_per_million_output,
354
+ "swe_score": model.swe_score,
355
+ "supports_tools": model.supports_tools,
356
+ "supports_multimodal": model.supports_multimodal,
357
+ "latest_version": model.latest_version,
358
+ "is_free": model.is_free,
359
+ }
360
+ for model in custom_models
361
+ ]
362
+
363
+ def _create_default_config(self) -> Config:
364
+ """
365
+ Create a default configuration.
366
+
367
+ Returns:
368
+ Default Config object
369
+ """
370
+ return Config(
371
+ default_provider="claude",
372
+ providers={},
373
+ storage_path=str(Path.home() / ".omnimancer"),
374
+ )
375
+
376
+ def get_storage_path(self) -> Path:
377
+ """
378
+ Get the storage path for Omnimancer data.
379
+
380
+ Returns:
381
+ Path object for storage directory
382
+ """
383
+ config = self.get_config()
384
+ storage_path = Path(config.storage_path).expanduser()
385
+ storage_path.mkdir(parents=True, exist_ok=True)
386
+ return storage_path
387
+
388
+ def is_first_run(self) -> bool:
389
+ """
390
+ Check if this is the first run (no config file exists).
391
+
392
+ Returns:
393
+ True if this is the first run
394
+ """
395
+ return not self.config_path.exists()
396
+
397
+ def validate_config(self) -> List[str]:
398
+ """
399
+ Validate the current configuration.
400
+
401
+ Returns:
402
+ List of validation errors (empty if valid)
403
+ """
404
+ try:
405
+ config = self.get_config()
406
+ except ConfigurationError as e:
407
+ return [f"Configuration file error: {e}"]
408
+
409
+ # Use optimized config validator with caching
410
+ from .config_validator import ConfigValidator
411
+
412
+ validator = ConfigValidator()
413
+
414
+ return validator.validate_config(config)
415
+
416
+ def setup_initial_config(
417
+ self,
418
+ provider_configs: Dict[str, Dict[str, str]],
419
+ default_provider: str,
420
+ ) -> None:
421
+ """
422
+ Set up initial configuration with provider details.
423
+
424
+ Args:
425
+ provider_configs: Dict mapping provider names to their config (api_key, model)
426
+ default_provider: Name of the default provider
427
+
428
+ Raises:
429
+ ConfigurationError: If setup fails
430
+ """
431
+ if not provider_configs:
432
+ raise ConfigurationError("At least one provider must be configured")
433
+
434
+ if default_provider not in provider_configs:
435
+ raise ConfigurationError(
436
+ f"Default provider '{default_provider}' not in provider configs"
437
+ )
438
+
439
+ # Create provider configurations
440
+ providers = {}
441
+ for provider_name, config_data in provider_configs.items():
442
+ if "api_key" not in config_data or "model" not in config_data:
443
+ raise ConfigurationError(
444
+ f"Provider '{provider_name}' missing api_key or model"
445
+ )
446
+
447
+ providers[provider_name] = ProviderConfig(
448
+ api_key=self._encrypt_api_key(config_data["api_key"]),
449
+ model=config_data["model"],
450
+ max_tokens=config_data.get("max_tokens"),
451
+ temperature=config_data.get("temperature"),
452
+ )
453
+
454
+ # Create and save configuration
455
+ self.config = Config(
456
+ default_provider=default_provider,
457
+ providers=providers,
458
+ storage_path=str(Path.home() / ".omnimancer"),
459
+ )
460
+
461
+ self.save_config()
462
+
463
+ def update_provider_settings(self, provider_name: str, **kwargs) -> None:
464
+ """
465
+ Update settings for a specific provider.
466
+
467
+ Args:
468
+ provider_name: Name of the provider
469
+ **kwargs: Settings to update (model, max_tokens, temperature)
470
+ """
471
+ config = self.get_config()
472
+
473
+ if provider_name not in config.providers:
474
+ raise ConfigurationError(f"Provider '{provider_name}' is not configured")
475
+
476
+ provider_config = config.providers[provider_name]
477
+
478
+ if "model" in kwargs:
479
+ provider_config.model = kwargs["model"]
480
+ if "max_tokens" in kwargs:
481
+ provider_config.max_tokens = kwargs["max_tokens"]
482
+ if "temperature" in kwargs:
483
+ provider_config.temperature = kwargs["temperature"]
484
+
485
+ self.save_config()
486
+
487
+ def get_mcp_config(self) -> MCPConfig:
488
+ """
489
+ Get MCP configuration.
490
+
491
+ Returns:
492
+ MCPConfig object
493
+ """
494
+ config = self.get_config()
495
+ return config.mcp
496
+
497
+ def set_mcp_config(self, mcp_config: MCPConfig) -> None:
498
+ """
499
+ Set MCP configuration.
500
+
501
+ Args:
502
+ mcp_config: MCP configuration to set
503
+ """
504
+ config = self.get_config()
505
+ config.mcp = mcp_config
506
+ self.save_config()
507
+
508
+ def get_mcp_server_config(self, server_name: str) -> Optional[MCPServerConfig]:
509
+ """
510
+ Get configuration for a specific MCP server.
511
+
512
+ Args:
513
+ server_name: Name of the MCP server
514
+
515
+ Returns:
516
+ MCPServerConfig or None if not found
517
+ """
518
+ mcp_config = self.get_mcp_config()
519
+ return mcp_config.servers.get(server_name)
520
+
521
+ def set_mcp_server_config(
522
+ self, server_name: str, server_config: MCPServerConfig
523
+ ) -> None:
524
+ """
525
+ Set configuration for an MCP server.
526
+
527
+ Args:
528
+ server_name: Name of the MCP server
529
+ server_config: Server configuration
530
+ """
531
+ config = self.get_config()
532
+ config.mcp.servers[server_name] = server_config
533
+ self.save_config()
534
+
535
+ def remove_mcp_server_config(self, server_name: str) -> bool:
536
+ """
537
+ Remove configuration for an MCP server.
538
+
539
+ Args:
540
+ server_name: Name of the MCP server to remove
541
+
542
+ Returns:
543
+ True if server was removed, False if not found
544
+ """
545
+ config = self.get_config()
546
+ if server_name in config.mcp.servers:
547
+ del config.mcp.servers[server_name]
548
+ self.save_config()
549
+ return True
550
+ return False
551
+
552
+ def get_enabled_mcp_servers(self) -> Dict[str, MCPServerConfig]:
553
+ """
554
+ Get all enabled MCP server configurations.
555
+
556
+ Returns:
557
+ Dictionary of enabled MCP server configurations
558
+ """
559
+ mcp_config = self.get_mcp_config()
560
+ return mcp_config.get_enabled_servers()
561
+
562
+ def enable_mcp_server(self, server_name: str) -> None:
563
+ """
564
+ Enable an MCP server.
565
+
566
+ Args:
567
+ server_name: Name of the MCP server
568
+
569
+ Raises:
570
+ MCPConfigurationError: If server is not configured
571
+ """
572
+ server_config = self.get_mcp_server_config(server_name)
573
+ if not server_config:
574
+ raise MCPConfigurationError(f"MCP server '{server_name}' is not configured")
575
+
576
+ server_config.enabled = True
577
+ self.save_config()
578
+
579
+ def disable_mcp_server(self, server_name: str) -> None:
580
+ """
581
+ Disable an MCP server.
582
+
583
+ Args:
584
+ server_name: Name of the MCP server
585
+
586
+ Raises:
587
+ MCPConfigurationError: If server is not configured
588
+ """
589
+ server_config = self.get_mcp_server_config(server_name)
590
+ if not server_config:
591
+ raise MCPConfigurationError(f"MCP server '{server_name}' is not configured")
592
+
593
+ server_config.enabled = False
594
+ self.save_config()
595
+
596
+ def validate_mcp_config(self) -> List[str]:
597
+ """
598
+ Validate MCP configuration.
599
+
600
+ Returns:
601
+ List of validation errors (empty if valid)
602
+ """
603
+ errors = []
604
+
605
+ try:
606
+ mcp_config = self.get_mcp_config()
607
+ except Exception as e:
608
+ return [f"MCP configuration error: {e}"]
609
+
610
+ # Validate server configurations
611
+ for server_name, server_config in mcp_config.servers.items():
612
+ if not server_config.name:
613
+ errors.append(f"MCP server '{server_name}' has no name")
614
+ if not server_config.command:
615
+ errors.append(f"MCP server '{server_name}' has no command")
616
+ if server_config.timeout <= 0:
617
+ errors.append(
618
+ f"MCP server '{server_name}' has invalid timeout: {server_config.timeout}"
619
+ )
620
+
621
+ # Validate global MCP settings
622
+ if mcp_config.auto_approve_timeout <= 0:
623
+ errors.append(
624
+ f"Invalid MCP auto_approve_timeout: {mcp_config.auto_approve_timeout}"
625
+ )
626
+ if mcp_config.max_concurrent_servers <= 0:
627
+ errors.append(
628
+ f"Invalid MCP max_concurrent_servers: {mcp_config.max_concurrent_servers}"
629
+ )
630
+
631
+ return errors
632
+
633
+ def load_mcp_config_from_file(self, mcp_config_path: str) -> None:
634
+ """
635
+ Load MCP configuration from a separate file.
636
+
637
+ Args:
638
+ mcp_config_path: Path to MCP configuration file
639
+
640
+ Raises:
641
+ MCPConfigurationError: If loading fails
642
+ """
643
+ try:
644
+ config_path = Path(mcp_config_path).expanduser()
645
+ if not config_path.exists():
646
+ raise MCPConfigurationError(f"MCP config file not found: {config_path}")
647
+
648
+ with open(config_path, "r") as f:
649
+ mcp_data = json.load(f)
650
+
651
+ # Validate and create MCP config
652
+ mcp_config = MCPConfig(**mcp_data)
653
+
654
+ # Update main configuration
655
+ config = self.get_config()
656
+ config.mcp = mcp_config
657
+ self.save_config()
658
+
659
+ except json.JSONDecodeError as e:
660
+ raise MCPConfigurationError(f"Invalid JSON in MCP config file: {e}")
661
+ except Exception as e:
662
+ raise MCPConfigurationError(f"Failed to load MCP configuration: {e}")
663
+
664
+ def export_mcp_config_to_file(self, mcp_config_path: str) -> None:
665
+ """
666
+ Export MCP configuration to a separate file.
667
+
668
+ Args:
669
+ mcp_config_path: Path to export MCP configuration
670
+
671
+ Raises:
672
+ MCPConfigurationError: If export fails
673
+ """
674
+ try:
675
+ config_path = Path(mcp_config_path).expanduser()
676
+ config_path.parent.mkdir(parents=True, exist_ok=True)
677
+
678
+ mcp_config = self.get_mcp_config()
679
+
680
+ with open(config_path, "w") as f:
681
+ json.dump(mcp_config.model_dump(mode="json"), f, indent=2)
682
+
683
+ except Exception as e:
684
+ raise MCPConfigurationError(f"Failed to export MCP configuration: {e}")
685
+
686
+ def validate_provider_config(
687
+ self, provider_name: str, provider_config: ProviderConfig
688
+ ) -> List[str]:
689
+ """
690
+ Validate configuration for a specific provider.
691
+
692
+ Args:
693
+ provider_name: Name of the provider
694
+ provider_config: Provider configuration to validate
695
+
696
+ Returns:
697
+ List of validation errors (empty if valid)
698
+ """
699
+ errors = []
700
+
701
+ # Basic validation
702
+ if not provider_config.model:
703
+ errors.append(f"Provider '{provider_name}' has no model specified")
704
+
705
+ # Provider-specific validation
706
+ if provider_name == "claude":
707
+ errors.extend(self._validate_claude_config(provider_config))
708
+ elif provider_name == "openai":
709
+ errors.extend(self._validate_openai_config(provider_config))
710
+ elif provider_name == "gemini":
711
+ errors.extend(self._validate_gemini_config(provider_config))
712
+ elif provider_name == "cohere":
713
+ errors.extend(self._validate_cohere_config(provider_config))
714
+ elif provider_name == "ollama":
715
+ errors.extend(self._validate_ollama_config(provider_config))
716
+ else:
717
+ # Generic validation for unknown providers
718
+ if not provider_config.api_key:
719
+ errors.append(f"Provider '{provider_name}' has no API key")
720
+
721
+ return errors
722
+
723
+ def _validate_claude_config(self, config: ProviderConfig) -> List[str]:
724
+ """Validate Claude provider configuration."""
725
+ errors = []
726
+
727
+ if not config.api_key:
728
+ errors.append("Claude provider requires an API key")
729
+ elif not config.api_key.startswith("sk-ant-"):
730
+ # Check if it's encrypted (base64) or plain text
731
+ try:
732
+ # Try to decrypt - if successful, it's encrypted
733
+ self._decrypt_api_key(config.api_key)
734
+ except:
735
+ # If decryption fails and doesn't start with sk-ant-, it's likely invalid
736
+ if not config.api_key.startswith("sk-ant-"):
737
+ errors.append("Claude API key should start with 'sk-ant-'")
738
+
739
+ # Validate model
740
+ valid_models = [
741
+ "claude-3-sonnet-20240229",
742
+ "claude-3-haiku-20240307",
743
+ "claude-3-opus-20240229",
744
+ "claude-3-5-sonnet-20241022",
745
+ ]
746
+ if config.model not in valid_models:
747
+ errors.append(
748
+ f"Unknown Claude model '{config.model}'. Valid models: {', '.join(valid_models)}"
749
+ )
750
+
751
+ return errors
752
+
753
+ def _validate_openai_config(self, config: ProviderConfig) -> List[str]:
754
+ """Validate OpenAI provider configuration."""
755
+ errors = []
756
+
757
+ if not config.api_key:
758
+ errors.append("OpenAI provider requires an API key")
759
+ elif not config.api_key.startswith("sk-"):
760
+ # Check if it's encrypted
761
+ try:
762
+ decrypted = self._decrypt_api_key(config.api_key)
763
+ if not decrypted.startswith("sk-"):
764
+ errors.append("OpenAI API key should start with 'sk-'")
765
+ except:
766
+ errors.append("OpenAI API key should start with 'sk-'")
767
+
768
+ # Validate model
769
+ valid_models = [
770
+ "gpt-4",
771
+ "gpt-4-turbo",
772
+ "gpt-4o",
773
+ "gpt-4o-mini",
774
+ "gpt-3.5-turbo",
775
+ "gpt-3.5-turbo-16k",
776
+ ]
777
+ if config.model not in valid_models:
778
+ errors.append(
779
+ f"Unknown OpenAI model '{config.model}'. Valid models: {', '.join(valid_models)}"
780
+ )
781
+
782
+ # Validate OpenAI-specific settings
783
+ if config.organization and not isinstance(config.organization, str):
784
+ errors.append("OpenAI organization must be a string")
785
+
786
+ return errors
787
+
788
+ def _validate_gemini_config(self, config: ProviderConfig) -> List[str]:
789
+ """Validate Gemini provider configuration."""
790
+ errors = []
791
+
792
+ if not config.api_key:
793
+ errors.append("Gemini provider requires an API key")
794
+ elif not config.api_key.startswith("AIza"):
795
+ # Check if it's encrypted
796
+ try:
797
+ decrypted = self._decrypt_api_key(config.api_key)
798
+ if not decrypted.startswith("AIza"):
799
+ errors.append("Gemini API key should start with 'AIza'")
800
+ except:
801
+ errors.append("Gemini API key should start with 'AIza'")
802
+
803
+ # Validate model
804
+ valid_models = ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.0-pro"]
805
+ if config.model not in valid_models:
806
+ errors.append(
807
+ f"Unknown Gemini model '{config.model}'. Valid models: {', '.join(valid_models)}"
808
+ )
809
+
810
+ # Validate Google-specific settings
811
+ if config.project_id and not isinstance(config.project_id, str):
812
+ errors.append("Google Cloud project_id must be a string")
813
+
814
+ return errors
815
+
816
+ def _validate_cohere_config(self, config: ProviderConfig) -> List[str]:
817
+ """Validate Cohere provider configuration."""
818
+ errors = []
819
+
820
+ if not config.api_key:
821
+ errors.append("Cohere provider requires an API key")
822
+ # Cohere API keys don't have a standard prefix, so we can't validate format
823
+
824
+ # Validate model
825
+ valid_models = [
826
+ "command-r",
827
+ "command-r-plus",
828
+ "command-light",
829
+ "command",
830
+ ]
831
+ if config.model not in valid_models:
832
+ errors.append(
833
+ f"Unknown Cohere model '{config.model}'. Valid models: {', '.join(valid_models)}"
834
+ )
835
+
836
+ return errors
837
+
838
+ def _validate_ollama_config(self, config: ProviderConfig) -> List[str]:
839
+ """Validate Ollama provider configuration."""
840
+ errors = []
841
+
842
+ # Ollama doesn't require an API key (local server)
843
+ # But we should validate the base_url if provided
844
+ if config.base_url:
845
+ if not config.base_url.startswith(("http://", "https://")):
846
+ errors.append("Ollama base_url must start with 'http://' or 'https://'")
847
+
848
+ # Model validation is difficult for Ollama since models are dynamic
849
+ # We'll just ensure it's not empty
850
+ if not config.model:
851
+ errors.append("Ollama provider requires a model name")
852
+
853
+ # Validate timeout for Ollama (can be slower than cloud providers)
854
+ if config.timeout and config.timeout < 10:
855
+ errors.append(
856
+ "Ollama timeout should be at least 10 seconds for local inference"
857
+ )
858
+
859
+ return errors
860
+
861
+ def get_provider_defaults(self, provider_name: str) -> Dict[str, any]:
862
+ """
863
+ Get default configuration values for a provider.
864
+
865
+ Args:
866
+ provider_name: Name of the provider
867
+
868
+ Returns:
869
+ Dictionary of default configuration values
870
+ """
871
+ defaults = {
872
+ "claude": {
873
+ "model": "claude-3-sonnet-20240229",
874
+ "max_tokens": 4096,
875
+ "temperature": 0.7,
876
+ "provider_type": "claude",
877
+ "supports_tools": True,
878
+ "supports_multimodal": True,
879
+ "supports_streaming": True,
880
+ "supports_function_calling": True,
881
+ "supports_system_messages": True,
882
+ "enabled": True,
883
+ "priority": 10,
884
+ "max_retries": 3,
885
+ "retry_delay": 1.0,
886
+ "exponential_backoff": True,
887
+ "health_check_enabled": True,
888
+ "health_check_interval": 300,
889
+ "health_check_timeout": 10.0,
890
+ "auth_type": "api_key",
891
+ },
892
+ "openai": {
893
+ "model": "gpt-4",
894
+ "max_tokens": 4096,
895
+ "temperature": 0.7,
896
+ "provider_type": "openai",
897
+ "supports_tools": True,
898
+ "supports_multimodal": True,
899
+ "supports_streaming": True,
900
+ "supports_function_calling": True,
901
+ "supports_system_messages": True,
902
+ "enabled": True,
903
+ "priority": 9,
904
+ "max_retries": 3,
905
+ "retry_delay": 1.0,
906
+ "exponential_backoff": True,
907
+ "health_check_enabled": True,
908
+ "health_check_interval": 300,
909
+ "health_check_timeout": 10.0,
910
+ "auth_type": "api_key",
911
+ },
912
+ "gemini": {
913
+ "model": "gemini-1.5-pro",
914
+ "max_tokens": 8192,
915
+ "temperature": 0.7,
916
+ "provider_type": "gemini",
917
+ "supports_tools": True,
918
+ "supports_multimodal": True,
919
+ "supports_streaming": True,
920
+ "supports_function_calling": True,
921
+ "supports_system_messages": True,
922
+ "enabled": True,
923
+ "priority": 8,
924
+ "max_retries": 3,
925
+ "retry_delay": 1.0,
926
+ "exponential_backoff": True,
927
+ "health_check_enabled": True,
928
+ "health_check_interval": 300,
929
+ "health_check_timeout": 10.0,
930
+ "auth_type": "api_key",
931
+ "safety_settings": {
932
+ "HARM_CATEGORY_HARASSMENT": "BLOCK_MEDIUM_AND_ABOVE",
933
+ "HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
934
+ "HARM_CATEGORY_SEXUALLY_EXPLICIT": "BLOCK_MEDIUM_AND_ABOVE",
935
+ "HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
936
+ },
937
+ },
938
+ "cohere": {
939
+ "model": "command-r",
940
+ "max_tokens": 4096,
941
+ "temperature": 0.7,
942
+ "provider_type": "cohere",
943
+ "supports_tools": False,
944
+ "supports_multimodal": False,
945
+ "supports_streaming": True,
946
+ "supports_function_calling": False,
947
+ "supports_system_messages": True,
948
+ "enabled": True,
949
+ "priority": 7,
950
+ "max_retries": 3,
951
+ "retry_delay": 1.0,
952
+ "exponential_backoff": True,
953
+ "health_check_enabled": True,
954
+ "health_check_interval": 300,
955
+ "health_check_timeout": 10.0,
956
+ "auth_type": "api_key",
957
+ },
958
+ "ollama": {
959
+ "model": "llama2",
960
+ "max_tokens": 4096,
961
+ "temperature": 0.7,
962
+ "base_url": "http://localhost:11434",
963
+ "timeout": 60.0,
964
+ "provider_type": "ollama",
965
+ "supports_tools": False,
966
+ "supports_multimodal": False,
967
+ "supports_streaming": True,
968
+ "supports_function_calling": False,
969
+ "supports_system_messages": True,
970
+ "enabled": True,
971
+ "priority": 5,
972
+ "max_retries": 3,
973
+ "retry_delay": 2.0,
974
+ "exponential_backoff": True,
975
+ "health_check_enabled": True,
976
+ "health_check_interval": 600,
977
+ "health_check_timeout": 15.0,
978
+ "auth_type": "none",
979
+ "num_ctx": 4096,
980
+ "repeat_penalty": 1.1,
981
+ },
982
+ }
983
+
984
+ return defaults.get(
985
+ provider_name,
986
+ {
987
+ "max_tokens": 4096,
988
+ "temperature": 0.7,
989
+ "supports_tools": False,
990
+ "supports_multimodal": False,
991
+ "supports_streaming": True,
992
+ "supports_function_calling": False,
993
+ "supports_system_messages": True,
994
+ "enabled": True,
995
+ "priority": 0,
996
+ "max_retries": 3,
997
+ "retry_delay": 1.0,
998
+ "exponential_backoff": True,
999
+ "health_check_enabled": True,
1000
+ "health_check_interval": 300,
1001
+ "health_check_timeout": 10.0,
1002
+ "auth_type": "api_key",
1003
+ },
1004
+ )
1005
+
1006
+ def create_provider_config(
1007
+ self, provider_name: str, api_key: str = None, **kwargs
1008
+ ) -> ProviderConfig:
1009
+ """
1010
+ Create a provider configuration with defaults.
1011
+
1012
+ Args:
1013
+ provider_name: Name of the provider
1014
+ api_key: API key (optional for providers like Ollama)
1015
+ **kwargs: Additional configuration options
1016
+
1017
+ Returns:
1018
+ ProviderConfig with defaults applied
1019
+ """
1020
+ defaults = self.get_provider_defaults(provider_name)
1021
+
1022
+ # Merge defaults with provided kwargs
1023
+ config_data = {**defaults, **kwargs}
1024
+
1025
+ # Add API key if provided
1026
+ if api_key:
1027
+ config_data["api_key"] = self._encrypt_api_key(api_key)
1028
+ elif provider_name != "ollama": # Ollama doesn't need API key
1029
+ config_data["api_key"] = None
1030
+
1031
+ return ProviderConfig(**config_data)
1032
+
1033
+ def load_config_from_sources(
1034
+ self,
1035
+ env_vars: Optional[Dict[str, str]] = None,
1036
+ cli_args: Optional[Dict[str, Any]] = None,
1037
+ ) -> Config:
1038
+ """
1039
+ Load configuration from multiple sources with precedence.
1040
+
1041
+ Args:
1042
+ env_vars: Environment variables to consider
1043
+ cli_args: Command line arguments to consider
1044
+
1045
+ Returns:
1046
+ Merged configuration
1047
+ """
1048
+ # Start with file-based configuration
1049
+ config = self.load_config()
1050
+
1051
+ # Merge environment variables
1052
+ if env_vars:
1053
+ config.merge_from_env(env_vars)
1054
+
1055
+ # Merge CLI arguments (highest precedence)
1056
+ if cli_args:
1057
+ self._merge_cli_args(config, cli_args)
1058
+
1059
+ return config
1060
+
1061
+ def _merge_cli_args(self, config: Config, cli_args: Dict[str, Any]) -> None:
1062
+ """Merge CLI arguments into configuration."""
1063
+ # Map CLI arguments to configuration fields
1064
+ cli_mapping = {
1065
+ "provider": "default_provider",
1066
+ "model": lambda: self._set_provider_model(config, cli_args.get("model")),
1067
+ "temperature": lambda: self._set_provider_temperature(
1068
+ config, cli_args.get("temperature")
1069
+ ),
1070
+ "max_tokens": lambda: self._set_provider_max_tokens(
1071
+ config, cli_args.get("max_tokens")
1072
+ ),
1073
+ "debug": "debug_mode",
1074
+ "log_level": "log_level",
1075
+ }
1076
+
1077
+ for cli_arg, config_path in cli_mapping.items():
1078
+ if cli_arg in cli_args and cli_args[cli_arg] is not None:
1079
+ if callable(config_path):
1080
+ config_path()
1081
+ else:
1082
+ setattr(config, config_path, cli_args[cli_arg])
1083
+ config.config_sources[config_path] = f"cli:{cli_arg}"
1084
+
1085
+ def _set_provider_model(self, config: Config, model: str) -> None:
1086
+ """Set model for the default provider."""
1087
+ if model and config.default_provider in config.providers:
1088
+ config.providers[config.default_provider].model = model
1089
+ config.config_sources[f"providers.{config.default_provider}.model"] = (
1090
+ "cli:model"
1091
+ )
1092
+
1093
+ def _set_provider_temperature(self, config: Config, temperature: float) -> None:
1094
+ """Set temperature for the default provider."""
1095
+ if temperature is not None and config.default_provider in config.providers:
1096
+ config.providers[config.default_provider].temperature = temperature
1097
+ config.config_sources[
1098
+ f"providers.{config.default_provider}.temperature"
1099
+ ] = "cli:temperature"
1100
+
1101
+ def _set_provider_max_tokens(self, config: Config, max_tokens: int) -> None:
1102
+ """Set max_tokens for the default provider."""
1103
+ if max_tokens is not None and config.default_provider in config.providers:
1104
+ config.providers[config.default_provider].max_tokens = max_tokens
1105
+ config.config_sources[f"providers.{config.default_provider}.max_tokens"] = (
1106
+ "cli:max_tokens"
1107
+ )
1108
+
1109
+ def get_effective_config(self) -> Config:
1110
+ """
1111
+ Get the effective configuration (considering active profile).
1112
+
1113
+ Returns:
1114
+ The active configuration
1115
+ """
1116
+ config = self.get_config()
1117
+ return config.get_active_config()
1118
+
1119
+ def create_profile(
1120
+ self,
1121
+ name: str,
1122
+ description: Optional[str] = None,
1123
+ copy_from_current: bool = True,
1124
+ ) -> ConfigProfile:
1125
+ """
1126
+ Create a new configuration profile.
1127
+
1128
+ Args:
1129
+ name: Profile name
1130
+ description: Optional profile description
1131
+ copy_from_current: Whether to copy current settings
1132
+
1133
+ Returns:
1134
+ Created ConfigProfile
1135
+ """
1136
+ config = self.get_config()
1137
+
1138
+ if copy_from_current:
1139
+ profile = config.create_profile(name, description)
1140
+ else:
1141
+ # Create empty profile with minimal settings
1142
+ profile = ConfigProfile(
1143
+ name=name,
1144
+ description=description,
1145
+ default_provider="claude",
1146
+ providers={},
1147
+ )
1148
+ config.profiles[name] = profile
1149
+
1150
+ self.save_config()
1151
+ return profile
1152
+
1153
+ def switch_profile(self, profile_name: str) -> None:
1154
+ """
1155
+ Switch to a different configuration profile.
1156
+
1157
+ Args:
1158
+ profile_name: Name of the profile to switch to
1159
+ """
1160
+ config = self.get_config()
1161
+ config.switch_profile(profile_name)
1162
+ self.save_config()
1163
+
1164
+ def delete_profile(self, profile_name: str) -> bool:
1165
+ """
1166
+ Delete a configuration profile.
1167
+
1168
+ Args:
1169
+ profile_name: Name of the profile to delete
1170
+
1171
+ Returns:
1172
+ True if profile was deleted
1173
+ """
1174
+ config = self.get_config()
1175
+ result = config.delete_profile(profile_name)
1176
+ if result:
1177
+ self.save_config()
1178
+ return result
1179
+
1180
+ def list_profiles(self) -> List[str]:
1181
+ """
1182
+ List all available configuration profiles.
1183
+
1184
+ Returns:
1185
+ List of profile names
1186
+ """
1187
+ config = self.get_config()
1188
+ return list(config.profiles.keys())
1189
+
1190
+ def get_profile(self, profile_name: str) -> Optional[ConfigProfile]:
1191
+ """
1192
+ Get a specific configuration profile.
1193
+
1194
+ Args:
1195
+ profile_name: Name of the profile
1196
+
1197
+ Returns:
1198
+ ConfigProfile or None if not found
1199
+ """
1200
+ config = self.get_config()
1201
+ return config.profiles.get(profile_name)
1202
+
1203
+ def backup_config(self, backup_path: Optional[str] = None) -> str:
1204
+ """
1205
+ Create a backup of the current configuration.
1206
+
1207
+ Args:
1208
+ backup_path: Optional path for backup file
1209
+
1210
+ Returns:
1211
+ Path to the backup file
1212
+ """
1213
+ if not backup_path:
1214
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1215
+ backup_path = str(
1216
+ self.config_path.parent / f"config_backup_{timestamp}.json"
1217
+ )
1218
+
1219
+ backup_path = Path(backup_path)
1220
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
1221
+
1222
+ # Copy current config to backup location
1223
+ if self.config_path.exists():
1224
+ import shutil
1225
+
1226
+ shutil.copy2(self.config_path, backup_path)
1227
+
1228
+ return str(backup_path)
1229
+
1230
+ def restore_config(self, backup_path: str) -> None:
1231
+ """
1232
+ Restore configuration from a backup.
1233
+
1234
+ Args:
1235
+ backup_path: Path to the backup file
1236
+
1237
+ Raises:
1238
+ ConfigurationError: If restore fails
1239
+ """
1240
+ backup_path = Path(backup_path)
1241
+ if not backup_path.exists():
1242
+ raise ConfigurationError(f"Backup file not found: {backup_path}")
1243
+
1244
+ try:
1245
+ # Validate backup file first
1246
+ with open(backup_path, "r") as f:
1247
+ backup_data = json.load(f)
1248
+
1249
+ # Try to create Config object to validate
1250
+ Config(**backup_data)
1251
+
1252
+ # If validation passes, restore the backup
1253
+ import shutil
1254
+
1255
+ shutil.copy2(backup_path, self.config_path)
1256
+
1257
+ # Reload configuration
1258
+ self.config = None
1259
+ self.load_config()
1260
+
1261
+ except Exception as e:
1262
+ raise ConfigurationError(f"Failed to restore configuration: {e}")
1263
+
1264
+ def get_config_info(self) -> Dict[str, Any]:
1265
+ """
1266
+ Get information about the current configuration.
1267
+
1268
+ Returns:
1269
+ Dictionary with configuration metadata
1270
+ """
1271
+ config = self.get_config()
1272
+
1273
+ return {
1274
+ "config_path": str(self.config_path),
1275
+ "config_version": config.config_version,
1276
+ "created_at": (
1277
+ config.created_at.isoformat() if config.created_at else None
1278
+ ),
1279
+ "updated_at": (
1280
+ config.updated_at.isoformat() if config.updated_at else None
1281
+ ),
1282
+ "providers_count": len(config.providers),
1283
+ "enabled_providers": len(config.get_enabled_providers()),
1284
+ "profiles_count": len(config.profiles),
1285
+ "active_profile": config.active_profile,
1286
+ "mcp_servers_count": len(config.mcp.servers),
1287
+ "enabled_mcp_servers": len(config.mcp.get_enabled_servers()),
1288
+ "default_provider": config.default_provider,
1289
+ "storage_path": config.storage_path,
1290
+ "config_sources": config.config_sources,
1291
+ }
1292
+ """
1293
+ Load configuration from multiple sources (file, environment, CLI args).
1294
+
1295
+ Args:
1296
+ env_vars: Environment variables to merge
1297
+ cli_args: CLI arguments to merge
1298
+
1299
+ Returns:
1300
+ Loaded Config object with merged settings
1301
+ """
1302
+ # Start with file-based configuration
1303
+ config = self.load_config()
1304
+
1305
+ # Merge environment variables
1306
+ if env_vars:
1307
+ config.merge_from_env(env_vars)
1308
+
1309
+ # Merge CLI arguments (highest priority)
1310
+ if cli_args:
1311
+ self._merge_cli_args(config, cli_args)
1312
+
1313
+ # Update timestamps
1314
+ from datetime import datetime
1315
+
1316
+ if not config.created_at:
1317
+ config.created_at = datetime.now()
1318
+ config.updated_at = datetime.now()
1319
+
1320
+ self.config = config
1321
+ return config
1322
+
1323
+ def _merge_cli_args(self, config: Config, cli_args: Dict[str, Any]) -> None:
1324
+ """
1325
+ Merge CLI arguments into configuration.
1326
+
1327
+ Args:
1328
+ config: Configuration object to update
1329
+ cli_args: CLI arguments to merge
1330
+ """
1331
+ # Map CLI arguments to configuration fields
1332
+ cli_mapping = {
1333
+ "provider": "default_provider",
1334
+ "model": "providers.{provider}.model",
1335
+ "max_tokens": "providers.{provider}.max_tokens",
1336
+ "temperature": "providers.{provider}.temperature",
1337
+ "storage_path": "storage_path",
1338
+ "profile": "active_profile",
1339
+ }
1340
+
1341
+ for cli_arg, config_path in cli_mapping.items():
1342
+ if cli_arg in cli_args and cli_args[cli_arg] is not None:
1343
+ value = cli_args[cli_arg]
1344
+
1345
+ # Handle provider-specific settings
1346
+ if "{provider}" in config_path:
1347
+ provider = cli_args.get("provider", config.default_provider)
1348
+ config_path = config_path.format(provider=provider)
1349
+
1350
+ # Ensure provider exists
1351
+ if provider not in config.providers:
1352
+ config.providers[provider] = self.create_provider_config(
1353
+ provider
1354
+ )
1355
+
1356
+ # Set the value and track source
1357
+ self._set_nested_value_in_config(config, config_path, value)
1358
+ config.config_sources[config_path] = "cli"
1359
+
1360
+ def _set_nested_value_in_config(
1361
+ self, config: Config, path: str, value: Any
1362
+ ) -> None:
1363
+ """
1364
+ Set a nested configuration value from a dot-separated path.
1365
+
1366
+ Args:
1367
+ config: Configuration object to update
1368
+ path: Dot-separated path to the setting
1369
+ value: Value to set
1370
+ """
1371
+ parts = path.split(".")
1372
+ obj = config
1373
+
1374
+ # Navigate to the parent object
1375
+ for part in parts[:-1]:
1376
+ if hasattr(obj, part):
1377
+ obj = getattr(obj, part)
1378
+ elif isinstance(obj, dict) and part in obj:
1379
+ obj = obj[part]
1380
+ else:
1381
+ return # Path doesn't exist
1382
+
1383
+ # Set the final value with type conversion
1384
+ final_key = parts[-1]
1385
+ if hasattr(obj, final_key):
1386
+ current_value = getattr(obj, final_key)
1387
+ try:
1388
+ # Convert string values to appropriate types
1389
+ if isinstance(current_value, bool):
1390
+ converted_value = str(value).lower() in (
1391
+ "true",
1392
+ "1",
1393
+ "yes",
1394
+ "on",
1395
+ )
1396
+ elif isinstance(current_value, int):
1397
+ converted_value = int(value)
1398
+ elif isinstance(current_value, float):
1399
+ converted_value = float(value)
1400
+ else:
1401
+ converted_value = value
1402
+ setattr(obj, final_key, converted_value)
1403
+ except (ValueError, TypeError):
1404
+ # If conversion fails, keep as string
1405
+ setattr(obj, final_key, value)
1406
+ elif isinstance(obj, dict):
1407
+ obj[final_key] = value
1408
+
1409
+ def create_profile(
1410
+ self, name: str, description: Optional[str] = None
1411
+ ) -> ConfigProfile:
1412
+ """
1413
+ Create a new configuration profile.
1414
+
1415
+ Args:
1416
+ name: Profile name
1417
+ description: Optional profile description
1418
+
1419
+ Returns:
1420
+ Created ConfigProfile
1421
+ """
1422
+ config = self.get_config()
1423
+ profile = config.create_profile(name, description)
1424
+ self.save_config()
1425
+ return profile
1426
+
1427
+ def list_profiles(self) -> Dict[str, str]:
1428
+ """
1429
+ List all configuration profiles.
1430
+
1431
+ Returns:
1432
+ Dictionary mapping profile names to descriptions
1433
+ """
1434
+ config = self.get_config()
1435
+ return {
1436
+ name: profile.description or "" for name, profile in config.profiles.items()
1437
+ }
1438
+
1439
+ def switch_profile(self, profile_name: str) -> None:
1440
+ """
1441
+ Switch to a different configuration profile.
1442
+
1443
+ Args:
1444
+ profile_name: Name of the profile to switch to
1445
+
1446
+ Raises:
1447
+ ConfigurationError: If profile doesn't exist
1448
+ """
1449
+ config = self.get_config()
1450
+ if profile_name not in config.profiles:
1451
+ raise ConfigurationError(f"Profile '{profile_name}' not found")
1452
+
1453
+ config.switch_profile(profile_name)
1454
+ self.save_config()
1455
+
1456
+ def get_active_profile_name(self) -> Optional[str]:
1457
+ """
1458
+ Get the name of the active profile.
1459
+
1460
+ Returns:
1461
+ Active profile name or None if no profile is active
1462
+ """
1463
+ config = self.get_config()
1464
+ return config.active_profile
1465
+
1466
+ def delete_profile(self, profile_name: str) -> bool:
1467
+ """
1468
+ Delete a configuration profile.
1469
+
1470
+ Args:
1471
+ profile_name: Name of the profile to delete
1472
+
1473
+ Returns:
1474
+ True if profile was deleted, False if not found
1475
+ """
1476
+ config = self.get_config()
1477
+ result = config.delete_profile(profile_name)
1478
+ if result:
1479
+ self.save_config()
1480
+ return result
1481
+
1482
+ def update_profile_provider_settings(
1483
+ self, profile_name: str, provider_name: str, **kwargs
1484
+ ) -> None:
1485
+ """
1486
+ Update provider settings in a specific profile.
1487
+
1488
+ Args:
1489
+ profile_name: Name of the profile
1490
+ provider_name: Name of the provider
1491
+ **kwargs: Settings to update
1492
+
1493
+ Raises:
1494
+ ConfigurationError: If profile doesn't exist
1495
+ """
1496
+ config = self.get_config()
1497
+ if profile_name not in config.profiles:
1498
+ raise ConfigurationError(f"Profile '{profile_name}' not found")
1499
+
1500
+ profile = config.profiles[profile_name]
1501
+
1502
+ if provider_name not in profile.providers:
1503
+ profile.providers[provider_name] = self.create_provider_config(
1504
+ provider_name
1505
+ )
1506
+
1507
+ provider_config = profile.providers[provider_name]
1508
+
1509
+ # Update settings
1510
+ for key, value in kwargs.items():
1511
+ if hasattr(provider_config, key):
1512
+ setattr(provider_config, key, value)
1513
+
1514
+ self.save_config()
1515
+
1516
+ def get_effective_config(self) -> Config:
1517
+ """
1518
+ Get the effective configuration (considering active profile).
1519
+
1520
+ Returns:
1521
+ Effective Config object
1522
+ """
1523
+ config = self.get_config()
1524
+ return config.get_active_config()
1525
+
1526
+ def migrate_config_format(self) -> bool:
1527
+ """
1528
+ Migrate configuration from older format to current format.
1529
+
1530
+ Returns:
1531
+ True if migration was performed, False if not needed
1532
+ """
1533
+ if not self.config_path.exists():
1534
+ return False
1535
+
1536
+ try:
1537
+ with open(self.config_path, "r") as f:
1538
+ config_data = json.load(f)
1539
+
1540
+ # Check if migration is needed
1541
+ if config_data.get("config_version") == "2.0":
1542
+ return False # Already current version
1543
+
1544
+ # Backup original config
1545
+ self.backup_config()
1546
+
1547
+ # Migrate provider configurations
1548
+ if "providers" in config_data:
1549
+ for provider_name, provider_data in config_data["providers"].items():
1550
+ # Add provider_type if missing
1551
+ if "provider_type" not in provider_data:
1552
+ provider_data["provider_type"] = provider_name
1553
+
1554
+ # Add capability flags based on provider type
1555
+ defaults = self.get_provider_defaults(provider_name)
1556
+ for key in [
1557
+ "supports_tools",
1558
+ "supports_multimodal",
1559
+ "supports_streaming",
1560
+ ]:
1561
+ if key not in provider_data:
1562
+ provider_data[key] = defaults.get(key, False)
1563
+
1564
+ # Add new fields
1565
+ config_data["config_version"] = "2.0"
1566
+ config_data["profiles"] = config_data.get("profiles", {})
1567
+ config_data["active_profile"] = config_data.get("active_profile")
1568
+ config_data["config_sources"] = config_data.get("config_sources", {})
1569
+
1570
+ from datetime import datetime
1571
+
1572
+ config_data["created_at"] = config_data.get(
1573
+ "created_at", datetime.now().isoformat()
1574
+ )
1575
+ config_data["updated_at"] = datetime.now().isoformat()
1576
+
1577
+ # Save migrated config
1578
+ with open(self.config_path, "w") as f:
1579
+ json.dump(config_data, f, indent=2)
1580
+
1581
+ # Reload config
1582
+ self.config = None
1583
+ self.load_config()
1584
+
1585
+ return True
1586
+
1587
+ except Exception as e:
1588
+ raise ConfigurationError(f"Failed to migrate configuration: {e}")
1589
+
1590
+ def backup_config(self) -> str:
1591
+ """
1592
+ Create a backup of the current configuration.
1593
+
1594
+ Returns:
1595
+ Path to the backup file
1596
+ """
1597
+ if not self.config_path.exists():
1598
+ raise ConfigurationError("No configuration file to backup")
1599
+
1600
+ from datetime import datetime
1601
+
1602
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1603
+ backup_path = self.config_path.parent / f"config_backup_{timestamp}.json"
1604
+
1605
+ import shutil
1606
+
1607
+ shutil.copy2(self.config_path, backup_path)
1608
+
1609
+ return str(backup_path)
1610
+
1611
+ def restore_config(self, backup_path: str) -> None:
1612
+ """
1613
+ Restore configuration from a backup file.
1614
+
1615
+ Args:
1616
+ backup_path: Path to the backup file
1617
+
1618
+ Raises:
1619
+ ConfigurationError: If restore fails
1620
+ """
1621
+ backup_file = Path(backup_path)
1622
+ if not backup_file.exists():
1623
+ raise ConfigurationError(f"Backup file not found: {backup_path}")
1624
+
1625
+ try:
1626
+ import shutil
1627
+
1628
+ shutil.copy2(backup_file, self.config_path)
1629
+
1630
+ # Clear cached config and reload
1631
+ self.config = None
1632
+ self.load_config()
1633
+
1634
+ except Exception as e:
1635
+ raise ConfigurationError(f"Failed to restore configuration: {e}")
1636
+
1637
+ def get_config_summary(self) -> Dict[str, Any]:
1638
+ """
1639
+ Get a summary of the current configuration.
1640
+
1641
+ Returns:
1642
+ Dictionary with configuration summary
1643
+ """
1644
+ config = self.get_config()
1645
+
1646
+ # Provider capabilities
1647
+ provider_capabilities = {}
1648
+ for provider_name, provider_config in config.providers.items():
1649
+ provider_capabilities[provider_name] = {
1650
+ "supports_tools": getattr(provider_config, "supports_tools", False),
1651
+ "supports_multimodal": getattr(
1652
+ provider_config, "supports_multimodal", False
1653
+ ),
1654
+ "supports_streaming": getattr(
1655
+ provider_config, "supports_streaming", True
1656
+ ),
1657
+ "provider_type": getattr(
1658
+ provider_config, "provider_type", provider_name
1659
+ ),
1660
+ }
1661
+
1662
+ return {
1663
+ "default_provider": config.default_provider,
1664
+ "providers": list(config.providers.keys()),
1665
+ "profiles": list(config.profiles.keys()),
1666
+ "active_profile": config.active_profile,
1667
+ "mcp_enabled": config.mcp.enabled,
1668
+ "mcp_servers": len(config.mcp.servers),
1669
+ "provider_capabilities": provider_capabilities,
1670
+ "config_version": config.config_version,
1671
+ "storage_path": config.storage_path,
1672
+ }
1673
+
1674
+ def _set_nested_config_value(self, config: Config, path: str, value: Any) -> None:
1675
+ """
1676
+ Set a nested configuration value from a dot-separated path.
1677
+
1678
+ Args:
1679
+ config: Configuration object to update
1680
+ path: Dot-separated path to the setting
1681
+ value: Value to set
1682
+ """
1683
+ parts = path.split(".")
1684
+ obj = config
1685
+
1686
+ # Navigate to the parent object
1687
+ for part in parts[:-1]:
1688
+ if hasattr(obj, part):
1689
+ obj = getattr(obj, part)
1690
+ elif isinstance(obj, dict) and part in obj:
1691
+ obj = obj[part]
1692
+ else:
1693
+ return # Path doesn't exist
1694
+
1695
+ # Set the final value
1696
+ final_key = parts[-1]
1697
+ if hasattr(obj, final_key):
1698
+ setattr(obj, final_key, value)
1699
+ elif isinstance(obj, dict):
1700
+ obj[final_key] = value
1701
+
1702
+ def create_profile(
1703
+ self,
1704
+ name: str,
1705
+ description: Optional[str] = None,
1706
+ copy_from_current: bool = True,
1707
+ ) -> None:
1708
+ """
1709
+ Create a new configuration profile.
1710
+
1711
+ Args:
1712
+ name: Name of the profile
1713
+ description: Optional description
1714
+ copy_from_current: Whether to copy current settings to the new profile
1715
+ """
1716
+ config = self.get_config()
1717
+
1718
+ if name in config.profiles:
1719
+ raise ConfigurationError(f"Profile '{name}' already exists")
1720
+
1721
+ if copy_from_current:
1722
+ profile = config.create_profile(name, description)
1723
+ else:
1724
+ # Create empty profile with defaults
1725
+ profile = ConfigProfile(
1726
+ name=name,
1727
+ description=description,
1728
+ default_provider="claude",
1729
+ providers={},
1730
+ )
1731
+ config.profiles[name] = profile
1732
+
1733
+ self.save_config()
1734
+
1735
+ def switch_profile(self, profile_name: str) -> None:
1736
+ """
1737
+ Switch to a different configuration profile.
1738
+
1739
+ Args:
1740
+ profile_name: Name of the profile to switch to
1741
+ """
1742
+ config = self.get_config()
1743
+ config.switch_profile(profile_name)
1744
+ self.save_config()
1745
+
1746
+ def delete_profile(self, profile_name: str) -> bool:
1747
+ """
1748
+ Delete a configuration profile.
1749
+
1750
+ Args:
1751
+ profile_name: Name of the profile to delete
1752
+
1753
+ Returns:
1754
+ True if profile was deleted, False if not found
1755
+ """
1756
+ config = self.get_config()
1757
+ result = config.delete_profile(profile_name)
1758
+ if result:
1759
+ self.save_config()
1760
+ return result
1761
+
1762
+ def list_profiles(self) -> Dict[str, str]:
1763
+ """
1764
+ List all available configuration profiles.
1765
+
1766
+ Returns:
1767
+ Dictionary mapping profile names to descriptions
1768
+ """
1769
+ config = self.get_config()
1770
+ return {
1771
+ name: profile.description or "No description"
1772
+ for name, profile in config.profiles.items()
1773
+ }
1774
+
1775
+ def get_active_profile_name(self) -> Optional[str]:
1776
+ """
1777
+ Get the name of the currently active profile.
1778
+
1779
+ Returns:
1780
+ Active profile name or None if using main config
1781
+ """
1782
+ config = self.get_config()
1783
+ return config.active_profile
1784
+
1785
+ def get_effective_config(self) -> Config:
1786
+ """
1787
+ Get the effective configuration (active profile or main config).
1788
+
1789
+ Returns:
1790
+ The configuration that should be used for operations
1791
+ """
1792
+ config = self.get_config()
1793
+ return config.get_active_config()
1794
+
1795
+ def update_profile_provider_settings(
1796
+ self, profile_name: str, provider_name: str, **kwargs
1797
+ ) -> None:
1798
+ """
1799
+ Update provider settings for a specific profile.
1800
+
1801
+ Args:
1802
+ profile_name: Name of the profile
1803
+ provider_name: Name of the provider
1804
+ **kwargs: Settings to update
1805
+ """
1806
+ config = self.get_config()
1807
+
1808
+ if profile_name not in config.profiles:
1809
+ raise ConfigurationError(f"Profile '{profile_name}' not found")
1810
+
1811
+ profile = config.profiles[profile_name]
1812
+
1813
+ if provider_name not in profile.providers:
1814
+ # Create provider config with defaults
1815
+ profile.providers[provider_name] = self.create_provider_config(
1816
+ provider_name
1817
+ )
1818
+
1819
+ provider_config = profile.providers[provider_name]
1820
+
1821
+ # Update settings
1822
+ for key, value in kwargs.items():
1823
+ if hasattr(provider_config, key):
1824
+ setattr(provider_config, key, value)
1825
+
1826
+ self.save_config()
1827
+
1828
+ def migrate_config_format(self) -> bool:
1829
+ """
1830
+ Migrate configuration from older format to current format.
1831
+
1832
+ Returns:
1833
+ True if migration was performed, False if no migration needed
1834
+ """
1835
+ # Load the raw config data first
1836
+ if not self.config_path.exists():
1837
+ return False
1838
+
1839
+ try:
1840
+ with open(self.config_path, "r") as f:
1841
+ raw_config = json.load(f)
1842
+ except (json.JSONDecodeError, FileNotFoundError):
1843
+ return False
1844
+
1845
+ # Check if migration is needed
1846
+ if raw_config.get("config_version") == "2.0":
1847
+ return False # Already current version
1848
+
1849
+ # Perform migration
1850
+ migrated = False
1851
+
1852
+ # Add missing fields with defaults
1853
+ if "profiles" not in raw_config:
1854
+ raw_config["profiles"] = {}
1855
+ migrated = True
1856
+
1857
+ if "active_profile" not in raw_config:
1858
+ raw_config["active_profile"] = None
1859
+ migrated = True
1860
+
1861
+ if "config_version" not in raw_config:
1862
+ raw_config["config_version"] = "2.0"
1863
+ migrated = True
1864
+
1865
+ if "config_sources" not in raw_config:
1866
+ raw_config["config_sources"] = {}
1867
+ migrated = True
1868
+
1869
+ # Migrate provider configurations
1870
+ if "providers" in raw_config:
1871
+ for provider_name, provider_config in raw_config["providers"].items():
1872
+ if "provider_type" not in provider_config:
1873
+ provider_config["provider_type"] = provider_name
1874
+ migrated = True
1875
+
1876
+ if "supports_tools" not in provider_config:
1877
+ defaults = self.get_provider_defaults(provider_name)
1878
+ provider_config["supports_tools"] = defaults.get(
1879
+ "supports_tools", False
1880
+ )
1881
+ provider_config["supports_multimodal"] = defaults.get(
1882
+ "supports_multimodal", False
1883
+ )
1884
+ provider_config["supports_streaming"] = defaults.get(
1885
+ "supports_streaming", True
1886
+ )
1887
+ migrated = True
1888
+
1889
+ # Add timestamps
1890
+ from datetime import datetime
1891
+
1892
+ if "created_at" not in raw_config or not raw_config["created_at"]:
1893
+ raw_config["created_at"] = datetime.now().isoformat()
1894
+ migrated = True
1895
+
1896
+ if "updated_at" not in raw_config:
1897
+ raw_config["updated_at"] = datetime.now().isoformat()
1898
+ migrated = True
1899
+
1900
+ if migrated:
1901
+ # Save the migrated config
1902
+ with open(self.config_path, "w") as f:
1903
+ json.dump(raw_config, f, indent=2)
1904
+
1905
+ # Reload the config
1906
+ self.config = None
1907
+ self.load_config()
1908
+
1909
+ return migrated
1910
+
1911
+ def backup_config(self, backup_path: Optional[str] = None) -> str:
1912
+ """
1913
+ Create a backup of the current configuration.
1914
+
1915
+ Args:
1916
+ backup_path: Optional path for backup file
1917
+
1918
+ Returns:
1919
+ Path to the backup file
1920
+ """
1921
+ if not backup_path:
1922
+ from datetime import datetime
1923
+
1924
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1925
+ backup_path = str(
1926
+ self.config_path.parent / f"config_backup_{timestamp}.json"
1927
+ )
1928
+
1929
+ backup_path = Path(backup_path)
1930
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
1931
+
1932
+ # Copy current config to backup location
1933
+ if self.config_path.exists():
1934
+ import shutil
1935
+
1936
+ shutil.copy2(self.config_path, backup_path)
1937
+
1938
+ return str(backup_path)
1939
+
1940
+ def restore_config(self, backup_path: str) -> None:
1941
+ """
1942
+ Restore configuration from a backup file.
1943
+
1944
+ Args:
1945
+ backup_path: Path to the backup file
1946
+
1947
+ Raises:
1948
+ ConfigurationError: If restore fails
1949
+ """
1950
+ backup_path = Path(backup_path)
1951
+
1952
+ if not backup_path.exists():
1953
+ raise ConfigurationError(f"Backup file not found: {backup_path}")
1954
+
1955
+ try:
1956
+ # Validate backup file
1957
+ with open(backup_path, "r") as f:
1958
+ backup_data = json.load(f)
1959
+
1960
+ # Test that we can create a Config object from backup data
1961
+ Config(**backup_data)
1962
+
1963
+ # Create backup of current config before restore
1964
+ if self.config_path.exists():
1965
+ import time
1966
+ from datetime import datetime
1967
+
1968
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1969
+ # Add milliseconds to avoid filename collision
1970
+ millis = int(time.time() * 1000) % 1000
1971
+ current_backup_path = (
1972
+ self.config_path.parent
1973
+ / f"config_pre_restore_{timestamp}_{millis}.json"
1974
+ )
1975
+ import shutil
1976
+
1977
+ shutil.copy2(self.config_path, current_backup_path)
1978
+
1979
+ # Copy backup to current config location
1980
+ import shutil
1981
+
1982
+ shutil.copy2(backup_path, self.config_path)
1983
+
1984
+ # Clear cached config and reload
1985
+ self.config = None
1986
+ self.load_config()
1987
+
1988
+ except Exception as e:
1989
+ raise ConfigurationError(f"Failed to restore configuration: {e}")
1990
+
1991
+ def get_config_summary(self) -> Dict[str, Any]:
1992
+ """
1993
+ Get a summary of the current configuration.
1994
+
1995
+ Returns:
1996
+ Dictionary with configuration summary
1997
+ """
1998
+ config = self.get_effective_config()
1999
+
2000
+ summary = {
2001
+ "config_version": getattr(config, "config_version", "1.0"),
2002
+ "default_provider": config.default_provider,
2003
+ "providers": list(config.providers.keys()),
2004
+ "active_profile": getattr(config, "active_profile", None),
2005
+ "profiles": list(getattr(config, "profiles", {}).keys()),
2006
+ "mcp_enabled": config.mcp.enabled,
2007
+ "mcp_servers": len(config.mcp.servers),
2008
+ "enabled_mcp_servers": len(config.mcp.get_enabled_servers()),
2009
+ "storage_path": config.storage_path,
2010
+ "created_at": getattr(config, "created_at", None),
2011
+ "updated_at": getattr(config, "updated_at", None),
2012
+ }
2013
+
2014
+ # Add provider capabilities summary
2015
+ provider_capabilities = {}
2016
+ for name, provider_config in config.providers.items():
2017
+ provider_capabilities[name] = {
2018
+ "model": provider_config.model,
2019
+ "supports_tools": getattr(provider_config, "supports_tools", False),
2020
+ "supports_multimodal": getattr(
2021
+ provider_config, "supports_multimodal", False
2022
+ ),
2023
+ "supports_streaming": getattr(
2024
+ provider_config, "supports_streaming", True
2025
+ ),
2026
+ "provider_type": getattr(provider_config, "provider_type", name),
2027
+ }
2028
+
2029
+ summary["provider_capabilities"] = provider_capabilities
2030
+
2031
+ return summary
2032
+
2033
+ def validate_config_comprehensive(self) -> Dict[str, List[str]]:
2034
+ """
2035
+ Perform comprehensive configuration validation using ConfigValidator.
2036
+
2037
+ Returns:
2038
+ Dictionary mapping validation categories to error lists
2039
+ """
2040
+ try:
2041
+ config = self.get_config()
2042
+ validator = ConfigValidator(config)
2043
+ return validator.validate_full_config()
2044
+ except Exception as e:
2045
+ return {"general": [f"Configuration validation failed: {e}"]}
2046
+
2047
+ def create_config_backup(self, backup_path: Optional[str] = None) -> str:
2048
+ """
2049
+ Create a backup of the current configuration.
2050
+
2051
+ Args:
2052
+ backup_path: Optional path for backup file
2053
+
2054
+ Returns:
2055
+ Path to the backup file
2056
+
2057
+ Raises:
2058
+ ConfigurationError: If backup creation fails
2059
+ """
2060
+ migration = ConfigMigration(self.config_path)
2061
+ try:
2062
+ if backup_path:
2063
+ backup_path_obj = Path(backup_path)
2064
+ backup_path_obj.parent.mkdir(parents=True, exist_ok=True)
2065
+ import shutil
2066
+
2067
+ shutil.copy2(self.config_path, backup_path_obj)
2068
+ return str(backup_path_obj)
2069
+ else:
2070
+ return str(migration.create_backup())
2071
+ except Exception as e:
2072
+ raise ConfigurationError(f"Failed to create configuration backup: {e}")
2073
+
2074
+ def restore_config_backup(self, backup_path: str) -> None:
2075
+ """
2076
+ Restore configuration from a backup.
2077
+
2078
+ Args:
2079
+ backup_path: Path to the backup file
2080
+
2081
+ Raises:
2082
+ ConfigurationError: If restore fails
2083
+ """
2084
+ migration = ConfigMigration(self.config_path)
2085
+ try:
2086
+ migration.restore_backup(Path(backup_path))
2087
+ # Reload configuration after restore
2088
+ self.config = None
2089
+ self.load_config()
2090
+ except Exception as e:
2091
+ raise ConfigurationError(f"Failed to restore configuration backup: {e}")
2092
+
2093
+ def list_config_backups(self) -> List[Dict[str, Any]]:
2094
+ """
2095
+ List available configuration backups.
2096
+
2097
+ Returns:
2098
+ List of backup information dictionaries
2099
+ """
2100
+ migration = ConfigMigration(self.config_path)
2101
+ return migration.list_backups()
2102
+
2103
+ def cleanup_old_backups(self, keep_count: int = 10) -> int:
2104
+ """
2105
+ Clean up old backup files, keeping only the most recent ones.
2106
+
2107
+ Args:
2108
+ keep_count: Number of backups to keep
2109
+
2110
+ Returns:
2111
+ Number of backups deleted
2112
+ """
2113
+ migration = ConfigMigration(self.config_path)
2114
+ return migration.cleanup_old_backups(keep_count)
2115
+
2116
+ def migrate_config_manually(self) -> Tuple[bool, List[str]]:
2117
+ """
2118
+ Manually trigger configuration migration.
2119
+
2120
+ Returns:
2121
+ Tuple of (success, list of migration messages)
2122
+ """
2123
+ migration = ConfigMigration(self.config_path)
2124
+ success, messages = migration.migrate_config()
2125
+ if success:
2126
+ # Reload configuration after migration
2127
+ self.config = None
2128
+ self.load_config()
2129
+ return success, messages
2130
+
2131
+ def check_migration_needed(self) -> bool:
2132
+ """
2133
+ Check if configuration migration is needed.
2134
+
2135
+ Returns:
2136
+ True if migration is needed
2137
+ """
2138
+ migration = ConfigMigration(self.config_path)
2139
+ return migration.needs_migration()
2140
+
2141
+ def get_config_health_report(self) -> Dict[str, Any]:
2142
+ """
2143
+ Get a comprehensive health report of the configuration.
2144
+
2145
+ Returns:
2146
+ Dictionary with configuration health information
2147
+ """
2148
+ try:
2149
+ config = self.get_config()
2150
+ validation_results = self.validate_config_comprehensive()
2151
+
2152
+ # Count errors by category
2153
+ error_counts = {}
2154
+ total_errors = 0
2155
+ for category, errors in validation_results.items():
2156
+ if isinstance(errors, list):
2157
+ error_counts[category] = len(errors)
2158
+ total_errors += len(errors)
2159
+ elif isinstance(errors, dict):
2160
+ # For nested categories like providers
2161
+ category_total = sum(
2162
+ len(provider_errors) for provider_errors in errors.values()
2163
+ )
2164
+ error_counts[category] = category_total
2165
+ total_errors += category_total
2166
+
2167
+ # Get provider health status
2168
+ provider_health = {}
2169
+ for provider_name in config.providers.keys():
2170
+ provider_health[provider_name] = {
2171
+ "enabled": config.providers[provider_name].enabled,
2172
+ "has_api_key": bool(config.providers[provider_name].api_key),
2173
+ "health_check_enabled": config.providers[
2174
+ provider_name
2175
+ ].health_check_enabled,
2176
+ }
2177
+
2178
+ return {
2179
+ "overall_health": ("healthy" if total_errors == 0 else "issues_found"),
2180
+ "total_errors": total_errors,
2181
+ "error_counts_by_category": error_counts,
2182
+ "validation_results": validation_results,
2183
+ "config_version": config.config_version,
2184
+ "migration_needed": self.check_migration_needed(),
2185
+ "provider_health": provider_health,
2186
+ "mcp_status": {
2187
+ "enabled": config.mcp.enabled,
2188
+ "servers_configured": len(config.mcp.servers),
2189
+ "servers_enabled": len(config.mcp.get_enabled_servers()),
2190
+ },
2191
+ "backup_info": {"available_backups": len(self.list_config_backups())},
2192
+ }
2193
+ except Exception as e:
2194
+ return {
2195
+ "overall_health": "error",
2196
+ "error": str(e),
2197
+ "total_errors": 1,
2198
+ "validation_results": {"general": [f"Health check failed: {e}"]},
2199
+ }