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.
@@ -139,6 +139,9 @@ dmypy.json
139
139
  *.swo
140
140
  *~
141
141
 
142
+ # Claude Code local state (worktrees, session files)
143
+ .claude/
144
+
142
145
  # OS
143
146
  .DS_Store
144
147
  .DS_Store?
@@ -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.13
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.1.10
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.13" # x-release-please-version
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.1.10",
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
- """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
- )
1174
+ """Refresh the recorded skills the way 'dg skills update' does (B3).
1183
1175
 
1184
- state = get_skills_state()
1185
- if not state.get("installed_skills") or not state.get("auto_update", True):
1186
- return
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
- save_skills_state(state)
1205
- console.print("[dim]AI assistant skills updated[/dim]")
1206
- except Exception:
1207
- pass # Non-fatal
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 == ""