claude-mpm 4.0.23__py3-none-any.whl → 4.0.25__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.
- claude_mpm/BUILD_NUMBER +1 -1
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/BASE_AGENT_TEMPLATE.md +4 -1
- claude_mpm/agents/BASE_PM.md +3 -0
- claude_mpm/agents/templates/code_analyzer.json +2 -2
- claude_mpm/cli/commands/agents.py +453 -113
- claude_mpm/cli/commands/aggregate.py +107 -15
- claude_mpm/cli/commands/cleanup.py +142 -10
- claude_mpm/cli/commands/config.py +358 -224
- claude_mpm/cli/commands/info.py +184 -75
- claude_mpm/cli/commands/mcp_command_router.py +5 -76
- claude_mpm/cli/commands/mcp_install_commands.py +68 -36
- claude_mpm/cli/commands/mcp_server_commands.py +30 -37
- claude_mpm/cli/commands/memory.py +331 -61
- claude_mpm/cli/commands/monitor.py +101 -7
- claude_mpm/cli/commands/run.py +368 -8
- claude_mpm/cli/commands/tickets.py +206 -24
- claude_mpm/cli/parsers/mcp_parser.py +3 -0
- claude_mpm/cli/shared/__init__.py +40 -0
- claude_mpm/cli/shared/argument_patterns.py +212 -0
- claude_mpm/cli/shared/command_base.py +234 -0
- claude_mpm/cli/shared/error_handling.py +238 -0
- claude_mpm/cli/shared/output_formatters.py +231 -0
- claude_mpm/config/agent_config.py +29 -8
- claude_mpm/core/container.py +6 -4
- claude_mpm/core/service_registry.py +4 -2
- claude_mpm/core/shared/__init__.py +17 -0
- claude_mpm/core/shared/config_loader.py +320 -0
- claude_mpm/core/shared/path_resolver.py +277 -0
- claude_mpm/core/shared/singleton_manager.py +208 -0
- claude_mpm/hooks/claude_hooks/memory_integration.py +4 -2
- claude_mpm/hooks/claude_hooks/response_tracking.py +14 -3
- claude_mpm/hooks/memory_integration_hook.py +11 -2
- claude_mpm/services/agents/deployment/agent_deployment.py +43 -23
- claude_mpm/services/agents/deployment/deployment_wrapper.py +71 -0
- claude_mpm/services/agents/deployment/pipeline/pipeline_context.py +1 -0
- claude_mpm/services/agents/deployment/pipeline/steps/agent_processing_step.py +43 -0
- claude_mpm/services/agents/deployment/processors/agent_deployment_context.py +4 -0
- claude_mpm/services/agents/deployment/processors/agent_processor.py +1 -1
- claude_mpm/services/agents/loading/base_agent_manager.py +11 -3
- claude_mpm/services/agents/registry/deployed_agent_discovery.py +14 -5
- claude_mpm/services/event_aggregator.py +4 -2
- claude_mpm/services/mcp_gateway/config/config_loader.py +89 -28
- claude_mpm/services/mcp_gateway/config/configuration.py +29 -0
- claude_mpm/services/mcp_gateway/registry/service_registry.py +22 -5
- claude_mpm/services/memory/builder.py +6 -1
- claude_mpm/services/response_tracker.py +3 -1
- claude_mpm/services/runner_configuration_service.py +15 -6
- claude_mpm/services/shared/__init__.py +20 -0
- claude_mpm/services/shared/async_service_base.py +219 -0
- claude_mpm/services/shared/config_service_base.py +292 -0
- claude_mpm/services/shared/lifecycle_service_base.py +317 -0
- claude_mpm/services/shared/manager_base.py +303 -0
- claude_mpm/services/shared/service_factory.py +308 -0
- {claude_mpm-4.0.23.dist-info → claude_mpm-4.0.25.dist-info}/METADATA +19 -13
- {claude_mpm-4.0.23.dist-info → claude_mpm-4.0.25.dist-info}/RECORD +60 -44
- {claude_mpm-4.0.23.dist-info → claude_mpm-4.0.25.dist-info}/WHEEL +0 -0
- {claude_mpm-4.0.23.dist-info → claude_mpm-4.0.25.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.0.23.dist-info → claude_mpm-4.0.25.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.0.23.dist-info → claude_mpm-4.0.25.dist-info}/top_level.txt +0 -0
|
@@ -14,6 +14,7 @@ from typing import Any, Dict, List, Optional, Type, TypeVar
|
|
|
14
14
|
|
|
15
15
|
from claude_mpm.core.logger import get_logger
|
|
16
16
|
from claude_mpm.services.mcp_gateway.core.base import BaseMCPService
|
|
17
|
+
from claude_mpm.services.shared import ManagerBase
|
|
17
18
|
from claude_mpm.services.mcp_gateway.core.interfaces import (
|
|
18
19
|
IMCPCommunication,
|
|
19
20
|
IMCPConfiguration,
|
|
@@ -26,7 +27,7 @@ from claude_mpm.services.mcp_gateway.core.interfaces import (
|
|
|
26
27
|
T = TypeVar("T")
|
|
27
28
|
|
|
28
29
|
|
|
29
|
-
class MCPServiceRegistry:
|
|
30
|
+
class MCPServiceRegistry(ManagerBase):
|
|
30
31
|
"""
|
|
31
32
|
Service registry for MCP Gateway components.
|
|
32
33
|
|
|
@@ -42,19 +43,19 @@ class MCPServiceRegistry:
|
|
|
42
43
|
- Support service dependency chains
|
|
43
44
|
"""
|
|
44
45
|
|
|
45
|
-
def __init__(self):
|
|
46
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
46
47
|
"""Initialize the service registry."""
|
|
47
|
-
|
|
48
|
+
super().__init__("mcp_service_registry", config=config)
|
|
48
49
|
|
|
49
50
|
# Thread safety
|
|
50
51
|
self._lock = RLock()
|
|
51
52
|
|
|
52
|
-
# Service storage
|
|
53
|
+
# Service storage (in addition to base class _items)
|
|
53
54
|
self._services: Dict[Type, Any] = {}
|
|
54
55
|
self._singletons: Dict[Type, Any] = {}
|
|
55
56
|
self._factories: Dict[Type, callable] = {}
|
|
56
57
|
|
|
57
|
-
# Service metadata
|
|
58
|
+
# Service metadata (note: base class has _item_metadata, this is for service-specific metadata)
|
|
58
59
|
self._metadata: Dict[Type, Dict[str, Any]] = {}
|
|
59
60
|
|
|
60
61
|
# Service health tracking
|
|
@@ -350,6 +351,22 @@ class MCPServiceRegistry:
|
|
|
350
351
|
|
|
351
352
|
self.logger.info("Service registry cleared")
|
|
352
353
|
|
|
354
|
+
# Abstract methods required by ManagerBase
|
|
355
|
+
def _do_initialize(self) -> bool:
|
|
356
|
+
"""Initialize the service registry."""
|
|
357
|
+
self.logger.info("MCP Service Registry initialized")
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
def _validate_item(self, item_id: str, item: Any) -> bool:
|
|
361
|
+
"""Validate a service before registration."""
|
|
362
|
+
# For services, we validate that they implement the expected interface
|
|
363
|
+
return item is not None
|
|
364
|
+
|
|
365
|
+
def _do_scan_items(self) -> int:
|
|
366
|
+
"""Scan for available services."""
|
|
367
|
+
# For service registry, we don't auto-scan - services are explicitly registered
|
|
368
|
+
return len(self._services)
|
|
369
|
+
|
|
353
370
|
|
|
354
371
|
# Global service registry instance
|
|
355
372
|
_service_registry: Optional[MCPServiceRegistry] = None
|
|
@@ -30,6 +30,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
30
30
|
|
|
31
31
|
from claude_mpm.core.config import Config
|
|
32
32
|
from claude_mpm.core.mixins import LoggerMixin
|
|
33
|
+
from claude_mpm.core.shared.config_loader import ConfigLoader
|
|
33
34
|
from claude_mpm.core.unified_paths import get_path_manager
|
|
34
35
|
from claude_mpm.services.memory.router import MemoryRouter
|
|
35
36
|
from claude_mpm.services.project.analyzer import ProjectAnalyzer
|
|
@@ -110,7 +111,11 @@ class MemoryBuilder(LoggerMixin):
|
|
|
110
111
|
working_directory: Optional working directory for project-specific analysis
|
|
111
112
|
"""
|
|
112
113
|
super().__init__()
|
|
113
|
-
|
|
114
|
+
if config:
|
|
115
|
+
self.config = config
|
|
116
|
+
else:
|
|
117
|
+
config_loader = ConfigLoader()
|
|
118
|
+
self.config = config_loader.load_main_config()
|
|
114
119
|
self.project_root = get_path_manager().project_root
|
|
115
120
|
# Use current working directory by default, not project root
|
|
116
121
|
self.working_directory = working_directory or Path(os.getcwd())
|
|
@@ -23,6 +23,7 @@ from datetime import datetime
|
|
|
23
23
|
from typing import Any, Dict, Optional
|
|
24
24
|
|
|
25
25
|
from claude_mpm.core.config import Config
|
|
26
|
+
from claude_mpm.core.shared.config_loader import ConfigLoader
|
|
26
27
|
from claude_mpm.services.claude_session_logger import ClaudeSessionLogger
|
|
27
28
|
|
|
28
29
|
logger = logging.getLogger(__name__)
|
|
@@ -48,7 +49,8 @@ class ResponseTracker:
|
|
|
48
49
|
"""
|
|
49
50
|
# Load configuration if not provided
|
|
50
51
|
if config is None:
|
|
51
|
-
|
|
52
|
+
config_loader = ConfigLoader()
|
|
53
|
+
config = config_loader.load_main_config()
|
|
52
54
|
self.config = config
|
|
53
55
|
|
|
54
56
|
# Check if response tracking is enabled
|
|
@@ -20,6 +20,7 @@ from claude_mpm.core.config import Config
|
|
|
20
20
|
from claude_mpm.core.container import ServiceLifetime, get_container
|
|
21
21
|
from claude_mpm.core.logger import get_project_logger
|
|
22
22
|
from claude_mpm.core.logging_config import get_logger
|
|
23
|
+
from claude_mpm.core.shared.config_loader import ConfigLoader
|
|
23
24
|
from claude_mpm.services.core.interfaces import RunnerConfigurationInterface
|
|
24
25
|
|
|
25
26
|
|
|
@@ -73,12 +74,19 @@ class RunnerConfigurationService(BaseService, RunnerConfigurationInterface):
|
|
|
73
74
|
"""
|
|
74
75
|
try:
|
|
75
76
|
# Use singleton Config instance to prevent duplicate loading
|
|
77
|
+
config_loader = ConfigLoader()
|
|
76
78
|
if config_path:
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
+
# Use specific config file with ConfigLoader
|
|
80
|
+
from claude_mpm.core.shared.config_loader import ConfigPattern
|
|
81
|
+
pattern = ConfigPattern(
|
|
82
|
+
filenames=[Path(config_path).name],
|
|
83
|
+
search_paths=[str(Path(config_path).parent)],
|
|
84
|
+
env_prefix="CLAUDE_MPM_"
|
|
85
|
+
)
|
|
86
|
+
config = config_loader.load_config(pattern, cache_key=f"runner_{config_path}")
|
|
79
87
|
else:
|
|
80
|
-
# Use
|
|
81
|
-
config =
|
|
88
|
+
# Use main config
|
|
89
|
+
config = config_loader.load_main_config()
|
|
82
90
|
|
|
83
91
|
return {
|
|
84
92
|
"config": config,
|
|
@@ -163,9 +171,10 @@ class RunnerConfigurationService(BaseService, RunnerConfigurationInterface):
|
|
|
163
171
|
"websocket_port": kwargs.get("websocket_port", 8765),
|
|
164
172
|
}
|
|
165
173
|
|
|
166
|
-
# Initialize main configuration
|
|
174
|
+
# Initialize main configuration using ConfigLoader
|
|
167
175
|
try:
|
|
168
|
-
|
|
176
|
+
config_loader = ConfigLoader()
|
|
177
|
+
config = config_loader.load_main_config()
|
|
169
178
|
except Exception as e:
|
|
170
179
|
self.logger.error("Failed to load configuration", exc_info=True)
|
|
171
180
|
raise RuntimeError(f"Configuration initialization failed: {e}") from e
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared service utilities to reduce code duplication.
|
|
3
|
+
|
|
4
|
+
This module provides common base classes and utilities that can be used
|
|
5
|
+
across different service implementations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .async_service_base import AsyncServiceBase
|
|
9
|
+
from .config_service_base import ConfigServiceBase
|
|
10
|
+
from .lifecycle_service_base import LifecycleServiceBase
|
|
11
|
+
from .manager_base import ManagerBase
|
|
12
|
+
from .service_factory import ServiceFactory
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncServiceBase",
|
|
16
|
+
"ConfigServiceBase",
|
|
17
|
+
"LifecycleServiceBase",
|
|
18
|
+
"ManagerBase",
|
|
19
|
+
"ServiceFactory",
|
|
20
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for asynchronous services to reduce duplication.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ...core.mixins import LoggerMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncServiceState(Enum):
|
|
14
|
+
"""Standard states for async services."""
|
|
15
|
+
UNINITIALIZED = "uninitialized"
|
|
16
|
+
INITIALIZING = "initializing"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
STOPPING = "stopping"
|
|
19
|
+
STOPPED = "stopped"
|
|
20
|
+
ERROR = "error"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncServiceBase(LoggerMixin, ABC):
|
|
24
|
+
"""
|
|
25
|
+
Base class for asynchronous services.
|
|
26
|
+
|
|
27
|
+
Provides common patterns:
|
|
28
|
+
- State management
|
|
29
|
+
- Lifecycle methods
|
|
30
|
+
- Error handling
|
|
31
|
+
- Background task management
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, service_name: str, config: Optional[Dict[str, Any]] = None):
|
|
35
|
+
"""
|
|
36
|
+
Initialize async service.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
service_name: Name of the service
|
|
40
|
+
config: Optional configuration dictionary
|
|
41
|
+
"""
|
|
42
|
+
self.service_name = service_name
|
|
43
|
+
self._logger_name = f"service.{service_name}"
|
|
44
|
+
self.config = config or {}
|
|
45
|
+
|
|
46
|
+
# State management
|
|
47
|
+
self._state = AsyncServiceState.UNINITIALIZED
|
|
48
|
+
self._state_lock = asyncio.Lock()
|
|
49
|
+
|
|
50
|
+
# Background tasks
|
|
51
|
+
self._background_tasks: set = set()
|
|
52
|
+
self._shutdown_event = asyncio.Event()
|
|
53
|
+
|
|
54
|
+
# Error tracking
|
|
55
|
+
self._last_error: Optional[Exception] = None
|
|
56
|
+
self._error_count = 0
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def state(self) -> AsyncServiceState:
|
|
60
|
+
"""Get current service state."""
|
|
61
|
+
return self._state
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_running(self) -> bool:
|
|
65
|
+
"""Check if service is running."""
|
|
66
|
+
return self._state == AsyncServiceState.RUNNING
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_healthy(self) -> bool:
|
|
70
|
+
"""Check if service is healthy."""
|
|
71
|
+
return self._state == AsyncServiceState.RUNNING and self._last_error is None
|
|
72
|
+
|
|
73
|
+
async def initialize(self) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Initialize the service.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if initialization successful
|
|
79
|
+
"""
|
|
80
|
+
async with self._state_lock:
|
|
81
|
+
if self._state != AsyncServiceState.UNINITIALIZED:
|
|
82
|
+
self.logger.warning(f"Service {self.service_name} already initialized")
|
|
83
|
+
return self._state == AsyncServiceState.RUNNING
|
|
84
|
+
|
|
85
|
+
self._state = AsyncServiceState.INITIALIZING
|
|
86
|
+
self.logger.info(f"Initializing service: {self.service_name}")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
success = await self._do_initialize()
|
|
90
|
+
if success:
|
|
91
|
+
self._state = AsyncServiceState.RUNNING
|
|
92
|
+
self.logger.info(f"Service {self.service_name} initialized successfully")
|
|
93
|
+
else:
|
|
94
|
+
self._state = AsyncServiceState.ERROR
|
|
95
|
+
self.logger.error(f"Service {self.service_name} initialization failed")
|
|
96
|
+
|
|
97
|
+
return success
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
self._state = AsyncServiceState.ERROR
|
|
101
|
+
self._last_error = e
|
|
102
|
+
self._error_count += 1
|
|
103
|
+
self.logger.error(f"Service {self.service_name} initialization error: {e}", exc_info=True)
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
async def shutdown(self) -> None:
|
|
107
|
+
"""Shutdown the service gracefully."""
|
|
108
|
+
async with self._state_lock:
|
|
109
|
+
if self._state in (AsyncServiceState.STOPPED, AsyncServiceState.STOPPING):
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self._state = AsyncServiceState.STOPPING
|
|
113
|
+
self.logger.info(f"Shutting down service: {self.service_name}")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Signal shutdown to background tasks
|
|
117
|
+
self._shutdown_event.set()
|
|
118
|
+
|
|
119
|
+
# Cancel background tasks
|
|
120
|
+
await self._cancel_background_tasks()
|
|
121
|
+
|
|
122
|
+
# Service-specific shutdown
|
|
123
|
+
await self._do_shutdown()
|
|
124
|
+
|
|
125
|
+
self._state = AsyncServiceState.STOPPED
|
|
126
|
+
self.logger.info(f"Service {self.service_name} shut down successfully")
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
self._state = AsyncServiceState.ERROR
|
|
130
|
+
self._last_error = e
|
|
131
|
+
self.logger.error(f"Service {self.service_name} shutdown error: {e}", exc_info=True)
|
|
132
|
+
|
|
133
|
+
async def restart(self) -> bool:
|
|
134
|
+
"""Restart the service."""
|
|
135
|
+
self.logger.info(f"Restarting service: {self.service_name}")
|
|
136
|
+
await self.shutdown()
|
|
137
|
+
|
|
138
|
+
# Reset state for restart
|
|
139
|
+
self._state = AsyncServiceState.UNINITIALIZED
|
|
140
|
+
self._shutdown_event.clear()
|
|
141
|
+
self._last_error = None
|
|
142
|
+
|
|
143
|
+
return await self.initialize()
|
|
144
|
+
|
|
145
|
+
def create_background_task(self, coro, name: str = None) -> asyncio.Task:
|
|
146
|
+
"""
|
|
147
|
+
Create and track a background task.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
coro: Coroutine to run
|
|
151
|
+
name: Optional task name
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Created task
|
|
155
|
+
"""
|
|
156
|
+
task = asyncio.create_task(coro, name=name)
|
|
157
|
+
self._background_tasks.add(task)
|
|
158
|
+
|
|
159
|
+
# Remove task from set when done
|
|
160
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
161
|
+
|
|
162
|
+
return task
|
|
163
|
+
|
|
164
|
+
async def _cancel_background_tasks(self) -> None:
|
|
165
|
+
"""Cancel all background tasks."""
|
|
166
|
+
if not self._background_tasks:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
self.logger.debug(f"Cancelling {len(self._background_tasks)} background tasks")
|
|
170
|
+
|
|
171
|
+
# Cancel all tasks
|
|
172
|
+
for task in self._background_tasks:
|
|
173
|
+
if not task.done():
|
|
174
|
+
task.cancel()
|
|
175
|
+
|
|
176
|
+
# Wait for cancellation with timeout
|
|
177
|
+
try:
|
|
178
|
+
await asyncio.wait_for(
|
|
179
|
+
asyncio.gather(*self._background_tasks, return_exceptions=True),
|
|
180
|
+
timeout=5.0
|
|
181
|
+
)
|
|
182
|
+
except asyncio.TimeoutError:
|
|
183
|
+
self.logger.warning("Some background tasks did not cancel within timeout")
|
|
184
|
+
|
|
185
|
+
self._background_tasks.clear()
|
|
186
|
+
|
|
187
|
+
async def health_check(self) -> Dict[str, Any]:
|
|
188
|
+
"""
|
|
189
|
+
Perform health check.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Health status dictionary
|
|
193
|
+
"""
|
|
194
|
+
return {
|
|
195
|
+
"service": self.service_name,
|
|
196
|
+
"state": self._state.value,
|
|
197
|
+
"healthy": self.is_healthy,
|
|
198
|
+
"error_count": self._error_count,
|
|
199
|
+
"last_error": str(self._last_error) if self._last_error else None,
|
|
200
|
+
"background_tasks": len(self._background_tasks)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@abstractmethod
|
|
204
|
+
async def _do_initialize(self) -> bool:
|
|
205
|
+
"""
|
|
206
|
+
Service-specific initialization logic.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True if initialization successful
|
|
210
|
+
"""
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
async def _do_shutdown(self) -> None:
|
|
214
|
+
"""Service-specific shutdown logic."""
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
"""String representation."""
|
|
219
|
+
return f"{self.__class__.__name__}(name={self.service_name}, state={self._state.value})"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for configuration-heavy services to reduce duplication.
|
|
3
|
+
|
|
4
|
+
UPDATED: Migrated to use shared ConfigLoader pattern (TSK-0141)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from ...core.config import Config
|
|
12
|
+
from ...core.mixins import LoggerMixin
|
|
13
|
+
from ...core.shared.config_loader import ConfigLoader, ConfigPattern
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigServiceBase(LoggerMixin, ABC):
|
|
17
|
+
"""
|
|
18
|
+
Base class for services that heavily use configuration.
|
|
19
|
+
|
|
20
|
+
Provides common patterns:
|
|
21
|
+
- Configuration loading and validation
|
|
22
|
+
- Environment variable handling
|
|
23
|
+
- Configuration file discovery
|
|
24
|
+
- Default value management
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self,
|
|
28
|
+
service_name: str,
|
|
29
|
+
config: Optional[Union[Dict[str, Any], Config]] = None,
|
|
30
|
+
config_section: Optional[str] = None,
|
|
31
|
+
config_dir: Optional[Union[str, Path]] = None):
|
|
32
|
+
"""
|
|
33
|
+
Initialize config service.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
service_name: Name of the service
|
|
37
|
+
config: Configuration instance or dictionary
|
|
38
|
+
config_section: Optional section name in config
|
|
39
|
+
config_dir: Optional directory to search for config files
|
|
40
|
+
"""
|
|
41
|
+
self.service_name = service_name
|
|
42
|
+
self._logger_name = f"service.{service_name}"
|
|
43
|
+
self.config_section = config_section or service_name.lower()
|
|
44
|
+
self._config_loader = ConfigLoader()
|
|
45
|
+
|
|
46
|
+
# Initialize configuration
|
|
47
|
+
if isinstance(config, Config):
|
|
48
|
+
self._config = config
|
|
49
|
+
elif isinstance(config, dict):
|
|
50
|
+
self._config = Config(config)
|
|
51
|
+
else:
|
|
52
|
+
# Use ConfigLoader to load service configuration
|
|
53
|
+
self._config = self._config_loader.load_service_config(
|
|
54
|
+
service_name=service_name,
|
|
55
|
+
config_dir=config_dir
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Cache for processed config values
|
|
59
|
+
self._config_cache: Dict[str, Any] = {}
|
|
60
|
+
|
|
61
|
+
def get_config_value(self,
|
|
62
|
+
key: str,
|
|
63
|
+
default: Any = None,
|
|
64
|
+
required: bool = False,
|
|
65
|
+
config_type: type = None) -> Any:
|
|
66
|
+
"""
|
|
67
|
+
Get configuration value with validation and caching.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
key: Configuration key (supports dot notation)
|
|
71
|
+
default: Default value if not found
|
|
72
|
+
required: Whether the value is required
|
|
73
|
+
config_type: Expected type for validation
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Configuration value
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If required value is missing or type validation fails
|
|
80
|
+
"""
|
|
81
|
+
# Build full key with section prefix
|
|
82
|
+
if self.config_section and not key.startswith(f"{self.config_section}."):
|
|
83
|
+
full_key = f"{self.config_section}.{key}"
|
|
84
|
+
else:
|
|
85
|
+
full_key = key
|
|
86
|
+
|
|
87
|
+
# Check cache first
|
|
88
|
+
if full_key in self._config_cache:
|
|
89
|
+
return self._config_cache[full_key]
|
|
90
|
+
|
|
91
|
+
# Get value from config
|
|
92
|
+
value = self._config.get(full_key, default)
|
|
93
|
+
|
|
94
|
+
# Handle required values
|
|
95
|
+
if required and value is None:
|
|
96
|
+
raise ValueError(f"Required configuration value missing: {full_key}")
|
|
97
|
+
|
|
98
|
+
# Type validation
|
|
99
|
+
if config_type is not None and value is not None:
|
|
100
|
+
if not isinstance(value, config_type):
|
|
101
|
+
try:
|
|
102
|
+
# Try to convert
|
|
103
|
+
if config_type == bool and isinstance(value, str):
|
|
104
|
+
value = value.lower() in ('true', '1', 'yes', 'on')
|
|
105
|
+
elif config_type == Path:
|
|
106
|
+
value = Path(value).expanduser()
|
|
107
|
+
else:
|
|
108
|
+
value = config_type(value)
|
|
109
|
+
except (ValueError, TypeError) as e:
|
|
110
|
+
raise ValueError(f"Invalid type for {full_key}: expected {config_type.__name__}, got {type(value).__name__}") from e
|
|
111
|
+
|
|
112
|
+
# Cache the processed value
|
|
113
|
+
self._config_cache[full_key] = value
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
def get_config_section(self, section: str = None) -> Dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Get entire configuration section.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
section: Section name (defaults to service section)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Configuration section as dictionary
|
|
125
|
+
"""
|
|
126
|
+
section_name = section or self.config_section
|
|
127
|
+
return self._config.get(section_name, {})
|
|
128
|
+
|
|
129
|
+
def validate_config(self, schema: Dict[str, Any]) -> List[str]:
|
|
130
|
+
"""
|
|
131
|
+
Validate configuration against a schema.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
schema: Validation schema
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of validation errors
|
|
138
|
+
"""
|
|
139
|
+
errors = []
|
|
140
|
+
|
|
141
|
+
# Check required fields
|
|
142
|
+
required_fields = schema.get('required', [])
|
|
143
|
+
for field in required_fields:
|
|
144
|
+
try:
|
|
145
|
+
self.get_config_value(field, required=True)
|
|
146
|
+
except ValueError as e:
|
|
147
|
+
errors.append(str(e))
|
|
148
|
+
|
|
149
|
+
# Check field types
|
|
150
|
+
field_types = schema.get('types', {})
|
|
151
|
+
for field, expected_type in field_types.items():
|
|
152
|
+
try:
|
|
153
|
+
value = self.get_config_value(field)
|
|
154
|
+
if value is not None and not isinstance(value, expected_type):
|
|
155
|
+
errors.append(f"Invalid type for {field}: expected {expected_type.__name__}")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
errors.append(f"Error validating {field}: {e}")
|
|
158
|
+
|
|
159
|
+
# Check field constraints
|
|
160
|
+
constraints = schema.get('constraints', {})
|
|
161
|
+
for field, constraint in constraints.items():
|
|
162
|
+
try:
|
|
163
|
+
value = self.get_config_value(field)
|
|
164
|
+
if value is not None:
|
|
165
|
+
if 'min' in constraint and value < constraint['min']:
|
|
166
|
+
errors.append(f"{field} must be >= {constraint['min']}")
|
|
167
|
+
if 'max' in constraint and value > constraint['max']:
|
|
168
|
+
errors.append(f"{field} must be <= {constraint['max']}")
|
|
169
|
+
if 'choices' in constraint and value not in constraint['choices']:
|
|
170
|
+
errors.append(f"{field} must be one of {constraint['choices']}")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
errors.append(f"Error validating constraint for {field}: {e}")
|
|
173
|
+
|
|
174
|
+
return errors
|
|
175
|
+
|
|
176
|
+
def load_config_file(self, config_path: Union[str, Path]) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
Load additional configuration from file.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
config_path: Path to configuration file
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if loaded successfully
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
self._config.load_file(config_path)
|
|
188
|
+
# Clear cache since config changed
|
|
189
|
+
self._config_cache.clear()
|
|
190
|
+
self.logger.info(f"Loaded configuration from {config_path}")
|
|
191
|
+
return True
|
|
192
|
+
except Exception as e:
|
|
193
|
+
self.logger.error(f"Failed to load config from {config_path}: {e}")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def find_config_file(self,
|
|
197
|
+
filename: str,
|
|
198
|
+
search_paths: List[Union[str, Path]] = None) -> Optional[Path]:
|
|
199
|
+
"""
|
|
200
|
+
Find configuration file in standard locations.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
filename: Configuration filename
|
|
204
|
+
search_paths: Additional paths to search
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Path to found configuration file or None
|
|
208
|
+
"""
|
|
209
|
+
default_paths = [
|
|
210
|
+
Path.cwd() / ".claude-mpm",
|
|
211
|
+
Path.home() / ".claude-mpm",
|
|
212
|
+
Path.cwd(),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
if search_paths:
|
|
216
|
+
search_paths = [Path(p) for p in search_paths] + default_paths
|
|
217
|
+
else:
|
|
218
|
+
search_paths = default_paths
|
|
219
|
+
|
|
220
|
+
for search_path in search_paths:
|
|
221
|
+
config_file = search_path / filename
|
|
222
|
+
if config_file.exists() and config_file.is_file():
|
|
223
|
+
self.logger.debug(f"Found config file: {config_file}")
|
|
224
|
+
return config_file
|
|
225
|
+
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def get_env_config(self, prefix: str = None) -> Dict[str, Any]:
|
|
229
|
+
"""
|
|
230
|
+
Get configuration from environment variables.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
prefix: Environment variable prefix (defaults to service name)
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Configuration dictionary from environment
|
|
237
|
+
"""
|
|
238
|
+
env_prefix = prefix or f"CLAUDE_MPM_{self.service_name.upper()}_"
|
|
239
|
+
|
|
240
|
+
# Use shared ConfigLoader for consistent environment variable handling
|
|
241
|
+
return self._config_loader._load_env_config(env_prefix)
|
|
242
|
+
|
|
243
|
+
def merge_env_config(self, prefix: str = None) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Merge environment configuration into main config.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
prefix: Environment variable prefix
|
|
249
|
+
"""
|
|
250
|
+
env_config = self.get_env_config(prefix)
|
|
251
|
+
if env_config:
|
|
252
|
+
for key, value in env_config.items():
|
|
253
|
+
full_key = f"{self.config_section}.{key}"
|
|
254
|
+
self._config.set(full_key, value)
|
|
255
|
+
|
|
256
|
+
# Clear cache since config changed
|
|
257
|
+
self._config_cache.clear()
|
|
258
|
+
self.logger.debug(f"Merged {len(env_config)} environment variables")
|
|
259
|
+
|
|
260
|
+
def reload_config(self, config_dir: Optional[Union[str, Path]] = None) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Reload configuration using ConfigLoader pattern.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
config_dir: Optional directory to search for config files
|
|
266
|
+
"""
|
|
267
|
+
self._config = self._config_loader.load_service_config(
|
|
268
|
+
service_name=self.service_name,
|
|
269
|
+
config_dir=config_dir
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Clear cache
|
|
273
|
+
self._config_cache.clear()
|
|
274
|
+
|
|
275
|
+
self.logger.info(f"Configuration reloaded for service: {self.service_name}")
|
|
276
|
+
|
|
277
|
+
def get_config_summary(self) -> Dict[str, Any]:
|
|
278
|
+
"""
|
|
279
|
+
Get summary of current configuration.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Configuration summary
|
|
283
|
+
"""
|
|
284
|
+
section_config = self.get_config_section()
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
"service": self.service_name,
|
|
288
|
+
"section": self.config_section,
|
|
289
|
+
"keys": list(section_config.keys()),
|
|
290
|
+
"key_count": len(section_config),
|
|
291
|
+
"cached_values": len(self._config_cache)
|
|
292
|
+
}
|