deepctl-cmd-plugin 0.1.13__tar.gz → 0.1.14__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.13 → deepctl_cmd_plugin-0.1.14}/.gitignore +3 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/CHANGELOG.md +16 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/PKG-INFO +2 -2
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/pyproject.toml +2 -2
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/src/deepctl_cmd_plugin/command.py +27 -33
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/tests/unit/test_plugin_command.py +542 -0
- deepctl_cmd_plugin-0.1.14/tests/unit/test_plugin_legacy_v03.py +94 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/README.md +0 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/src/deepctl_cmd_plugin/__init__.py +0 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/src/deepctl_cmd_plugin/models.py +0 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/src/deepctl_cmd_plugin/strategies.py +0 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/tests/__init__.py +0 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/tests/unit/__init__.py +0 -0
- {deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/tests/unit/test_strategies.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [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
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **skills:** prove and remove deepctl 0.3.x skill files after the new folders land ([f65e0e2](https://github.com/deepgram/cli/commit/f65e0e22868b3d8f7ac4e81d9163045aca3383ec))
|
|
9
|
+
* **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))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **skills:** install skills from login and plugin through the shared installer ([1d34a22](https://github.com/deepgram/cli/commit/1d34a22c6b6c6ee2d87b3ee83657e7c28998a8fd))
|
|
15
|
+
* **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))
|
|
16
|
+
* **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))
|
|
17
|
+
* **skills:** re-check records in dg skills update and the plugin refresh ([b0b1595](https://github.com/deepgram/cli/commit/b0b15959214085e818a551f1c37cfb1645001c6a))
|
|
18
|
+
|
|
3
19
|
## [0.1.13](https://github.com/deepgram/cli/compare/deepctl-cmd-plugin-v0.1.12...deepctl-cmd-plugin-v0.1.13) (2026-09-23)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: deepctl-cmd-plugin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.14
|
|
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.
|
|
7
|
+
Requires-Dist: deepctl-core>=0.2.19
|
|
8
8
|
Requires-Dist: packaging>=21.0
|
|
9
9
|
Requires-Dist: toml>=0.10.2
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
@@ -2,11 +2,11 @@
|
|
|
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.14" # 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.
|
|
9
|
+
"deepctl-core>=0.2.19",
|
|
10
10
|
"packaging>=21.0",
|
|
11
11
|
"toml>=0.10.2",
|
|
12
12
|
]
|
|
@@ -2,7 +2,6 @@
|
|
|
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
|
|
@@ -29,6 +28,7 @@ from deepctl_core.plugin_env import (
|
|
|
29
28
|
save_plugin_state,
|
|
30
29
|
)
|
|
31
30
|
from rich.console import Console
|
|
31
|
+
from rich.markup import escape
|
|
32
32
|
from rich.table import Table
|
|
33
33
|
|
|
34
34
|
from .models import (
|
|
@@ -1171,37 +1171,31 @@ except:
|
|
|
1171
1171
|
return "varies"
|
|
1172
1172
|
|
|
1173
1173
|
def _maybe_update_skills(self) -> None:
|
|
1174
|
-
"""
|
|
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
|
-
)
|
|
1174
|
+
"""Refresh the recorded skills the way 'dg skills update' does (B3).
|
|
1183
1175
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
)
|
|
1176
|
+
Never changes the plugin command's exit code; a failure prints a
|
|
1177
|
+
warning on stderr.
|
|
1178
|
+
"""
|
|
1179
|
+
from deepctl_core import skill_generator as sg
|
|
1203
1180
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1181
|
+
try:
|
|
1182
|
+
state = sg.get_skills_state()
|
|
1183
|
+
names = {*state.get("skill_folders", {}), *state["installed_skills"]}
|
|
1184
|
+
if not state.get("auto_update", True):
|
|
1185
|
+
return
|
|
1186
|
+
plan = [
|
|
1187
|
+
(g, sg._ref_for(g.cli_name, state))
|
|
1188
|
+
for g in sg.get_all_generators()
|
|
1189
|
+
if g.cli_name in names and g.skills_root() is not None
|
|
1190
|
+
]
|
|
1191
|
+
if not plan: # None recorded, or hint-only only: records untouched.
|
|
1192
|
+
return
|
|
1193
|
+
for _gen, _paths, leftover in sg.install_for(plan, since=state):
|
|
1194
|
+
if leftover:
|
|
1195
|
+
print_warning(escape(sg._msg("E12", staging=leftover)), stderr=True)
|
|
1196
|
+
except Exception as exc: # Non-fatal: the plugin operation succeeded.
|
|
1197
|
+
sg.warn_install_failure(
|
|
1198
|
+
"AI assistant skills were not updated",
|
|
1199
|
+
exc,
|
|
1200
|
+
"run 'dg skills update' to try again",
|
|
1201
|
+
)
|
|
@@ -1,21 +1,185 @@
|
|
|
1
1
|
"""Unit tests for plugin command."""
|
|
2
2
|
|
|
3
|
+
import ast
|
|
4
|
+
import hashlib
|
|
5
|
+
import inspect
|
|
3
6
|
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
4
9
|
from pathlib import Path
|
|
5
10
|
from unittest.mock import MagicMock, patch
|
|
6
11
|
|
|
7
12
|
import click
|
|
8
13
|
import pytest
|
|
9
14
|
from click.testing import CliRunner
|
|
15
|
+
from deepctl_cmd_plugin import command as plugin_module
|
|
10
16
|
from deepctl_cmd_plugin.command import PluginCommand
|
|
11
17
|
from deepctl_cmd_plugin.models import (
|
|
12
18
|
PluginInstallOptions,
|
|
13
19
|
PluginOperationResult,
|
|
14
20
|
)
|
|
15
21
|
from deepctl_cmd_update.installation import InstallMethod
|
|
22
|
+
from deepctl_core import output, skill_bundle
|
|
23
|
+
from deepctl_core import skill_generator as sg
|
|
16
24
|
from deepctl_core.auth import AuthManager
|
|
17
25
|
from deepctl_core.client import DeepgramClient
|
|
18
26
|
from deepctl_core.config import Config
|
|
27
|
+
from deepctl_core.skill_bundle import RepoSkill, SkillFetchError
|
|
28
|
+
|
|
29
|
+
REF = skill_bundle.DEFAULT_SKILLS_COMMIT
|
|
30
|
+
RETRY = "run 'dg skills update' to try again"
|
|
31
|
+
AGAIN = ", then run the command again."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def retried(msg):
|
|
35
|
+
"""The refresh's warning for ``msg``: it names the retry, not a plugin rerun."""
|
|
36
|
+
if AGAIN in msg:
|
|
37
|
+
return msg.replace(AGAIN, f", then {RETRY}.")
|
|
38
|
+
assert msg.endswith(".")
|
|
39
|
+
return msg[:-1] + f"; {RETRY}."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def invoke_install(cmd=None):
|
|
43
|
+
"""Run ``dg plugin install p`` through click with the pip step stubbed out."""
|
|
44
|
+
cmd = cmd or PluginCommand()
|
|
45
|
+
ok = PluginOperationResult(
|
|
46
|
+
success=True, action="install", package="p", message="Installed p"
|
|
47
|
+
)
|
|
48
|
+
group = click.Group("plugin", commands=cmd.setup_commands())
|
|
49
|
+
obj = {"config": MagicMock(), "auth_manager": MagicMock(), "client": MagicMock()}
|
|
50
|
+
with patch.object(cmd, "install_plugin", return_value=ok):
|
|
51
|
+
return CliRunner().invoke(group, ["install", "p"], obj=obj)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture(autouse=True)
|
|
55
|
+
def _no_real_skills_state(tmp_path, monkeypatch):
|
|
56
|
+
"""No test here reads or writes the developer's real skills.json (T19).
|
|
57
|
+
|
|
58
|
+
Handler tests that succeed run the real skills refresh; with no records
|
|
59
|
+
under this throwaway path it returns before any fetch.
|
|
60
|
+
"""
|
|
61
|
+
skills_dir = tmp_path / "deepctl-skills"
|
|
62
|
+
monkeypatch.setattr(sg, "_SKILLS_DIR", skills_dir)
|
|
63
|
+
monkeypatch.setattr(sg, "_STATE_FILE", skills_dir / "skills.json")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def use_home(monkeypatch, home):
|
|
67
|
+
"""Point every home lookup the skills refresh makes at ``home``."""
|
|
68
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
|
70
|
+
monkeypatch.setenv("HOME", str(home))
|
|
71
|
+
monkeypatch.setenv("USERPROFILE", str(home))
|
|
72
|
+
monkeypatch.setattr(sg, "_SKILLS_DIR", home / ".deepctl" / "skills")
|
|
73
|
+
monkeypatch.setattr(sg, "_STATE_FILE", home / ".deepctl" / "skills" / "skills.json")
|
|
74
|
+
return home
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture
|
|
78
|
+
def home(tmp_path, monkeypatch):
|
|
79
|
+
"""A throwaway HOME with the output mode pinned (S4) and no PATH detection."""
|
|
80
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
81
|
+
monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False)
|
|
82
|
+
for con in (output.console, output.stderr_console, plugin_module.console):
|
|
83
|
+
monkeypatch.setattr(con, "_width", 400)
|
|
84
|
+
saved = dict(output._output_config)
|
|
85
|
+
output._output_config.update(agentic=True, format="default", quiet=False)
|
|
86
|
+
yield use_home(monkeypatch, tmp_path / "home")
|
|
87
|
+
output._output_config.clear()
|
|
88
|
+
output._output_config.update(saved)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture
|
|
92
|
+
def bundle(tmp_path, monkeypatch):
|
|
93
|
+
"""Patch the one fetch point; return the list of refs fetched."""
|
|
94
|
+
fetched = []
|
|
95
|
+
|
|
96
|
+
def fetch(ref=None):
|
|
97
|
+
fetched.append(ref)
|
|
98
|
+
skills = []
|
|
99
|
+
for name in ("api", "docs"):
|
|
100
|
+
folder = tmp_path / "bundle" / "skills" / name
|
|
101
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
(folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n{ref}\n".encode())
|
|
103
|
+
skills.append(RepoSkill(name, folder))
|
|
104
|
+
return skills
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch)
|
|
107
|
+
return fetched
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def gen(cli):
|
|
111
|
+
return next(g for g in sg.get_all_generators() if g.cli_name == cli)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_state(state):
|
|
115
|
+
sg._STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
sg._STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def state_bytes():
|
|
120
|
+
try:
|
|
121
|
+
return sg._STATE_FILE.read_bytes()
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def normalized(home):
|
|
127
|
+
"""skills.json with paths rebased on ``home`` and timestamps dropped."""
|
|
128
|
+
text = sg._STATE_FILE.read_text(encoding="utf-8")
|
|
129
|
+
state = json.loads(text.replace(json.dumps(str(home))[1:-1], "~"))
|
|
130
|
+
for section in ("skill_folders", "installed_skills"):
|
|
131
|
+
for tool in state.get(section, {}).values():
|
|
132
|
+
tool.pop("installed_at", None)
|
|
133
|
+
return state
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sha_tree(path):
|
|
137
|
+
path = Path(path)
|
|
138
|
+
if path.is_symlink():
|
|
139
|
+
return {"": "link:" + os.readlink(path)}
|
|
140
|
+
return {
|
|
141
|
+
str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest()
|
|
142
|
+
for p in sorted(path.rglob("*"))
|
|
143
|
+
if p.is_file()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def staging_dirs(where):
|
|
148
|
+
if not where.is_dir():
|
|
149
|
+
return []
|
|
150
|
+
return [n for n in os.listdir(where) if n.startswith(sg._STAGING_PREFIX)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def bundle_for(where, ref):
|
|
154
|
+
"""A separate bundle whose SKILL.md files name ``ref``."""
|
|
155
|
+
skills = []
|
|
156
|
+
for name in ("api", "docs"):
|
|
157
|
+
folder = where.parent / f"bundle-{ref}" / "skills" / name
|
|
158
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
(folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n{ref}\n".encode())
|
|
160
|
+
skills.append(RepoSkill(name, folder))
|
|
161
|
+
return skills
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fail_for(monkeypatch, cli, exc):
|
|
165
|
+
real = sg.install_tool
|
|
166
|
+
|
|
167
|
+
def install_tool(g, *a, **k):
|
|
168
|
+
if g.cli_name == cli:
|
|
169
|
+
raise exc
|
|
170
|
+
return real(g, *a, **k)
|
|
171
|
+
|
|
172
|
+
monkeypatch.setattr(sg, "install_tool", install_tool)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def refresh(capsys):
|
|
176
|
+
capsys.readouterr()
|
|
177
|
+
PluginCommand()._maybe_update_skills()
|
|
178
|
+
return capsys.readouterr()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def warnings(err):
|
|
182
|
+
return [line for line in err.splitlines() if line.startswith(("WARN:", "⚠"))]
|
|
19
183
|
|
|
20
184
|
|
|
21
185
|
class TestPluginCommand:
|
|
@@ -607,3 +771,381 @@ class TestPluginCommand:
|
|
|
607
771
|
assert self.command._needs_isolated_venv(InstallMethod.PIP) is False
|
|
608
772
|
assert self.command._needs_isolated_venv(InstallMethod.PIPX) is False
|
|
609
773
|
assert self.command._needs_isolated_venv(InstallMethod.UV) is False
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
class TestSkillsRefresh:
|
|
777
|
+
"""B3: the plugin refresh installs through the path 'dg skills update' uses."""
|
|
778
|
+
|
|
779
|
+
def test_refresh_writes_the_records_dg_skills_update_would(
|
|
780
|
+
self, home, bundle, monkeypatch, capsys, tmp_path
|
|
781
|
+
):
|
|
782
|
+
from deepctl_cmd_skills.command import SkillsCommand
|
|
783
|
+
|
|
784
|
+
homes = [home, use_home(monkeypatch, tmp_path / "other")]
|
|
785
|
+
for h in homes:
|
|
786
|
+
use_home(monkeypatch, h)
|
|
787
|
+
for cli in ("claude", "cursor"):
|
|
788
|
+
h.joinpath(*gen(cli).homes[0]).mkdir(parents=True)
|
|
789
|
+
SkillsCommand()._handle_install(install_all=True)
|
|
790
|
+
use_home(monkeypatch, homes[0])
|
|
791
|
+
out, err = refresh(capsys)
|
|
792
|
+
assert (out, err) == ("", "")
|
|
793
|
+
use_home(monkeypatch, homes[1])
|
|
794
|
+
SkillsCommand()._handle_update()
|
|
795
|
+
after_update = normalized(homes[1])
|
|
796
|
+
use_home(monkeypatch, homes[0])
|
|
797
|
+
assert normalized(homes[0]) == after_update
|
|
798
|
+
assert set(after_update["skill_folders"]) == {"claude", "cursor"}
|
|
799
|
+
|
|
800
|
+
@pytest.mark.parametrize("quiet", [False, True])
|
|
801
|
+
def test_successful_refresh_keeps_skills_off_stdout_and_quiet_silent(
|
|
802
|
+
self, home, bundle, quiet
|
|
803
|
+
):
|
|
804
|
+
output._output_config.update(agentic=False, quiet=quiet)
|
|
805
|
+
write_state({"installed_skills": {"claude": {"paths": []}}})
|
|
806
|
+
result = invoke_install()
|
|
807
|
+
assert result.exit_code == 0, result.output
|
|
808
|
+
assert bundle == [REF]
|
|
809
|
+
assert "skills" not in result.stdout.lower()
|
|
810
|
+
assert "skills" not in result.stderr.lower()
|
|
811
|
+
if quiet:
|
|
812
|
+
assert (result.stdout, result.stderr) == ("", "")
|
|
813
|
+
|
|
814
|
+
@pytest.mark.parametrize("agentic", [False, True])
|
|
815
|
+
def test_refresh_second_tool_failure_keeps_first_recorded_warns_once_exit_zero(
|
|
816
|
+
self, home, bundle, monkeypatch, agentic
|
|
817
|
+
):
|
|
818
|
+
output._output_config["agentic"] = agentic
|
|
819
|
+
write_state(
|
|
820
|
+
{"installed_skills": {"claude": {"paths": []}, "cursor": {"paths": []}}}
|
|
821
|
+
)
|
|
822
|
+
exc = sg._err("E5", gen("cursor"), reason="No space left on device")
|
|
823
|
+
fail_for(monkeypatch, "cursor", exc)
|
|
824
|
+
result = invoke_install()
|
|
825
|
+
assert result.exit_code == 0, result.output
|
|
826
|
+
assert "not updated" not in result.stdout
|
|
827
|
+
assert len(warnings(result.stderr)) == 1
|
|
828
|
+
assert warnings(result.stderr)[0].endswith(
|
|
829
|
+
"AI assistant skills were not updated: " + retried(str(exc))
|
|
830
|
+
)
|
|
831
|
+
claude = sg.get_skills_state()["skill_folders"]["claude"]["folders"]
|
|
832
|
+
assert {n: r["state"] for n, r in claude.items()} == {
|
|
833
|
+
"api": "installed",
|
|
834
|
+
"docs": "installed",
|
|
835
|
+
}
|
|
836
|
+
assert "cursor" not in sg.get_skills_state()["skill_folders"]
|
|
837
|
+
|
|
838
|
+
def test_refresh_keeps_03x_and_hint_only_records_byte_identical(
|
|
839
|
+
self, home, bundle, capsys
|
|
840
|
+
):
|
|
841
|
+
old = Path.home() / ".claude" / "commands" / "deepgram" / "setup-mcp.md"
|
|
842
|
+
old.parent.mkdir(parents=True)
|
|
843
|
+
old.write_bytes(b"mine") # A folder for setup-mcp never lands: kept.
|
|
844
|
+
rule = Path.home() / ".amazonq" / "rules" / "deepctl.md"
|
|
845
|
+
conf = Path.home() / ".aider.conf.yml"
|
|
846
|
+
legacy = {
|
|
847
|
+
"claude": {"paths": [str(old)], "version": "0.3.0", "commands_hash": "h"},
|
|
848
|
+
"amazonq": {"paths": [str(rule)], "version": "0.3.0"},
|
|
849
|
+
"aider": {"paths": [str(conf)], "commands_hash": "h"},
|
|
850
|
+
}
|
|
851
|
+
write_state({"installed_skills": legacy})
|
|
852
|
+
_, err = refresh(capsys)
|
|
853
|
+
state = sg.get_skills_state()
|
|
854
|
+
if os.name == "nt":
|
|
855
|
+
assert len(warnings(err)) == 1
|
|
856
|
+
assert "does not clean 0.3.x content on Windows" in warnings(err)[0]
|
|
857
|
+
assert str(old) not in state["installed_skills"]["claude"]["paths"]
|
|
858
|
+
assert "v03" not in state["skill_folders"]["claude"]
|
|
859
|
+
else:
|
|
860
|
+
assert warnings(err) == []
|
|
861
|
+
assert state["installed_skills"] == legacy
|
|
862
|
+
assert set(state["skill_folders"]) == {"claude"}
|
|
863
|
+
assert (state["skill_folders"]["claude"].get("v03") is True) != (
|
|
864
|
+
os.name == "nt"
|
|
865
|
+
)
|
|
866
|
+
assert (gen("claude").skills_root() / "api" / "SKILL.md").is_file()
|
|
867
|
+
assert bundle == [REF]
|
|
868
|
+
|
|
869
|
+
def _install_then_overlap(self, monkeypatch, action):
|
|
870
|
+
list(sg.install_for([(gen("claude"), REF), (gen("cursor"), REF)]))
|
|
871
|
+
real, pending = skill_bundle.fetch_skill_bundle, [action]
|
|
872
|
+
|
|
873
|
+
def fetch(ref=None):
|
|
874
|
+
if pending:
|
|
875
|
+
pending.pop()()
|
|
876
|
+
return real(ref)
|
|
877
|
+
|
|
878
|
+
monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch)
|
|
879
|
+
|
|
880
|
+
def test_refresh_skips_a_tool_removed_during_its_download(
|
|
881
|
+
self, home, bundle, monkeypatch, capsys
|
|
882
|
+
):
|
|
883
|
+
self._install_then_overlap(monkeypatch, lambda: sg.remove_tool(gen("claude")))
|
|
884
|
+
out, err = refresh(capsys)
|
|
885
|
+
assert out == ""
|
|
886
|
+
assert warnings(err) == ["WARN: " + sg._msg("E31", gen("claude"))]
|
|
887
|
+
state = sg.get_skills_state()
|
|
888
|
+
assert "claude" not in state["skill_folders"]
|
|
889
|
+
assert "claude" not in state["installed_skills"]
|
|
890
|
+
assert not (gen("claude").skills_root() / "api").exists()
|
|
891
|
+
assert set(state["skill_folders"]["cursor"]["folders"]) == {"api", "docs"}
|
|
892
|
+
|
|
893
|
+
def test_refresh_keeps_an_explicit_ref_set_during_its_download(
|
|
894
|
+
self, home, bundle, monkeypatch
|
|
895
|
+
):
|
|
896
|
+
skills = bundle_for(home, "newer")
|
|
897
|
+
self._install_then_overlap(
|
|
898
|
+
monkeypatch,
|
|
899
|
+
lambda: sg.install_tool(gen("claude"), skills, ref="newer", version="9"),
|
|
900
|
+
)
|
|
901
|
+
result = invoke_install()
|
|
902
|
+
assert result.exit_code == 0, result.output
|
|
903
|
+
assert warnings(result.stderr) == ["WARN: " + sg._msg("E30", gen("claude"))]
|
|
904
|
+
assert sg.get_skills_state()["skill_folders"]["claude"]["skills_ref"] == "newer"
|
|
905
|
+
assert sg.get_skills_state()["skill_folders"]["cursor"]["skills_ref"] == REF
|
|
906
|
+
|
|
907
|
+
def test_refresh_keeps_a_newer_copy_of_the_same_moving_ref(
|
|
908
|
+
self, home, bundle, monkeypatch, capsys
|
|
909
|
+
):
|
|
910
|
+
"""B1 (review): REF moved; the newer download landed first and stays."""
|
|
911
|
+
newer = bundle_for(home, "newer")
|
|
912
|
+
self._install_then_overlap(
|
|
913
|
+
monkeypatch,
|
|
914
|
+
lambda: sg.install_tool(gen("claude"), newer, ref=REF, version="9"),
|
|
915
|
+
)
|
|
916
|
+
out, err = refresh(capsys)
|
|
917
|
+
assert out == ""
|
|
918
|
+
assert warnings(err) == ["WARN: " + sg._msg("E32", gen("claude"))]
|
|
919
|
+
api = gen("claude").skills_root() / "api" / "SKILL.md"
|
|
920
|
+
assert api.read_bytes() == (newer[0].path / "SKILL.md").read_bytes()
|
|
921
|
+
state = sg.get_skills_state()["skill_folders"]
|
|
922
|
+
assert (state["claude"]["skills_ref"], state["claude"]["version"]) == (REF, "9")
|
|
923
|
+
assert state["cursor"]["version"] != "9"
|
|
924
|
+
|
|
925
|
+
@pytest.mark.parametrize(
|
|
926
|
+
"state",
|
|
927
|
+
[
|
|
928
|
+
None,
|
|
929
|
+
{"installed_skills": {}},
|
|
930
|
+
{"installed_skills": {"claude": {"paths": []}}, "auto_update": False},
|
|
931
|
+
{"installed_skills": {"amazonq": {"paths": []}, "aider": {"paths": []}}},
|
|
932
|
+
],
|
|
933
|
+
ids=["no-file", "empty", "auto-update-off", "hint-only"],
|
|
934
|
+
)
|
|
935
|
+
def test_refresh_noops_without_fetch(self, home, bundle, capsys, state):
|
|
936
|
+
if state is not None:
|
|
937
|
+
write_state(state)
|
|
938
|
+
saved = state_bytes()
|
|
939
|
+
out, err = refresh(capsys)
|
|
940
|
+
assert bundle == []
|
|
941
|
+
assert state_bytes() == saved
|
|
942
|
+
assert (out, err) == ("", "")
|
|
943
|
+
assert not gen("claude").skills_root().exists()
|
|
944
|
+
|
|
945
|
+
@pytest.mark.parametrize("agentic", [False, True])
|
|
946
|
+
def test_refresh_fetch_failure_warns_once_exit_zero_keeps_records(
|
|
947
|
+
self, home, monkeypatch, agentic
|
|
948
|
+
):
|
|
949
|
+
output._output_config["agentic"] = agentic
|
|
950
|
+
write_state({"installed_skills": {"claude": {"paths": []}}})
|
|
951
|
+
saved = state_bytes()
|
|
952
|
+
|
|
953
|
+
def fetch(ref=None):
|
|
954
|
+
raise SkillFetchError("Could not download the skills.")
|
|
955
|
+
|
|
956
|
+
monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch)
|
|
957
|
+
result = invoke_install()
|
|
958
|
+
assert result.exit_code == 0, result.output
|
|
959
|
+
assert "not updated" not in result.stdout
|
|
960
|
+
assert "skills updated" not in result.stdout
|
|
961
|
+
assert warnings(result.stderr) == [
|
|
962
|
+
("WARN: " if agentic else "⚠ ")
|
|
963
|
+
+ "AI assistant skills were not updated: Could not download the skills; "
|
|
964
|
+
"run 'dg skills update' to try again."
|
|
965
|
+
]
|
|
966
|
+
assert state_bytes() == saved
|
|
967
|
+
assert not gen("claude").skills_root().exists()
|
|
968
|
+
|
|
969
|
+
def test_refresh_invalid_recorded_ref_warns_once_exit_zero_keeps_records(
|
|
970
|
+
self, home, bundle
|
|
971
|
+
):
|
|
972
|
+
list(sg.install_for([(gen("claude"), REF)]))
|
|
973
|
+
state = json.loads(state_bytes())
|
|
974
|
+
state["skill_folders"]["claude"]["skills_ref"] = "../evil"
|
|
975
|
+
write_state(state)
|
|
976
|
+
saved, tree = state_bytes(), sha_tree(gen("claude").skills_root())
|
|
977
|
+
result = invoke_install()
|
|
978
|
+
assert result.exit_code == 0, result.output
|
|
979
|
+
assert "updated" not in result.stdout
|
|
980
|
+
[line] = warnings(result.stderr)
|
|
981
|
+
assert line.startswith("WARN: AI assistant skills were not updated: ")
|
|
982
|
+
assert "'../evil'" in line
|
|
983
|
+
assert line.endswith(f"; {RETRY}.")
|
|
984
|
+
assert bundle == [REF]
|
|
985
|
+
assert state_bytes() == saved
|
|
986
|
+
assert sha_tree(gen("claude").skills_root()) == tree
|
|
987
|
+
|
|
988
|
+
def test_refresh_follows_recorded_ref_and_env_wins(
|
|
989
|
+
self, home, bundle, capsys, monkeypatch
|
|
990
|
+
):
|
|
991
|
+
list(sg.install_for([(gen("claude"), "my-branch")]))
|
|
992
|
+
refresh(capsys)
|
|
993
|
+
assert bundle == ["my-branch", "my-branch"]
|
|
994
|
+
assert sg.get_skills_state()["skill_folders"]["claude"]["skills_ref"] == (
|
|
995
|
+
"my-branch"
|
|
996
|
+
)
|
|
997
|
+
monkeypatch.setenv(skill_bundle.REF_ENV_VAR, "env-ref")
|
|
998
|
+
refresh(capsys)
|
|
999
|
+
monkeypatch.delenv(skill_bundle.REF_ENV_VAR)
|
|
1000
|
+
assert bundle[-1] == "env-ref"
|
|
1001
|
+
write_state({"installed_skills": {"cursor": {"paths": []}}})
|
|
1002
|
+
refresh(capsys)
|
|
1003
|
+
assert bundle[-1] == REF
|
|
1004
|
+
|
|
1005
|
+
@pytest.mark.parametrize("kind", ["dir", "symlink", "dangling-symlink"])
|
|
1006
|
+
def test_refresh_conflict_refuses_every_tool(
|
|
1007
|
+
self, home, bundle, capsys, tmp_path, kind
|
|
1008
|
+
):
|
|
1009
|
+
list(sg.install_for([(gen("claude"), REF)]))
|
|
1010
|
+
write_state(
|
|
1011
|
+
{**json.loads(state_bytes()), "installed_skills": {"cursor": {"paths": []}}}
|
|
1012
|
+
)
|
|
1013
|
+
saved, claude = state_bytes(), sha_tree(gen("claude").skills_root())
|
|
1014
|
+
dest = gen("cursor").skills_root() / "api"
|
|
1015
|
+
dest.parent.mkdir(parents=True)
|
|
1016
|
+
mine = tmp_path / "mine"
|
|
1017
|
+
mine.mkdir()
|
|
1018
|
+
(mine / "notes.md").write_bytes(b"mine")
|
|
1019
|
+
if kind == "dir":
|
|
1020
|
+
shutil.copytree(mine, dest)
|
|
1021
|
+
else:
|
|
1022
|
+
target = mine if kind == "symlink" else tmp_path / "gone"
|
|
1023
|
+
try:
|
|
1024
|
+
dest.symlink_to(target, target_is_directory=True)
|
|
1025
|
+
except (OSError, NotImplementedError) as exc:
|
|
1026
|
+
pytest.skip(f"cannot create a symlink here: {exc}")
|
|
1027
|
+
before = sha_tree(dest)
|
|
1028
|
+
out, err = refresh(capsys)
|
|
1029
|
+
e1 = sg._msg("E1", paths=str(dest))
|
|
1030
|
+
assert warnings(err) == [
|
|
1031
|
+
f"WARN: AI assistant skills were not updated: {e1.removesuffix(AGAIN)}, "
|
|
1032
|
+
"then run 'dg skills update' to try again."
|
|
1033
|
+
]
|
|
1034
|
+
assert "updated" not in out
|
|
1035
|
+
assert sha_tree(dest) == before
|
|
1036
|
+
assert sha_tree(mine) == {"notes.md": hashlib.sha256(b"mine").hexdigest()}
|
|
1037
|
+
assert state_bytes() == saved
|
|
1038
|
+
assert sha_tree(gen("claude").skills_root()) == claude
|
|
1039
|
+
assert bundle == [REF, REF]
|
|
1040
|
+
|
|
1041
|
+
def test_refresh_edited_folder_warns_e22_and_leaves_it(self, home, bundle, capsys):
|
|
1042
|
+
list(sg.install_for([(gen("claude"), REF)]))
|
|
1043
|
+
api = gen("claude").skills_root() / "api"
|
|
1044
|
+
with open(api / "SKILL.md", "ab") as f:
|
|
1045
|
+
f.write(b"mine\n")
|
|
1046
|
+
before, saved = sha_tree(api), state_bytes()
|
|
1047
|
+
_, err = refresh(capsys)
|
|
1048
|
+
e22 = sg._msg("E22", dest=api)
|
|
1049
|
+
assert warnings(err) == [
|
|
1050
|
+
f"WARN: AI assistant skills were not updated: {retried(e22)}"
|
|
1051
|
+
]
|
|
1052
|
+
assert sha_tree(api) == before
|
|
1053
|
+
assert state_bytes() == saved
|
|
1054
|
+
|
|
1055
|
+
def test_refresh_multi_problem_warning_names_the_retry_in_every_sentence(
|
|
1056
|
+
self, home, bundle, monkeypatch, capsys
|
|
1057
|
+
):
|
|
1058
|
+
monkeypatch.setattr(output.stderr_console, "_width", 2000)
|
|
1059
|
+
write_state({"installed_skills": {"claude": {"paths": []}}})
|
|
1060
|
+
p1, p2, p3 = (home / n for n in ("a", "b", "c"))
|
|
1061
|
+
fail_for(monkeypatch, "claude", sg.SkillOwnershipError([p1], [p2, p3]))
|
|
1062
|
+
_, err = refresh(capsys)
|
|
1063
|
+
[line] = warnings(err)
|
|
1064
|
+
assert "run the command again" not in line
|
|
1065
|
+
assert line.count(f", then {RETRY}.") == 3
|
|
1066
|
+
assert line.endswith(f", then {RETRY}.")
|
|
1067
|
+
|
|
1068
|
+
def test_refresh_corrupt_skills_json_warns_and_keeps_bytes(
|
|
1069
|
+
self, home, bundle, capsys
|
|
1070
|
+
):
|
|
1071
|
+
sg._STATE_FILE.parent.mkdir(parents=True)
|
|
1072
|
+
sg._STATE_FILE.write_bytes(b'{"installed_skills": []}')
|
|
1073
|
+
out, err = refresh(capsys)
|
|
1074
|
+
e7 = sg._msg("E7")
|
|
1075
|
+
assert warnings(err) == [
|
|
1076
|
+
f"WARN: AI assistant skills were not updated: {retried(e7)}"
|
|
1077
|
+
]
|
|
1078
|
+
assert out == ""
|
|
1079
|
+
assert state_bytes() == b'{"installed_skills": []}'
|
|
1080
|
+
assert bundle == []
|
|
1081
|
+
|
|
1082
|
+
@pytest.mark.parametrize("handler", ["install", "update", "remove"])
|
|
1083
|
+
@pytest.mark.parametrize("success", [True, False])
|
|
1084
|
+
def test_refresh_runs_after_install_update_remove_only_on_success(
|
|
1085
|
+
self, handler, success
|
|
1086
|
+
):
|
|
1087
|
+
cmd = PluginCommand()
|
|
1088
|
+
result = PluginOperationResult(
|
|
1089
|
+
success=success, action=handler, package="p", message="m"
|
|
1090
|
+
)
|
|
1091
|
+
method = "remove_plugin" if handler == "remove" else "install_plugin"
|
|
1092
|
+
with (
|
|
1093
|
+
patch.object(cmd, method, return_value=result),
|
|
1094
|
+
patch.object(cmd, "_maybe_update_skills") as refresh_mock,
|
|
1095
|
+
):
|
|
1096
|
+
call = getattr(cmd, f"_handle_{handler}")
|
|
1097
|
+
args = (MagicMock(), MagicMock(), MagicMock())
|
|
1098
|
+
if success:
|
|
1099
|
+
call(*args, package="p", yes=True)
|
|
1100
|
+
else:
|
|
1101
|
+
with pytest.raises(click.ClickException):
|
|
1102
|
+
call(*args, package="p", yes=True)
|
|
1103
|
+
assert refresh_mock.call_count == (1 if success else 0)
|
|
1104
|
+
|
|
1105
|
+
def test_only_install_update_remove_refresh(self):
|
|
1106
|
+
tree = ast.parse(inspect.getsource(PluginCommand).lstrip())
|
|
1107
|
+
callers = {
|
|
1108
|
+
fn.name
|
|
1109
|
+
for fn in ast.walk(tree)
|
|
1110
|
+
if isinstance(fn, ast.FunctionDef)
|
|
1111
|
+
for node in ast.walk(fn)
|
|
1112
|
+
if isinstance(node, ast.Attribute) and node.attr == "_maybe_update_skills"
|
|
1113
|
+
}
|
|
1114
|
+
assert callers == {"_handle_install", "_handle_update", "_handle_remove"}
|
|
1115
|
+
|
|
1116
|
+
def test_refresh_ctrl_c_in_second_tool_keeps_first_and_no_staging(
|
|
1117
|
+
self, home, bundle, monkeypatch
|
|
1118
|
+
):
|
|
1119
|
+
write_state(
|
|
1120
|
+
{"installed_skills": {"claude": {"paths": []}, "cursor": {"paths": []}}}
|
|
1121
|
+
)
|
|
1122
|
+
real = sg._swap
|
|
1123
|
+
|
|
1124
|
+
def swap(g, *a, **k):
|
|
1125
|
+
if g.cli_name == "cursor":
|
|
1126
|
+
raise KeyboardInterrupt
|
|
1127
|
+
return real(g, *a, **k)
|
|
1128
|
+
|
|
1129
|
+
monkeypatch.setattr(sg, "_swap", swap)
|
|
1130
|
+
with pytest.raises(KeyboardInterrupt):
|
|
1131
|
+
PluginCommand()._maybe_update_skills()
|
|
1132
|
+
state = sg.get_skills_state()
|
|
1133
|
+
claude = state["skill_folders"]["claude"]["folders"]
|
|
1134
|
+
assert {r["state"] for r in claude.values()} == {"installed"}
|
|
1135
|
+
assert "cursor" not in state.get("skill_folders", {})
|
|
1136
|
+
for cli in ("claude", "cursor"):
|
|
1137
|
+
assert staging_dirs(gen(cli).skills_root()) == []
|
|
1138
|
+
|
|
1139
|
+
def test_refresh_leftover_staging_warns_on_stderr(
|
|
1140
|
+
self, home, bundle, monkeypatch, capsys
|
|
1141
|
+
):
|
|
1142
|
+
output._output_config["agentic"] = False
|
|
1143
|
+
write_state({"installed_skills": {"claude": {"paths": []}}})
|
|
1144
|
+
staging = gen("claude").skills_root() / ".deepctl-staging-x"
|
|
1145
|
+
real = sg.install_tool
|
|
1146
|
+
monkeypatch.setattr(
|
|
1147
|
+
sg, "install_tool", lambda *a, **k: (real(*a, **k)[0], staging)
|
|
1148
|
+
)
|
|
1149
|
+
out, err = refresh(capsys)
|
|
1150
|
+
assert err.splitlines() == ["⚠ " + sg._msg("E12", staging=staging)]
|
|
1151
|
+
assert out == ""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""dg plugin's skills refresh runs the deepctl 0.3.x cleanup on stderr only."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import pytest
|
|
11
|
+
from click.testing import CliRunner
|
|
12
|
+
from deepctl_cmd_plugin import command as plugin_module
|
|
13
|
+
from deepctl_cmd_plugin.command import PluginCommand
|
|
14
|
+
from deepctl_cmd_plugin.models import PluginOperationResult
|
|
15
|
+
from deepctl_core import output, skill_bundle
|
|
16
|
+
from deepctl_core import skill_generator as sg
|
|
17
|
+
from deepctl_core.skill_bundle import RepoSkill
|
|
18
|
+
|
|
19
|
+
FIX = Path(__file__).parents[3] / "deepctl-core" / "tests" / "unit" / "fixtures"
|
|
20
|
+
FIX = FIX / "legacy_v03"
|
|
21
|
+
NAMES = ("api", "docs", "setup-mcp", "starters")
|
|
22
|
+
pytestmark = pytest.mark.skipif(
|
|
23
|
+
os.name == "nt", reason="legacy cleanup is intentionally disabled on Windows"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(autouse=True)
|
|
28
|
+
def home(tmp_path, monkeypatch):
|
|
29
|
+
home = tmp_path / "home"
|
|
30
|
+
home.mkdir()
|
|
31
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
|
32
|
+
monkeypatch.setenv("HOME", str(home))
|
|
33
|
+
monkeypatch.setenv("USERPROFILE", str(home))
|
|
34
|
+
monkeypatch.setattr(sg, "_SKILLS_DIR", home / ".deepctl" / "skills")
|
|
35
|
+
monkeypatch.setattr(sg, "_STATE_FILE", home / ".deepctl" / "skills" / "skills.json")
|
|
36
|
+
for con in (output.console, output.stderr_console, plugin_module.console):
|
|
37
|
+
monkeypatch.setattr(con, "_width", 400)
|
|
38
|
+
monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False)
|
|
39
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
40
|
+
|
|
41
|
+
def fetch(ref=None):
|
|
42
|
+
skills = []
|
|
43
|
+
for name in NAMES:
|
|
44
|
+
folder = tmp_path / "bundle" / "skills" / name
|
|
45
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
(folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n".encode())
|
|
47
|
+
skills.append(RepoSkill(name, folder))
|
|
48
|
+
return skills
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch)
|
|
51
|
+
saved = dict(output._output_config)
|
|
52
|
+
yield home
|
|
53
|
+
output._output_config.clear()
|
|
54
|
+
output._output_config.update(saved)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def seed():
|
|
58
|
+
paths = []
|
|
59
|
+
for n in NAMES:
|
|
60
|
+
path = Path.home() / ".claude" / "commands" / "deepgram" / f"{n}.md"
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_bytes((FIX / f"{n}.md").read_bytes())
|
|
63
|
+
paths.append(str(path))
|
|
64
|
+
edited = Path.home() / ".cursor" / "rules" / "deepctl.mdc"
|
|
65
|
+
edited.parent.mkdir(parents=True)
|
|
66
|
+
edited.write_bytes((FIX / "deepctl.mdc").read_bytes() + b"mine\n")
|
|
67
|
+
legacy = {"claude": {"paths": paths}, "cursor": {"paths": [str(edited)]}}
|
|
68
|
+
sg._STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
sg._STATE_FILE.write_text(json.dumps({"installed_skills": legacy}), "utf-8")
|
|
70
|
+
return [Path(p) for p in paths], edited
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("agentic", [False, True])
|
|
74
|
+
def test_plugin_remove_json_keeps_cleanup_off_stdout(agentic):
|
|
75
|
+
output._output_config.update(agentic=agentic, format="json", quiet=False)
|
|
76
|
+
files, edited = seed()
|
|
77
|
+
cmd = PluginCommand()
|
|
78
|
+
ok = PluginOperationResult(
|
|
79
|
+
success=True, action="remove", package="foo", message="Removed foo"
|
|
80
|
+
)
|
|
81
|
+
group = click.Group("plugin", commands=cmd.setup_commands())
|
|
82
|
+
obj = {"config": MagicMock(), "auth_manager": MagicMock(), "client": MagicMock()}
|
|
83
|
+
with patch.object(cmd, "remove_plugin", return_value=ok):
|
|
84
|
+
result = CliRunner().invoke(group, ["remove", "foo", "--yes"], obj=obj)
|
|
85
|
+
assert result.exit_code == 0, result.output
|
|
86
|
+
assert not any(p.exists() for p in files)
|
|
87
|
+
assert edited.exists()
|
|
88
|
+
assert "0.3.x" not in result.stdout
|
|
89
|
+
assert "deepctl can't prove it wrote" not in result.stdout
|
|
90
|
+
err = " ".join(result.stderr.split())
|
|
91
|
+
assert "Removed deepctl 0.3.x files for Claude Code" in err
|
|
92
|
+
assert "deepctl can't prove it wrote" in err
|
|
93
|
+
if agentic:
|
|
94
|
+
assert result.stdout == ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deepctl_cmd_plugin-0.1.13 → deepctl_cmd_plugin-0.1.14}/src/deepctl_cmd_plugin/strategies.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|