Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# output.py is CRLF on main and ruff format keeps it that way; let git diff --check accept it.
packages/deepctl-core/src/deepctl_core/output.py whitespace=cr-at-eol
packages/deepctl-core/tests/unit/fixtures/legacy_v03/** -text
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,13 @@ These commands abort if the filesystem cannot provide locking or no-replace dire

If you edit a deepctl folder, or add a file or link to it, deepctl leaves it alone: `update` stops without changing anything and `remove` won't delete it. To get updates again, rename or move your edited copy, or delete it yourself. Opening a skill folder in Finder or Explorer can add `.DS_Store`, `Thumbs.db` or `desktop.ini`, which counts as an edit.

Files from deepctl 0.3.x, such as `~/.claude/commands/deepgram/*.md` and the rules files like `~/.cursor/rules/deepctl.mdc`, are currently kept, and `dg skills remove` doesn't delete them.
#### Upgrading from deepctl 0.2.16 through 0.3.x

Once `dg skills install` or `update` (or `dg login`, or `dg plugin install`, `update` or `remove`) has installed a tool's skill folders, deepctl removes the files older deepctl wrote for that tool, and prints which ones on stderr: `~/.claude/commands/deepgram/*.md`, `~/.cursor/rules/deepctl.mdc`, `~/.cline/rules/deepctl.md`, and the section between the `<!-- BEGIN deepctl CLI Reference` and `<!-- END deepctl CLI Reference -->` lines in `~/.codex/instructions.md`, `~/.gemini/GEMINI.md` and `~/.opencode/agents.md`. In those three files, everything between the two marker lines is removed, including anything you changed there; the rest of the file is kept, apart from the blank line deepctl added before its section.

deepctl removes a file only if its content is exactly a deepgram/skills version that older deepctl copied, even if you made that copy yourself. It keeps a file you edited, a link, a file in a folder reached through a link (such as a dotfiles `~/.claude`), and a section that is incomplete, repeated or in a read-only or locked file, and warns once about each file 0.3.x recorded. An I/O failure, such as permission denied, is retried on the next install or update, with a warning for files 0.3.x recorded. Amazon Q Developer and Aider files (`~/.amazonq/rules/deepctl.md`, `~/.deepctl/skills/deepctl-conventions.md`) are kept, because those tools have no skill folders; if you delete Aider's, also remove its entry under `read:` in `~/.aider.conf.yml`.

While deepctl works on a file, it moves it to `.deepctl-v03-<name>` in the same folder. If deepctl is interrupted, the next install or update puts the file back and says so. If both the file and its `.deepctl-v03-<name>` are there, as after you saved the file while deepctl was editing it, deepctl changes neither and names both on each run until you compare them and delete the `.deepctl-v03-<name>` copy; it does the same if `.deepctl-v03-<name>` is a link or folder deepctl didn't make. A `.deepctl-v03-*.tmp` file is an unused draft; delete it.

### Starter Apps

Expand Down
71 changes: 71 additions & 0 deletions packages/deepctl-cmd-login/tests/unit/test_login_legacy_v03.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""dg login's skills step runs the deepctl 0.3.x cleanup and prints it on stderr."""

import shutil
import sys
from pathlib import Path
from unittest.mock import patch

import click
import pytest
from deepctl_cmd_login import command as login_module
from deepctl_cmd_login.command import LoginCommand
from deepctl_core import output, skill_bundle
from deepctl_core import skill_generator as sg
from deepctl_core.skill_bundle import RepoSkill

FIX = Path(__file__).parents[3] / "deepctl-core" / "tests" / "unit" / "fixtures"
FIX = FIX / "legacy_v03"
NAMES = ("api", "docs", "setup-mcp", "starters")


@pytest.fixture(autouse=True)
def home(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setattr(sg, "_SKILLS_DIR", home / ".deepctl" / "skills")
monkeypatch.setattr(sg, "_STATE_FILE", home / ".deepctl" / "skills" / "skills.json")
for con in (output.console, output.stderr_console, login_module.console):
monkeypatch.setattr(con, "_width", 400)
monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False)
monkeypatch.setattr(shutil, "which", lambda name: None)

def fetch(ref=None):
skills = []
for name in NAMES:
folder = tmp_path / "bundle" / "skills" / name
folder.mkdir(parents=True, exist_ok=True)
(folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n".encode())
skills.append(RepoSkill(name, folder))
return skills

monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch)
saved = dict(output._output_config)
yield home
output._output_config.clear()
output._output_config.update(saved)


@pytest.mark.parametrize("agentic", [False, True])
def test_login_skills_step_cleans_up_on_stderr(monkeypatch, capsys, agentic):
output._output_config.update(agentic=agentic, format="default", quiet=False)
files = []
for n in NAMES:
path = Path.home() / ".claude" / "commands" / "deepgram" / f"{n}.md"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes((FIX / f"{n}.md").read_bytes())
files.append(path)
# Login offers skills only with no record, so these files are unrecorded
# (skills.json was lost or deleted): the bytes are the proof, not the record.
capsys.readouterr()
monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
cmd = LoginCommand()
cmd._guided = True
with patch.object(login_module.Prompt, "ask", return_value="all"):
cmd._maybe_prompt_skills_setup()
out, err = (click.unstyle(s) for s in capsys.readouterr())
assert not any(p.exists() for p in files)
assert "0.3.x" not in out
assert "Removed deepctl 0.3.x files for Claude Code" in " ".join(err.split())
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,9 @@ def test_refresh_second_tool_failure_keeps_first_recorded_warns_once_exit_zero(
def test_refresh_keeps_03x_and_hint_only_records_byte_identical(
self, home, bundle, capsys
):
old = Path.home() / ".claude" / "commands" / "deepgram" / "api.md"
old = Path.home() / ".claude" / "commands" / "deepgram" / "setup-mcp.md"
old.parent.mkdir(parents=True)
old.write_bytes(b"mine") # A folder for setup-mcp never lands: kept.
rule = Path.home() / ".amazonq" / "rules" / "deepctl.md"
conf = Path.home() / ".aider.conf.yml"
legacy = {
Expand Down
90 changes: 90 additions & 0 deletions packages/deepctl-cmd-plugin/tests/unit/test_plugin_legacy_v03.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""dg plugin's skills refresh runs the deepctl 0.3.x cleanup on stderr only."""

import json
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch

import click
import pytest
from click.testing import CliRunner
from deepctl_cmd_plugin import command as plugin_module
from deepctl_cmd_plugin.command import PluginCommand
from deepctl_cmd_plugin.models import PluginOperationResult
from deepctl_core import output, skill_bundle
from deepctl_core import skill_generator as sg
from deepctl_core.skill_bundle import RepoSkill

FIX = Path(__file__).parents[3] / "deepctl-core" / "tests" / "unit" / "fixtures"
FIX = FIX / "legacy_v03"
NAMES = ("api", "docs", "setup-mcp", "starters")


@pytest.fixture(autouse=True)
def home(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setattr(sg, "_SKILLS_DIR", home / ".deepctl" / "skills")
monkeypatch.setattr(sg, "_STATE_FILE", home / ".deepctl" / "skills" / "skills.json")
for con in (output.console, output.stderr_console, plugin_module.console):
monkeypatch.setattr(con, "_width", 400)
monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False)
monkeypatch.setattr(shutil, "which", lambda name: None)

def fetch(ref=None):
skills = []
for name in NAMES:
folder = tmp_path / "bundle" / "skills" / name
folder.mkdir(parents=True, exist_ok=True)
(folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n".encode())
skills.append(RepoSkill(name, folder))
return skills

monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch)
saved = dict(output._output_config)
yield home
output._output_config.clear()
output._output_config.update(saved)


def seed():
paths = []
for n in NAMES:
path = Path.home() / ".claude" / "commands" / "deepgram" / f"{n}.md"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes((FIX / f"{n}.md").read_bytes())
paths.append(str(path))
edited = Path.home() / ".cursor" / "rules" / "deepctl.mdc"
edited.parent.mkdir(parents=True)
edited.write_bytes((FIX / "deepctl.mdc").read_bytes() + b"mine\n")
legacy = {"claude": {"paths": paths}, "cursor": {"paths": [str(edited)]}}
sg._STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
sg._STATE_FILE.write_text(json.dumps({"installed_skills": legacy}), "utf-8")
return [Path(p) for p in paths], edited


@pytest.mark.parametrize("agentic", [False, True])
def test_plugin_remove_json_keeps_cleanup_off_stdout(agentic):
output._output_config.update(agentic=agentic, format="json", quiet=False)
files, edited = seed()
cmd = PluginCommand()
ok = PluginOperationResult(
success=True, action="remove", package="foo", message="Removed foo"
)
group = click.Group("plugin", commands=cmd.setup_commands())
obj = {"config": MagicMock(), "auth_manager": MagicMock(), "client": MagicMock()}
with patch.object(cmd, "remove_plugin", return_value=ok):
result = CliRunner().invoke(group, ["remove", "foo", "--yes"], obj=obj)
assert result.exit_code == 0, result.output
assert not any(p.exists() for p in files)
assert edited.exists()
assert "0.3.x" not in result.stdout
assert "deepctl can't prove it wrote" not in result.stdout
err = " ".join(result.stderr.split())
assert "Removed deepctl 0.3.x files for Claude Code" in err
assert "deepctl can't prove it wrote" in err
if agentic:
assert result.stdout == ""
17 changes: 11 additions & 6 deletions packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,14 @@ def _handle_status(self) -> None:
notes += [sg._msg("E16", path=p) for p in st.leftovers]
if found and st.root is None:
notes.append(sg._msg("E15", gen))
if st.root and gen.cli_name in legacy and gen.cli_name not in recs:
v03 = recs.get(gen.cli_name, {"v03": 1}).get("v03")
if st.root and gen.cli_name in legacy and v03:
old.append(gen.display_name)
console.print(table)
for note in dict.fromkeys(notes):
print_warning(escape(note))
if old:
note = f"Files from deepctl 0.3.x are recorded for {', '.join(old)}; run 'dg skills update' to install the skill folders, and the old files stay until a later release."
note = f"Files from deepctl 0.3.x are recorded for {', '.join(old)}; 'dg skills install' or 'dg skills update' removes the ones deepctl can prove it wrote once the skill folders are installed."
print_info(escape(note))
if detected and not recs and not legacy:
print_info("Run 'dg skills install' to set up AI assistant integrations.")
Expand Down Expand Up @@ -424,13 +425,17 @@ def _handle_remove(
for note in notes:
print_warning(escape(note))
paths = legacy.get(cli_key, {}).get("paths", [])
old = recs.get(cli_key, {}).get("v03") or any(
Path(p).parent != gen.skills_root() for p in paths
) # Not 0.3.x if every path is one of our folders.
v03 = "files from deepctl 0.3.x stay until a later release."
left = [p for p in paths if Path(p).parent != gen.skills_root()]
old = [p for p in left if not sg._v03_gone(p)] # On disk, else no note.
v03 = f"its deepctl 0.3.x files were kept: {', '.join(old)}; delete any you don't need, or 'dg skills install' removes the ones deepctl can prove it wrote."
if cli_key in sg._V03_SHARED: # A section in the user's own file.
v03 = f"the deepctl 0.3.x section in {', '.join(old)}, if any, was kept; 'dg skills install' removes it when it can do so safely; if it is still there afterwards, remove the lines between its marker lines yourself."
c10 = f"For {gen.display_name}, {v03}"
if cli_key not in recs:
c10 = f"{gen.display_name} has no skill folders recorded, so nothing was removed{'; ' + v03 if old else '.'}"
if gen.skills_root() is None and old:
a = " and its entry under 'read:' in ~/.aider.conf.yml"
c10 = f"{gen.display_name} has no skill folders, so nothing was removed and its deepctl 0.3.x file is kept; delete it{a * (cli_key == 'aider')} yourself if you don't need it."
if old or cli_key not in recs:
print_info(escape(c10))
failed = failed or bool(
Expand Down
31 changes: 19 additions & 12 deletions packages/deepctl-cmd-skills/tests/unit/test_skills_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,9 @@ def test_remove_exit_one_when_staging_left(self, bundle, monkeypatch, capsys):
@pytest.mark.parametrize("old", [True, False])
def test_remove_notes_03x_files_left_behind(self, bundle, capsys, old):
detect("claude")
kept = Path.home() / ".claude" / "commands" / "deepgram" / "setup-mcp.md"
kept.parent.mkdir(parents=True)
kept.write_bytes(b"mine") # A folder for setup-mcp never lands: kept.
if old:
write_state(
{
Expand All @@ -452,7 +455,7 @@ def test_remove_notes_03x_files_left_behind(self, bundle, capsys, old):
/ ".claude"
/ "commands"
/ "deepgram"
/ "api.md"
/ "setup-mcp.md"
)
]
}
Expand All @@ -466,7 +469,7 @@ def test_remove_notes_03x_files_left_behind(self, bundle, capsys, old):
err = err_text(capsys)
assert ("0.3.x" in err) == old
assert (
"For Claude Code, files from deepctl 0.3.x stay until a later release."
f"For Claude Code, its deepctl 0.3.x files were kept: {kept}; delete any you don't need, or 'dg skills install' removes the ones deepctl can prove it wrote."
in err
) == old
assert "OK: Removed 2 skill folders from 1 tool." in err
Expand Down Expand Up @@ -681,8 +684,12 @@ def test_remove_moved_folder_reports_e4_and_exits_one(self, monkeypatch, capsys)

def test_remove_notes_03x_files_after_a_plugin_refresh(self, bundle, capsys):
detect("claude")
old = Path.home() / ".claude" / "commands" / "deepgram" / "api.md"
old = Path.home() / ".claude" / "commands" / "deepgram" / "setup-mcp.md"
old.parent.mkdir(parents=True)
old.write_bytes(b"mine") # A folder for setup-mcp never lands: kept.
rule = Path.home() / ".amazonq" / "rules" / "deepctl.md"
rule.parent.mkdir(parents=True)
rule.write_bytes(b"0.3.x rules") # On disk, so remove names it.
write_state(
{
"installed_skills": {
Expand All @@ -702,15 +709,11 @@ def test_remove_notes_03x_files_after_a_plugin_refresh(self, bundle, capsys):
SkillsCommand()._handle_update() # A later write keeps the flag.
capsys.readouterr()
SkillsCommand()._handle_remove(remove_all=True)
err, v03 = (
err_text(capsys),
"files from deepctl 0.3.x stay until a later release.",
)
err = err_text(capsys)
v03 = f"its deepctl 0.3.x files were kept: {old}; delete any you don't need, or 'dg skills install' removes the ones deepctl can prove it wrote."
assert f"For Claude Code, {v03}" in err
assert (
f"Amazon Q Developer has no skill folders recorded, so nothing was removed; {v03}"
in err
)
q = "Amazon Q Developer has no skill folders, so nothing was removed and its deepctl 0.3.x file is kept; delete it yourself if you don't need it."
assert q in err

def test_update_edited_folder_exits_one_with_rename_advice(self, bundle):
detect("claude")
Expand Down Expand Up @@ -812,7 +815,11 @@ def fingerprinted():
assert unchanged()
fingerprinted()
assert len(disk_state()["skill_folders"]) == 6
assert disk_state()["installed_skills"] == legacy
recorded = disk_state()["installed_skills"]
assert set(recorded) == set(tools) # Login and startup key on these.
assert {t: recorded[t] for t in ("amazonq", "aider")} == {
t: legacy[t] for t in ("amazonq", "aider")
} # Hint-only tools: never cleaned up.
cmd._handle_update()
assert unchanged()
fingerprinted()
Expand Down
Loading
Loading