deepctl-cmd-plugin 0.1.10__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,5 @@
1
+ """Plugin management command for deepctl."""
2
+
3
+ from .command import PluginCommand
4
+
5
+ __all__ = ["PluginCommand"]
@@ -0,0 +1,1107 @@
1
+ """Plugin management command implementation."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+ from deepctl_cmd_update.installation import InstallationDetector, InstallMethod
11
+ from deepctl_core.auth import AuthManager
12
+ from deepctl_core.base_group_command import BaseGroupCommand
13
+ from deepctl_core.client import DeepgramClient
14
+ from deepctl_core.config import Config
15
+ from deepctl_core.output import print_error, print_info, print_success
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from .models import (
20
+ PluginAction,
21
+ PluginInstallOptions,
22
+ PluginOperationResult,
23
+ PluginPackage,
24
+ PluginRegistryEntry,
25
+ PluginSearchResult,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ class PluginCommand(BaseGroupCommand):
32
+ """Plugin management command."""
33
+
34
+ name = "plugin"
35
+ help = "Manage deepctl plugins"
36
+
37
+ def __init__(self) -> None:
38
+ """Initialize plugin command."""
39
+ super().__init__()
40
+ self.detector = InstallationDetector()
41
+ self._python_executable = sys.executable
42
+
43
+ # Plugin environment paths
44
+ self._plugin_dir = Path.home() / ".deepctl" / "plugins"
45
+ self._plugin_venv = self._plugin_dir / "venv"
46
+ self._plugin_state_file = self._plugin_dir / "plugins.json"
47
+
48
+ def execute(self, ctx: click.Context, **kwargs: Any) -> None:
49
+ """Execute plugin group command.
50
+
51
+ Args:
52
+ ctx: Click context
53
+ **kwargs: Additional arguments
54
+ """
55
+ # Get config from context
56
+ config = ctx.obj.get("config") if ctx.obj else None
57
+ if not config:
58
+ # If no config in context, create one
59
+ config = Config()
60
+
61
+ # Create auth manager and client
62
+ auth_manager = AuthManager(config)
63
+ client = DeepgramClient(config, auth_manager)
64
+
65
+ # Store in context for subcommands
66
+ ctx.obj = ctx.obj or {}
67
+ ctx.obj["config"] = config
68
+ ctx.obj["auth_manager"] = auth_manager
69
+ ctx.obj["client"] = client
70
+
71
+ # Continue with normal group execution
72
+ super().execute(ctx, **kwargs)
73
+
74
+ def setup_commands(self) -> list[click.Command]:
75
+ """Set up plugin management commands.
76
+
77
+ Returns:
78
+ List of subcommands
79
+ """
80
+
81
+ # Create a wrapper for subcommands to handle context
82
+ def context_wrapper(func: Any) -> Any:
83
+ """Wrap subcommand to provide config and auth."""
84
+
85
+ @click.pass_context
86
+ def wrapper(ctx: click.Context, /, **kwargs: Any) -> Any:
87
+ # Try to get from parent context first
88
+ if ctx.parent and ctx.parent.obj:
89
+ config = ctx.parent.obj.get("config")
90
+ auth_manager = ctx.parent.obj.get("auth_manager")
91
+ client = ctx.parent.obj.get("client")
92
+ if config and auth_manager and client:
93
+ return func(config, auth_manager, client, **kwargs)
94
+
95
+ # Fallback - look for deepctl_context
96
+ current_ctx: click.Context | None = ctx
97
+ while current_ctx:
98
+ if hasattr(current_ctx, "deepctl_context"):
99
+ config = current_ctx.deepctl_context.get("config")
100
+ if config:
101
+ auth_manager = AuthManager(config)
102
+ client = DeepgramClient(config, auth_manager)
103
+ return func(config, auth_manager, client, **kwargs)
104
+ current_ctx = current_ctx.parent
105
+
106
+ # Final fallback
107
+ config = Config()
108
+ auth_manager = AuthManager(config)
109
+ client = DeepgramClient(config, auth_manager)
110
+ return func(config, auth_manager, client, **kwargs)
111
+
112
+ wrapper.__name__ = func.__name__
113
+ wrapper.__doc__ = func.__doc__
114
+ return wrapper
115
+
116
+ # Define all subcommands
117
+ return [
118
+ self._create_install_command(context_wrapper),
119
+ self._create_list_command(context_wrapper),
120
+ self._create_update_command(context_wrapper),
121
+ self._create_remove_command(context_wrapper),
122
+ self._create_search_command(context_wrapper),
123
+ ]
124
+
125
+ def _ensure_plugin_environment(self) -> tuple[bool, str]:
126
+ """Ensure plugin environment exists.
127
+
128
+ Returns:
129
+ Tuple of (success, python_executable_path)
130
+ """
131
+ # Create plugin directory if it doesn't exist
132
+ self._plugin_dir.mkdir(parents=True, exist_ok=True)
133
+
134
+ # Check if venv exists
135
+ if not self._plugin_venv.exists():
136
+ print_info("Creating plugin environment...")
137
+ try:
138
+ # Create virtual environment
139
+ subprocess.run(
140
+ [sys.executable, "-m", "venv", str(self._plugin_venv)],
141
+ check=True,
142
+ capture_output=True,
143
+ text=True,
144
+ )
145
+
146
+ # Get the python executable in the venv
147
+ if sys.platform == "win32":
148
+ venv_python = self._plugin_venv / "Scripts" / "python.exe"
149
+ else:
150
+ venv_python = self._plugin_venv / "bin" / "python"
151
+
152
+ # Ensure pip is installed and up to date
153
+ subprocess.run(
154
+ [
155
+ str(venv_python),
156
+ "-m",
157
+ "pip",
158
+ "install",
159
+ "--upgrade",
160
+ "pip",
161
+ ],
162
+ check=True,
163
+ capture_output=True,
164
+ text=True,
165
+ )
166
+
167
+ print_success("Plugin environment created successfully")
168
+ return True, str(venv_python)
169
+
170
+ except subprocess.CalledProcessError as e:
171
+ print_error(f"Failed to create plugin environment: {e}")
172
+ return False, ""
173
+
174
+ # Venv exists, return python executable
175
+ if sys.platform == "win32":
176
+ venv_python = self._plugin_venv / "Scripts" / "python.exe"
177
+ else:
178
+ venv_python = self._plugin_venv / "bin" / "python"
179
+
180
+ return True, str(venv_python)
181
+
182
+ def _get_plugin_state(self) -> dict[str, Any]:
183
+ """Get plugin state from file.
184
+
185
+ Returns:
186
+ Plugin state dictionary
187
+ """
188
+ if self._plugin_state_file.exists():
189
+ try:
190
+ result: dict[str, Any] = json.loads(
191
+ self._plugin_state_file.read_text()
192
+ )
193
+ return result
194
+ except Exception:
195
+ return {"plugins": {}}
196
+ return {"plugins": {}}
197
+
198
+ def _save_plugin_state(self, state: dict[str, Any]) -> None:
199
+ """Save plugin state to file.
200
+
201
+ Args:
202
+ state: Plugin state to save
203
+ """
204
+ self._plugin_state_file.write_text(json.dumps(state, indent=2))
205
+
206
+ def _create_install_command(self, context_wrapper: Any) -> click.Command:
207
+ """Create the install subcommand."""
208
+
209
+ @click.command(name="install", help="Install a plugin")
210
+ @click.argument("package")
211
+ @click.option(
212
+ "--version",
213
+ "-v",
214
+ help="Specific version to install",
215
+ )
216
+ @click.option(
217
+ "--upgrade",
218
+ "-U",
219
+ is_flag=True,
220
+ help="Upgrade if already installed",
221
+ )
222
+ @click.option(
223
+ "--pre",
224
+ is_flag=True,
225
+ help="Include pre-release versions",
226
+ )
227
+ @click.option(
228
+ "--force-reinstall",
229
+ is_flag=True,
230
+ help="Force reinstallation",
231
+ )
232
+ @click.option(
233
+ "--index-url",
234
+ help="Base URL of Python package index",
235
+ )
236
+ @click.option(
237
+ "--extra-index-url",
238
+ help="Extra URLs of package indexes",
239
+ )
240
+ @click.option(
241
+ "--git",
242
+ "git_url",
243
+ help="Install from git repository URL",
244
+ )
245
+ @click.option(
246
+ "--editable",
247
+ "-e",
248
+ is_flag=True,
249
+ help="Install in editable mode",
250
+ )
251
+ def install_cmd(**kwargs: Any) -> None:
252
+ """Install a plugin handler."""
253
+ pass # Handler is wrapped by context_wrapper
254
+
255
+ # Apply the context wrapper
256
+ install_cmd.callback = context_wrapper(
257
+ lambda config, auth_manager, client, **kwargs: self._handle_install(
258
+ config, auth_manager, client, **kwargs
259
+ )
260
+ )
261
+
262
+ return install_cmd
263
+
264
+ def _handle_install(
265
+ self,
266
+ config: Config,
267
+ auth_manager: AuthManager,
268
+ client: DeepgramClient,
269
+ **kwargs: Any,
270
+ ) -> None:
271
+ """Handle install command."""
272
+ # Extract package argument
273
+ package = kwargs.pop("package", "")
274
+
275
+ # Check if package is a git URL
276
+ if package.startswith("git+") or (
277
+ (package.startswith("http://") or package.startswith("https://"))
278
+ and ".git" in package
279
+ ):
280
+ # This is a git URL
281
+ kwargs["git_url"] = package
282
+ # Extract package name from URL for tracking
283
+ # e.g., git+https://github.com/user/deepctl-plugin-foo.git -> deepctl-plugin-foo
284
+ if "/" in package:
285
+ potential_name = package.split("/")[-1]
286
+ if potential_name.endswith(".git"):
287
+ potential_name = potential_name[:-4]
288
+ kwargs["package"] = potential_name
289
+ else:
290
+ kwargs["package"] = package
291
+ else:
292
+ kwargs["package"] = package
293
+
294
+ options = PluginInstallOptions(**kwargs)
295
+ result = self.install_plugin(config, auth_manager, client, options)
296
+
297
+ if result.success:
298
+ print_success(result.message)
299
+ else:
300
+ print_error(result.error or result.message)
301
+ raise click.ClickException(result.error or result.message)
302
+
303
+ def _create_list_command(self, context_wrapper: Any) -> click.Command:
304
+ """Create the list subcommand."""
305
+
306
+ @click.command(name="list", help="List installed plugins")
307
+ @click.option(
308
+ "--verbose",
309
+ "-v",
310
+ is_flag=True,
311
+ help="Show detailed information",
312
+ )
313
+ def list_cmd(**kwargs: Any) -> None:
314
+ """List plugins handler."""
315
+ pass # Handler is wrapped by context_wrapper
316
+
317
+ # Apply the context wrapper
318
+ list_cmd.callback = context_wrapper(
319
+ lambda config, auth_manager, client, **kwargs: self.list_plugins(
320
+ config, auth_manager, client, **kwargs
321
+ )
322
+ )
323
+
324
+ return list_cmd
325
+
326
+ def _create_update_command(self, context_wrapper: Any) -> click.Command:
327
+ """Create the update subcommand."""
328
+
329
+ @click.command(name="update", help="Update a plugin")
330
+ @click.argument("package")
331
+ @click.option(
332
+ "--pre",
333
+ is_flag=True,
334
+ help="Include pre-release versions",
335
+ )
336
+ def update_cmd(**kwargs: Any) -> None:
337
+ """Update plugin handler."""
338
+ pass # Handler is wrapped by context_wrapper
339
+
340
+ # Apply the context wrapper
341
+ update_cmd.callback = context_wrapper(
342
+ lambda config, auth_manager, client, **kwargs: self._handle_update(
343
+ config, auth_manager, client, **kwargs
344
+ )
345
+ )
346
+
347
+ return update_cmd
348
+
349
+ def _handle_update(
350
+ self,
351
+ config: Config,
352
+ auth_manager: AuthManager,
353
+ client: DeepgramClient,
354
+ **kwargs: Any,
355
+ ) -> None:
356
+ """Handle update command."""
357
+ options = PluginInstallOptions(
358
+ package=kwargs["package"],
359
+ upgrade=True,
360
+ pre=kwargs.get("pre", False),
361
+ )
362
+ result = self.install_plugin(config, auth_manager, client, options)
363
+
364
+ if result.success:
365
+ print_success(result.message)
366
+ else:
367
+ print_error(result.error or result.message)
368
+ raise click.ClickException(result.error or result.message)
369
+
370
+ def _create_remove_command(self, context_wrapper: Any) -> click.Command:
371
+ """Create the remove subcommand."""
372
+
373
+ @click.command(name="remove", help="Remove a plugin")
374
+ @click.argument("package")
375
+ @click.option(
376
+ "--yes",
377
+ "-y",
378
+ is_flag=True,
379
+ help="Skip confirmation",
380
+ )
381
+ def remove_cmd(**kwargs: Any) -> None:
382
+ """Remove plugin handler."""
383
+ pass # Handler is wrapped by context_wrapper
384
+
385
+ # Apply the context wrapper
386
+ remove_cmd.callback = context_wrapper(
387
+ lambda config, auth_manager, client, **kwargs: self._handle_remove(
388
+ config, auth_manager, client, **kwargs
389
+ )
390
+ )
391
+
392
+ return remove_cmd
393
+
394
+ def _handle_remove(
395
+ self,
396
+ config: Config,
397
+ auth_manager: AuthManager,
398
+ client: DeepgramClient,
399
+ **kwargs: Any,
400
+ ) -> None:
401
+ """Handle remove command."""
402
+ package = kwargs["package"]
403
+ yes = kwargs.get("yes", False)
404
+
405
+ if not yes and not click.confirm(
406
+ f"Are you sure you want to remove {package}?"
407
+ ):
408
+ return
409
+
410
+ result = self.remove_plugin(config, auth_manager, client, package)
411
+
412
+ if result.success:
413
+ print_success(result.message)
414
+ else:
415
+ print_error(result.error or result.message)
416
+ raise click.ClickException(result.error or result.message)
417
+
418
+ def _create_search_command(self, context_wrapper: Any) -> click.Command:
419
+ """Create the search subcommand."""
420
+
421
+ @click.command(name="search", help="Search for available plugins")
422
+ @click.argument("query", required=False)
423
+ @click.option(
424
+ "--installed",
425
+ is_flag=True,
426
+ help="Show only installed plugins",
427
+ )
428
+ @click.option(
429
+ "--available",
430
+ is_flag=True,
431
+ help="Show only available (not installed) plugins",
432
+ )
433
+ def search_cmd(**kwargs: Any) -> None:
434
+ """Search plugins handler."""
435
+ pass # Handler is wrapped by context_wrapper
436
+
437
+ # Apply the context wrapper
438
+ search_cmd.callback = context_wrapper(
439
+ lambda config, auth_manager, client, **kwargs: self._handle_search(
440
+ config, auth_manager, client, **kwargs
441
+ )
442
+ )
443
+
444
+ return search_cmd
445
+
446
+ def _handle_search(
447
+ self,
448
+ config: Config,
449
+ auth_manager: AuthManager,
450
+ client: DeepgramClient,
451
+ **kwargs: Any,
452
+ ) -> None:
453
+ """Handle search command."""
454
+ query = kwargs.get("query", "")
455
+ show_installed = kwargs.get("installed", False)
456
+ show_available = kwargs.get("available", False)
457
+
458
+ # Get available plugins from registry
459
+ registry_plugins = self._get_plugin_registry()
460
+
461
+ # Get installed plugins
462
+ installed_plugins = {p.name: p for p in self._discover_plugins()}
463
+
464
+ # Filter plugins based on query
465
+ search_results = []
466
+ for plugin in registry_plugins:
467
+ # Check if plugin matches query
468
+ if query:
469
+ query_lower = query.lower()
470
+ if (
471
+ query_lower not in plugin.name.lower()
472
+ and query_lower not in plugin.description.lower()
473
+ and not any(
474
+ query_lower in kw.lower() for kw in plugin.keywords
475
+ )
476
+ ):
477
+ continue
478
+
479
+ # Check installed status
480
+ is_installed = (
481
+ plugin.install_name is not None
482
+ and plugin.install_name in installed_plugins
483
+ ) or plugin.name in installed_plugins
484
+ installed_version = None
485
+ if is_installed:
486
+ installed_plugin = (
487
+ installed_plugins.get(plugin.install_name)
488
+ if plugin.install_name
489
+ else None
490
+ ) or installed_plugins.get(plugin.name)
491
+ if installed_plugin:
492
+ installed_version = installed_plugin.version
493
+
494
+ # Apply filters
495
+ if show_installed and not is_installed:
496
+ continue
497
+ if show_available and is_installed:
498
+ continue
499
+
500
+ search_results.append(
501
+ PluginSearchResult(
502
+ plugin=plugin,
503
+ installed=is_installed,
504
+ installed_version=installed_version,
505
+ )
506
+ )
507
+
508
+ # Display results
509
+ if not search_results:
510
+ if query:
511
+ print_info(f"No plugins found matching '{query}'")
512
+ else:
513
+ print_info("No plugins found")
514
+ return
515
+
516
+ # Create table
517
+ table = Table(title="Available Plugins")
518
+ table.add_column("Name", style="cyan", no_wrap=True)
519
+ table.add_column("Description", style="white")
520
+ table.add_column("Version", style="green")
521
+ table.add_column("Status", style="yellow")
522
+
523
+ for result in search_results:
524
+ status = "Installed" if result.installed else "Available"
525
+ if result.installed and result.installed_version:
526
+ status = f"Installed ({result.installed_version})"
527
+
528
+ table.add_row(
529
+ result.plugin.name,
530
+ result.plugin.description,
531
+ result.plugin.version,
532
+ status,
533
+ )
534
+
535
+ console.print(table)
536
+
537
+ # Show install hint
538
+ available_count = sum(1 for r in search_results if not r.installed)
539
+ if available_count > 0:
540
+ print_info(
541
+ "\nTo install a plugin, use: deepctl plugin install <name>"
542
+ )
543
+
544
+ def _get_plugin_registry(self) -> list[PluginRegistryEntry]:
545
+ """Get the plugin registry.
546
+
547
+ For now, this returns a hardcoded list. In the future, this will
548
+ fetch from a .well-known URL or plugin registry service.
549
+
550
+ Returns:
551
+ List of available plugins
552
+ """
553
+ # TODO: Fetch from https://deepgram.com/.well-known/deepctl-plugins.json
554
+ return [
555
+ PluginRegistryEntry(
556
+ name="deepctl-plugin-example",
557
+ description="Example plugin demonstrating the plugin system",
558
+ version="0.1.8",
559
+ author="Deepgram DevRel",
560
+ url="https://github.com/deepgram/deepctl",
561
+ keywords=["example", "demo", "plugin"],
562
+ install_name="deepctl-plugin-example",
563
+ ),
564
+ PluginRegistryEntry(
565
+ name="deepctl-plugin-tts",
566
+ description="Text-to-speech functionality for Deepgram",
567
+ version="0.2.0",
568
+ author="Deepgram",
569
+ url="https://github.com/deepgram/deepctl-plugin-tts",
570
+ keywords=["tts", "text-to-speech", "audio", "synthesis"],
571
+ install_name="deepctl-plugin-tts",
572
+ ),
573
+ PluginRegistryEntry(
574
+ name="deepctl-plugin-analyze",
575
+ description="Advanced audio analysis tools",
576
+ version="0.1.0",
577
+ author="Deepgram",
578
+ url="https://github.com/deepgram/deepctl-plugin-analyze",
579
+ keywords=["analyze", "audio", "analysis", "metrics"],
580
+ install_name="deepctl-plugin-analyze",
581
+ ),
582
+ PluginRegistryEntry(
583
+ name="deepctl-plugin-batch",
584
+ description="Batch processing utilities for large-scale transcription",
585
+ version="0.3.1",
586
+ author="Deepgram",
587
+ url="https://github.com/deepgram/deepctl-plugin-batch",
588
+ keywords=["batch", "bulk", "processing", "scale"],
589
+ install_name="deepctl-plugin-batch",
590
+ ),
591
+ PluginRegistryEntry(
592
+ name="deepctl-plugin-export",
593
+ description="Export transcriptions to various formats (SRT, VTT, JSON, etc.)",
594
+ version="0.2.5",
595
+ author="Deepgram Community",
596
+ url="https://github.com/deepgram-community/deepctl-plugin-export",
597
+ keywords=["export", "srt", "vtt", "subtitle", "captions"],
598
+ install_name="deepctl-plugin-export",
599
+ ),
600
+ PluginRegistryEntry(
601
+ name="deepctl-plugin-realtime",
602
+ description="Enhanced real-time transcription with WebSocket support",
603
+ version="0.4.0",
604
+ author="Deepgram",
605
+ url="https://github.com/deepgram/deepctl-plugin-realtime",
606
+ keywords=["realtime", "websocket", "streaming", "live"],
607
+ install_name="deepctl-plugin-realtime",
608
+ ),
609
+ PluginRegistryEntry(
610
+ name="deepctl-plugin-translate",
611
+ description="Translation capabilities for transcribed text",
612
+ version="0.1.2",
613
+ author="Deepgram Labs",
614
+ url="https://github.com/deepgram-labs/deepctl-plugin-translate",
615
+ keywords=["translate", "translation", "language", "i18n"],
616
+ install_name="deepctl-plugin-translate",
617
+ ),
618
+ ]
619
+
620
+ def install_plugin(
621
+ self,
622
+ config: Config,
623
+ auth_manager: AuthManager,
624
+ client: DeepgramClient,
625
+ options: PluginInstallOptions,
626
+ ) -> PluginOperationResult:
627
+ """Install a plugin.
628
+
629
+ Args:
630
+ config: CLI configuration
631
+ auth_manager: Authentication manager
632
+ client: Deepgram client
633
+ options: Installation options
634
+
635
+ Returns:
636
+ Operation result
637
+ """
638
+ # Detect installation method
639
+ install_info = self.detector.detect()
640
+
641
+ # Determine which Python executable to use
642
+ if install_info.method in [
643
+ InstallMethod.SYSTEM,
644
+ InstallMethod.UNKNOWN,
645
+ ]:
646
+ # For system installations, use isolated plugin environment
647
+ print_info(
648
+ "System installation detected, using isolated plugin environment..."
649
+ )
650
+ success, python_exe = self._ensure_plugin_environment()
651
+ if not success:
652
+ return PluginOperationResult(
653
+ success=False,
654
+ action=PluginAction.INSTALL,
655
+ package=options.package,
656
+ message="Failed to create plugin environment",
657
+ error="Could not create isolated environment for plugins",
658
+ )
659
+ target_python = python_exe
660
+ using_plugin_env = True
661
+ else:
662
+ # For other installations, use the same environment
663
+ target_python = self._python_executable
664
+ using_plugin_env = False
665
+
666
+ # Build pip command
667
+ cmd = [target_python, "-m", "pip", "install"]
668
+
669
+ # Add options
670
+ if options.upgrade:
671
+ cmd.append("--upgrade")
672
+ if options.pre:
673
+ cmd.append("--pre")
674
+ if options.force_reinstall:
675
+ cmd.append("--force-reinstall")
676
+ if options.index_url:
677
+ cmd.extend(["--index-url", options.index_url])
678
+ if options.extra_index_url:
679
+ cmd.extend(["--extra-index-url", options.extra_index_url])
680
+ if options.editable:
681
+ cmd.append("--editable")
682
+
683
+ # Add package
684
+ if options.git_url:
685
+ package_spec = options.git_url
686
+ elif options.version:
687
+ package_spec = f"{options.package}=={options.version}"
688
+ else:
689
+ package_spec = options.package
690
+
691
+ cmd.append(package_spec)
692
+
693
+ # Execute installation
694
+ try:
695
+ print_info(f"Installing {options.package}...")
696
+ subprocess.run(cmd, capture_output=True, text=True, check=True)
697
+
698
+ # Get installed version
699
+ installed_version = self._get_package_version(
700
+ options.package, target_python
701
+ )
702
+
703
+ # Update plugin state if using plugin environment
704
+ if using_plugin_env:
705
+ state = self._get_plugin_state()
706
+ state["plugins"][options.package] = {
707
+ "version": installed_version,
708
+ "source": "plugin_env",
709
+ }
710
+ self._save_plugin_state(state)
711
+
712
+ return PluginOperationResult(
713
+ success=True,
714
+ action=PluginAction.INSTALL,
715
+ package=options.package,
716
+ message=f"Successfully installed {options.package}",
717
+ installed_version=installed_version,
718
+ )
719
+
720
+ except subprocess.CalledProcessError as e:
721
+ return PluginOperationResult(
722
+ success=False,
723
+ action=PluginAction.INSTALL,
724
+ package=options.package,
725
+ message=f"Failed to install {options.package}",
726
+ error=e.stderr or str(e),
727
+ )
728
+
729
+ def list_plugins(
730
+ self,
731
+ config: Config,
732
+ auth_manager: AuthManager,
733
+ client: DeepgramClient,
734
+ verbose: bool = False,
735
+ ) -> None:
736
+ """List installed plugins.
737
+
738
+ Args:
739
+ config: CLI configuration
740
+ auth_manager: Authentication manager
741
+ client: Deepgram client
742
+ verbose: Show detailed information
743
+ """
744
+ # Get all installed packages that provide deepctl plugins
745
+ plugins = self._discover_plugins()
746
+
747
+ if not plugins:
748
+ print_info("No plugins installed")
749
+ return
750
+
751
+ # Create table
752
+ table = Table(title="Installed Plugins")
753
+ table.add_column("Name", style="cyan", no_wrap=True)
754
+ table.add_column("Version", style="green")
755
+ table.add_column("Entry Point", style="dim")
756
+
757
+ if verbose:
758
+ table.add_column("Type", style="yellow")
759
+ table.add_column("Commands", style="magenta")
760
+ table.add_column("Environment", style="blue")
761
+
762
+ for plugin in plugins:
763
+ row = [
764
+ plugin.name,
765
+ plugin.version or "Unknown",
766
+ plugin.entry_point or "N/A",
767
+ ]
768
+
769
+ if verbose:
770
+ plugin_type = "Built-in" if plugin.is_builtin else "External"
771
+ commands = self._get_plugin_commands(plugin.name)
772
+
773
+ # Check if this is from plugin environment
774
+ state = self._get_plugin_state()
775
+ env = (
776
+ "Plugin Env"
777
+ if plugin.name in state.get("plugins", {})
778
+ else "Main Env"
779
+ )
780
+
781
+ row.extend([plugin_type, commands, env])
782
+
783
+ table.add_row(*row)
784
+
785
+ console.print(table)
786
+
787
+ if verbose:
788
+ # Show installation info
789
+ install_info = self.detector.detect()
790
+ print_info(f"\nInstallation method: {install_info.method.value}")
791
+ if install_info.method == InstallMethod.SYSTEM:
792
+ print_info(
793
+ "Using isolated plugin environment at: ~/.deepctl/plugins/venv"
794
+ )
795
+
796
+ def update_plugin(
797
+ self,
798
+ config: Config,
799
+ auth_manager: AuthManager,
800
+ client: DeepgramClient,
801
+ package: str,
802
+ pre: bool = False,
803
+ ) -> PluginOperationResult:
804
+ """Update a plugin.
805
+
806
+ Args:
807
+ config: CLI configuration
808
+ auth_manager: Authentication manager
809
+ client: Deepgram client
810
+ package: Package name to update
811
+ pre: Include pre-release versions
812
+
813
+ Returns:
814
+ Operation result
815
+ """
816
+ options = PluginInstallOptions(
817
+ package=package,
818
+ upgrade=True,
819
+ pre=pre,
820
+ )
821
+ return self.install_plugin(config, auth_manager, client, options)
822
+
823
+ def remove_plugin(
824
+ self,
825
+ config: Config,
826
+ auth_manager: AuthManager,
827
+ client: DeepgramClient,
828
+ package: str,
829
+ ) -> PluginOperationResult:
830
+ """Remove a plugin.
831
+
832
+ Args:
833
+ config: CLI configuration
834
+ auth_manager: Authentication manager
835
+ client: Deepgram client
836
+ package: Package name to remove
837
+
838
+ Returns:
839
+ Operation result
840
+ """
841
+ # Check if plugin is installed
842
+ plugins = self._discover_plugins()
843
+ plugin_found = any(
844
+ p.name == package for p in plugins if not p.is_builtin
845
+ )
846
+
847
+ if not plugin_found:
848
+ return PluginOperationResult(
849
+ success=False,
850
+ action=PluginAction.REMOVE,
851
+ package=package,
852
+ message=f"Plugin {package} is not installed",
853
+ )
854
+
855
+ # Detect installation method
856
+ install_info = self.detector.detect()
857
+
858
+ # Determine which Python executable to use
859
+ state = self._get_plugin_state()
860
+ if package in state.get("plugins", {}):
861
+ # Plugin is in isolated environment
862
+ _, python_exe = self._ensure_plugin_environment()
863
+ target_python = python_exe
864
+ using_plugin_env = True
865
+ elif install_info.method in [
866
+ InstallMethod.SYSTEM,
867
+ InstallMethod.UNKNOWN,
868
+ ]:
869
+ # System installation but plugin not in plugin env - shouldn't happen
870
+ return PluginOperationResult(
871
+ success=False,
872
+ action=PluginAction.REMOVE,
873
+ package=package,
874
+ message="Cannot remove plugin from system installation",
875
+ error="This plugin was not installed via deepctl",
876
+ )
877
+ else:
878
+ # Use same environment
879
+ target_python = self._python_executable
880
+ using_plugin_env = False
881
+
882
+ # Build pip command
883
+ cmd = [target_python, "-m", "pip", "uninstall", "-y", package]
884
+
885
+ # Execute removal
886
+ try:
887
+ print_info(f"Removing {package}...")
888
+ subprocess.run(cmd, capture_output=True, text=True, check=True)
889
+
890
+ # Update plugin state if using plugin environment
891
+ if using_plugin_env:
892
+ state = self._get_plugin_state()
893
+ state["plugins"].pop(package, None)
894
+ self._save_plugin_state(state)
895
+
896
+ return PluginOperationResult(
897
+ success=True,
898
+ action=PluginAction.REMOVE,
899
+ package=package,
900
+ message=f"Successfully removed {package}",
901
+ )
902
+
903
+ except subprocess.CalledProcessError as e:
904
+ return PluginOperationResult(
905
+ success=False,
906
+ action=PluginAction.REMOVE,
907
+ package=package,
908
+ message=f"Failed to remove {package}",
909
+ error=e.stderr or str(e),
910
+ )
911
+
912
+ def _discover_plugins(self) -> list[PluginPackage]:
913
+ """Discover all installed plugins.
914
+
915
+ Returns:
916
+ List of discovered plugins
917
+ """
918
+ plugins = []
919
+ discovered_names = set()
920
+
921
+ # Discover from main environment
922
+ main_env_plugins = self._discover_from_environment(sys.executable)
923
+ for plugin in main_env_plugins:
924
+ # Use a combination of name and entry point to identify unique plugins
925
+ plugin_key = f"{plugin.name}:{plugin.entry_point}"
926
+ if plugin_key not in discovered_names:
927
+ discovered_names.add(plugin_key)
928
+ plugins.append(plugin)
929
+
930
+ # Also discover from plugin environment if it exists
931
+ if self._plugin_venv.exists():
932
+ _, plugin_python = self._ensure_plugin_environment()
933
+ plugin_env_plugins = self._discover_from_environment(plugin_python)
934
+
935
+ # Add only plugins not already discovered
936
+ for plugin in plugin_env_plugins:
937
+ plugin_key = f"{plugin.name}:{plugin.entry_point}"
938
+ if plugin_key not in discovered_names:
939
+ discovered_names.add(plugin_key)
940
+ plugins.append(plugin)
941
+
942
+ return plugins
943
+
944
+ def _discover_from_environment(
945
+ self, python_exe: str
946
+ ) -> list[PluginPackage]:
947
+ """Discover plugins from a specific Python environment.
948
+
949
+ Args:
950
+ python_exe: Path to Python executable
951
+
952
+ Returns:
953
+ List of discovered plugins
954
+ """
955
+ plugins = []
956
+
957
+ # Get entry points from the specified environment
958
+ try:
959
+ # Use a simpler approach - run importlib.metadata directly
960
+ if python_exe == sys.executable:
961
+ # For the current environment, use importlib directly
962
+ import importlib.metadata as metadata
963
+
964
+ for dist in metadata.distributions():
965
+ if dist.metadata:
966
+ eps = dist.entry_points
967
+ if hasattr(eps, "select"):
968
+ # Newer versions
969
+ for ep in eps.select(group="deepctl.commands"):
970
+ plugins.append(
971
+ PluginPackage(
972
+ name=dist.name,
973
+ version=dist.version,
974
+ entry_point=f"{ep.name}={ep.value}",
975
+ is_builtin=dist.name.startswith(
976
+ "deepctl-cmd-"
977
+ ),
978
+ )
979
+ )
980
+ # Also check for external plugins
981
+ for ep in eps.select(group="deepctl.plugins"):
982
+ plugins.append(
983
+ PluginPackage(
984
+ name=dist.name,
985
+ version=dist.version,
986
+ entry_point=f"{ep.name}={ep.value}",
987
+ is_builtin=False,
988
+ )
989
+ )
990
+ else:
991
+ # Older versions
992
+ if "deepctl.commands" in eps:
993
+ for ep in eps["deepctl.commands"]:
994
+ plugins.append(
995
+ PluginPackage(
996
+ name=dist.name,
997
+ version=dist.version,
998
+ entry_point=f"{ep.name}={ep.value}",
999
+ is_builtin=dist.name.startswith(
1000
+ "deepctl-cmd-"
1001
+ ),
1002
+ )
1003
+ )
1004
+ # Also check for external plugins
1005
+ if "deepctl.plugins" in eps:
1006
+ for ep in eps["deepctl.plugins"]:
1007
+ plugins.append(
1008
+ PluginPackage(
1009
+ name=dist.name,
1010
+ version=dist.version,
1011
+ entry_point=f"{ep.name}={ep.value}",
1012
+ is_builtin=False,
1013
+ )
1014
+ )
1015
+ else:
1016
+ # For other environments, try subprocess
1017
+ code = """import json;from importlib import metadata;plugins=[];
1018
+ for dist in metadata.distributions():
1019
+ if dist.metadata:
1020
+ eps = dist.entry_points
1021
+ if hasattr(eps, 'select'):
1022
+ for group in ['deepctl.commands', 'deepctl.plugins']:
1023
+ for ep in eps.select(group=group):
1024
+ plugins.append({
1025
+ "name": dist.name,
1026
+ "version": dist.version,
1027
+ "entry_point": f"{ep.name}={ep.value}",
1028
+ "is_builtin": dist.name.startswith("deepctl-cmd-") if group == "deepctl.commands" else False
1029
+ })
1030
+ else:
1031
+ for group in ['deepctl.commands', 'deepctl.plugins']:
1032
+ if group in eps:
1033
+ for ep in eps[group]:
1034
+ plugins.append({
1035
+ "name": dist.name,
1036
+ "version": dist.version,
1037
+ "entry_point": f"{ep.name}={ep.value}",
1038
+ "is_builtin": dist.name.startswith("deepctl-cmd-") if group == "deepctl.commands" else False
1039
+ })
1040
+ print(json.dumps(plugins))"""
1041
+
1042
+ result = subprocess.run(
1043
+ [python_exe, "-c", code],
1044
+ capture_output=True,
1045
+ text=True,
1046
+ check=True,
1047
+ )
1048
+
1049
+ plugin_data = json.loads(result.stdout)
1050
+ for data in plugin_data:
1051
+ plugins.append(PluginPackage(**data))
1052
+
1053
+ except Exception as e:
1054
+ # Silently ignore subprocess failures for plugin environments
1055
+ if python_exe != sys.executable:
1056
+ pass
1057
+ else:
1058
+ print_info(f"Warning: Could not discover plugins: {e}")
1059
+
1060
+ return plugins
1061
+
1062
+ def _get_package_version(
1063
+ self, package: str, python_exe: str | None = None
1064
+ ) -> str | None:
1065
+ """Get installed package version.
1066
+
1067
+ Args:
1068
+ package: Package name
1069
+ python_exe: Python executable to use (defaults to current)
1070
+
1071
+ Returns:
1072
+ Package version or None
1073
+ """
1074
+ if python_exe is None:
1075
+ python_exe = self._python_executable
1076
+
1077
+ try:
1078
+ code = f"""
1079
+ import importlib.metadata
1080
+ try:
1081
+ print(importlib.metadata.version("{package}"))
1082
+ except:
1083
+ print("")
1084
+ """
1085
+ result = subprocess.run(
1086
+ [python_exe, "-c", code],
1087
+ capture_output=True,
1088
+ text=True,
1089
+ check=True,
1090
+ )
1091
+ version = result.stdout.strip()
1092
+ return version if version else None
1093
+ except Exception:
1094
+ return None
1095
+
1096
+ def _get_plugin_commands(self, plugin_name: str) -> str:
1097
+ """Get commands provided by a plugin.
1098
+
1099
+ Args:
1100
+ plugin_name: Plugin package name
1101
+
1102
+ Returns:
1103
+ Comma-separated list of commands
1104
+ """
1105
+ # This is a simplified version - in reality you'd introspect
1106
+ # the entry points to find actual command names
1107
+ return "varies"
@@ -0,0 +1,75 @@
1
+ """Models for plugin management command."""
2
+
3
+ from enum import Enum
4
+
5
+ from deepctl_core.models import BaseModel
6
+ from pydantic import Field
7
+
8
+
9
+ class PluginAction(str, Enum):
10
+ """Plugin management actions."""
11
+
12
+ INSTALL = "install"
13
+ LIST = "list"
14
+ UPDATE = "update"
15
+ REMOVE = "remove"
16
+ SEARCH = "search"
17
+
18
+
19
+ class PluginPackage(BaseModel):
20
+ """Information about a plugin package."""
21
+
22
+ name: str
23
+ version: str | None = None
24
+ source: str | None = None # pypi, git, local
25
+ installed_version: str | None = None
26
+ available_version: str | None = None
27
+ entry_point: str | None = None
28
+ is_builtin: bool = False
29
+
30
+
31
+ class PluginRegistryEntry(BaseModel):
32
+ """Entry in the plugin registry."""
33
+
34
+ name: str
35
+ description: str
36
+ version: str
37
+ author: str | None = None
38
+ url: str | None = None
39
+ keywords: list[str] = Field(default_factory=list)
40
+ # Package name for installation if different from name
41
+ install_name: str | None = None
42
+
43
+
44
+ class PluginSearchResult(BaseModel):
45
+ """Result from plugin search."""
46
+
47
+ plugin: PluginRegistryEntry
48
+ installed: bool = False
49
+ installed_version: str | None = None
50
+
51
+
52
+ class PluginInstallOptions(BaseModel):
53
+ """Options for plugin installation."""
54
+
55
+ package: str
56
+ version: str | None = None
57
+ upgrade: bool = False
58
+ pre: bool = False # Allow pre-release versions
59
+ force_reinstall: bool = False
60
+ index_url: str | None = None
61
+ extra_index_url: str | None = None
62
+ git_url: str | None = None
63
+ editable: bool = False
64
+
65
+
66
+ class PluginOperationResult(BaseModel):
67
+ """Result of a plugin operation."""
68
+
69
+ success: bool
70
+ action: str # One of PluginAction values
71
+ package: str
72
+ message: str
73
+ error: str | None = None
74
+ installed_version: str | None = None
75
+ previous_version: str | None = None
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepctl-cmd-plugin
3
+ Version: 0.1.10
4
+ Summary: Plugin management command for deepctl
5
+ Author-email: Deepgram DevRel <devrel@deepgram.com>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: deepctl-core>=0.1.10
8
+ Requires-Dist: packaging>=21.0
9
+ Requires-Dist: toml>=0.10.2
10
+ Description-Content-Type: text/markdown
11
+
12
+ # deepctl-cmd-plugin
13
+
14
+ Plugin management command for deepctl CLI.
15
+
16
+ ## Description
17
+
18
+ This command provides plugin management functionality for deepctl, allowing users to:
19
+
20
+ - Search for available plugins in the registry
21
+ - Install plugins from PyPI, GitHub, or local paths
22
+ - List installed plugins
23
+ - Update plugins to newer versions
24
+ - Remove plugins
25
+
26
+ This solves the environment isolation issue that occurs when deepctl is installed via pipx, uv, brew, or other package managers that create isolated environments.
27
+
28
+ ## Commands
29
+
30
+ - `deepctl plugin search [query]` - Search for available plugins
31
+ - `deepctl plugin install <package>` - Install a plugin
32
+ - `deepctl plugin list` - List installed plugins
33
+ - `deepctl plugin update <package>` - Update a plugin
34
+ - `deepctl plugin remove <package>` - Remove a plugin
35
+
36
+ ## Examples
37
+
38
+ ### Search for plugins
39
+
40
+ ```bash
41
+ # Show all available plugins
42
+ deepctl plugin search
43
+
44
+ # Search for plugins by keyword
45
+ deepctl plugin search audio
46
+
47
+ # Show only installed plugins
48
+ deepctl plugin search --installed
49
+
50
+ # Show only available (not installed) plugins
51
+ deepctl plugin search --available
52
+ ```
53
+
54
+ ### Install plugins
55
+
56
+ ```bash
57
+ # Install from PyPI
58
+ deepctl plugin install deepctl-plugin-example
59
+
60
+ # Install from GitHub
61
+ deepctl plugin install git+https://github.com/deepgram/deepctl-plugin-custom.git
62
+
63
+ # Install specific version
64
+ deepctl plugin install deepctl-plugin-example==1.0.0
65
+
66
+ # Install from local directory
67
+ deepctl plugin install -e /path/to/plugin
68
+ ```
69
+
70
+ ## How it works
71
+
72
+ The plugin command automatically detects how deepctl was installed:
73
+
74
+ - **pip/pipx/uv**: Plugins install directly into the same environment
75
+ - **System installations (brew, apt, etc.)**: Plugins install into an isolated environment at `~/.deepctl/plugins/venv`
76
+
77
+ This approach ensures all installation methods support plugins while maintaining system package manager integrity.
78
+
79
+ ## Plugin Registry
80
+
81
+ The `plugin search` command shows plugins from a curated registry. Currently, the registry is hardcoded but will eventually fetch from a `.well-known` URL or plugin registry service.
82
+
83
+ ## Development
84
+
85
+ This is a built-in command that is part of the deepctl core distribution.
@@ -0,0 +1,7 @@
1
+ deepctl_cmd_plugin/__init__.py,sha256=sSUnnSKmU3b4E0deVbDAfzj5K5YAWGjOnhdluMHNoUw,110
2
+ deepctl_cmd_plugin/command.py,sha256=O5bQS0y9iHG-9JVH5iPoaDP9_ORs4rwPDAIksiwUoGs,39181
3
+ deepctl_cmd_plugin/models.py,sha256=URxMP3EhmJ1IftNS9-t-t5uziSFG1jSklKPGTGT2NGs,1821
4
+ deepctl_cmd_plugin-0.1.10.dist-info/METADATA,sha256=TlHqgUnfIGiR0FuI5kNjylepiwTA_CoWJ_xWIAQy_D8,2478
5
+ deepctl_cmd_plugin-0.1.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ deepctl_cmd_plugin-0.1.10.dist-info/entry_points.txt,sha256=dB_y0wmCYzoVlrN3CugB4EwNjE6AxYuh07-kkgy3Mpk,69
7
+ deepctl_cmd_plugin-0.1.10.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [deepctl.commands]
2
+ plugin = deepctl_cmd_plugin.command:PluginCommand