xsoar-cli 1.0.9__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.
@@ -0,0 +1,273 @@
1
+ """
2
+ Plugin management commands for XSOAR CLI
3
+
4
+ This module provides CLI commands for managing plugins, including
5
+ listing, loading, reloading, and creating example plugins.
6
+ """
7
+
8
+ import logging
9
+
10
+ import click
11
+
12
+ from .manager import PluginManager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @click.group(help="Manage XSOAR CLI plugins")
18
+ def plugins():
19
+ """Plugin management commands."""
20
+
21
+
22
+ @click.command(help="List all available and loaded plugins")
23
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed information")
24
+ @click.pass_context
25
+ def list_plugins(ctx: click.Context, verbose: bool):
26
+ """List all plugins in the plugins directory."""
27
+ plugin_manager = PluginManager()
28
+
29
+ # Discover all plugins
30
+ discovered = plugin_manager.discover_plugins()
31
+
32
+ # Load all plugins to get their info
33
+ plugin_manager.load_all_plugins(ignore_errors=True)
34
+
35
+ loaded_info = plugin_manager.get_plugin_info()
36
+ failed_info = plugin_manager.get_failed_plugins()
37
+
38
+ if not discovered:
39
+ return
40
+
41
+ click.echo(f"Plugins directory: {plugin_manager.plugins_dir}")
42
+ click.echo(f"Discovered {len(discovered)} plugin files\n")
43
+
44
+ # Show loaded plugins
45
+ if loaded_info:
46
+ click.echo("Loaded Plugins:")
47
+ for plugin_name, info in loaded_info.items():
48
+ if verbose:
49
+ click.echo(f" {plugin_name}")
50
+ click.echo(f" Name: {info['name']}")
51
+ click.echo(f" Version: {info['version']}")
52
+ click.echo(f" Description: {info['description']}")
53
+ else:
54
+ click.echo(f" {plugin_name} (v{info['version']})")
55
+ click.echo()
56
+
57
+ # Show failed plugins
58
+ if failed_info:
59
+ click.echo("Failed Plugins:")
60
+ for plugin_name, error in failed_info.items():
61
+ if verbose:
62
+ click.echo(f" {plugin_name}: {error}")
63
+ else:
64
+ click.echo(f" {plugin_name}")
65
+ click.echo()
66
+
67
+ # Show unloaded plugins (discovered but not loaded and not failed)
68
+ unloaded = set(discovered) - set(loaded_info.keys()) - set(failed_info.keys())
69
+ if unloaded:
70
+ click.echo("Unloaded Plugins:")
71
+ for plugin_name in unloaded:
72
+ click.echo(f" {plugin_name}")
73
+
74
+ # Show command conflicts
75
+ conflicts = plugin_manager.get_command_conflicts()
76
+ if conflicts:
77
+ click.echo()
78
+ click.echo("Command Conflicts:")
79
+ for conflict in conflicts:
80
+ click.echo(f" Plugin '{conflict['plugin_name']}' command '{conflict['command_name']}' conflicts with core command")
81
+ click.echo(f" Plugin version: {conflict['plugin_version']}")
82
+ click.echo(" Solution: Rename the command in your plugin or use a command group")
83
+
84
+
85
+ @click.command(help="Reload a specific plugin")
86
+ @click.argument("plugin_name", type=str)
87
+ def reload(plugin_name: str):
88
+ """Reload a specific plugin."""
89
+ plugin_manager = PluginManager()
90
+
91
+ try:
92
+ click.echo(f"Reloading plugin: {plugin_name}...")
93
+ plugin = plugin_manager.reload_plugin(plugin_name)
94
+
95
+ if plugin:
96
+ click.echo(f"Successfully reloaded plugin: {plugin_name}")
97
+ click.echo(f" Name: {plugin.name}")
98
+ click.echo(f" Version: {plugin.version}")
99
+ else:
100
+ click.echo(f"Failed to reload plugin: {plugin_name}")
101
+
102
+ except Exception as e:
103
+ click.echo(f"Error reloading plugin {plugin_name}: {e}")
104
+
105
+
106
+ @click.command(help="Show information about a specific plugin")
107
+ @click.argument("plugin_name", type=str)
108
+ def info(plugin_name: str):
109
+ """Show detailed information about a plugin."""
110
+ plugin_manager = PluginManager()
111
+
112
+ try:
113
+ plugin = plugin_manager.load_plugin(plugin_name)
114
+
115
+ if plugin:
116
+ click.echo("Plugin Information:")
117
+ click.echo(f" File: {plugin_manager.plugins_dir / plugin_name}.py")
118
+ click.echo(f" Name: {plugin.name}")
119
+ click.echo(f" Version: {plugin.version}")
120
+ click.echo(f" Description: {plugin.description or 'No description provided'}")
121
+
122
+ # Try to get command info
123
+ try:
124
+ command = plugin.get_command()
125
+ click.echo(f" Command: {command.name}")
126
+ if hasattr(command, "commands"):
127
+ subcommands = list(command.commands.keys())
128
+ if subcommands:
129
+ click.echo(f" Subcommands: {', '.join(subcommands)}")
130
+ except Exception as e:
131
+ click.echo(f" Command: Error loading command ({e})")
132
+ else:
133
+ click.echo(f"Plugin not found or failed to load: {plugin_name}")
134
+
135
+ except Exception as e:
136
+ click.echo(f"Error loading plugin {plugin_name}: {e}")
137
+
138
+
139
+ @click.command(help="Validate all plugins")
140
+ def validate():
141
+ """Validate all plugins in the plugins directory."""
142
+ plugin_manager = PluginManager()
143
+
144
+ discovered = plugin_manager.discover_plugins()
145
+
146
+ if not discovered:
147
+ click.echo(f"No plugins found in {plugin_manager.plugins_dir}")
148
+ return
149
+
150
+ click.echo(f"Validating {len(discovered)} plugins...\n")
151
+
152
+ all_valid = True
153
+
154
+ for plugin_name in discovered:
155
+ try:
156
+ plugin = plugin_manager.load_plugin(plugin_name)
157
+ if plugin:
158
+ # Test that the plugin can provide a command
159
+ command = plugin.get_command()
160
+ if not isinstance(command, (click.Command, click.Group)):
161
+ raise ValueError("get_command() must return a Click Command or Group")
162
+
163
+ click.echo(f"{plugin_name}: Valid")
164
+ else:
165
+ click.echo(f"{plugin_name}: Failed to load")
166
+ all_valid = False
167
+
168
+ except Exception as e:
169
+ click.echo(f"{plugin_name}: {e}")
170
+ all_valid = False
171
+
172
+ # Check for command conflicts by attempting registration
173
+ try:
174
+ from xsoar_cli.cli import cli
175
+
176
+ temp_plugin_manager = PluginManager()
177
+ temp_plugin_manager.load_all_plugins(ignore_errors=True)
178
+ temp_plugin_manager.register_plugin_commands(cli)
179
+
180
+ conflicts = temp_plugin_manager.get_command_conflicts()
181
+ if conflicts:
182
+ click.echo("\nCommand Conflicts Detected:")
183
+ for conflict in conflicts:
184
+ click.echo(f" Plugin '{conflict['plugin_name']}' command '{conflict['command_name']}' conflicts with core command")
185
+ click.echo(" Solution: Rename the command or use a command group")
186
+ all_valid = False
187
+ except Exception as e:
188
+ click.echo(f"\nCould not check for command conflicts: {e}")
189
+
190
+ click.echo()
191
+ if all_valid:
192
+ click.echo("All plugins are valid!")
193
+ else:
194
+ click.echo("Some plugins have validation errors.")
195
+
196
+
197
+ @click.command(help="Open the plugins directory")
198
+ def open_dir():
199
+ """Open the plugins directory in the system file manager."""
200
+ plugin_manager = PluginManager()
201
+ plugins_dir = plugin_manager.plugins_dir
202
+
203
+ click.echo(f"Plugins directory: {plugins_dir}")
204
+
205
+ # Try to open the directory
206
+ import subprocess
207
+ import sys
208
+
209
+ try:
210
+ if sys.platform == "win32":
211
+ subprocess.run(["explorer", str(plugins_dir)], check=True)
212
+ elif sys.platform == "darwin":
213
+ subprocess.run(["open", str(plugins_dir)], check=True)
214
+ else:
215
+ subprocess.run(["xdg-open", str(plugins_dir)], check=True)
216
+
217
+ click.echo("Opened plugins directory in file manager.")
218
+
219
+ except (subprocess.CalledProcessError, FileNotFoundError):
220
+ click.echo("Could not open directory automatically.")
221
+ click.echo(f"Please navigate to: {plugins_dir}")
222
+
223
+
224
+ # Add all commands to the plugins group
225
+ @click.command(help="Check for command conflicts with core CLI")
226
+ def check_conflicts():
227
+ """Check for command conflicts between plugins and core CLI."""
228
+ plugin_manager = PluginManager()
229
+
230
+ # Load all plugins
231
+ plugin_manager.load_all_plugins(ignore_errors=True)
232
+
233
+ # Check conflicts by attempting registration with a temporary CLI group
234
+ import click
235
+
236
+ temp_cli = click.Group()
237
+
238
+ # Add core commands to temp CLI to simulate real conflicts
239
+ core_commands = ["case", "config", "graph", "manifest", "pack", "playbook", "plugins"]
240
+ for cmd_name in core_commands:
241
+ temp_cli.add_command(click.Command(cmd_name, callback=lambda: None))
242
+
243
+ # Attempt to register plugin commands
244
+ plugin_manager.register_plugin_commands(temp_cli)
245
+
246
+ conflicts = plugin_manager.get_command_conflicts()
247
+
248
+ if not conflicts:
249
+ click.echo("No command conflicts detected!")
250
+ click.echo("All plugin commands have unique names.")
251
+ return
252
+
253
+ click.echo(f"Found {len(conflicts)} command conflict(s):")
254
+ click.echo()
255
+
256
+ for conflict in conflicts:
257
+ click.echo(f"Plugin: {conflict['plugin_name']} (v{conflict['plugin_version']})")
258
+ click.echo(f" Command: '{conflict['command_name']}'")
259
+ click.echo(" Conflicts with: Core CLI command")
260
+ click.echo()
261
+
262
+ click.echo("Solutions:")
263
+ click.echo(" Rename the conflicting command in your plugin")
264
+ click.echo(" Use a command group to namespace your commands")
265
+ click.echo(" Example: Instead of 'case', use 'mycase' or 'myplugin case'")
266
+
267
+
268
+ plugins.add_command(list_plugins, name="list")
269
+ plugins.add_command(reload)
270
+ plugins.add_command(info)
271
+ plugins.add_command(validate)
272
+ plugins.add_command(check_conflicts, name="check-conflicts")
273
+ plugins.add_command(open_dir, name="open")
@@ -0,0 +1,328 @@
1
+ """
2
+ Plugin Manager for XSOAR CLI
3
+
4
+ This module handles the discovery, loading, and management of plugins
5
+ for the xsoar-cli application from a local directory.
6
+ """
7
+
8
+ import importlib.util
9
+ import logging
10
+ import sys
11
+ import types
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from . import PluginLoadError, PluginRegistrationError, XSOARPlugin
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class PluginManager:
22
+ """
23
+ Manages the discovery, loading, and registration of XSOAR CLI plugins
24
+ from the ~/.local/xsoar-cli/plugins directory.
25
+ """
26
+
27
+ def __init__(self, plugins_dir: Path | None = None) -> None:
28
+ """
29
+ Initialize the plugin manager.
30
+
31
+ Args:
32
+ plugins_dir: Custom plugins directory. If None, uses ~/.local/xsoar-cli/plugins
33
+ """
34
+ if plugins_dir is None:
35
+ self.plugins_dir = Path.home() / ".local" / "xsoar-cli" / "plugins"
36
+ else:
37
+ self.plugins_dir = plugins_dir
38
+
39
+ self.loaded_plugins: dict[str, XSOARPlugin] = {}
40
+ self.failed_plugins: dict[str, Exception] = {}
41
+ self.command_conflicts: list[dict[str, str]] = []
42
+
43
+ # Ensure plugins directory exists
44
+ self.plugins_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ # Add plugins directory to Python path if not already there
47
+ plugins_dir_str = str(self.plugins_dir)
48
+ if plugins_dir_str not in sys.path:
49
+ sys.path.insert(0, plugins_dir_str)
50
+
51
+ def discover_plugins(self) -> list[str]:
52
+ """
53
+ Discover available plugins by scanning the plugins directory for Python files.
54
+
55
+ Returns:
56
+ List of plugin module names found
57
+ """
58
+ plugin_names = []
59
+
60
+ if not self.plugins_dir.exists():
61
+ logger.info(f"Plugins directory does not exist: {self.plugins_dir}")
62
+ return plugin_names
63
+
64
+ for file_path in self.plugins_dir.glob("*.py"):
65
+ if file_path.name.startswith("__"):
66
+ continue # Skip __init__.py, __pycache__, etc.
67
+
68
+ module_name = file_path.stem
69
+ plugin_names.append(module_name)
70
+
71
+ logger.info(f"Discovered {len(plugin_names)} plugin files: {plugin_names}")
72
+ return plugin_names
73
+
74
+ def _load_module_from_file(self, module_name: str, file_path: Path) -> types.ModuleType:
75
+ """
76
+ Load a Python module from a file path.
77
+
78
+ Args:
79
+ module_name: Name to give the module
80
+ file_path: Path to the Python file
81
+
82
+ Returns:
83
+ The loaded module
84
+ """
85
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
86
+ if spec is None or spec.loader is None:
87
+ raise PluginLoadError(f"Could not load module spec for {file_path}")
88
+
89
+ module = importlib.util.module_from_spec(spec)
90
+
91
+ # Inject XSOARPlugin class into the module's namespace
92
+ # This allows plugins to use XSOARPlugin without complex imports
93
+ from . import XSOARPlugin
94
+
95
+ module.XSOARPlugin = XSOARPlugin
96
+
97
+ sys.modules[module_name] = module
98
+ spec.loader.exec_module(module)
99
+ return module
100
+
101
+ def _find_plugin_classes(self, module: types.ModuleType) -> list[type[XSOARPlugin]]:
102
+ """
103
+ Find all XSOARPlugin classes in a module.
104
+
105
+ Args:
106
+ module: The module to search
107
+
108
+ Returns:
109
+ List of plugin classes found
110
+ """
111
+ plugin_classes = []
112
+
113
+ for attr_name in dir(module):
114
+ attr = getattr(module, attr_name)
115
+
116
+ # Check if it's a class that inherits from XSOARPlugin
117
+ if isinstance(attr, type) and issubclass(attr, XSOARPlugin) and attr is not XSOARPlugin:
118
+ plugin_classes.append(attr)
119
+
120
+ return plugin_classes
121
+
122
+ def load_plugin(self, plugin_name: str) -> XSOARPlugin | None:
123
+ """
124
+ Load a single plugin by name.
125
+
126
+ Args:
127
+ plugin_name: Name of the plugin module to load
128
+
129
+ Returns:
130
+ The loaded plugin instance, or None if loading failed
131
+
132
+ Raises:
133
+ PluginLoadError: If the plugin fails to load
134
+ """
135
+ if plugin_name in self.loaded_plugins:
136
+ return self.loaded_plugins[plugin_name]
137
+
138
+ try:
139
+ file_path = self.plugins_dir / f"{plugin_name}.py"
140
+ if not file_path.exists():
141
+ raise PluginLoadError(f"Plugin file not found: {file_path}")
142
+
143
+ # Load the module
144
+ module = self._load_module_from_file(plugin_name, file_path)
145
+
146
+ # Find plugin classes in the module
147
+ plugin_classes = self._find_plugin_classes(module)
148
+
149
+ if not plugin_classes:
150
+ raise PluginLoadError(
151
+ f"No XSOARPlugin classes found in {plugin_name}.py",
152
+ )
153
+
154
+ if len(plugin_classes) > 1:
155
+ logger.warning(
156
+ f"Multiple plugin classes found in {plugin_name}.py, using the first one",
157
+ )
158
+
159
+ # Instantiate the first plugin class found
160
+ plugin_class = plugin_classes[0]
161
+ plugin_instance = plugin_class()
162
+
163
+ # Initialize the plugin
164
+ try:
165
+ plugin_instance.initialize()
166
+ except Exception as e:
167
+ raise PluginLoadError(f"Plugin '{plugin_name}' initialization failed: {e}")
168
+
169
+ # Store the loaded plugin
170
+ self.loaded_plugins[plugin_name] = plugin_instance
171
+ logger.info(f"Successfully loaded plugin: {plugin_name}")
172
+
173
+ return plugin_instance
174
+
175
+ except Exception as e:
176
+ self.failed_plugins[plugin_name] = e
177
+ logger.error(f"Failed to load plugin '{plugin_name}': {e}")
178
+ raise PluginLoadError(f"Failed to load plugin '{plugin_name}': {e}")
179
+
180
+ def load_all_plugins(self, *, ignore_errors: bool = True) -> dict[str, XSOARPlugin]:
181
+ """
182
+ Load all discovered plugins.
183
+
184
+ Args:
185
+ ignore_errors: If True, continue loading other plugins when one fails
186
+
187
+ Returns:
188
+ Dictionary of successfully loaded plugins
189
+
190
+ Raises:
191
+ PluginLoadError: If ignore_errors is False and any plugin fails to load
192
+ """
193
+ discovered_plugins = self.discover_plugins()
194
+
195
+ for plugin_name in discovered_plugins:
196
+ try:
197
+ self.load_plugin(plugin_name)
198
+ except PluginLoadError as e:
199
+ if not ignore_errors:
200
+ raise
201
+ logger.warning(f"Skipping failed plugin: {e}")
202
+
203
+ return self.loaded_plugins.copy()
204
+
205
+ def register_plugin_commands(self, cli_group: click.Group) -> None:
206
+ """
207
+ Register all loaded plugin commands with the CLI group.
208
+
209
+ Args:
210
+ cli_group: The Click group to register commands with
211
+
212
+ Raises:
213
+ PluginRegistrationError: If a plugin command fails to register
214
+ """
215
+ conflicts = []
216
+
217
+ for plugin_name, plugin in self.loaded_plugins.items():
218
+ try:
219
+ command = plugin.get_command()
220
+ if not isinstance(command, (click.Command, click.Group)):
221
+ raise PluginRegistrationError(
222
+ f"Plugin '{plugin_name}' get_command() must return a Click Command or Group",
223
+ )
224
+
225
+ # Ensure the command name doesn't conflict with existing commands
226
+ if command.name in cli_group.commands:
227
+ conflict_info = {
228
+ "plugin_name": plugin_name,
229
+ "command_name": command.name,
230
+ "plugin_version": plugin.version,
231
+ }
232
+ conflicts.append(conflict_info)
233
+ logger.warning(
234
+ f"Plugin '{plugin_name}' command '{command.name}' conflicts with existing command",
235
+ )
236
+ continue
237
+
238
+ cli_group.add_command(command)
239
+ logger.info(f"Registered command '{command.name}' from plugin '{plugin_name}'")
240
+
241
+ except Exception as e:
242
+ error_msg = f"Failed to register plugin '{plugin_name}': {e}"
243
+ logger.error(error_msg)
244
+ raise PluginRegistrationError(error_msg)
245
+
246
+ # Store conflicts for later reporting
247
+ self.command_conflicts = conflicts
248
+
249
+ def unload_plugin(self, plugin_name: str) -> None:
250
+ """
251
+ Unload a plugin and call its cleanup method.
252
+
253
+ Args:
254
+ plugin_name: Name of the plugin to unload
255
+ """
256
+ if plugin_name in self.loaded_plugins:
257
+ plugin = self.loaded_plugins[plugin_name]
258
+ try:
259
+ plugin.cleanup()
260
+ except Exception as e:
261
+ logger.warning(f"Plugin '{plugin_name}' cleanup failed: {e}")
262
+ del self.loaded_plugins[plugin_name]
263
+ logger.info(f"Unloaded plugin: {plugin_name}")
264
+
265
+ def unload_all_plugins(self) -> None:
266
+ """Unload all plugins and call their cleanup methods."""
267
+ plugin_names = list(self.loaded_plugins.keys())
268
+ for plugin_name in plugin_names:
269
+ self.unload_plugin(plugin_name)
270
+
271
+ def get_plugin_info(self) -> dict[str, dict[str, str]]:
272
+ """
273
+ Get information about all loaded plugins.
274
+
275
+ Returns:
276
+ Dictionary with plugin information
277
+ """
278
+ info = {}
279
+ for plugin_name, plugin in self.loaded_plugins.items():
280
+ info[plugin_name] = {
281
+ "name": plugin.name,
282
+ "version": plugin.version,
283
+ "description": plugin.description or "No description provided",
284
+ }
285
+ return info
286
+
287
+ def get_failed_plugins(self) -> dict[str, str]:
288
+ """
289
+ Get information about plugins that failed to load.
290
+
291
+ Returns:
292
+ Dictionary with plugin names and error messages
293
+ """
294
+ return {name: str(error) for name, error in self.failed_plugins.items()}
295
+
296
+ def get_command_conflicts(self) -> list[dict[str, str]]:
297
+ """
298
+ Get information about command conflicts.
299
+
300
+ Returns:
301
+ List of dictionaries with conflict information
302
+ """
303
+ return self.command_conflicts.copy()
304
+
305
+ def reload_plugin(self, plugin_name: str) -> XSOARPlugin | None:
306
+ """
307
+ Reload a plugin by unloading and loading it again.
308
+
309
+ Args:
310
+ plugin_name: Name of the plugin to reload
311
+
312
+ Returns:
313
+ The reloaded plugin instance, or None if reloading failed
314
+ """
315
+ # Unload if already loaded
316
+ if plugin_name in self.loaded_plugins:
317
+ self.unload_plugin(plugin_name)
318
+
319
+ # Remove from failed plugins if it was there
320
+ if plugin_name in self.failed_plugins:
321
+ del self.failed_plugins[plugin_name]
322
+
323
+ # Remove from sys.modules to force reload
324
+ if plugin_name in sys.modules:
325
+ del sys.modules[plugin_name]
326
+
327
+ # Load again
328
+ return self.load_plugin(plugin_name)