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.
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/.gitignore +4 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/CHANGELOG.md +15 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/PKG-INFO +2 -1
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/pyproject.toml +6 -1
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/src/deepctl_cmd_plugin/command.py +154 -95
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/src/deepctl_cmd_plugin/models.py +1 -1
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/src/deepctl_cmd_plugin/strategies.py +1 -1
- deepctl_cmd_plugin-0.1.15/tests/unit/test_plugin_agent_gate.py +64 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/tests/unit/test_plugin_command.py +387 -89
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/tests/unit/test_plugin_legacy_v03.py +9 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/tests/unit/test_strategies.py +10 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/README.md +0 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/src/deepctl_cmd_plugin/__init__.py +0 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/tests/__init__.py +0 -0
- {deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/tests/unit/__init__.py +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
self.
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
"""
|
|
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
|
-
|
|
635
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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:
|
{deepctl_cmd_plugin-0.1.14 → deepctl_cmd_plugin-0.1.15}/src/deepctl_cmd_plugin/strategies.py
RENAMED
|
@@ -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
|