deepctl-cmd-plugin 0.1.13__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/
@@ -139,6 +143,9 @@ dmypy.json
139
143
  *.swo
140
144
  *~
141
145
 
146
+ # Claude Code local state (worktrees, session files)
147
+ .claude/
148
+
142
149
  # OS
143
150
  .DS_Store
144
151
  .DS_Store?
@@ -0,0 +1,65 @@
1
+ # Changelog
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
+
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)
19
+
20
+
21
+ ### Features
22
+
23
+ * **skills:** prove and remove deepctl 0.3.x skill files after the new folders land ([f65e0e2](https://github.com/deepgram/cli/commit/f65e0e22868b3d8f7ac4e81d9163045aca3383ec))
24
+ * **skills:** remove the deepctl 0.3.x skill files deepctl can prove it wrote ([#130](https://github.com/deepgram/cli/issues/130)) ([8be5172](https://github.com/deepgram/cli/commit/8be5172fc8d101dbcb60f5b1fb7b36e5f4cfbcff))
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **skills:** install skills from login and plugin through the shared installer ([1d34a22](https://github.com/deepgram/cli/commit/1d34a22c6b6c6ee2d87b3ee83657e7c28998a8fd))
30
+ * **skills:** keep a newer skills ref and a removed tool when a refresh overlaps ([#126](https://github.com/deepgram/cli/issues/126)) ([3c9d1cf](https://github.com/deepgram/cli/commit/3c9d1cf3121a649a67b74ca9992cafdb0f2159ac))
31
+ * **skills:** login and plugin install skills through the shared installer ([#125](https://github.com/deepgram/cli/issues/125)) ([3675f7a](https://github.com/deepgram/cli/commit/3675f7a21a34f59a6d1ab5c1c463149fec66d0e3))
32
+ * **skills:** re-check records in dg skills update and the plugin refresh ([b0b1595](https://github.com/deepgram/cli/commit/b0b15959214085e818a551f1c37cfb1645001c6a))
33
+
34
+ ## [0.1.13](https://github.com/deepgram/cli/compare/deepctl-cmd-plugin-v0.1.12...deepctl-cmd-plugin-v0.1.13) (2026-09-23)
35
+
36
+
37
+ ### Bug Fixes
38
+
39
+ * **plugin:** return exit code 2, rather than success, when plugin removal is cancelled ([0dcfd2c](https://github.com/deepgram/cli/commit/0dcfd2c0d68a6095f4f6651e51ba6995f2b430d3))
40
+ * **plugin:** write the plugin-removal confirmation prompt to stderr so JSON output stays parseable ([0497ac1](https://github.com/deepgram/cli/commit/0497ac1892333c09d2a482f0b10ccd59d1f1dba2))
41
+
42
+ ## [0.1.12](https://github.com/deepgram/cli/compare/deepctl-cmd-plugin-v0.1.11...deepctl-cmd-plugin-v0.1.12) (2026-05-09)
43
+
44
+
45
+ ### Features
46
+
47
+ * **telemetry:** full Sentry observability + per-command usage tags ([#75](https://github.com/deepgram/cli/issues/75)) ([0fe43d2](https://github.com/deepgram/cli/commit/0fe43d2e00c58d8101ef2bd4b5aaf4437db9f0cf))
48
+
49
+ ## [0.1.11](https://github.com/deepgram/cli/compare/deepctl-cmd-plugin-v0.1.10...deepctl-cmd-plugin-v0.1.11) (2026-03-09)
50
+
51
+
52
+ ### Features
53
+
54
+ * Add plugin search command with hardcoded registry ([bd533cb](https://github.com/deepgram/cli/commit/bd533cb4b90b531fc522c4b2fd9a582d05d7861a))
55
+ * Add universal plugin support for all installation methods ([6c6d3c3](https://github.com/deepgram/cli/commit/6c6d3c3f87a3451c9474e8a1bcde6bd0148d4acc))
56
+ * **mcp:** fix auth, switch to streamable-http, and improve READMEs ([8e76d60](https://github.com/deepgram/cli/commit/8e76d6096ec319b5f0c85d57b299a7f05a60b5a8))
57
+ * **plugin:** add install-aware plugin system with venv bridge and strategy pattern ([7dd849b](https://github.com/deepgram/cli/commit/7dd849b6d5cc66fc6a9d54d665aad8909830b5ef))
58
+ * **skills:** add `deepctl skills` command and agent-native CLI metadata ([5654d40](https://github.com/deepgram/cli/commit/5654d40d3a6c2caf790a9de37c17ad60c150e8d3))
59
+
60
+
61
+ ### Bug Fixes
62
+
63
+ * Remove duplicate entries in plugin list ([11a3fe3](https://github.com/deepgram/cli/commit/11a3fe340e320df46c3d6ed872f469205b32163f))
64
+ * resolve all ruff and mypy linting issues ([83eaa7a](https://github.com/deepgram/cli/commit/83eaa7a54093eae72e9f6f08ec021980abc2a9fd))
65
+ * **tooling:** resolve all ruff, mypy, and Makefile issues ([3500379](https://github.com/deepgram/cli/commit/35003791a94ce74b40292dad091e5139299a620e))
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepctl-cmd-plugin
3
- Version: 0.1.13
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-core>=0.1.10
7
+ Requires-Dist: deepctl-cmd-update>=0.2.8
8
+ Requires-Dist: deepctl-core>=0.2.19
8
9
  Requires-Dist: packaging>=21.0
9
10
  Requires-Dist: toml>=0.10.2
10
11
  Description-Content-Type: text/markdown
@@ -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.13" # 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
- "deepctl-core>=0.1.10",
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"
@@ -2,33 +2,46 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib.metadata
6
5
  import json
7
6
  import subprocess
8
7
  import sys
9
8
  from typing import TYPE_CHECKING, Any
10
9
 
11
10
  if TYPE_CHECKING:
11
+ from rich.text import Text
12
+
12
13
  from .strategies import PluginInstallStrategy
13
14
 
14
15
  import click
15
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
16
19
  from deepctl_core.auth import AuthManager
17
20
  from deepctl_core.base_group_command import BaseGroupCommand
18
21
  from deepctl_core.client import DeepgramClient
19
22
  from deepctl_core.config import Config
20
- 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
+ )
21
31
  from deepctl_core.plugin_env import (
22
- PLUGIN_DIR,
23
- PLUGIN_STATE_FILE,
24
- PLUGIN_VENV,
25
32
  find_system_python,
26
33
  get_plugin_state,
27
34
  get_venv_python,
28
35
  is_frozen,
36
+ make_plugin_dirs_private,
37
+ plugin_paths,
38
+ read_plugin_state_file,
29
39
  save_plugin_state,
40
+ skipped_plugin_venvs,
30
41
  )
42
+ from deepctl_core.safe_text import literal
31
43
  from rich.console import Console
44
+ from rich.markup import escape
32
45
  from rich.table import Table
33
46
 
34
47
  from .models import (
@@ -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:
@@ -1171,37 +1230,31 @@ except:
1171
1230
  return "varies"
1172
1231
 
1173
1232
  def _maybe_update_skills(self) -> None:
1174
- """Regenerate AI CLI skills if installed (best-effort)."""
1175
- try:
1176
- from deepctl_core.skill_generator import (
1177
- _commands_hash,
1178
- collect_command_metadata,
1179
- get_all_generators,
1180
- get_skills_state,
1181
- save_skills_state,
1182
- )
1183
-
1184
- state = get_skills_state()
1185
- if not state.get("installed_skills") or not state.get("auto_update", True):
1186
- return
1233
+ """Refresh the recorded skills the way 'dg skills update' does (B3).
1187
1234
 
1188
- commands = collect_command_metadata()
1189
- version = importlib.metadata.version("deepctl")
1190
- generators = {g.cli_name: g for g in get_all_generators()}
1191
-
1192
- for cli_name, info in state["installed_skills"].items():
1193
- gen = generators.get(cli_name)
1194
- if gen:
1195
- paths = gen.install(commands, version)
1196
- info.update(
1197
- {
1198
- "paths": [str(p) for p in paths],
1199
- "version": version,
1200
- "commands_hash": _commands_hash(commands),
1201
- }
1202
- )
1235
+ Never changes the plugin command's exit code; a failure prints a
1236
+ warning on stderr.
1237
+ """
1238
+ from deepctl_core import skill_generator as sg
1203
1239
 
1204
- save_skills_state(state)
1205
- console.print("[dim]AI assistant skills updated[/dim]")
1206
- except Exception:
1207
- pass # Non-fatal
1240
+ try:
1241
+ state = sg.get_skills_state()
1242
+ names = {*state.get("skill_folders", {}), *state["installed_skills"]}
1243
+ if not state.get("auto_update", True):
1244
+ return
1245
+ plan = [
1246
+ (g, sg._ref_for(g.cli_name, state))
1247
+ for g in sg.get_all_generators()
1248
+ if g.cli_name in names and g.skills_root() is not None
1249
+ ]
1250
+ if not plan: # None recorded, or hint-only only: records untouched.
1251
+ return
1252
+ for _gen, _paths, leftover in sg.install_for(plan, since=state):
1253
+ if leftover:
1254
+ print_warning(escape(sg._msg("E12", staging=leftover)), stderr=True)
1255
+ except Exception as exc: # Non-fatal: the plugin operation succeeded.
1256
+ sg.warn_install_failure(
1257
+ "AI assistant skills were not updated",
1258
+ exc,
1259
+ "run 'dg skills update' to try again",
1260
+ )