iotsploit-core 0.0.6__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 (41) hide show
  1. iotsploit_core/__init__.py +7 -0
  2. iotsploit_core/context.py +27 -0
  3. iotsploit_core/core/__init__.py +0 -0
  4. iotsploit_core/core/base_plugin.py +290 -0
  5. iotsploit_core/core/device_config.py +105 -0
  6. iotsploit_core/core/device_manager.py +942 -0
  7. iotsploit_core/core/device_registry.py +118 -0
  8. iotsploit_core/core/device_scanner.py +74 -0
  9. iotsploit_core/core/device_spec.py +105 -0
  10. iotsploit_core/core/device_store.py +86 -0
  11. iotsploit_core/core/execution_backend.py +464 -0
  12. iotsploit_core/core/execution_queue.py +456 -0
  13. iotsploit_core/core/exploit_manager.py +728 -0
  14. iotsploit_core/core/exploit_spec.py +73 -0
  15. iotsploit_core/core/stream_manager.py +157 -0
  16. iotsploit_core/core/tool_config.py +430 -0
  17. iotsploit_core/core/tool_manager.py +1166 -0
  18. iotsploit_core/core/tool_service.py +936 -0
  19. iotsploit_core/domain/__init__.py +3 -0
  20. iotsploit_core/domain/device.py +70 -0
  21. iotsploit_core/domain/execution_plan.py +29 -0
  22. iotsploit_core/domain/plugin.py +17 -0
  23. iotsploit_core/domain/stream.py +63 -0
  24. iotsploit_core/domain/target.py +243 -0
  25. iotsploit_core/platforms/__init__.py +19 -0
  26. iotsploit_core/platforms/consts.py +25 -0
  27. iotsploit_core/ports/__init__.py +6 -0
  28. iotsploit_core/ports/driver_state_repo.py +15 -0
  29. iotsploit_core/ports/plugin_repo.py +26 -0
  30. iotsploit_core/ports/stream_backend.py +28 -0
  31. iotsploit_core/ports/task_runner.py +22 -0
  32. iotsploit_core/ports/wifi_backend.py +148 -0
  33. iotsploit_core/py.typed +2 -0
  34. iotsploit_core/utils/__init__.py +53 -0
  35. iotsploit_core/utils/exceptions.py +152 -0
  36. iotsploit_core/utils/helpers.py +65 -0
  37. iotsploit_core/utils/iots_logger.py +164 -0
  38. iotsploit_core/utils/result.py +41 -0
  39. iotsploit_core-0.0.6.dist-info/METADATA +117 -0
  40. iotsploit_core-0.0.6.dist-info/RECORD +41 -0
  41. iotsploit_core-0.0.6.dist-info/WHEEL +4 -0
@@ -0,0 +1,73 @@
1
+ import pluggy
2
+ from typing import Any, Dict, Optional
3
+ from dataclasses import dataclass
4
+
5
+ hookspec = pluggy.HookspecMarker("exploit_mgr")
6
+
7
+ @dataclass
8
+ class AsyncExploitResult:
9
+ status: bool = None
10
+ message: str = ""
11
+ progress: float = 0
12
+ data: Dict[str, Any] = None
13
+
14
+ def update(self, status=None, message=None, progress=None, data=None):
15
+ if status is not None:
16
+ self.status = status
17
+ if message is not None:
18
+ self.message = message
19
+ if progress is not None:
20
+ self.progress = progress
21
+ if data is not None:
22
+ self.data = data
23
+
24
+ class ExploitResult:
25
+ def __init__(self, success: bool, message: str, data: Dict[str, Any]):
26
+ self.success = success
27
+ self.message = message
28
+ self.data = data
29
+
30
+ def __repr__(self):
31
+ return f"ExploitResult(success={self.success}, message={self.message}, data={self.data})"
32
+
33
+ class ExploitPluginSpec:
34
+ @hookspec
35
+ def initialize(self, ctx: Optional[Any] = None, device_plugin: Optional[Any] = None):
36
+ """
37
+ Initialize the exploit plugin.
38
+
39
+ Notes:
40
+ - This project supports two initialization styles:
41
+ - New style: initialize(ctx) where ctx is a platform backend context (e.g., PluginContext)
42
+ - Legacy style: initialize() or initialize(device_plugin)
43
+ - Hookspec must declare all argument names used by hookimpls; otherwise pluggy will reject plugins.
44
+
45
+ :param ctx: Platform/backend context object (e.g., PluginContext), optional
46
+ :param device_plugin: Legacy device plugin instance (e.g., JLinkAbility, USBAbility), optional
47
+ """
48
+
49
+ @hookspec
50
+ def execute(self, target: Optional[Any] = None, parameters: Optional[dict] = None) -> ExploitResult:
51
+ """
52
+ Execute the exploit on the specified target using the initialized device plugin.
53
+
54
+ :param target: The target of the exploit (e.g., an address or identifier)
55
+ :param parameters: Dictionary of parameters for the exploit
56
+ :return: An ExploitResult object containing the result of the exploit
57
+ """
58
+
59
+ @hookspec
60
+ async def execute_async(self, target: Optional[Any] = None, parameters: Optional[dict] = None) -> AsyncExploitResult:
61
+ """
62
+ Execute the exploit asynchronously on the specified target using the initialized device plugin.
63
+
64
+ :param target: The target of the exploit (e.g., an address or identifier)
65
+ :param parameters: Dictionary of parameters for the exploit
66
+ :return: An AsyncExploitResult object containing the result of the exploit
67
+ """
68
+
69
+ @hookspec
70
+ def cleanup(self):
71
+ """
72
+ Perform any necessary cleanup after the exploit execution.
73
+ """
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ """Core stream facade (no Django/Channels hard dependency).
4
+
5
+ This module intentionally avoids importing Django/Channels/Redis at import time, so that
6
+ `iotsploit_core.core` can be imported in standalone contexts. In a Django runtime, a real
7
+ backend should be injected during app startup (composition root).
8
+ """
9
+
10
+ import asyncio
11
+ from queue import Queue
12
+ from typing import Dict, Optional
13
+
14
+ from iotsploit_core.domain.stream import StreamAction, StreamData, StreamSource, StreamType
15
+ from iotsploit_core.ports.stream_backend import StreamBackend
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Re-export domain stream types for backward compatibility with existing imports.
22
+ __all__ = [
23
+ "StreamType",
24
+ "StreamSource",
25
+ "StreamAction",
26
+ "StreamData",
27
+ "StreamWrapper",
28
+ "StreamManager",
29
+ ]
30
+
31
+
32
+ class StreamWrapper:
33
+ """Wrapper class to handle async operations for stream management."""
34
+
35
+ def __init__(self, stream_manager):
36
+ self.stream_manager = stream_manager
37
+
38
+ def _get_or_create_loop(self):
39
+ try:
40
+ loop = asyncio.get_event_loop()
41
+ except RuntimeError:
42
+ loop = asyncio.new_event_loop()
43
+ asyncio.set_event_loop(loop)
44
+ return loop
45
+
46
+ def register_stream(self, channel):
47
+ loop = self._get_or_create_loop()
48
+ if loop.is_running():
49
+ return loop.create_task(self.stream_manager.register_stream(channel))
50
+ return loop.run_until_complete(self.stream_manager.register_stream(channel))
51
+
52
+ def unregister_stream(self, channel):
53
+ loop = self._get_or_create_loop()
54
+ if loop.is_running():
55
+ return loop.create_task(self.stream_manager.unregister_stream(channel))
56
+ return loop.run_until_complete(self.stream_manager.unregister_stream(channel))
57
+
58
+ def stop_broadcast(self, channel):
59
+ loop = self._get_or_create_loop()
60
+ if loop.is_running():
61
+ return loop.create_task(self.stream_manager.stop_broadcast(channel))
62
+ return loop.run_until_complete(self.stream_manager.stop_broadcast(channel))
63
+
64
+ def broadcast_data(self, stream_data):
65
+ loop = self._get_or_create_loop()
66
+ if loop.is_running():
67
+ return loop.create_task(self.stream_manager.broadcast_data(stream_data))
68
+ return loop.run_until_complete(self.stream_manager.broadcast_data(stream_data))
69
+
70
+
71
+ class _NoopStreamBackend:
72
+ """Fallback stream manager when Django/Channels is unavailable.
73
+
74
+ Keeps API shape compatible but does not broadcast over WebSocket.
75
+ """
76
+
77
+ def __init__(self):
78
+ self._client_queues: Dict[str, Queue] = {}
79
+
80
+ async def register_stream(self, channel: str):
81
+ if channel not in self._client_queues:
82
+ self._client_queues[channel] = Queue()
83
+
84
+ async def unregister_stream(self, channel: str):
85
+ self._client_queues.pop(channel, None)
86
+
87
+ async def broadcast_data(self, stream_data: StreamData):
88
+ # For CLIENT source, enqueue so server-side can consume.
89
+ if stream_data.source == StreamSource.CLIENT:
90
+ q = self._client_queues.get(stream_data.channel)
91
+ if q is not None:
92
+ q.put(stream_data)
93
+
94
+ async def stop_broadcast(self, channel: str):
95
+ return
96
+
97
+ def get_active_channels(self):
98
+ return list(self._client_queues.keys())
99
+
100
+ def get_broadcast_channels(self):
101
+ return []
102
+
103
+ def get_client_data(self) -> Optional[StreamData]:
104
+ for ch in list(self._client_queues.keys()):
105
+ q = self._client_queues[ch]
106
+ if not q.empty():
107
+ return q.get_nowait()
108
+ return None
109
+
110
+
111
+ class StreamManager:
112
+ """Core stream manager that delegates to an injected backend.
113
+
114
+ Default backend is a Noop implementation. Django wiring should inject a real backend
115
+ during app startup (composition root).
116
+ """
117
+
118
+ _instance = None
119
+
120
+ def __new__(cls):
121
+ if cls._instance is None:
122
+ cls._instance = super().__new__(cls)
123
+ return cls._instance
124
+
125
+ def __init__(self):
126
+ if hasattr(self, "_backend"):
127
+ return
128
+ self._backend: StreamBackend = _NoopStreamBackend()
129
+
130
+ @classmethod
131
+ def configure_backend(cls, backend: StreamBackend) -> None:
132
+ """Wire a backend implementation (to be called from Django wiring)."""
133
+
134
+ inst = cls()
135
+ inst._backend = backend
136
+
137
+ # Delegate API
138
+ async def register_stream(self, channel: str):
139
+ return await self._backend.register_stream(channel)
140
+
141
+ async def unregister_stream(self, channel: str):
142
+ return await self._backend.unregister_stream(channel)
143
+
144
+ async def broadcast_data(self, stream_data: StreamData):
145
+ return await self._backend.broadcast_data(stream_data)
146
+
147
+ async def stop_broadcast(self, channel: str):
148
+ return await self._backend.stop_broadcast(channel)
149
+
150
+ def get_active_channels(self):
151
+ return self._backend.get_active_channels()
152
+
153
+ def get_broadcast_channels(self):
154
+ return self._backend.get_broadcast_channels()
155
+
156
+ def get_client_data(self) -> Optional[StreamData]:
157
+ return self._backend.get_client_data()
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ JSON-based Tool Configuration Management
4
+ =======================================
5
+
6
+ Manages tool configurations using JSON files instead of hardcoded Python dictionaries.
7
+ This allows for easy modification of tool definitions without code changes.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Any, Union
14
+ from dataclasses import dataclass, field, asdict
15
+ from enum import Enum
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ @dataclass
20
+ class ToolConfig:
21
+ """Tool configuration data structure"""
22
+ name: str
23
+ aliases: List[str] = field(default_factory=list)
24
+ min_version: Optional[str] = None
25
+ platforms: List[str] = field(default_factory=lambda: ['linux', 'darwin', 'windows'])
26
+ path: Optional[str] = None
27
+ description: Optional[str] = None
28
+ category: Optional[str] = None
29
+ required: bool = False
30
+ install_hint: Optional[str] = None
31
+ version_command: List[str] = field(default_factory=lambda: ['--version'])
32
+
33
+ def to_dict(self) -> Dict[str, Any]:
34
+ """Convert to dictionary for JSON serialization"""
35
+ return asdict(self)
36
+
37
+ @classmethod
38
+ def from_dict(cls, data: Dict[str, Any]) -> 'ToolConfig':
39
+ """Create from dictionary loaded from JSON"""
40
+ # Filter out fields that are not part of ToolConfig constructor
41
+ # These fields are runtime fields that should not be passed to constructor
42
+ excluded_fields = {
43
+ 'version', 'status', 'last_checked', 'metadata', 'dependencies',
44
+ 'max_version'
45
+ }
46
+
47
+ # Create a filtered copy of the data
48
+ filtered_data = {k: v for k, v in data.items() if k not in excluded_fields}
49
+
50
+ return cls(**filtered_data)
51
+
52
+ class ToolConfigManager:
53
+ """Manages tool configurations from JSON files"""
54
+
55
+ def __init__(self, config_dir: Optional[Union[str, Path]] = None):
56
+ self.config_dir = Path(config_dir) if config_dir else self._get_default_config_dir()
57
+ self.config_dir.mkdir(parents=True, exist_ok=True)
58
+ self.logger = logging.getLogger("tool_config")
59
+ self._tool_configs: Dict[str, ToolConfig] = {}
60
+ self._category_configs: Dict[str, Dict[str, Any]] = {}
61
+
62
+ # Load configurations
63
+ self._load_all_configs()
64
+
65
+ def _get_default_config_dir(self) -> Path:
66
+ """Get default configuration directory"""
67
+ # Use project root/configs/tools
68
+ project_root = Path(__file__).parent.parent.parent
69
+ return project_root / "configs" / "tools"
70
+
71
+ def _load_all_configs(self):
72
+ """Load all tool configurations from JSON files"""
73
+ self.logger.info(f"Loading tool configurations from {self.config_dir}")
74
+
75
+ # Load category configurations
76
+ self._load_category_configs()
77
+
78
+ # Load individual tool configurations
79
+ self._load_tool_configs()
80
+
81
+ self.logger.info(f"Loaded {len(self._tool_configs)} tool configurations")
82
+
83
+ def _load_category_configs(self):
84
+ """Load category configuration files"""
85
+ category_files = list(self.config_dir.glob("*_category.json"))
86
+
87
+ for category_file in category_files:
88
+ try:
89
+ with open(category_file, 'r', encoding='utf-8') as f:
90
+ category_data = json.load(f)
91
+
92
+ category_name = category_file.stem.replace('_category', '')
93
+ self._category_configs[category_name] = category_data
94
+
95
+ self.logger.debug(f"Loaded category config: {category_name}")
96
+
97
+ except Exception as e:
98
+ self.logger.error(f"Failed to load category config {category_file}: {e}")
99
+
100
+ def _load_tool_configs(self):
101
+ """Load tool configuration files"""
102
+ # Load from category files
103
+ for category_name, category_data in self._category_configs.items():
104
+ tools = category_data.get('tools', [])
105
+ for tool_data in tools:
106
+ try:
107
+ # Add category info to tool
108
+ tool_data['category'] = category_name
109
+ tool_config = ToolConfig.from_dict(tool_data)
110
+ self._tool_configs[tool_config.name] = tool_config
111
+
112
+ # Also register aliases
113
+ for alias in tool_config.aliases:
114
+ self._tool_configs[alias] = tool_config
115
+
116
+ except Exception as e:
117
+ self.logger.error(f"Failed to load tool config {tool_data.get('name', 'unknown')}: {e}")
118
+
119
+ # Load individual tool files
120
+ tool_files = [f for f in self.config_dir.glob("*.json") if not f.name.endswith('_category.json')]
121
+
122
+ for tool_file in tool_files:
123
+ try:
124
+ with open(tool_file, 'r', encoding='utf-8') as f:
125
+ tool_data = json.load(f)
126
+
127
+ # Handle different JSON structures
128
+ if 'tools' in tool_data and isinstance(tool_data['tools'], list):
129
+ # This is a tools collection file (like tools.json)
130
+ tools = tool_data['tools']
131
+ for tool_item in tools:
132
+ try:
133
+ tool_config = ToolConfig.from_dict(tool_item)
134
+ self._tool_configs[tool_config.name] = tool_config
135
+
136
+ # Also register aliases
137
+ for alias in tool_config.aliases:
138
+ self._tool_configs[alias] = tool_config
139
+ except Exception as e:
140
+ self.logger.error(f"Failed to load tool {tool_item.get('name', 'unknown')} from {tool_file.name}: {e}")
141
+
142
+ elif isinstance(tool_data, list):
143
+ # This is an array of tools
144
+ tools = tool_data
145
+ for tool_item in tools:
146
+ try:
147
+ tool_config = ToolConfig.from_dict(tool_item)
148
+ self._tool_configs[tool_config.name] = tool_config
149
+
150
+ # Also register aliases
151
+ for alias in tool_config.aliases:
152
+ self._tool_configs[alias] = tool_config
153
+ except Exception as e:
154
+ self.logger.error(f"Failed to load tool {tool_item.get('name', 'unknown')} from {tool_file.name}: {e}")
155
+
156
+ elif 'name' in tool_data:
157
+ # This is a single tool configuration
158
+ tool_config = ToolConfig.from_dict(tool_data)
159
+ self._tool_configs[tool_config.name] = tool_config
160
+
161
+ # Also register aliases
162
+ for alias in tool_config.aliases:
163
+ self._tool_configs[alias] = tool_config
164
+
165
+ else:
166
+ self.logger.warning(f"Unrecognized tool config format in {tool_file.name}")
167
+
168
+ self.logger.debug(f"Loaded tool config file: {tool_file.name}")
169
+
170
+ except Exception as e:
171
+ self.logger.error(f"Failed to load tool config {tool_file}: {e}")
172
+
173
+ def get_tool_config(self, tool_name: str) -> Optional[ToolConfig]:
174
+ """Get tool configuration by name or alias"""
175
+ return self._tool_configs.get(tool_name)
176
+
177
+ def get_tools_by_category(self, category: str) -> List[ToolConfig]:
178
+ """Get all tools in a specific category"""
179
+ return [config for config in self._tool_configs.values()
180
+ if config.category == category and config.name in self._tool_configs]
181
+
182
+ def get_all_categories(self) -> List[str]:
183
+ """Get list of all categories"""
184
+ categories = set()
185
+ for config in self._tool_configs.values():
186
+ if config.category:
187
+ categories.add(config.category)
188
+ return sorted(list(categories))
189
+
190
+ def get_category_info(self, category: str) -> Optional[Dict[str, Any]]:
191
+ """Get category information"""
192
+ return self._category_configs.get(category)
193
+
194
+ def add_tool_config(self, tool_config: ToolConfig, save: bool = True) -> bool:
195
+ """Add a new tool configuration"""
196
+ try:
197
+ self._tool_configs[tool_config.name] = tool_config
198
+
199
+ # Register aliases
200
+ for alias in tool_config.aliases:
201
+ self._tool_configs[alias] = tool_config
202
+
203
+ if save:
204
+ self._save_tool_config(tool_config)
205
+
206
+ self.logger.info(f"Added tool configuration: {tool_config.name}")
207
+ return True
208
+
209
+ except Exception as e:
210
+ self.logger.error(f"Failed to add tool config {tool_config.name}: {e}")
211
+ return False
212
+
213
+ def _save_tool_config(self, tool_config: ToolConfig):
214
+ """Save tool configuration to JSON file"""
215
+ if tool_config.category:
216
+ # Save to category file
217
+ category_file = self.config_dir / f"{tool_config.category}_category.json"
218
+
219
+ if category_file.exists():
220
+ with open(category_file, 'r', encoding='utf-8') as f:
221
+ category_data = json.load(f)
222
+ else:
223
+ category_data = {
224
+ "name": tool_config.category.title(),
225
+ "description": f"Tools for {tool_config.category}",
226
+ "tools": []
227
+ }
228
+
229
+ # Add or update tool in category
230
+ tools = category_data.get('tools', [])
231
+ tool_dict = tool_config.to_dict()
232
+ del tool_dict['category'] # Don't store category in tool data
233
+
234
+ # Remove existing tool if present
235
+ tools = [t for t in tools if t.get('name') != tool_config.name]
236
+ tools.append(tool_dict)
237
+
238
+ category_data['tools'] = tools
239
+
240
+ with open(category_file, 'w', encoding='utf-8') as f:
241
+ json.dump(category_data, f, indent=2, ensure_ascii=False)
242
+ else:
243
+ # Save as individual tool file
244
+ tool_file = self.config_dir / f"{tool_config.name}.json"
245
+ with open(tool_file, 'w', encoding='utf-8') as f:
246
+ json.dump(tool_config.to_dict(), f, indent=2, ensure_ascii=False)
247
+
248
+ def remove_tool_config(self, tool_name: str) -> bool:
249
+ """Remove tool configuration"""
250
+ try:
251
+ if tool_name in self._tool_configs:
252
+ tool_config = self._tool_configs[tool_name]
253
+
254
+ # Remove from memory
255
+ del self._tool_configs[tool_name]
256
+ for alias in tool_config.aliases:
257
+ if alias in self._tool_configs:
258
+ del self._tool_configs[alias]
259
+
260
+ # Remove from file
261
+ self._remove_tool_from_file(tool_config)
262
+
263
+ self.logger.info(f"Removed tool configuration: {tool_name}")
264
+ return True
265
+ else:
266
+ self.logger.warning(f"Tool configuration not found: {tool_name}")
267
+ return False
268
+
269
+ except Exception as e:
270
+ self.logger.error(f"Failed to remove tool config {tool_name}: {e}")
271
+ return False
272
+
273
+ def _remove_tool_from_file(self, tool_config: ToolConfig):
274
+ """Remove tool from its configuration file"""
275
+ if tool_config.category:
276
+ # Remove from category file
277
+ category_file = self.config_dir / f"{tool_config.category}_category.json"
278
+
279
+ if category_file.exists():
280
+ with open(category_file, 'r', encoding='utf-8') as f:
281
+ category_data = json.load(f)
282
+
283
+ tools = category_data.get('tools', [])
284
+ tools = [t for t in tools if t.get('name') != tool_config.name]
285
+ category_data['tools'] = tools
286
+
287
+ with open(category_file, 'w', encoding='utf-8') as f:
288
+ json.dump(category_data, f, indent=2, ensure_ascii=False)
289
+ else:
290
+ # Remove individual tool file
291
+ tool_file = self.config_dir / f"{tool_config.name}.json"
292
+ if tool_file.exists():
293
+ tool_file.unlink()
294
+
295
+ def reload_configs(self):
296
+ """Reload all configurations from files"""
297
+ self._tool_configs.clear()
298
+ self._category_configs.clear()
299
+ self._load_all_configs()
300
+ self.logger.info("Reloaded all tool configurations")
301
+
302
+ def export_config(self, output_file: Union[str, Path]) -> bool:
303
+ """Export all configurations to a single JSON file"""
304
+ try:
305
+ output_path = Path(output_file)
306
+
307
+ export_data = {
308
+ "categories": self._category_configs,
309
+ "tools": {name: config.to_dict() for name, config in self._tool_configs.items()
310
+ if name == config.name} # Only export primary names, not aliases
311
+ }
312
+
313
+ with open(output_path, 'w', encoding='utf-8') as f:
314
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
315
+
316
+ self.logger.info(f"Exported configurations to {output_path}")
317
+ return True
318
+
319
+ except Exception as e:
320
+ self.logger.error(f"Failed to export configurations: {e}")
321
+ return False
322
+
323
+ def import_config(self, input_file: Union[str, Path]) -> bool:
324
+ """Import configurations from a JSON file"""
325
+ try:
326
+ input_path = Path(input_file)
327
+
328
+ with open(input_path, 'r', encoding='utf-8') as f:
329
+ import_data = json.load(f)
330
+
331
+ # Import categories
332
+ categories = import_data.get('categories', {})
333
+ for category_name, category_data in categories.items():
334
+ self._category_configs[category_name] = category_data
335
+
336
+ # Import tools
337
+ tools = import_data.get('tools', {})
338
+ for tool_name, tool_data in tools.items():
339
+ tool_config = ToolConfig.from_dict(tool_data)
340
+ self._tool_configs[tool_config.name] = tool_config
341
+
342
+ # Register aliases
343
+ for alias in tool_config.aliases:
344
+ self._tool_configs[alias] = tool_config
345
+
346
+ self.logger.info(f"Imported configurations from {input_path}")
347
+ return True
348
+
349
+ except Exception as e:
350
+ self.logger.error(f"Failed to import configurations: {e}")
351
+ return False
352
+
353
+ def validate_configs(self) -> Dict[str, List[str]]:
354
+ """Validate all tool configurations"""
355
+ issues = {
356
+ 'missing_required_fields': [],
357
+ 'invalid_platforms': [],
358
+ 'duplicate_names': [],
359
+ 'invalid_categories': []
360
+ }
361
+
362
+ seen_names = set()
363
+ valid_platforms = {'linux', 'darwin', 'windows'}
364
+
365
+ for tool_name, config in self._tool_configs.items():
366
+ # Skip aliases
367
+ if tool_name != config.name:
368
+ continue
369
+
370
+ # Check required fields
371
+ if not config.name:
372
+ issues['missing_required_fields'].append(f"Tool missing name")
373
+
374
+ # Check for duplicates
375
+ if config.name in seen_names:
376
+ issues['duplicate_names'].append(config.name)
377
+ seen_names.add(config.name)
378
+
379
+ # Check platforms
380
+ for platform in config.platforms:
381
+ if platform not in valid_platforms:
382
+ issues['invalid_platforms'].append(f"{config.name}: {platform}")
383
+
384
+ # Check category exists
385
+ if config.category and config.category not in self._category_configs:
386
+ issues['invalid_categories'].append(f"{config.name}: {config.category}")
387
+
388
+ return issues
389
+
390
+ def get_stats(self) -> Dict[str, Any]:
391
+ """Get statistics about loaded configurations"""
392
+ unique_tools = {config.name: config for config in self._tool_configs.values()}
393
+
394
+ stats = {
395
+ 'total_tools': len(unique_tools),
396
+ 'total_aliases': len(self._tool_configs) - len(unique_tools),
397
+ 'categories': len(self._category_configs),
398
+ 'tools_by_category': {},
399
+ 'tools_by_platform': {'linux': 0, 'darwin': 0, 'windows': 0},
400
+ 'required_tools': 0,
401
+ 'optional_tools': 0
402
+ }
403
+
404
+ for config in unique_tools.values():
405
+ # Count by category
406
+ category = config.category or 'uncategorized'
407
+ stats['tools_by_category'][category] = stats['tools_by_category'].get(category, 0) + 1
408
+
409
+ # Count by platform
410
+ for platform in config.platforms:
411
+ if platform in stats['tools_by_platform']:
412
+ stats['tools_by_platform'][platform] += 1
413
+
414
+ # Count required vs optional
415
+ if config.required:
416
+ stats['required_tools'] += 1
417
+ else:
418
+ stats['optional_tools'] += 1
419
+
420
+ return stats
421
+
422
+ # Singleton instance
423
+ _config_manager = None
424
+
425
+ def get_tool_config_manager() -> ToolConfigManager:
426
+ """Get singleton tool configuration manager"""
427
+ global _config_manager
428
+ if _config_manager is None:
429
+ _config_manager = ToolConfigManager()
430
+ return _config_manager