deepctl-cmd-plugin 0.1.14__tar.gz → 0.1.15__tar.gz

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.
@@ -15,6 +15,10 @@ downloads/
15
15
  eggs/
16
16
  .eggs/
17
17
  lib/
18
+ # The npm launcher's sources, not a build output.
19
+ !/npm/lib/
20
+ # Written by the release (npm/scripts/write_requirements.py).
21
+ /npm/requirements-*.txt
18
22
  lib64/
19
23
  parts/
20
24
  sdist/
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.15](https://github.com/deepgram/cli/compare/deepctl-cmd-plugin-v0.1.14...deepctl-cmd-plugin-v0.1.15) (2026-11-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** detect npm launcher installs in update, plugin and skills ([07e6fd9](https://github.com/deepgram/cli/commit/07e6fd9f8ba337a743e6a50ad96a2b29de53d37d))
9
+ * **npm:** make @deepgram/cli the real Deepgram CLI ([#146](https://github.com/deepgram/cli/issues/146)) ([8ed20ee](https://github.com/deepgram/cli/commit/8ed20ee349c635e7a4b5c3d8ecbae8a4fa558fe1))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **cli:** keep ffprobe path and plugin changes out of AI agent sessions ([2df96f0](https://github.com/deepgram/cli/commit/2df96f0ee9cee8953f7e9f8ca4bf23f8c7ccea19))
15
+ * **plugin:** keep plugins from replacing built-in commands ([dff78a2](https://github.com/deepgram/cli/commit/dff78a233a255b17f831e599bf417f125d0e5b7f))
16
+ * **plugin:** make the plugin folders private ([6b9c257](https://github.com/deepgram/cli/commit/6b9c2576f116d81df1a6ed5015a7ae598aa04aa6))
17
+
3
18
  ## [0.1.14](https://github.com/deepgram/cli/compare/deepctl-cmd-plugin-v0.1.13...deepctl-cmd-plugin-v0.1.14) (2026-10-09)
4
19
 
5
20
 
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepctl-cmd-plugin
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: Plugin management command for deepctl
5
5
  Author-email: Deepgram DevRel <devrel@deepgram.com>
6
6
  Requires-Python: >=3.10
7
+ Requires-Dist: deepctl-cmd-update>=0.2.8
7
8
  Requires-Dist: deepctl-core>=0.2.19
8
9
  Requires-Dist: packaging>=21.0
9
10
  Requires-Dist: toml>=0.10.2
@@ -2,11 +2,12 @@
2
2
  name = "deepctl-cmd-plugin"
3
3
  description = "Plugin management command for deepctl"
4
4
  readme = "README.md"
5
- version = "0.1.14" # x-release-please-version
5
+ version = "0.1.15" # x-release-please-version
6
6
  requires-python = ">=3.10"
7
7
  authors = [{ name = "Deepgram DevRel", email = "devrel@deepgram.com" }]
8
8
  dependencies = [
9
9
  "deepctl-core>=0.2.19",
10
+ "deepctl-cmd-update>=0.2.8",
10
11
  "packaging>=21.0",
11
12
  "toml>=0.10.2",
12
13
  ]
@@ -14,6 +15,10 @@ dependencies = [
14
15
  [project.entry-points."deepctl.commands"]
15
16
  plugin = "deepctl_cmd_plugin.command:PluginCommand"
16
17
 
18
+ [tool.uv.sources]
19
+ deepctl-core = { workspace = true }
20
+ deepctl-cmd-update = { workspace = true }
21
+
17
22
  [build-system]
18
23
  requires = ["hatchling"]
19
24
  build-backend = "hatchling.build"
@@ -8,25 +8,38 @@ import sys
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from rich.text import Text
12
+
11
13
  from .strategies import PluginInstallStrategy
12
14
 
13
15
  import click
14
16
  from deepctl_cmd_update.installation import InstallationDetector, InstallMethod
17
+ from deepctl_core import messages as msg
18
+ from deepctl_core._plugin_trust import real_home_plugin_venv
15
19
  from deepctl_core.auth import AuthManager
16
20
  from deepctl_core.base_group_command import BaseGroupCommand
17
21
  from deepctl_core.client import DeepgramClient
18
22
  from deepctl_core.config import Config
19
- from deepctl_core.output import print_error, print_info, print_success, print_warning
23
+ from deepctl_core.output import (
24
+ is_ai_agent,
25
+ print_error,
26
+ print_hint,
27
+ print_info,
28
+ print_success,
29
+ print_warning,
30
+ )
20
31
  from deepctl_core.plugin_env import (
21
- PLUGIN_DIR,
22
- PLUGIN_STATE_FILE,
23
- PLUGIN_VENV,
24
32
  find_system_python,
25
33
  get_plugin_state,
26
34
  get_venv_python,
27
35
  is_frozen,
36
+ make_plugin_dirs_private,
37
+ plugin_paths,
38
+ read_plugin_state_file,
28
39
  save_plugin_state,
40
+ skipped_plugin_venvs,
29
41
  )
42
+ from deepctl_core.safe_text import literal
30
43
  from rich.console import Console
31
44
  from rich.markup import escape
32
45
  from rich.table import Table
@@ -42,6 +55,31 @@ from .models import (
42
55
 
43
56
  console = Console()
44
57
 
58
+ E_PLUGIN_ENV_UNAVAILABLE = (
59
+ "Can't use the plugin environment: when an AI agent runs dg, plugins load "
60
+ "only from your account's home directory, not through a symlink or from "
61
+ "inside the working directory."
62
+ )
63
+ A_PLUGIN_ENV_UNAVAILABLE = (
64
+ "A person must run this command in their own terminal. If you are an AI "
65
+ "agent, ask the user."
66
+ )
67
+
68
+
69
+ def _print_env_unavailable_hint() -> None:
70
+ """The agent hint after E-PLUGIN-ENV-UNAVAILABLE."""
71
+ print_hint(A_PLUGIN_ENV_UNAVAILABLE)
72
+
73
+
74
+ def _refuse_in_agent_session(action: str) -> None:
75
+ """Installed plugins run in every later dg command: an AI agent session
76
+ never installs, updates or removes them. Scripts and CI without an agent
77
+ marker still can."""
78
+ if is_ai_agent():
79
+ print_error(msg.E_PLUGIN_AGENT.format(action=action))
80
+ print_hint(msg.E_PERSON_ONLY_AGENT_HINT)
81
+ raise SystemExit(1)
82
+
45
83
 
46
84
  class PluginCommand(BaseGroupCommand):
47
85
  """Plugin management command."""
@@ -50,7 +88,7 @@ class PluginCommand(BaseGroupCommand):
50
88
  help = "Manage deepctl plugins"
51
89
 
52
90
  examples = [
53
- "dg plugin search transcribe",
91
+ "dg plugin search example",
54
92
  "dg plugin install deepctl-plugin-example",
55
93
  "dg plugin list",
56
94
  "dg plugin update deepctl-plugin-example",
@@ -68,10 +106,13 @@ class PluginCommand(BaseGroupCommand):
68
106
  self.detector = InstallationDetector()
69
107
  self._python_executable = sys.executable
70
108
 
71
- # Use shared plugin environment paths
72
- self._plugin_dir = PLUGIN_DIR
73
- self._plugin_venv = PLUGIN_VENV
74
- self._plugin_state_file = PLUGIN_STATE_FILE
109
+ # Shared plugin environment paths. None when this run must not use
110
+ # the plugin environment (see plugin_env.plugin_paths).
111
+ paths = plugin_paths()
112
+ self._plugin_paths = paths
113
+ self._plugin_dir = paths.dir if paths else None
114
+ self._plugin_venv = paths.venv if paths else None
115
+ self._plugin_state_file = paths.state_file if paths else None
75
116
 
76
117
  def execute(self, ctx: click.Context, **kwargs: Any) -> None:
77
118
  """Execute plugin group command.
@@ -161,12 +202,18 @@ class PluginCommand(BaseGroupCommand):
161
202
  Returns:
162
203
  Tuple of (success, python_executable_path)
163
204
  """
164
- # Create plugin directory if it doesn't exist
165
- self._plugin_dir.mkdir(parents=True, exist_ok=True)
205
+ if self._plugin_dir is None or self._plugin_venv is None:
206
+ return False, ""
207
+
208
+ # The plugin directory and venv are owner-only (0700) whatever the
209
+ # umask; this also narrows ones an earlier version left open.
210
+ make_plugin_dirs_private(self._plugin_venv)
166
211
 
167
212
  # Check if venv exists
168
213
  if not self._plugin_venv.exists():
169
214
  print_info("Creating plugin environment...")
215
+ self._plugin_venv.mkdir(mode=0o700)
216
+ make_plugin_dirs_private(self._plugin_venv)
170
217
 
171
218
  # Determine which Python to use for venv creation
172
219
  if is_frozen():
@@ -192,7 +239,7 @@ class PluginCommand(BaseGroupCommand):
192
239
  )
193
240
 
194
241
  # Get the python executable in the venv
195
- venv_python = get_venv_python()
242
+ venv_python = get_venv_python(self._plugin_venv)
196
243
  if venv_python is None:
197
244
  if sys.platform == "win32":
198
245
  venv_python_path = self._plugin_venv / "Scripts" / "python.exe"
@@ -219,7 +266,7 @@ class PluginCommand(BaseGroupCommand):
219
266
  return False, ""
220
267
 
221
268
  # Venv exists — return its python executable
222
- venv_python = get_venv_python()
269
+ venv_python = get_venv_python(self._plugin_venv)
223
270
  if venv_python is None:
224
271
  if sys.platform == "win32":
225
272
  venv_python = str(self._plugin_venv / "Scripts" / "python.exe")
@@ -263,7 +310,9 @@ class PluginCommand(BaseGroupCommand):
263
310
  Returns:
264
311
  Plugin state dictionary
265
312
  """
266
- return get_plugin_state()
313
+ if self._plugin_state_file is None:
314
+ return {"plugins": {}}
315
+ return get_plugin_state(self._plugin_state_file)
267
316
 
268
317
  def _save_plugin_state(self, state: dict[str, Any]) -> None:
269
318
  """Save plugin state to file.
@@ -271,7 +320,9 @@ class PluginCommand(BaseGroupCommand):
271
320
  Args:
272
321
  state: Plugin state to save
273
322
  """
274
- save_plugin_state(state)
323
+ if self._plugin_paths is None:
324
+ raise RuntimeError(E_PLUGIN_ENV_UNAVAILABLE)
325
+ save_plugin_state(state, self._plugin_paths)
275
326
 
276
327
  def _create_install_command(self, context_wrapper: Any) -> click.Command:
277
328
  """Create the install subcommand."""
@@ -339,6 +390,7 @@ class PluginCommand(BaseGroupCommand):
339
390
  **kwargs: Any,
340
391
  ) -> None:
341
392
  """Handle install command."""
393
+ _refuse_in_agent_session("install")
342
394
  # Extract package argument
343
395
  package = kwargs.pop("package", "")
344
396
 
@@ -369,6 +421,8 @@ class PluginCommand(BaseGroupCommand):
369
421
  self._maybe_update_skills()
370
422
  else:
371
423
  print_error(result.error or result.message)
424
+ if result.error == E_PLUGIN_ENV_UNAVAILABLE:
425
+ _print_env_unavailable_hint()
372
426
  raise click.ClickException(result.error or result.message)
373
427
 
374
428
  def _create_list_command(self, context_wrapper: Any) -> click.Command:
@@ -425,6 +479,7 @@ class PluginCommand(BaseGroupCommand):
425
479
  **kwargs: Any,
426
480
  ) -> None:
427
481
  """Handle update command."""
482
+ _refuse_in_agent_session("update")
428
483
  options = PluginInstallOptions(
429
484
  package=kwargs["package"],
430
485
  upgrade=True,
@@ -437,6 +492,8 @@ class PluginCommand(BaseGroupCommand):
437
492
  self._maybe_update_skills()
438
493
  else:
439
494
  print_error(result.error or result.message)
495
+ if result.error == E_PLUGIN_ENV_UNAVAILABLE:
496
+ _print_env_unavailable_hint()
440
497
  raise click.ClickException(result.error or result.message)
441
498
 
442
499
  def _create_remove_command(self, context_wrapper: Any) -> click.Command:
@@ -471,6 +528,7 @@ class PluginCommand(BaseGroupCommand):
471
528
  **kwargs: Any,
472
529
  ) -> None:
473
530
  """Handle remove command."""
531
+ _refuse_in_agent_session("remove")
474
532
  package = kwargs["package"]
475
533
  yes = kwargs.get("yes", False)
476
534
 
@@ -493,6 +551,8 @@ class PluginCommand(BaseGroupCommand):
493
551
  self._maybe_update_skills()
494
552
  else:
495
553
  print_error(result.error or result.message)
554
+ if result.error == E_PLUGIN_ENV_UNAVAILABLE:
555
+ _print_env_unavailable_hint()
496
556
  raise click.ClickException(result.error or result.message)
497
557
 
498
558
  def _create_search_command(self, context_wrapper: Any) -> click.Command:
@@ -595,18 +655,16 @@ class PluginCommand(BaseGroupCommand):
595
655
  table = Table(title="Available Plugins")
596
656
  table.add_column("Name", style="cyan", no_wrap=True)
597
657
  table.add_column("Description", style="white")
598
- table.add_column("Version", style="green")
599
658
  table.add_column("Status", style="yellow")
600
659
 
601
660
  for result in search_results:
602
- status = "Installed" if result.installed else "Available"
661
+ status: str | Text = "Installed" if result.installed else "Available"
603
662
  if result.installed and result.installed_version:
604
- status = f"Installed ({result.installed_version})"
663
+ status = literal(f"Installed ({result.installed_version})")
605
664
 
606
665
  table.add_row(
607
666
  result.plugin.name,
608
667
  result.plugin.description,
609
- result.plugin.version,
610
668
  status,
611
669
  )
612
670
 
@@ -615,90 +673,36 @@ class PluginCommand(BaseGroupCommand):
615
673
  # Show install hint
616
674
  available_count = sum(1 for r in search_results if not r.installed)
617
675
  if available_count > 0:
618
- print_info("\nTo install a plugin, use: deepctl plugin install <name>")
676
+ print_info("To install one, run: dg plugin install <name>", stderr=True)
619
677
 
620
678
  def _get_plugin_registry(self) -> list[PluginRegistryEntry]:
621
- """Get the plugin registry.
622
-
623
- For now, this returns a hardcoded list. In the future, this will
624
- fetch from a .well-known URL or plugin registry service.
679
+ """Plugins Deepgram publishes, from a static list in this package.
625
680
 
626
681
  Returns:
627
682
  List of available plugins
628
683
  """
629
- # TODO: Fetch from https://deepgram.com/.well-known/deepctl-plugins.json
630
684
  return [
631
685
  PluginRegistryEntry(
632
686
  name="deepctl-plugin-example",
633
687
  description="Example plugin demonstrating the plugin system",
634
- version="0.1.8",
635
- author="Deepgram DevRel",
636
- url="https://github.com/deepgram/deepctl",
688
+ author="Deepgram",
689
+ url="https://github.com/deepgram/cli/tree/main/packages/deepctl-plugin-example",
637
690
  keywords=["example", "demo", "plugin"],
638
691
  install_name="deepctl-plugin-example",
639
692
  ),
640
- PluginRegistryEntry(
641
- name="deepctl-plugin-tts",
642
- description="Text-to-speech functionality for Deepgram",
643
- version="0.2.0",
644
- author="Deepgram",
645
- url="https://github.com/deepgram/deepctl-plugin-tts",
646
- keywords=["tts", "text-to-speech", "audio", "synthesis"],
647
- install_name="deepctl-plugin-tts",
648
- ),
649
- PluginRegistryEntry(
650
- name="deepctl-plugin-analyze",
651
- description="Advanced audio analysis tools",
652
- version="0.1.0",
653
- author="Deepgram",
654
- url="https://github.com/deepgram/deepctl-plugin-analyze",
655
- keywords=["analyze", "audio", "analysis", "metrics"],
656
- install_name="deepctl-plugin-analyze",
657
- ),
658
- PluginRegistryEntry(
659
- name="deepctl-plugin-batch",
660
- description="Batch processing utilities for large-scale transcription",
661
- version="0.3.1",
662
- author="Deepgram",
663
- url="https://github.com/deepgram/deepctl-plugin-batch",
664
- keywords=["batch", "bulk", "processing", "scale"],
665
- install_name="deepctl-plugin-batch",
666
- ),
667
- PluginRegistryEntry(
668
- name="deepctl-plugin-export",
669
- description="Export transcriptions to various formats (SRT, VTT, JSON, etc.)",
670
- version="0.2.5",
671
- author="Deepgram Community",
672
- url="https://github.com/deepgram-community/deepctl-plugin-export",
673
- keywords=["export", "srt", "vtt", "subtitle", "captions"],
674
- install_name="deepctl-plugin-export",
675
- ),
676
- PluginRegistryEntry(
677
- name="deepctl-plugin-realtime",
678
- description="Enhanced real-time transcription with WebSocket support",
679
- version="0.4.0",
680
- author="Deepgram",
681
- url="https://github.com/deepgram/deepctl-plugin-realtime",
682
- keywords=["realtime", "websocket", "streaming", "live"],
683
- install_name="deepctl-plugin-realtime",
684
- ),
685
- PluginRegistryEntry(
686
- name="deepctl-plugin-translate",
687
- description="Translation capabilities for transcribed text",
688
- version="0.1.2",
689
- author="Deepgram Labs",
690
- url="https://github.com/deepgram-labs/deepctl-plugin-translate",
691
- keywords=["translate", "translation", "language", "i18n"],
692
- install_name="deepctl-plugin-translate",
693
- ),
694
693
  ]
695
694
 
696
695
  def _needs_isolated_venv(self, method: InstallMethod) -> bool:
697
- """Check if this installation method needs the isolated plugin venv."""
696
+ """Check if this installation method needs the isolated plugin venv.
697
+
698
+ npm counts: the launcher's venv or uv environment is replaced on every
699
+ npm version bump, so plugins installed into it would vanish.
700
+ """
698
701
  return method in (
699
702
  InstallMethod.HOMEBREW,
700
703
  InstallMethod.SYSTEM,
701
704
  InstallMethod.UNKNOWN,
705
+ InstallMethod.NPM,
702
706
  )
703
707
 
704
708
  def _get_strategy(
@@ -712,6 +716,8 @@ class PluginCommand(BaseGroupCommand):
712
716
  from .strategies import get_strategy
713
717
 
714
718
  if self._needs_isolated_venv(method):
719
+ if self._plugin_paths is None:
720
+ raise RuntimeError(E_PLUGIN_ENV_UNAVAILABLE)
715
721
  print_info(
716
722
  f"{method.value.title()} installation detected, "
717
723
  "using isolated plugin environment..."
@@ -771,7 +777,9 @@ class PluginCommand(BaseGroupCommand):
771
777
 
772
778
  # Get installed version
773
779
  target_python = (
774
- get_venv_python() if using_plugin_env else self._python_executable
780
+ get_venv_python(self._plugin_venv)
781
+ if using_plugin_env
782
+ else self._python_executable
775
783
  )
776
784
  installed_version = self._get_package_version(options.package, target_python)
777
785
 
@@ -809,8 +817,9 @@ class PluginCommand(BaseGroupCommand):
809
817
  """
810
818
  # Get all installed packages that provide deepctl plugins
811
819
  plugins = self._discover_plugins()
820
+ skipped = self._skipped_plugins()
812
821
 
813
- if not plugins:
822
+ if not plugins and not skipped:
814
823
  print_info("No plugins installed")
815
824
  return
816
825
 
@@ -826,10 +835,10 @@ class PluginCommand(BaseGroupCommand):
826
835
  table.add_column("Environment", style="blue")
827
836
 
828
837
  for plugin in plugins:
829
- row = [
830
- plugin.name,
831
- plugin.version or "Unknown",
832
- plugin.entry_point or "N/A",
838
+ row: list[str | Text] = [
839
+ literal(plugin.name),
840
+ literal(plugin.version or "Unknown"),
841
+ literal(plugin.entry_point or "N/A"),
833
842
  ]
834
843
 
835
844
  if verbose:
@@ -844,10 +853,16 @@ class PluginCommand(BaseGroupCommand):
844
853
  else "Main Env"
845
854
  )
846
855
 
847
- row.extend([plugin_type, commands, env])
856
+ row.extend([plugin_type, literal(commands), env])
848
857
 
849
858
  table.add_row(*row)
850
859
 
860
+ for name, version in skipped:
861
+ skipped_row: list[str | Text] = [literal(name), literal(version), "Skipped"]
862
+ if verbose:
863
+ skipped_row.extend(["External", "", "Plugin Env"])
864
+ table.add_row(*skipped_row)
865
+
851
866
  console.print(table)
852
867
 
853
868
  if verbose:
@@ -912,6 +927,14 @@ class PluginCommand(BaseGroupCommand):
912
927
  plugin_found = any(p.name == package for p in plugins if not p.is_builtin)
913
928
 
914
929
  if not plugin_found:
930
+ if any(name == package for name, _version in self._skipped_plugins()):
931
+ return PluginOperationResult(
932
+ success=False,
933
+ action=PluginAction.REMOVE,
934
+ package=package,
935
+ message=f"Failed to remove {package}",
936
+ error=E_PLUGIN_ENV_UNAVAILABLE,
937
+ )
915
938
  return PluginOperationResult(
916
939
  success=False,
917
940
  action=PluginAction.REMOVE,
@@ -930,7 +953,15 @@ class PluginCommand(BaseGroupCommand):
930
953
  # Force isolated-venv strategy for this plugin
931
954
  from .strategies import IsolatedVenvStrategy
932
955
 
933
- _, python_exe = self._ensure_plugin_environment()
956
+ ok_env, python_exe = self._ensure_plugin_environment()
957
+ if not ok_env:
958
+ return PluginOperationResult(
959
+ success=False,
960
+ action=PluginAction.REMOVE,
961
+ package=package,
962
+ message=f"Failed to remove {package}",
963
+ error=E_PLUGIN_ENV_UNAVAILABLE,
964
+ )
934
965
  strategy: PluginInstallStrategy = IsolatedVenvStrategy(python_exe)
935
966
  elif self._needs_isolated_venv(install_info.method):
936
967
  return PluginOperationResult(
@@ -977,6 +1008,31 @@ class PluginCommand(BaseGroupCommand):
977
1008
  message=f"Successfully removed {package}",
978
1009
  )
979
1010
 
1011
+ def _skipped_plugins(self) -> list[tuple[str, str]]:
1012
+ """Plugins recorded in the real-home plugin venv when this run skips it.
1013
+
1014
+ Only that venv's ``plugins.json`` is read (defensively); a skipped
1015
+ venv found through ``$HOME`` is never read, and nothing in any
1016
+ skipped venv runs.
1017
+ """
1018
+ rows: list[tuple[str, str]] = []
1019
+ trusted = real_home_plugin_venv()
1020
+ if trusted is None:
1021
+ return rows
1022
+ for venv in skipped_plugin_venvs():
1023
+ if venv != trusted:
1024
+ continue
1025
+ state = read_plugin_state_file(venv.parent / "plugins.json")
1026
+ entries = state.get("plugins")
1027
+ if not isinstance(entries, dict):
1028
+ continue
1029
+ for name, info in entries.items():
1030
+ version = info.get("version") if isinstance(info, dict) else None
1031
+ rows.append(
1032
+ (str(name), version if isinstance(version, str) else "Unknown")
1033
+ )
1034
+ return rows
1035
+
980
1036
  def _discover_plugins(self) -> list[PluginPackage]:
981
1037
  """Discover all installed plugins.
982
1038
 
@@ -995,10 +1051,13 @@ class PluginCommand(BaseGroupCommand):
995
1051
  discovered_names.add(plugin_key)
996
1052
  plugins.append(plugin)
997
1053
 
998
- # Also discover from plugin environment if it exists
999
- if self._plugin_venv.exists():
1000
- _, plugin_python = self._ensure_plugin_environment()
1001
- plugin_env_plugins = self._discover_from_environment(plugin_python)
1054
+ # Also discover from plugin environment if it exists (never from a
1055
+ # venv this run doesn't use; see plugin_env.plugin_paths)
1056
+ if self._plugin_venv is not None and self._plugin_venv.exists():
1057
+ ok_env, plugin_python = self._ensure_plugin_environment()
1058
+ plugin_env_plugins = (
1059
+ self._discover_from_environment(plugin_python) if ok_env else []
1060
+ )
1002
1061
 
1003
1062
  # Add only plugins not already discovered
1004
1063
  for plugin in plugin_env_plugins:
@@ -33,7 +33,7 @@ class PluginRegistryEntry(BaseModel):
33
33
 
34
34
  name: str
35
35
  description: str
36
- version: str
36
+ version: str | None = None
37
37
  author: str | None = None
38
38
  url: str | None = None
39
39
  keywords: list[str] = Field(default_factory=list)
@@ -272,7 +272,7 @@ def get_strategy(
272
272
  if method in (InstallMethod.UVX, InstallMethod.PIPX_RUN):
273
273
  return EphemeralStrategy(method)
274
274
 
275
- # HOMEBREW, SYSTEM, UNKNOWN → isolated venv
275
+ # HOMEBREW, SYSTEM, UNKNOWN, NPM → isolated venv
276
276
  if venv_python is None:
277
277
  raise ValueError(
278
278
  f"venv_python is required for IsolatedVenvStrategy (method={method.value})"
@@ -0,0 +1,64 @@
1
+ """Plugins run in every later dg command, so an AI agent session can't
2
+ install, update or remove them. Scripts and CI without an agent marker can."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from unittest.mock import MagicMock
7
+
8
+ import pytest
9
+ from deepctl_cmd_plugin import command as plugin_module
10
+ from deepctl_cmd_plugin.command import PluginCommand
11
+ from deepctl_core import output
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _wide(monkeypatch):
16
+ monkeypatch.setattr(output.stderr_console, "_width", 400)
17
+
18
+
19
+ HANDLERS = [
20
+ ("_handle_install", {"package": "deepctl-plugin-evil"}),
21
+ ("_handle_update", {"package": "deepctl-plugin-evil"}),
22
+ ("_handle_remove", {"package": "deepctl-plugin-evil", "yes": True}),
23
+ ]
24
+
25
+
26
+ @pytest.mark.parametrize(
27
+ ("handler", "kwargs"),
28
+ [
29
+ ("_handle_install", {"package": "deepctl-plugin-evil"}),
30
+ ("_handle_update", {"package": "deepctl-plugin-evil"}),
31
+ ("_handle_remove", {"package": "deepctl-plugin-evil", "yes": True}),
32
+ ],
33
+ )
34
+ def test_refused_in_agent_sessions(monkeypatch, capsys, handler, kwargs):
35
+ monkeypatch.setattr(plugin_module, "is_ai_agent", lambda: True)
36
+ command = PluginCommand()
37
+ command.install_plugin = MagicMock()
38
+ command.remove_plugin = MagicMock()
39
+ with pytest.raises(SystemExit) as exc:
40
+ getattr(command, handler)(MagicMock(), MagicMock(), MagicMock(), **kwargs)
41
+ assert exc.value.code == 1
42
+ command.install_plugin.assert_not_called()
43
+ command.remove_plugin.assert_not_called()
44
+ err = capsys.readouterr().err
45
+ assert "doesn't run in an AI agent session. Nothing was changed." in err
46
+ assert "A person must run this command. If you are an AI agent, ask the user." in err
47
+
48
+
49
+ @pytest.mark.parametrize(("handler", "kwargs"), HANDLERS)
50
+ def test_scripts_and_ci_without_an_agent_marker_may_run(
51
+ monkeypatch, capsys, handler, kwargs
52
+ ):
53
+ """A non-TTY run or CI=true alone isn't refused; only hard AI-agent
54
+ signals are."""
55
+ monkeypatch.setenv("CI", "true")
56
+ monkeypatch.setattr(output, "is_agentic", lambda: True)
57
+ monkeypatch.setattr(plugin_module, "is_ai_agent", lambda: False)
58
+ command = PluginCommand()
59
+ command.install_plugin = MagicMock()
60
+ command.remove_plugin = MagicMock()
61
+ command._maybe_update_skills = MagicMock()
62
+ getattr(command, handler)(MagicMock(), MagicMock(), MagicMock(), **kwargs)
63
+ assert "AI agent session" not in capsys.readouterr().err
64
+ assert command.install_plugin.called or command.remove_plugin.called