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.
- iotsploit_core/__init__.py +7 -0
- iotsploit_core/context.py +27 -0
- iotsploit_core/core/__init__.py +0 -0
- iotsploit_core/core/base_plugin.py +290 -0
- iotsploit_core/core/device_config.py +105 -0
- iotsploit_core/core/device_manager.py +942 -0
- iotsploit_core/core/device_registry.py +118 -0
- iotsploit_core/core/device_scanner.py +74 -0
- iotsploit_core/core/device_spec.py +105 -0
- iotsploit_core/core/device_store.py +86 -0
- iotsploit_core/core/execution_backend.py +464 -0
- iotsploit_core/core/execution_queue.py +456 -0
- iotsploit_core/core/exploit_manager.py +728 -0
- iotsploit_core/core/exploit_spec.py +73 -0
- iotsploit_core/core/stream_manager.py +157 -0
- iotsploit_core/core/tool_config.py +430 -0
- iotsploit_core/core/tool_manager.py +1166 -0
- iotsploit_core/core/tool_service.py +936 -0
- iotsploit_core/domain/__init__.py +3 -0
- iotsploit_core/domain/device.py +70 -0
- iotsploit_core/domain/execution_plan.py +29 -0
- iotsploit_core/domain/plugin.py +17 -0
- iotsploit_core/domain/stream.py +63 -0
- iotsploit_core/domain/target.py +243 -0
- iotsploit_core/platforms/__init__.py +19 -0
- iotsploit_core/platforms/consts.py +25 -0
- iotsploit_core/ports/__init__.py +6 -0
- iotsploit_core/ports/driver_state_repo.py +15 -0
- iotsploit_core/ports/plugin_repo.py +26 -0
- iotsploit_core/ports/stream_backend.py +28 -0
- iotsploit_core/ports/task_runner.py +22 -0
- iotsploit_core/ports/wifi_backend.py +148 -0
- iotsploit_core/py.typed +2 -0
- iotsploit_core/utils/__init__.py +53 -0
- iotsploit_core/utils/exceptions.py +152 -0
- iotsploit_core/utils/helpers.py +65 -0
- iotsploit_core/utils/iots_logger.py +164 -0
- iotsploit_core/utils/result.py +41 -0
- iotsploit_core-0.0.6.dist-info/METADATA +117 -0
- iotsploit_core-0.0.6.dist-info/RECORD +41 -0
- 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
|