diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8f64a622 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# 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 diff --git a/README.md b/README.md index b4b128d2..87dd317b 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,9 @@ dg skills remove --all # Delete the folders deepctl installed deepctl replaces or deletes a folder only if skills.json records it for that tool, it is a real folder directly in that tool's skills folder, it holds deepctl's `.deepctl-skill` marker, and its contents are exactly what deepctl installed. So folders from `npx skills add`, and symlinked skill folders, are left alone. -These commands abort if the filesystem cannot provide locking or no-replace directory moves. Installed skill folders and skills.json stay as they were, though the tool's skills folder and `~/.deepctl/skills/skills.json.lock` may already have been created. While another `dg skills` command is changing skills, the next one waits up to 30 seconds for it to finish. +These commands abort if the filesystem cannot provide locking or no-replace directory moves. Installed skill folders and skills.json stay as they were, though the tool's skills folder and `~/.deepctl/skills/skills.json.lock` may already have been created. While another `dg skills`, `dg login` or `dg plugin` command is changing skills, the next one waits up to 30 seconds for it to finish. + +`dg login` offers to install the skills, and `dg plugin install`, `update` and `remove` refresh the installed ones, through the same path as `dg skills install` and `update`. A skills installation failure prints a warning on stderr and does not change those commands' exit codes. Ctrl-C still cancels the command. 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. diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index 88d91afe..6b7d4de1 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -12,7 +12,9 @@ ProfilesResult, get_output_format, ) +from deepctl_core.output import print_warning from rich.console import Console +from rich.markup import escape from rich.prompt import Prompt from .models import LoginResult, LogoutResult, WhoamiResult @@ -237,43 +239,35 @@ def _maybe_prompt_skills_setup(self) -> None: console.print("[dim]No tools selected.[/dim]") return - # Install skills for selected tools - from deepctl_core.skill_generator import ( - _commands_hash, - collect_command_metadata, - save_skills_state, - ) + # The path 'dg skills install' uses (B3): one fetch, every tool + # preflighted, each tool recorded as it lands. + from deepctl_core import skill_generator as sg + from deepctl_core.skill_bundle import resolve_skills_ref console.print("\n[blue]Installing Deepgram skills...[/blue]") - - import importlib.metadata - from datetime import datetime, timezone - - commands = collect_command_metadata() - try: - version = importlib.metadata.version("deepctl") - except importlib.metadata.PackageNotFoundError: - version = "0.0.0" - for gen in selected: - paths = gen.install(commands, version) - cmd_hash = _commands_hash(commands) - state["installed_skills"][gen.cli_name] = { - "paths": [str(p) for p in paths], - "installed_at": datetime.now(timezone.utc).isoformat(), - "version": version, - "commands_hash": cmd_hash, - } - for p in paths: - console.print(f" [green]✓[/green] {gen.display_name} → {p}") - - save_skills_state(state) - console.print( - "\n[green]Skills installed![/green] " - "[dim]Run 'dg skills update' after plugin changes.[/dim]" + if gen.skills_root() is None: + print_warning(escape(sg._msg("E15", gen)), stderr=True) + ref, count = resolve_skills_ref(), 0 + for gen, paths, leftover in sg.install_for([(g, ref) for g in selected]): + count += len(paths) + line = f"{gen.display_name} → {gen.skills_root()} ({len(paths)} skills)" + console.print(f" [green]✓[/green] {escape(line)}") + if leftover: + print_warning(escape(sg._msg("E12", staging=leftover)), stderr=True) + if count: + console.print( + "\n[green]Skills installed![/green] " + "[dim]Run 'dg skills update' to get newer skills.[/dim]" + ) + except Exception as exc: # Never fails the login, and never silent (B3). + from deepctl_core.skill_generator import warn_install_failure + + warn_install_failure( + "Skills setup did not finish", + exc, + "run 'dg skills install' to try again", ) - except Exception: - pass # Best-effort — never fail the login def _cli_auth( self, diff --git a/packages/deepctl-cmd-login/tests/unit/test_login_command.py b/packages/deepctl-cmd-login/tests/unit/test_login_command.py index fa0d53d2..9ffd38f8 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_login_command.py +++ b/packages/deepctl-cmd-login/tests/unit/test_login_command.py @@ -1,16 +1,140 @@ """Tests for the login command.""" +import errno +import hashlib +import json +import shutil +import sys +from pathlib import Path from unittest.mock import MagicMock, Mock, call, patch +import click import pytest +from deepctl_cmd_login import command as login_module from deepctl_cmd_login.command import ( LoginCommand, LogoutCommand, ProfilesCommand, ) from deepctl_cmd_login.models import LoginResult, LogoutResult -from deepctl_core import AuthManager, Config, DeepgramClient +from deepctl_core import AuthManager, Config, DeepgramClient, output, skill_bundle +from deepctl_core import skill_generator as sg from deepctl_core.models import ProfileInfo, ProfilesResult +from deepctl_core.skill_bundle import RepoSkill, SkillFetchError + + +@pytest.fixture(autouse=True) +def _no_real_skills_state(tmp_path, monkeypatch): + """No test here reads or writes the developer's real skills.json (T19).""" + skills_dir = tmp_path / "deepctl-skills" + monkeypatch.setattr(sg, "_SKILLS_DIR", skills_dir) + monkeypatch.setattr(sg, "_STATE_FILE", skills_dir / "skills.json") + + +def use_home(monkeypatch, home): + """Point every home lookup the skills step makes at ``home``.""" + home.mkdir(parents=True, exist_ok=True) + 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") + return home + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """A throwaway HOME with the output mode pinned (S4) and no PATH detection.""" + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False) + for con in (output.console, output.stderr_console, login_module.console): + monkeypatch.setattr(con, "_width", 400) + saved = dict(output._output_config) + output._output_config.update(agentic=True, format="default", quiet=False) + yield use_home(monkeypatch, tmp_path / "home") + output._output_config.clear() + output._output_config.update(saved) + + +@pytest.fixture +def bundle(tmp_path, monkeypatch): + """Patch the one fetch point; return the list of refs fetched.""" + fetched = [] + + def fetch(ref=None): + fetched.append(ref) + skills = [] + for name in ("api", "docs"): + folder = tmp_path / "bundle" / "skills" / name + folder.mkdir(parents=True, exist_ok=True) + (folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n{ref}\n".encode()) + skills.append(RepoSkill(name, folder)) + return skills + + monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch) + return fetched + + +def detect(*clis): + for cli in clis: + Path.home().joinpath(*gen(cli).homes[0]).mkdir(parents=True, exist_ok=True) + + +def gen(cli): + return next(g for g in sg.get_all_generators() if g.cli_name == cli) + + +def run_skills_step(monkeypatch, capsys, answer="all"): + """Run login's skills step as a TTY user typing ``answer``; return (out, err).""" + 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=answer) as ask: + cmd._maybe_prompt_skills_setup() + out, err = capsys.readouterr() + return click.unstyle(out), click.unstyle(err), ask + + +def normalized(home): + """skills.json with paths rebased on ``home`` and timestamps dropped.""" + text = sg._STATE_FILE.read_text(encoding="utf-8") + state = json.loads(text.replace(json.dumps(str(home))[1:-1], "~")) + for section in ("skill_folders", "installed_skills"): + for tool in state.get(section, {}).values(): + tool.pop("installed_at", None) + return state + + +def sha_tree(path): + return { + str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(Path(path).rglob("*")) + if p.is_file() + } + + +RETRY = "run 'dg skills install' to try again" +AGAIN = ", then run the command again." + + +def retried(msg): + """Login's warning text for ``msg``: it names the retry, not a rerun of login.""" + if AGAIN in msg: + return msg.replace(AGAIN, f", then {RETRY}.") + assert msg.endswith(".") + return msg[:-1] + f"; {RETRY}." + + +def fail_for(monkeypatch, cli, exc): + real = sg.install_tool + + def install_tool(g, *a, **k): + if g.cli_name == cli: + raise exc + return real(g, *a, **k) + + monkeypatch.setattr(sg, "install_tool", install_tool) @pytest.fixture @@ -475,3 +599,250 @@ def test_env_key_without_profile_labeled_env( profile_key=None, ) assert result.key_source == "DEEPGRAM_API_KEY (env)" + + +class TestLoginSkillsThroughSharedInstaller: + """B3: login installs through the path 'dg skills install' uses.""" + + def test_login_writes_the_records_dg_skills_install_would( + self, home, bundle, monkeypatch, capsys, tmp_path + ): + from deepctl_cmd_skills.command import SkillsCommand + + detect("claude", "cursor") + out, err, _ = run_skills_step(monkeypatch, capsys) + assert err == "" + root = gen("claude").skills_root() + assert f"✓ Claude Code → {root} (2 skills)" in out + assert "Skills installed!" in out + from_login = normalized(home) + + other = use_home(monkeypatch, tmp_path / "other") + detect("claude", "cursor") + SkillsCommand()._handle_install(install_all=True) + assert normalized(other) == from_login + assert set(from_login["skill_folders"]) == {"claude", "cursor"} + + @pytest.mark.parametrize("leftover", [False, True]) + def test_login_second_tool_failure_keeps_first_recorded_warns_once_exit_zero( + self, + home, + bundle, + monkeypatch, + capsys, + leftover, + mock_config, + mock_auth_manager, + mock_client, + ): + detect("claude", "cursor") + exc = sg._err("E5", gen("cursor"), reason="No space left on device") + staging = gen("cursor").skills_root() / ".deepctl-staging-x" + exc.leftover = staging if leftover else None + fail_for(monkeypatch, "cursor", exc) + ok = LoginResult(status="success", message="ok", profile="default") + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + cmd = LoginCommand() + cmd._guided = True + capsys.readouterr() + with ( + patch.object(cmd, "_web_auth", return_value=ok), + patch.object(login_module.Prompt, "ask", return_value="all"), + ): + result = cmd.handle( + config=mock_config, auth_manager=mock_auth_manager, client=mock_client + ) + out, err = (click.unstyle(t) for t in capsys.readouterr()) + assert result.status == "success" + warning = "WARN: Skills setup did not finish: " + retried(str(exc)) + e12 = "WARN: " + sg._msg("E12", staging=staging) + assert err.splitlines() == ([e12] if leftover else []) + [warning] + assert "did not finish" not in out + assert f"✓ Claude Code → {gen('claude').skills_root()} (2 skills)" in out + assert "Skills installed!" not in out # As 'dg skills install': no summary. + claude = sg.get_skills_state()["skill_folders"]["claude"]["folders"] + assert {n: r["state"] for n, r in claude.items()} == { + "api": "installed", + "docs": "installed", + } + assert (gen("claude").skills_root() / "api" / "SKILL.md").is_file() + assert "cursor" not in sg.get_skills_state()["skill_folders"] + + def test_login_uses_the_skills_ref_env_var_and_records_it( + self, home, bundle, monkeypatch, capsys + ): + detect("claude") + monkeypatch.setenv(skill_bundle.REF_ENV_VAR, " v9.9.9 ") + _, err, _ = run_skills_step(monkeypatch, capsys) + assert err == "" + assert bundle == ["v9.9.9"] + record = sg.get_skills_state()["skill_folders"]["claude"] + assert record["skills_ref"] == "v9.9.9" + + def test_login_markup_in_home_path_prints_literally( + self, tmp_path, bundle, monkeypatch, capsys + ): + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False) + monkeypatch.setitem(output._output_config, "agentic", True) + for con in (output.console, output.stderr_console, login_module.console): + monkeypatch.setattr(con, "_width", 400) + use_home(monkeypatch, tmp_path / "[dim]home") + detect("claude") + out, err, _ = run_skills_step(monkeypatch, capsys) + root = gen("claude").skills_root() + assert "[dim]home" in str(root) + assert err == "" + assert f" ✓ Claude Code → {root} (2 skills)" in out.splitlines() + + def test_login_warning_is_on_stderr_outside_agentic_mode( + self, home, bundle, monkeypatch, capsys + ): + output._output_config["agentic"] = False + detect("claude") + fail_for(monkeypatch, "claude", sg._err("E5", gen("claude"), reason="nope")) + out, err, _ = run_skills_step(monkeypatch, capsys) + assert "did not finish" not in out + assert err.strip().startswith("⚠ Skills setup did not finish: ") + assert err.strip().endswith(f"; {RETRY}.") + + def test_login_conflict_in_any_tool_writes_nothing( + self, home, bundle, monkeypatch, capsys + ): + detect("claude", "cursor") + mine = gen("cursor").skills_root() / "api" + mine.mkdir(parents=True) + (mine / "notes.md").write_bytes(b"mine") + before = sha_tree(mine) + out, err, _ = run_skills_step(monkeypatch, capsys) + e1 = sg._msg("E1", paths=str(mine)) + assert err.splitlines() == [ + "WARN: Skills setup did not finish: " + e1.removesuffix(AGAIN) + ", then " + "run 'dg skills install' to try again." + ] + assert not gen("claude").skills_root().exists() + assert sha_tree(mine) == before + assert not sg._STATE_FILE.exists() + assert "Skills installed!" not in out + + def test_login_fetch_failure_warns_and_writes_nothing( + self, home, monkeypatch, capsys + ): + detect("claude") + + def fetch(ref=None): + raise SkillFetchError("Could not download the skills.") + + monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch) + _, err, _ = run_skills_step(monkeypatch, capsys) + assert err.splitlines() == [ + "WARN: Skills setup did not finish: Could not download the skills; " + "run 'dg skills install' to try again." + ] + assert not gen("claude").skills_root().exists() + assert not sg._STATE_FILE.exists() + + @pytest.mark.parametrize(("agentic", "prefix"), [(True, "WARN: "), (False, "⚠ ")]) + def test_login_hint_only_selection_warns_e15_on_stderr_installs_nothing( + self, home, bundle, monkeypatch, capsys, agentic, prefix + ): + output._output_config["agentic"] = agentic + detect("amazonq") + out, err, _ = run_skills_step(monkeypatch, capsys) + assert err.splitlines() == [prefix + sg._msg("E15", gen("amazonq"))] + assert bundle == [] + assert not sg._STATE_FILE.exists() + assert "Skills installed!" not in out + + def test_login_prompt_text_is_unchanged(self, home, bundle, monkeypatch, capsys): + detect("claude", "cursor") + out, _, ask = run_skills_step(monkeypatch, capsys, answer="none") + ask.assert_called_once_with( + "Install skills for (comma-separated numbers, [bold]all[/bold], or [bold]none[/bold])", + default="all", + ) + assert "AI coding tools detected:" in out + assert " 1. Claude Code" in out + assert " 2. Cursor" in out + assert "You can run 'dg skills setup' later." in out + assert bundle == [] + assert not sg._STATE_FILE.exists() + + def test_login_numbered_selection_installs_only_that_tool( + self, home, bundle, monkeypatch, capsys + ): + detect("claude", "cursor") + run_skills_step(monkeypatch, capsys, answer="2") + assert set(sg.get_skills_state()["skill_folders"]) == {"cursor"} + + def test_login_does_not_prompt_when_skills_recorded( + self, home, bundle, monkeypatch, capsys + ): + detect("claude") + rule = Path.home() / ".amazonq" / "rules" / "deepctl.md" + sg._STATE_FILE.parent.mkdir(parents=True) + sg._STATE_FILE.write_text( + json.dumps({"installed_skills": {"amazonq": {"paths": [str(rule)]}}}) + ) + saved = sg._STATE_FILE.read_bytes() + out, err, ask = run_skills_step(monkeypatch, capsys) + ask.assert_not_called() + assert (out, err) == ("", "") + assert sg._STATE_FILE.read_bytes() == saved + + def test_login_corrupt_skills_json_warns_once_and_does_not_prompt( + self, home, bundle, monkeypatch, capsys + ): + detect("claude") + sg._STATE_FILE.parent.mkdir(parents=True) + sg._STATE_FILE.write_bytes(b"{not json") + out, err, ask = run_skills_step(monkeypatch, capsys) + ask.assert_not_called() + assert err.splitlines() == [ + "WARN: Skills setup did not finish: " + retried(sg._msg("E7")) + ] + assert out == "" + assert sg._STATE_FILE.read_bytes() == b"{not json" + + def test_login_ctrl_c_at_prompt_propagates_and_writes_nothing( + self, home, bundle, monkeypatch + ): + detect("claude") + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + cmd = LoginCommand() + cmd._guided = True + with ( + patch.object(login_module.Prompt, "ask", side_effect=KeyboardInterrupt), + pytest.raises(KeyboardInterrupt), + ): + cmd._maybe_prompt_skills_setup() + assert bundle == [] + assert not sg._STATE_FILE.exists() + assert not gen("claude").skills_root().exists() + + def test_login_disk_error_mid_install_warns_and_exits_zero( + self, home, bundle, monkeypatch, capsys + ): + detect("claude") + + def full(*a, **k): + raise OSError(errno.ENOSPC, "No space left on device") + + monkeypatch.setattr(sg, "_swap", full) + _, err, _ = run_skills_step(monkeypatch, capsys) + e5 = sg._msg("E5", gen("claude"), reason="No space left on device") + assert err.splitlines()[-1] == f"WARN: Skills setup did not finish: {retried(e5)}" + + def test_login_leftover_staging_after_success_warns_on_stderr( + self, home, bundle, monkeypatch, capsys + ): + output._output_config["agentic"] = False + detect("claude") + staging = gen("claude").skills_root() / ".deepctl-staging-x" + real = sg.install_tool + monkeypatch.setattr( + sg, "install_tool", lambda *a, **k: (real(*a, **k)[0], staging) + ) + out, err, _ = run_skills_step(monkeypatch, capsys) + assert err.splitlines() == ["⚠ " + sg._msg("E12", staging=staging)] + assert "Skills installed!" in out diff --git a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py index 755112c6..256e4462 100644 --- a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py +++ b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib.metadata import json import subprocess import sys @@ -29,6 +28,7 @@ save_plugin_state, ) from rich.console import Console +from rich.markup import escape from rich.table import Table from .models import ( @@ -1171,37 +1171,31 @@ def _get_plugin_commands(self, plugin_name: str) -> str: return "varies" def _maybe_update_skills(self) -> None: - """Regenerate AI CLI skills if installed (best-effort).""" - try: - from deepctl_core.skill_generator import ( - _commands_hash, - collect_command_metadata, - get_all_generators, - get_skills_state, - save_skills_state, - ) + """Refresh the recorded skills the way 'dg skills update' does (B3). - state = get_skills_state() - if not state.get("installed_skills") or not state.get("auto_update", True): - return - - commands = collect_command_metadata() - version = importlib.metadata.version("deepctl") - generators = {g.cli_name: g for g in get_all_generators()} - - for cli_name, info in state["installed_skills"].items(): - gen = generators.get(cli_name) - if gen: - paths = gen.install(commands, version) - info.update( - { - "paths": [str(p) for p in paths], - "version": version, - "commands_hash": _commands_hash(commands), - } - ) + Never changes the plugin command's exit code; a failure prints a + warning on stderr. + """ + from deepctl_core import skill_generator as sg - save_skills_state(state) - console.print("[dim]AI assistant skills updated[/dim]") - except Exception: - pass # Non-fatal + try: + state = sg.get_skills_state() + names = {*state.get("skill_folders", {}), *state["installed_skills"]} + if not state.get("auto_update", True): + return + plan = [ + (g, sg._ref_for(g.cli_name, state)) + for g in sg.get_all_generators() + if g.cli_name in names and g.skills_root() is not None + ] + if not plan: # None recorded, or hint-only only: records untouched. + return + for _gen, _paths, leftover in sg.install_for(plan): + if leftover: + print_warning(escape(sg._msg("E12", staging=leftover)), stderr=True) + except Exception as exc: # Non-fatal: the plugin operation succeeded. + sg.warn_install_failure( + "AI assistant skills were not updated", + exc, + "run 'dg skills update' to try again", + ) diff --git a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py index 5c4cfa4f..70e7b9b1 100644 --- a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py +++ b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py @@ -1,21 +1,174 @@ """Unit tests for plugin command.""" +import ast +import hashlib +import inspect import json +import os +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 ( PluginInstallOptions, PluginOperationResult, ) from deepctl_cmd_update.installation import InstallMethod +from deepctl_core import output, skill_bundle +from deepctl_core import skill_generator as sg from deepctl_core.auth import AuthManager from deepctl_core.client import DeepgramClient from deepctl_core.config import Config +from deepctl_core.skill_bundle import RepoSkill, SkillFetchError + +REF = skill_bundle.DEFAULT_SKILLS_COMMIT +RETRY = "run 'dg skills update' to try again" +AGAIN = ", then run the command again." + + +def retried(msg): + """The refresh's warning for ``msg``: it names the retry, not a plugin rerun.""" + if AGAIN in msg: + return msg.replace(AGAIN, f", then {RETRY}.") + assert msg.endswith(".") + return msg[:-1] + f"; {RETRY}." + + +def invoke_install(cmd=None): + """Run ``dg plugin install p`` through click with the pip step stubbed out.""" + cmd = cmd or PluginCommand() + ok = PluginOperationResult( + success=True, action="install", package="p", message="Installed p" + ) + group = click.Group("plugin", commands=cmd.setup_commands()) + obj = {"config": MagicMock(), "auth_manager": MagicMock(), "client": MagicMock()} + with patch.object(cmd, "install_plugin", return_value=ok): + return CliRunner().invoke(group, ["install", "p"], obj=obj) + + +@pytest.fixture(autouse=True) +def _no_real_skills_state(tmp_path, monkeypatch): + """No test here reads or writes the developer's real skills.json (T19). + + Handler tests that succeed run the real skills refresh; with no records + under this throwaway path it returns before any fetch. + """ + skills_dir = tmp_path / "deepctl-skills" + monkeypatch.setattr(sg, "_SKILLS_DIR", skills_dir) + monkeypatch.setattr(sg, "_STATE_FILE", skills_dir / "skills.json") + + +def use_home(monkeypatch, home): + """Point every home lookup the skills refresh makes at ``home``.""" + home.mkdir(parents=True, exist_ok=True) + 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") + return home + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """A throwaway HOME with the output mode pinned (S4) and no PATH detection.""" + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.delenv(skill_bundle.REF_ENV_VAR, raising=False) + for con in (output.console, output.stderr_console, plugin_module.console): + monkeypatch.setattr(con, "_width", 400) + saved = dict(output._output_config) + output._output_config.update(agentic=True, format="default", quiet=False) + yield use_home(monkeypatch, tmp_path / "home") + output._output_config.clear() + output._output_config.update(saved) + + +@pytest.fixture +def bundle(tmp_path, monkeypatch): + """Patch the one fetch point; return the list of refs fetched.""" + fetched = [] + + def fetch(ref=None): + fetched.append(ref) + skills = [] + for name in ("api", "docs"): + folder = tmp_path / "bundle" / "skills" / name + folder.mkdir(parents=True, exist_ok=True) + (folder / "SKILL.md").write_bytes(f"---\nname: {name}\n---\n{ref}\n".encode()) + skills.append(RepoSkill(name, folder)) + return skills + + monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch) + return fetched + + +def gen(cli): + return next(g for g in sg.get_all_generators() if g.cli_name == cli) + + +def write_state(state): + sg._STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + sg._STATE_FILE.write_text(json.dumps(state), encoding="utf-8") + + +def state_bytes(): + try: + return sg._STATE_FILE.read_bytes() + except FileNotFoundError: + return None + + +def normalized(home): + """skills.json with paths rebased on ``home`` and timestamps dropped.""" + text = sg._STATE_FILE.read_text(encoding="utf-8") + state = json.loads(text.replace(json.dumps(str(home))[1:-1], "~")) + for section in ("skill_folders", "installed_skills"): + for tool in state.get(section, {}).values(): + tool.pop("installed_at", None) + return state + + +def sha_tree(path): + path = Path(path) + if path.is_symlink(): + return {"": "link:" + os.readlink(path)} + return { + str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(path.rglob("*")) + if p.is_file() + } + + +def staging_dirs(where): + if not where.is_dir(): + return [] + return [n for n in os.listdir(where) if n.startswith(sg._STAGING_PREFIX)] + + +def fail_for(monkeypatch, cli, exc): + real = sg.install_tool + + def install_tool(g, *a, **k): + if g.cli_name == cli: + raise exc + return real(g, *a, **k) + + monkeypatch.setattr(sg, "install_tool", install_tool) + + +def refresh(capsys): + capsys.readouterr() + PluginCommand()._maybe_update_skills() + return capsys.readouterr() + + +def warnings(err): + return [line for line in err.splitlines() if line.startswith(("WARN:", "⚠"))] class TestPluginCommand: @@ -607,3 +760,315 @@ def test_needs_isolated_venv(self) -> None: assert self.command._needs_isolated_venv(InstallMethod.PIP) is False assert self.command._needs_isolated_venv(InstallMethod.PIPX) is False assert self.command._needs_isolated_venv(InstallMethod.UV) is False + + +class TestSkillsRefresh: + """B3: the plugin refresh installs through the path 'dg skills update' uses.""" + + def test_refresh_writes_the_records_dg_skills_update_would( + self, home, bundle, monkeypatch, capsys, tmp_path + ): + from deepctl_cmd_skills.command import SkillsCommand + + homes = [home, use_home(monkeypatch, tmp_path / "other")] + for h in homes: + use_home(monkeypatch, h) + for cli in ("claude", "cursor"): + h.joinpath(*gen(cli).homes[0]).mkdir(parents=True) + SkillsCommand()._handle_install(install_all=True) + use_home(monkeypatch, homes[0]) + out, err = refresh(capsys) + assert (out, err) == ("", "") + use_home(monkeypatch, homes[1]) + SkillsCommand()._handle_update() + after_update = normalized(homes[1]) + use_home(monkeypatch, homes[0]) + assert normalized(homes[0]) == after_update + assert set(after_update["skill_folders"]) == {"claude", "cursor"} + + @pytest.mark.parametrize("quiet", [False, True]) + def test_successful_refresh_keeps_skills_off_stdout_and_quiet_silent( + self, home, bundle, quiet + ): + output._output_config.update(agentic=False, quiet=quiet) + write_state({"installed_skills": {"claude": {"paths": []}}}) + result = invoke_install() + assert result.exit_code == 0, result.output + assert bundle == [REF] + assert "skills" not in result.stdout.lower() + assert "skills" not in result.stderr.lower() + if quiet: + assert (result.stdout, result.stderr) == ("", "") + + @pytest.mark.parametrize("agentic", [False, True]) + def test_refresh_second_tool_failure_keeps_first_recorded_warns_once_exit_zero( + self, home, bundle, monkeypatch, agentic + ): + output._output_config["agentic"] = agentic + write_state( + {"installed_skills": {"claude": {"paths": []}, "cursor": {"paths": []}}} + ) + exc = sg._err("E5", gen("cursor"), reason="No space left on device") + fail_for(monkeypatch, "cursor", exc) + result = invoke_install() + assert result.exit_code == 0, result.output + assert "not updated" not in result.stdout + assert len(warnings(result.stderr)) == 1 + assert warnings(result.stderr)[0].endswith( + "AI assistant skills were not updated: " + retried(str(exc)) + ) + claude = sg.get_skills_state()["skill_folders"]["claude"]["folders"] + assert {n: r["state"] for n, r in claude.items()} == { + "api": "installed", + "docs": "installed", + } + assert "cursor" not in sg.get_skills_state()["skill_folders"] + + def test_refresh_keeps_03x_and_hint_only_records_byte_identical( + self, home, bundle, capsys + ): + old = Path.home() / ".claude" / "commands" / "deepgram" / "api.md" + rule = Path.home() / ".amazonq" / "rules" / "deepctl.md" + conf = Path.home() / ".aider.conf.yml" + legacy = { + "claude": {"paths": [str(old)], "version": "0.3.0", "commands_hash": "h"}, + "amazonq": {"paths": [str(rule)], "version": "0.3.0"}, + "aider": {"paths": [str(conf)], "commands_hash": "h"}, + } + write_state({"installed_skills": legacy}) + _, err = refresh(capsys) + assert warnings(err) == [] + state = sg.get_skills_state() + assert state["installed_skills"] == legacy + assert set(state["skill_folders"]) == {"claude"} + assert state["skill_folders"]["claude"]["v03"] is True + assert (gen("claude").skills_root() / "api" / "SKILL.md").is_file() + assert bundle == [REF] + + @pytest.mark.parametrize( + "state", + [ + None, + {"installed_skills": {}}, + {"installed_skills": {"claude": {"paths": []}}, "auto_update": False}, + {"installed_skills": {"amazonq": {"paths": []}, "aider": {"paths": []}}}, + ], + ids=["no-file", "empty", "auto-update-off", "hint-only"], + ) + def test_refresh_noops_without_fetch(self, home, bundle, capsys, state): + if state is not None: + write_state(state) + saved = state_bytes() + out, err = refresh(capsys) + assert bundle == [] + assert state_bytes() == saved + assert (out, err) == ("", "") + assert not gen("claude").skills_root().exists() + + @pytest.mark.parametrize("agentic", [False, True]) + def test_refresh_fetch_failure_warns_once_exit_zero_keeps_records( + self, home, monkeypatch, agentic + ): + output._output_config["agentic"] = agentic + write_state({"installed_skills": {"claude": {"paths": []}}}) + saved = state_bytes() + + def fetch(ref=None): + raise SkillFetchError("Could not download the skills.") + + monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch) + result = invoke_install() + assert result.exit_code == 0, result.output + assert "not updated" not in result.stdout + assert "skills updated" not in result.stdout + assert warnings(result.stderr) == [ + ("WARN: " if agentic else "⚠ ") + + "AI assistant skills were not updated: Could not download the skills; " + "run 'dg skills update' to try again." + ] + assert state_bytes() == saved + assert not gen("claude").skills_root().exists() + + def test_refresh_invalid_recorded_ref_warns_once_exit_zero_keeps_records( + self, home, bundle + ): + list(sg.install_for([(gen("claude"), REF)])) + state = json.loads(state_bytes()) + state["skill_folders"]["claude"]["skills_ref"] = "../evil" + write_state(state) + saved, tree = state_bytes(), sha_tree(gen("claude").skills_root()) + result = invoke_install() + assert result.exit_code == 0, result.output + assert "updated" not in result.stdout + [line] = warnings(result.stderr) + assert line.startswith("WARN: AI assistant skills were not updated: ") + assert "'../evil'" in line + assert line.endswith(f"; {RETRY}.") + assert bundle == [REF] + assert state_bytes() == saved + assert sha_tree(gen("claude").skills_root()) == tree + + def test_refresh_follows_recorded_ref_and_env_wins( + self, home, bundle, capsys, monkeypatch + ): + list(sg.install_for([(gen("claude"), "my-branch")])) + refresh(capsys) + assert bundle == ["my-branch", "my-branch"] + assert sg.get_skills_state()["skill_folders"]["claude"]["skills_ref"] == ( + "my-branch" + ) + monkeypatch.setenv(skill_bundle.REF_ENV_VAR, "env-ref") + refresh(capsys) + monkeypatch.delenv(skill_bundle.REF_ENV_VAR) + assert bundle[-1] == "env-ref" + write_state({"installed_skills": {"cursor": {"paths": []}}}) + refresh(capsys) + assert bundle[-1] == REF + + @pytest.mark.parametrize("kind", ["dir", "symlink", "dangling-symlink"]) + def test_refresh_conflict_refuses_every_tool( + self, home, bundle, capsys, tmp_path, kind + ): + list(sg.install_for([(gen("claude"), REF)])) + write_state( + {**json.loads(state_bytes()), "installed_skills": {"cursor": {"paths": []}}} + ) + saved, claude = state_bytes(), sha_tree(gen("claude").skills_root()) + dest = gen("cursor").skills_root() / "api" + dest.parent.mkdir(parents=True) + mine = tmp_path / "mine" + mine.mkdir() + (mine / "notes.md").write_bytes(b"mine") + if kind == "dir": + shutil.copytree(mine, dest) + else: + target = mine if kind == "symlink" else tmp_path / "gone" + try: + dest.symlink_to(target, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"cannot create a symlink here: {exc}") + before = sha_tree(dest) + out, err = refresh(capsys) + e1 = sg._msg("E1", paths=str(dest)) + assert warnings(err) == [ + f"WARN: AI assistant skills were not updated: {e1.removesuffix(AGAIN)}, " + "then run 'dg skills update' to try again." + ] + assert "updated" not in out + assert sha_tree(dest) == before + assert sha_tree(mine) == {"notes.md": hashlib.sha256(b"mine").hexdigest()} + assert state_bytes() == saved + assert sha_tree(gen("claude").skills_root()) == claude + assert bundle == [REF, REF] + + def test_refresh_edited_folder_warns_e22_and_leaves_it(self, home, bundle, capsys): + list(sg.install_for([(gen("claude"), REF)])) + api = gen("claude").skills_root() / "api" + with open(api / "SKILL.md", "ab") as f: + f.write(b"mine\n") + before, saved = sha_tree(api), state_bytes() + _, err = refresh(capsys) + e22 = sg._msg("E22", dest=api) + assert warnings(err) == [ + f"WARN: AI assistant skills were not updated: {retried(e22)}" + ] + assert sha_tree(api) == before + assert state_bytes() == saved + + def test_refresh_multi_problem_warning_names_the_retry_in_every_sentence( + self, home, bundle, monkeypatch, capsys + ): + monkeypatch.setattr(output.stderr_console, "_width", 2000) + write_state({"installed_skills": {"claude": {"paths": []}}}) + p1, p2, p3 = (home / n for n in ("a", "b", "c")) + fail_for(monkeypatch, "claude", sg.SkillOwnershipError([p1], [p2, p3])) + _, err = refresh(capsys) + [line] = warnings(err) + assert "run the command again" not in line + assert line.count(f", then {RETRY}.") == 3 + assert line.endswith(f", then {RETRY}.") + + def test_refresh_corrupt_skills_json_warns_and_keeps_bytes( + self, home, bundle, capsys + ): + sg._STATE_FILE.parent.mkdir(parents=True) + sg._STATE_FILE.write_bytes(b'{"installed_skills": []}') + out, err = refresh(capsys) + e7 = sg._msg("E7") + assert warnings(err) == [ + f"WARN: AI assistant skills were not updated: {retried(e7)}" + ] + assert out == "" + assert state_bytes() == b'{"installed_skills": []}' + assert bundle == [] + + @pytest.mark.parametrize("handler", ["install", "update", "remove"]) + @pytest.mark.parametrize("success", [True, False]) + def test_refresh_runs_after_install_update_remove_only_on_success( + self, handler, success + ): + cmd = PluginCommand() + result = PluginOperationResult( + success=success, action=handler, package="p", message="m" + ) + method = "remove_plugin" if handler == "remove" else "install_plugin" + with ( + patch.object(cmd, method, return_value=result), + patch.object(cmd, "_maybe_update_skills") as refresh_mock, + ): + call = getattr(cmd, f"_handle_{handler}") + args = (MagicMock(), MagicMock(), MagicMock()) + if success: + call(*args, package="p", yes=True) + else: + with pytest.raises(click.ClickException): + call(*args, package="p", yes=True) + assert refresh_mock.call_count == (1 if success else 0) + + def test_only_install_update_remove_refresh(self): + tree = ast.parse(inspect.getsource(PluginCommand).lstrip()) + callers = { + fn.name + for fn in ast.walk(tree) + if isinstance(fn, ast.FunctionDef) + for node in ast.walk(fn) + if isinstance(node, ast.Attribute) and node.attr == "_maybe_update_skills" + } + assert callers == {"_handle_install", "_handle_update", "_handle_remove"} + + def test_refresh_ctrl_c_in_second_tool_keeps_first_and_no_staging( + self, home, bundle, monkeypatch + ): + write_state( + {"installed_skills": {"claude": {"paths": []}, "cursor": {"paths": []}}} + ) + real = sg._swap + + def swap(g, *a, **k): + if g.cli_name == "cursor": + raise KeyboardInterrupt + return real(g, *a, **k) + + monkeypatch.setattr(sg, "_swap", swap) + with pytest.raises(KeyboardInterrupt): + PluginCommand()._maybe_update_skills() + state = sg.get_skills_state() + claude = state["skill_folders"]["claude"]["folders"] + assert {r["state"] for r in claude.values()} == {"installed"} + assert "cursor" not in state.get("skill_folders", {}) + for cli in ("claude", "cursor"): + assert staging_dirs(gen(cli).skills_root()) == [] + + def test_refresh_leftover_staging_warns_on_stderr( + self, home, bundle, monkeypatch, capsys + ): + output._output_config["agentic"] = False + write_state({"installed_skills": {"claude": {"paths": []}}}) + staging = gen("claude").skills_root() / ".deepctl-staging-x" + real = sg.install_tool + monkeypatch.setattr( + sg, "install_tool", lambda *a, **k: (real(*a, **k)[0], staging) + ) + out, err = refresh(capsys) + assert err.splitlines() == ["⚠ " + sg._msg("E12", staging=staging)] + assert out == "" diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py index e564ad80..b334b648 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib -import importlib.metadata from pathlib import Path from typing import TYPE_CHECKING, Any @@ -224,37 +223,24 @@ def setup_cmd(**kwargs: Any) -> None: # ------------------------------------------------------------------ def _install(self, plan: list[tuple[SkillGenerator, str]]) -> None: - """Fetch, preflight every tool, then install tool by tool.""" - from deepctl_core import skill_bundle + """Install through core's shared path, reporting each tool as it lands.""" from deepctl_core import skill_generator as sg for gen, _ in plan: if gen.skills_root() is None: print_warning(escape(sg._msg("E15", gen))) - plan = [(g, r) for g, r in plan if g.skills_root() is not None] - bundles = { - r: skill_bundle.fetch_skill_bundle(r) - for r in dict.fromkeys(r for _, r in plan) - } - unproven, edited = list[Path](), list[Path]() - for ref, skills in bundles.items(): - u, e = sg.install_conflicts([g for g, r in plan if r == ref], skills) - unproven, edited = unproven + u, edited + e - if unproven or edited: # Nothing is written for any tool. - raise sg.SkillOwnershipError(unproven, edited) - count, tools, v = 0, 0, _version() - for gen, ref in plan: - try: - paths, leftover = sg.install_tool(gen, bundles[ref], ref=ref, version=v) - except sg.SkillInstallError as exc: - if exc.leftover: - print_warning(escape(sg._msg("E12", staging=exc.leftover))) - raise - done = f"{gen.display_name}: installed {_n(len(paths), 'skill')} in {gen.skills_root()}." - print_success(escape(done)) - if leftover: # Only this run's staging (SF3). - print_warning(escape(sg._msg("E12", staging=leftover))) - count, tools = count + len(paths), tools + 1 + count, tools = 0, 0 + try: + for gen, paths, leftover in sg.install_for(plan): + done = f"{gen.display_name}: installed {_n(len(paths), 'skill')} in {gen.skills_root()}." + print_success(escape(done)) + if leftover: # Only this run's staging (SF3). + print_warning(escape(sg._msg("E12", staging=leftover))) + count, tools = count + len(paths), tools + 1 + except sg.SkillInstallError as exc: + if exc.leftover: + print_warning(escape(sg._msg("E12", staging=exc.leftover))) + raise if count: labels = ", ".join(dict.fromkeys(_label(r) for _, r in plan)) done = f"Installed {_n(count, 'skill folder')} for {_n(tools, 'tool')} from deepgram/skills {labels}." @@ -561,13 +547,6 @@ def _handle_setup(self, install_all: bool = False, ref: str | None = None) -> No self._install([(g, ref) for g in selected]) -def _version() -> str: - try: - return importlib.metadata.version("deepctl") - except importlib.metadata.PackageNotFoundError: - return "0.0.0" - - def _n(count: int, word: str) -> str: return f"{count} {word}{'' if count == 1 else 's'}" diff --git a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py index e0bdce52..5b953d7f 100644 --- a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py +++ b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py @@ -691,10 +691,14 @@ def test_remove_notes_03x_files_after_a_plugin_refresh(self, bundle, capsys): } } ) - state = sg.get_skills_state() # The plugin flow: shim, then a stale save. - for cli, info in state["installed_skills"].items(): - info.update(paths=[str(p) for p in gen(cli).install([], "x")]) - sg.save_skills_state(state) + from deepctl_cmd_plugin.command import PluginCommand + + capsys.readouterr() + PluginCommand()._maybe_update_skills() # The real plugin refresh. + assert err_text(capsys) == "" + assert "claude" in disk_state()["skill_folders"] + assert disk_state()["installed_skills"]["amazonq"] == {"paths": [str(rule)]} + assert disk_state()["installed_skills"]["claude"]["paths"] == [str(old)] SkillsCommand()._handle_update() # A later write keeps the flag. capsys.readouterr() SkillsCommand()._handle_remove(remove_all=True) diff --git a/packages/deepctl-core/src/deepctl_core/output.py b/packages/deepctl-core/src/deepctl_core/output.py index 04bc2ee4..54d5a3d7 100644 --- a/packages/deepctl-core/src/deepctl_core/output.py +++ b/packages/deepctl-core/src/deepctl_core/output.py @@ -369,13 +369,15 @@ def print_error(message: str) -> None: stderr_console.print(f"[red]✗[/red] {message}") -def print_warning(message: str) -> None: - """Print warning message.""" +def print_warning(message: str, *, stderr: bool = False) -> None: + """Print a warning; ``stderr=True`` keeps it off stdout outside agentic mode too.""" if not _output_config["quiet"]: if _output_config["agentic"]: stderr_console.print(f"WARN: {message}") else: - console.print(f"[yellow]⚠[/yellow] {message}") + (stderr_console if stderr else console).print( + f"[yellow]⚠[/yellow] {message}" + ) def print_info(message: str) -> None: diff --git a/packages/deepctl-core/src/deepctl_core/skill_generator.py b/packages/deepctl-core/src/deepctl_core/skill_generator.py index 3ef36b55..c6f4afa0 100644 --- a/packages/deepctl-core/src/deepctl_core/skill_generator.py +++ b/packages/deepctl-core/src/deepctl_core/skill_generator.py @@ -30,7 +30,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from rich.markup import escape + from deepctl_core import skill_bundle +from deepctl_core.output import print_warning from deepctl_core.skill_bundle import portable_name if sys.platform == "win32": @@ -43,28 +46,6 @@ from deepctl_core.skill_bundle import RepoSkill -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -@dataclass -class CommandMetadata: - """Metadata for a single deepctl command.""" - - name: str - full_command: str - help: str - agent_help: str - requires_auth: bool - ci_friendly: bool - examples: list[str] - arguments: list[dict[str, Any]] - is_group: bool - parent_group: str | None - source: str # "builtin" or "plugin" - - # --------------------------------------------------------------------------- # State management # --------------------------------------------------------------------------- @@ -295,570 +276,6 @@ def _update_state( raise _err(failure, gen, reason=_reason(exc)) from exc -def save_skills_state(state: dict[str, Any]) -> None: - """Persist the skills state file, keeping ``skill_folders`` from disk.""" - - def keep(disk: dict[str, Any]) -> None: - recs = disk.get(_RECORDS_KEY) - disk.clear() - disk.update({k: v for k, v in state.items() if k != _RECORDS_KEY}) - if recs is not None: - disk[_RECORDS_KEY] = recs - - _update_state(keep) - - -def _commands_hash(commands: list[CommandMetadata]) -> str: - """Compute a deterministic hash of the command set.""" - blob = json.dumps( - [ - { - "name": c.full_command, - "help": c.help, - "examples": c.examples, - "agent_help": c.agent_help, - } - for c in sorted(commands, key=lambda c: c.full_command) - ], - sort_keys=True, - ) - return "sha256:" + hashlib.sha256(blob.encode()).hexdigest()[:16] - - -def skills_need_update(commands: list[CommandMetadata]) -> bool: - """Return True if the installed skills are stale.""" - state = get_skills_state() - if not state.get("installed_skills"): - return False - new_hash = _commands_hash(commands) - return any( - info.get("commands_hash") != new_hash - for info in state["installed_skills"].values() - ) - - -# --------------------------------------------------------------------------- -# Command metadata collection -# --------------------------------------------------------------------------- - - -def collect_command_metadata() -> list[CommandMetadata]: - """Introspect all entry points and build a list of CommandMetadata.""" - commands: list[CommandMetadata] = [] - - eps = metadata.entry_points() - - # Top-level commands - for ep in eps.select(group="deepctl.commands"): - try: - cmd_class = ep.load() - instance = cmd_class() - is_group = getattr(instance, "is_group", False) - commands.append( - CommandMetadata( - name=instance.name, - full_command=f"deepctl {instance.name}", - help=instance.help, - agent_help=getattr(instance, "agent_help", ""), - requires_auth=getattr(instance, "requires_auth", False), - ci_friendly=getattr(instance, "ci_friendly", True), - examples=list(getattr(instance, "examples", [])), - arguments=_safe_get_arguments(instance), - is_group=is_group, - parent_group=None, - source="builtin", - ) - ) - except Exception: - pass - - # External plugins - for ep in eps.select(group="deepctl.plugins"): - try: - cmd_class = ep.load() - instance = cmd_class() - commands.append( - CommandMetadata( - name=instance.name, - full_command=f"deepctl {instance.name}", - help=instance.help, - agent_help=getattr(instance, "agent_help", ""), - requires_auth=getattr(instance, "requires_auth", False), - ci_friendly=getattr(instance, "ci_friendly", True), - examples=list(getattr(instance, "examples", [])), - arguments=_safe_get_arguments(instance), - is_group=False, - parent_group=None, - source="plugin", - ) - ) - except Exception: - pass - - # Subcommands (deepctl.subcommands.*) - # Discover subcommand groups by checking known group commands - group_names = [c.name for c in commands if c.is_group and c.parent_group is None] - for group_name in group_names: - sub_group = f"deepctl.subcommands.{group_name}" - for ep in eps.select(group=sub_group): - try: - cmd_class = ep.load() - instance = cmd_class() - commands.append( - CommandMetadata( - name=instance.name, - full_command=f"deepctl {group_name} {instance.name}", - help=instance.help, - agent_help=getattr(instance, "agent_help", ""), - requires_auth=getattr(instance, "requires_auth", False), - ci_friendly=getattr(instance, "ci_friendly", True), - examples=list(getattr(instance, "examples", [])), - arguments=_safe_get_arguments(instance), - is_group=False, - parent_group=group_name, - source="builtin", - ) - ) - except Exception: - pass - - return commands - - -def _safe_get_arguments(instance: Any) -> list[dict[str, Any]]: - """Safely call get_arguments(), returning [] on failure.""" - try: - args = instance.get_arguments() - # Sanitize — remove non-serializable types - clean: list[dict[str, Any]] = [] - for arg in args: - entry: dict[str, Any] = {} - for k, v in arg.items(): - if k == "type": - entry[k] = getattr(v, "__name__", str(v)) - else: - entry[k] = v - clean.append(entry) - return clean - except Exception: - return [] - - -# --------------------------------------------------------------------------- -# Content rendering -# --------------------------------------------------------------------------- - - -def render_developer_guide( - version: str, - *, - include_frontmatter: bool = False, -) -> str: - """Render the Deepgram Developer Guide skill content. - - This replaces the old command-metadata rendering with a comprehensive - guide covering all Deepgram products, SDKs, and developer resources. - - Args: - version: deepctl version string - include_frontmatter: If True, prepend YAML frontmatter (for Claude Code) - - Returns: - Rendered Markdown content - """ - lines: list[str] = [] - - if include_frontmatter: - lines.append("---") - lines.append( - "description: Deepgram Developer Guide — build with speech-to-text, " - "text-to-speech, audio intelligence, and voice agents" - ) - lines.append("---") - lines.append("") - - lines.append("# Deepgram Developer Guide") - lines.append("") - lines.append( - f"> Auto-generated by deepctl v{version} — regenerate with `dg skills update`" - ) - lines.append("") - - # --- Overview --- - lines.append("## Overview") - lines.append("") - lines.append( - "Deepgram is an AI speech platform providing APIs for speech-to-text (STT), " - "text-to-speech (TTS), audio intelligence, and real-time voice agents." - ) - lines.append("") - lines.append("- **Console:** ") - lines.append("- **Docs:** ") - lines.append("- **API Reference:** ") - lines.append("") - - # --- Authentication --- - lines.append("## Authentication") - lines.append("") - lines.append( - "All API requests require an API key. Create one at " - "." - ) - lines.append("") - lines.append("```bash") - lines.append("# Set as environment variable") - lines.append('export DEEPGRAM_API_KEY="your-api-key"') - lines.append("") - lines.append("# Or use the CLI") - lines.append("dg login") - lines.append("```") - lines.append("") - - # --- Speech-to-Text --- - lines.append("## Speech-to-Text (STT)") - lines.append("") - lines.append("Convert audio to text — pre-recorded files or real-time streams.") - lines.append("") - lines.append("### Models") - lines.append("") - lines.append("- **Nova-3** — Latest and most accurate. Best for most use cases.") - lines.append("- **Nova-2** — Previous generation. Still excellent accuracy.") - lines.append( - "- **Whisper** — Open-source model, available via Deepgram's infrastructure." - ) - lines.append("") - lines.append("### Key Features") - lines.append("") - lines.append("- **Diarization** (`diarize=true`) — Speaker identification") - lines.append( - "- **Smart Formatting** (`smart_format=true`) — " - "Punctuation, casing, numerals, dates" - ) - lines.append("- **Redaction** (`redact=true`) — PII removal (PCI, SSN, numbers)") - lines.append( - "- **Language Detection** (`detect_language=true`) — " - "Auto-detect spoken language" - ) - lines.append( - "- **Keywords** (`keywords=word:boost`) — Boost recognition of specific terms" - ) - lines.append( - "- **Utterances** (`utterances=true`) — Segment transcript by speaker turns" - ) - lines.append("- **Paragraphs** (`paragraphs=true`) — Auto-paragraph the transcript") - lines.append("") - lines.append("### Pre-recorded Example (Python)") - lines.append("") - lines.append("```python") - lines.append("from deepgram import DeepgramClient, PrerecordedOptions") - lines.append("") - lines.append('dg = DeepgramClient("DEEPGRAM_API_KEY")') - lines.append("") - lines.append('with open("audio.wav", "rb") as f:') - lines.append(' source = {"buffer": f.read()}') - lines.append("") - lines.append("options = PrerecordedOptions(") - lines.append(' model="nova-3", smart_format=True, diarize=True') - lines.append(")") - lines.append("") - lines.append('response = dg.listen.rest.v("1").transcribe_file(source, options)') - lines.append("print(response.results.channels[0].alternatives[0].transcript)") - lines.append("```") - lines.append("") - lines.append("### Pre-recorded Example (JavaScript)") - lines.append("") - lines.append("```javascript") - lines.append('import { createClient } from "@deepgram/sdk";') - lines.append("") - lines.append('const dg = createClient("DEEPGRAM_API_KEY");') - lines.append("") - lines.append("const { result } = await dg.listen.prerecorded.transcribeFile(") - lines.append(' fs.readFileSync("audio.wav"),') - lines.append(' { model: "nova-3", smart_format: true, diarize: true }') - lines.append(");") - lines.append("") - lines.append("console.log(result.results.channels[0].alternatives[0].transcript);") - lines.append("```") - lines.append("") - lines.append("### Streaming Example (Python)") - lines.append("") - lines.append("```python") - lines.append("from deepgram import DeepgramClient, LiveOptions") - lines.append("") - lines.append('dg = DeepgramClient("DEEPGRAM_API_KEY")') - lines.append('connection = dg.listen.websocket.v("1")') - lines.append("") - lines.append("def on_message(self, result, **kwargs):") - lines.append(" transcript = result.channel.alternatives[0].transcript") - lines.append(" if transcript:") - lines.append(' print(f"Transcript: {transcript}")') - lines.append("") - lines.append('connection.on("Results", on_message)') - lines.append("") - lines.append('options = LiveOptions(model="nova-3", language="en")') - lines.append("connection.start(options)") - lines.append("# Send audio data via connection.send(audio_bytes)") - lines.append("```") - lines.append("") - - # --- Text-to-Speech --- - lines.append("## Text-to-Speech (TTS)") - lines.append("") - lines.append( - "Generate natural-sounding speech from text using Deepgram's Aura and " - "Flux voices." - ) - lines.append("") - lines.append("### Models") - lines.append("") - lines.append( - "- **Aura-2** — Latest generation. High quality, low latency, many voices." - ) - lines.append("- **Aura** — Previous generation. Solid quality and performance.") - lines.append( - "- **Flux** — Conversational TTS on the Speak v2 WebSocket API " - "(streaming, turn-based). Voices like `flux-alexis-en`." - ) - lines.append("") - lines.append("### Popular Voices") - lines.append("") - lines.append("Aura voices follow `aura-2-{name}-en`. Examples:") - lines.append("- `aura-2-andromeda-en`, `aura-2-arcas-en`, `aura-2-atlas-en`") - lines.append("- `aura-2-luna-en`, `aura-2-stella-en`, `aura-2-helios-en`") - lines.append("") - lines.append( - "Flux (Speak v2) voices follow `flux-{name}-en` (English at launch), " - "e.g. `flux-alexis-en`." - ) - lines.append("") - lines.append("Full voice list: ") - lines.append("") - lines.append("### Aura TTS — Speak v1, batch REST (Python)") - lines.append("") - lines.append("```python") - lines.append("from deepgram import DeepgramClient") - lines.append("") - lines.append('client = DeepgramClient(api_key="DEEPGRAM_API_KEY")') - lines.append("") - lines.append("audio = client.speak.v1.audio.generate(") - lines.append(' text="Hello from Deepgram!",') - lines.append(' model="aura-2-andromeda-en",') - lines.append(' encoding="mp3",') - lines.append(")") - lines.append('with open("output.mp3", "wb") as f:') - lines.append(" for chunk in audio:") - lines.append(" f.write(chunk)") - lines.append("```") - lines.append("") - lines.append("### Flux TTS — Speak v2, WebSocket streaming (Python)") - lines.append("") - lines.append( - "`expressivity` is beta and defaults to `0` (nominal delivery) when omitted." - ) - lines.append("") - lines.append("```python") - lines.append("from deepgram import DeepgramClient") - lines.append("from deepgram.speak.v2.types.speak_v2speak import SpeakV2Speak") - lines.append("") - lines.append('client = DeepgramClient(api_key="DEEPGRAM_API_KEY")') - lines.append("") - lines.append("with client.speak.v2.connect(") - lines.append(' model="flux-alexis-en",') - lines.append(' encoding="linear16",') - lines.append(' sample_rate="24000",') - lines.append(" speed=1.0, # 0.85–1.15 in 0.05 steps (optional)") - lines.append(" expressivity=0, # beta; -2..2, default 0 = nominal (optional)") - lines.append(") as conn:") - lines.append( - ' conn.send_speak(SpeakV2Speak(type="Speak", text="Hello from Flux!"))' - ) - lines.append(" conn.send_flush()") - lines.append(" conn.send_close()") - lines.append(' with open("output.raw", "wb") as f:') - lines.append(" for message in conn:") - lines.append(" if isinstance(message, bytes):") - lines.append(" f.write(message) # raw linear16 PCM, 24kHz mono") - lines.append("```") - lines.append("") - lines.append("### Aura TTS — Speak v1 (JavaScript)") - lines.append("") - lines.append("```javascript") - lines.append('import { createClient } from "@deepgram/sdk";') - lines.append("") - lines.append('const client = createClient("DEEPGRAM_API_KEY");') - lines.append("") - lines.append("const response = await client.speak.v1.audio.generate({") - lines.append(' text: "Hello from Deepgram!",') - lines.append(' model: "aura-2-andromeda-en",') - lines.append("});") - lines.append("const buffer = await response.arrayBuffer();") - lines.append("// Write buffer to a file or audio output") - lines.append("```") - lines.append("") - - # --- Audio Intelligence --- - lines.append("## Audio Intelligence") - lines.append("") - lines.append( - "Extract meaning from audio beyond transcription. " - "Add these features as query parameters to STT requests." - ) - lines.append("") - lines.append( - "- **Summarization** (`summarize=v2`) — Generate a summary of the audio content" - ) - lines.append( - "- **Topic Detection** (`detect_topics=true`) — " - "Identify topics discussed in the audio" - ) - lines.append("- **Intent Recognition** (`intents=true`) — Detect speaker intents") - lines.append( - "- **Sentiment Analysis** (`sentiment=true`) — Analyze sentiment per utterance" - ) - lines.append("") - - # --- Voice Agent API --- - lines.append("## Voice Agent API") - lines.append("") - lines.append( - "Build real-time conversational voice AI with Deepgram's Voice Agent API. " - "Combines STT, TTS, and LLM orchestration over a single WebSocket." - ) - lines.append("") - lines.append("### Key Capabilities") - lines.append("") - lines.append("- Real-time bidirectional audio streaming") - lines.append("- Barge-in support (interrupt the agent mid-speech)") - lines.append("- Function calling (agent can invoke tools)") - lines.append("- Configurable LLM provider and voice") - lines.append("") - lines.append("Docs: ") - lines.append("") - - # --- SDKs --- - lines.append("## SDKs") - lines.append("") - lines.append("| Language | Package | Install |") - lines.append("|----------|---------|---------|") - lines.append( - "| Python | " - "[deepgram-sdk](https://github.com/deepgram/deepgram-python-sdk) | " - "`pip install deepgram-sdk` |" - ) - lines.append( - "| JavaScript/TS | " - "[@deepgram/sdk](https://github.com/deepgram/deepgram-js-sdk) | " - "`npm install @deepgram/sdk` |" - ) - lines.append( - "| Go | " - "[deepgram-go-sdk](https://github.com/deepgram/deepgram-go-sdk) | " - "`go get github.com/deepgram/deepgram-go-sdk` |" - ) - lines.append( - "| .NET | " - "[Deepgram.SDK](https://github.com/deepgram/deepgram-dotnet-sdk) | " - "`dotnet add package Deepgram` |" - ) - lines.append( - "| Rust | " - "[deepgram](https://github.com/deepgram/deepgram-rust-sdk) | " - "`cargo add deepgram` |" - ) - lines.append("") - - # --- deepctl CLI --- - lines.append("## deepctl CLI") - lines.append("") - lines.append( - "The `deepctl` CLI (aliases: `deepgram`, `dg`) provides command-line " - "access to Deepgram features." - ) - lines.append("") - lines.append("```bash") - lines.append("dg login # Authenticate") - lines.append("dg listen audio.wav # Transcribe a file") - lines.append("dg listen --mic # Live transcription from mic") - lines.append( - 'dg speak "Hello from Deepgram" --play # Text-to-speech, played aloud' - ) - lines.append("dg speak --list-voices # List available TTS voices") - lines.append("dg projects list # List projects") - lines.append("dg usage # View API usage") - lines.append("dg mcp # Start MCP server") - lines.append("dg --help # Full command reference") - lines.append("```") - lines.append("") - - # --- MCP Server --- - lines.append("## MCP Server Integration") - lines.append("") - lines.append( - "deepctl includes an MCP (Model Context Protocol) server that " - "exposes Deepgram tools to AI assistants." - ) - lines.append("") - lines.append("### Setup") - lines.append("") - lines.append("Add to your AI assistant's MCP configuration:") - lines.append("") - lines.append("```json") - lines.append("{") - lines.append(' "mcpServers": {') - lines.append(' "deepgram": {') - lines.append(' "command": "dg",') - lines.append(' "args": ["mcp"]') - lines.append(" }") - lines.append(" }") - lines.append("}") - lines.append("```") - lines.append("") - lines.append( - "The MCP server exposes tools for transcription, TTS, " - "project management, and usage queries." - ) - lines.append("") - - # --- Resources --- - lines.append("## Resources") - lines.append("") - lines.append("- **Documentation:** ") - lines.append("- **API Reference:** ") - lines.append("- **API Playground:** ") - lines.append("- **Console:** ") - lines.append("- **Discord:** ") - lines.append("- **GitHub:** ") - lines.append("- **Community:** ") - lines.append("- **Starter Apps:** ") - lines.append("- **Templates:** ") - lines.append("") - - return "\n".join(lines).rstrip() + "\n" - - -def render_skill_content( - commands: list[CommandMetadata], - version: str, - *, - include_frontmatter: bool = False, -) -> str: - """Render the full skill file content. - - Delegates to :func:`render_developer_guide` to produce a comprehensive - Deepgram developer guide rather than a CLI command reference. - - Args: - commands: List of command metadata (retained for backward compatibility; - not used for rendering) - version: deepctl version string - include_frontmatter: If True, prepend YAML frontmatter (for Claude Code) - - Returns: - Rendered Markdown content - """ - return render_developer_guide(version, include_frontmatter=include_frontmatter) - - def _folders(state: dict[str, Any], cli: str) -> dict[str, Any]: folders: dict[str, Any] = ( state.get(_RECORDS_KEY, {}).get(cli, {}).get("folders", {}) @@ -992,17 +409,6 @@ def detect(self) -> bool: found = any(Path.home().joinpath(*h).is_dir() for h in self.homes) return found or bool(self.binary and shutil.which(self.binary)) - def install(self, commands: list[CommandMetadata], version: str) -> list[Path]: # noqa: ARG002 - """Compatibility shim for login and plugin; goal 3 deletes it.""" - if self.skills_root() is None: # Hint-only: never fetches, never raises. - with contextlib.suppress(SkillInstallError, AttributeError, TypeError): - legacy = get_skills_state()["installed_skills"].get(self.cli_name, {}) - return [Path(p) for p in legacy.get("paths", [])] # Keeps 0.3.x. - return [] - ref = _ref_for(self.cli_name, get_skills_state()) # Keeps a --ref (SF5). - skills = skill_bundle.fetch_skill_bundle(ref) - return install_tool(self, skills, ref=ref, version=version)[0] - _GENERATORS = [ SkillGenerator(*row) @@ -1280,6 +686,61 @@ def settle(state: dict[str, Any]) -> None: # From proof on disk, not ``placed`` return [root / n for n in placed], leftover +def _deepctl_version() -> str: + try: + return metadata.version("deepctl") + except metadata.PackageNotFoundError: + return "0.0.0" + + +def install_for( + plan: Sequence[tuple[SkillGenerator, str]], +) -> Iterator[tuple[SkillGenerator, list[Path], Path | None]]: + """The one install path for 'dg skills', login and plugin (B3). + + Fetches each ref once and preflights every folder tool before anything is + written, then installs tool by tool, yielding (tool, placed, leftover). + Hint-only tools are skipped. The first failure is raised; every tool + yielded before it stays recorded. + """ + plan = [(g, r) for g, r in plan if g.skills_root() is not None] + bundles = { + r: skill_bundle.fetch_skill_bundle(r) for r in dict.fromkeys(r for _, r in plan) + } + unproven: list[Path] = [] + edited: list[Path] = [] + for ref, skills in bundles.items(): + u, e = install_conflicts([g for g, r in plan if r == ref], skills) + unproven += u + edited += e + if unproven or edited: # Nothing is written for any tool. + raise SkillOwnershipError(unproven, edited) + version = _deepctl_version() + for gen, ref in plan: + placed, leftover = install_tool(gen, bundles[ref], ref=ref, version=version) + yield gen, placed, leftover + + +def warn_install_failure(prefix: str, exc: Exception, retry: str) -> None: + """A plain warning on stderr, after an E12 line when staging was left. + + For best-effort callers (login, plugin): rerunning them does not retry the + skills step, so ``retry`` names the command that does. + """ + leftover = getattr(exc, "leftover", None) + if leftover: + print_warning(escape(_msg("E12", staging=leftover)), stderr=True) + text = str(exc) or type(exc).__name__ + text = text.replace("and changed nothing;", "and stopped;") # E27 + again = ", then run the command again." + if again in text: # Every sentence: an ownership error joins one per folder. + text = text.replace(again, f", then {retry}.") + else: + text = f"{text.rstrip('.')}; {retry}." + head = "" if " are installed, but " in text else f"{prefix}: " # E9b + print_warning(escape(f"{head}{text}"), stderr=True) + + @dataclass class RemoveResult: """What ``remove_tool`` did with each recorded folder.""" diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py index 3c0d8fa5..8d9bb851 100644 --- a/packages/deepctl-core/tests/unit/test_output_channels.py +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -287,3 +287,37 @@ def test_closed_file_value_error_still_exits_one(self) -> None: def test_any_other_value_error_surfaces(self) -> None: with pytest.raises(ValueError, match="something else"): self._run(ValueError("something else")) + + +class TestWarningOnStderr: + """``print_warning(stderr=True)`` keeps stdout clean in every mode (BG-3).""" + + @pytest.mark.parametrize(("agentic", "prefix"), [(False, "⚠ "), (True, "WARN: ")]) + def test_print_warning_stderr_keeps_stdout_clean( + self, capsys, agentic: bool, prefix: str + ) -> None: + from unittest.mock import patch + + with patch.dict(output._output_config, {"agentic": agentic, "quiet": False}): + output.print_warning("careful", stderr=True) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.strip() == f"{prefix}careful" + + def test_print_warning_default_stays_on_stdout_outside_agentic( + self, capsys + ) -> None: + from unittest.mock import patch + + with patch.dict(output._output_config, {"agentic": False, "quiet": False}): + output.print_warning("careful") + captured = capsys.readouterr() + assert captured.out.strip() == "⚠ careful" + assert captured.err == "" + + def test_print_warning_stderr_is_silenced_by_quiet(self, capsys) -> None: + from unittest.mock import patch + + with patch.dict(output._output_config, {"agentic": False, "quiet": True}): + output.print_warning("careful", stderr=True) + assert capsys.readouterr() == ("", "") diff --git a/packages/deepctl-core/tests/unit/test_skill_generator.py b/packages/deepctl-core/tests/unit/test_skill_generator.py index ff781800..365d8311 100644 --- a/packages/deepctl-core/tests/unit/test_skill_generator.py +++ b/packages/deepctl-core/tests/unit/test_skill_generator.py @@ -20,90 +20,23 @@ from deepctl_core import skill_generator as sg from deepctl_core.skill_bundle import RepoSkill from deepctl_core.skill_generator import ( - CommandMetadata, SkillInstallError, SkillOwnershipError, - _commands_hash, _fingerprint, _marker_ok, _msg, _ownership, _place, - collect_command_metadata, detect_ai_clis, get_all_generators, get_skills_state, install_conflicts, install_tool, remove_tool, - render_developer_guide, - render_skill_content, - save_skills_state, - skills_need_update, tool_status, ) -def _make_command(**overrides): - """Create a CommandMetadata with sensible defaults.""" - defaults = { - "name": "test", - "full_command": "deepctl test", - "help": "A test command", - "agent_help": "Test agent help", - "requires_auth": False, - "ci_friendly": True, - "examples": ["dg test foo"], - "arguments": [], - "is_group": False, - "parent_group": None, - "source": "builtin", - } - defaults.update(overrides) - return CommandMetadata(**defaults) - - -class TestCommandMetadata: - """Test CommandMetadata dataclass.""" - - def test_create(self): - cmd = _make_command() - assert cmd.name == "test" - assert cmd.full_command == "deepctl test" - assert cmd.examples == ["dg test foo"] - - def test_create_with_parent_group(self): - cmd = _make_command( - name="audio", parent_group="debug", full_command="deepctl debug audio" - ) - assert cmd.parent_group == "debug" - assert cmd.full_command == "deepctl debug audio" - - -class TestCommandsHash: - """Test _commands_hash.""" - - def test_deterministic(self): - cmds = [ - _make_command(), - _make_command(name="other", full_command="deepctl other"), - ] - h1 = _commands_hash(cmds) - h2 = _commands_hash(cmds) - assert h1 == h2 - assert h1.startswith("sha256:") - - def test_changes_when_commands_differ(self): - cmds1 = [_make_command()] - cmds2 = [_make_command(help="Different help")] - assert _commands_hash(cmds1) != _commands_hash(cmds2) - - def test_order_independent(self): - a = _make_command(name="a", full_command="deepctl a") - b = _make_command(name="b", full_command="deepctl b") - assert _commands_hash([a, b]) == _commands_hash([b, a]) - - class TestSkillsState: """Test state management functions.""" @@ -112,119 +45,6 @@ def test_get_skills_state_missing_file(self, tmp_path): state = get_skills_state() assert state == {"installed_skills": {}, "auto_update": True} - def test_save_and_get_skills_state(self, tmp_path): - state_file = tmp_path / "skills.json" - with ( - patch("deepctl_core.skill_generator._STATE_FILE", state_file), - patch("deepctl_core.skill_generator._SKILLS_DIR", tmp_path), - ): - save_skills_state( - {"installed_skills": {"claude": {}}, "auto_update": False} - ) - result = get_skills_state() - assert result["installed_skills"] == {"claude": {}} - assert result["auto_update"] is False - - def test_skills_need_update_no_installed(self): - with patch( - "deepctl_core.skill_generator.get_skills_state", - return_value={"installed_skills": {}}, - ): - assert skills_need_update([_make_command()]) is False - - def test_skills_need_update_stale_hash(self): - state = {"installed_skills": {"claude": {"commands_hash": "sha256:old"}}} - with patch("deepctl_core.skill_generator.get_skills_state", return_value=state): - assert skills_need_update([_make_command()]) is True - - -class TestRenderDeveloperGuide: - """Test render_developer_guide and render_skill_content delegation.""" - - def test_basic_render(self): - content = render_developer_guide("1.0.0") - assert "# Deepgram Developer Guide" in content - assert "v1.0.0" in content - assert "Authentication" in content - - def test_contains_stt_content(self): - content = render_developer_guide("1.0.0") - assert "Speech-to-Text" in content - assert "Nova-3" in content - assert "diarize" in content - assert "smart_format" in content - - def test_contains_tts_content(self): - content = render_developer_guide("1.0.0") - assert "Text-to-Speech" in content - assert "Aura-2" in content - assert "Aura and Flux voices" in content - assert "aura-2-andromeda-en" in content - assert "`expressivity` is beta" in content - assert "defaults to `0`" in content - - def test_contains_audio_intelligence(self): - content = render_developer_guide("1.0.0") - assert "Audio Intelligence" in content - assert "summarize" in content - assert "sentiment" in content - - def test_contains_voice_agent(self): - content = render_developer_guide("1.0.0") - assert "Voice Agent" in content - assert "barge-in" in content.lower() or "Barge-in" in content - - def test_contains_sdks(self): - content = render_developer_guide("1.0.0") - assert "deepgram-sdk" in content - assert "@deepgram/sdk" in content - assert "pip install" in content - assert "npm install" in content - - def test_contains_resources(self): - content = render_developer_guide("1.0.0") - assert "developers.deepgram.com" in content - assert "console.deepgram.com" in content - assert "discord.gg/deepgram" in content - assert "github.com/deepgram" in content - - def test_contains_mcp_server(self): - content = render_developer_guide("1.0.0") - assert "MCP" in content - assert '"dg"' in content or "'dg'" in content - assert "mcpServers" in content - - def test_contains_cli_section(self): - content = render_developer_guide("1.0.0") - assert "deepctl CLI" in content - assert "dg listen" in content - assert "dg login" in content - # The speak quickstart leads with playback and voice discovery. - assert 'dg speak "Hello from Deepgram" --play' in content - assert "dg speak --list-voices" in content - - def test_frontmatter(self): - content = render_developer_guide("1.0.0", include_frontmatter=True) - assert content.startswith("---\n") - assert "description:" in content - - def test_no_frontmatter_by_default(self): - content = render_developer_guide("1.0.0") - assert not content.startswith("---") - - def test_render_skill_content_delegates(self): - """render_skill_content should delegate to render_developer_guide.""" - cmds = [_make_command()] - content = render_skill_content(cmds, "1.0.0") - assert "# Deepgram Developer Guide" in content - assert "Speech-to-Text" in content - - def test_render_skill_content_frontmatter(self): - cmds = [_make_command()] - content = render_skill_content(cmds, "1.0.0", include_frontmatter=True) - assert content.startswith("---\n") - assert "description:" in content - # --------------------------------------------------------------------------- # Skill folder install: fixtures and helpers @@ -860,7 +680,7 @@ def racer(a, b, *x, **k): class TestB6: def test_record_before_swap_failure_moves_nothing(self, tmp_path, monkeypatch): - """_write_state is the write both save_skills_state() and the installer use.""" + """_write_state is the one write the installer uses (via _update_state).""" install(make_bundle(tmp_path)) v1, saved = sha_tree(root()), state_bytes() calls = [] @@ -900,25 +720,6 @@ def read_bytes(self): assert sha_tree(root()) == v1 assert staging_dirs(root()) == [] - def test_save_skills_state_failure_after_shim_install_keeps_records( - self, tmp_path, monkeypatch - ): - skills = make_bundle(tmp_path) - monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", lambda ref=None: skills) - state = get_skills_state() - paths = gen("claude").install([], "x") - state["installed_skills"]["claude"] = {"paths": [str(p) for p in paths]} - wrap( - monkeypatch, - sg, - "_write_state", - lambda s: (_ for _ in ()).throw(OSError(errno.EIO, "I/O error")), - ) - with pytest.raises(SkillInstallError) as exc: - save_skills_state(state) - assert str(exc.value) == _msg("E9c", reason="I/O error") - assert set(records()) == {"api", "docs"} - def test_final_save_failure_leaves_installing_records_that_prove_ownership( self, tmp_path, monkeypatch ): @@ -985,7 +786,7 @@ def test_state_dir_temp_lookalike_survives(self, tmp_path): lookalike.parent.mkdir(parents=True) lookalike.write_bytes(b"mine") install(make_bundle(tmp_path)) - save_skills_state(get_skills_state()) + sg._update_state(lambda state: None) # One more skills.json write. remove_tool(gen("claude")) assert lookalike.read_bytes() == b"mine" @@ -2186,7 +1987,7 @@ def test_corrupt_state_is_refused_and_left_byte_identical(self, tmp_path, conten expected = _msg("E7") for call in ( get_skills_state, - lambda: save_skills_state({"installed_skills": {}}), + lambda: sg._update_state(lambda state: None), lambda: install(make_bundle(tmp_path)), ): with pytest.raises(SkillInstallError) as exc: @@ -2201,7 +2002,7 @@ def fail(fd): monkeypatch.setattr(os, "fsync", fail) with pytest.raises(SkillInstallError) as exc: - save_skills_state({"installed_skills": {}}) + sg._update_state(lambda state: None) assert str(exc.value) == _msg("E9c", reason="No space left on device") assert [ n for n in os.listdir(sg._STATE_FILE.parent) if n.endswith(".tmp") @@ -2213,7 +2014,7 @@ def interrupt(fd): monkeypatch.setattr(os, "fsync", interrupt) with pytest.raises(KeyboardInterrupt): - save_skills_state({"installed_skills": {}}) + sg._update_state(lambda state: None) assert os.listdir(sg._STATE_FILE.parent) == ["skills.json.lock"] def test_symlinked_state_file_stays_a_symlink(self, tmp_path): @@ -2222,34 +2023,19 @@ def test_symlinked_state_file_stays_a_symlink(self, tmp_path): real.write_text('{"installed_skills": {}}', encoding="utf-8") sg._STATE_FILE.parent.mkdir(parents=True) symlink_or_skip(sg._STATE_FILE, rel(real, sg._STATE_FILE), is_dir=False) - save_skills_state({"installed_skills": {"x": {}}, "auto_update": False}) + sg._update_state(lambda state: state.update(auto_update=False)) assert sg._STATE_FILE.is_symlink() assert json.loads(real.read_text(encoding="utf-8"))["auto_update"] is False - def test_public_save_cannot_write_records(self): - forged = { - "claude": {"folders": {"api": {"state": "installed", "fingerprint": FP_A}}} - } - save_skills_state({"installed_skills": {}, "skill_folders": forged}) - assert "skill_folders" not in disk_state() - # --------------------------------------------------------------------------- -# Compatibility shim for login and plugin +# Survivors and the tool table # --------------------------------------------------------------------------- class TestSurvivors: """Each pins one guard a mutation sweep could otherwise drop unnoticed.""" - def test_save_validates_before_writing(self): - write_state({"installed_skills": {}, "auto_update": True}) - saved = state_bytes() - with pytest.raises(SkillInstallError) as exc: - save_skills_state({"installed_skills": [], "auto_update": True}) - assert str(exc.value) == _msg("E7") - assert state_bytes() == saved - def test_recorded_ref_is_validated(self): state = { "installed_skills": {}, @@ -2365,68 +2151,6 @@ def test_reason_drops_a_trailing_period(self): "Permission denied" ) - -class TestShim: - def test_stale_save_shim_login_flow_keeps_records(self, tmp_path, monkeypatch): - skills = make_bundle(tmp_path) - monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", lambda ref=None: skills) - state = get_skills_state() - paths = gen("claude").install([], "x") - state["installed_skills"]["claude"] = { - "paths": [str(p) for p in paths], - "version": "x", - } - save_skills_state(state) - assert set(records()) == {"api", "docs"} - assert install_conflicts([gen("claude")], skills) == ([], []) - - def test_stale_save_shim_plugin_flow_keeps_records(self, tmp_path, monkeypatch): - skills = make_bundle(tmp_path) - monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", lambda ref=None: skills) - write_state( - {"installed_skills": {"claude": {"paths": ["old"]}, "aider": {"paths": []}}} - ) - state = get_skills_state() - gens = {g.cli_name: g for g in get_all_generators()} - for cli, info in state["installed_skills"].items(): - info.update(paths=[str(p) for p in gens[cli].install([], "x")]) - save_skills_state(state) - assert set(records()) == {"api", "docs"} - assert install_conflicts([gen("claude")], skills) == ([], []) - - def test_shim_follows_recorded_ref(self, tmp_path, monkeypatch): - skills = make_bundle(tmp_path) - fetched = [] - monkeypatch.setattr( - skill_bundle, - "fetch_skill_bundle", - lambda ref=None: fetched.append(ref) or skills, - ) - install(skills, ref="my-branch") - gen("claude").install([], "x") - assert fetched == ["my-branch"] - assert disk_state()["skill_folders"]["claude"]["skills_ref"] == "my-branch" - monkeypatch.setenv(skill_bundle.REF_ENV_VAR, "env-ref") - gen("claude").install([], "x") - assert fetched[-1] == "env-ref" - monkeypatch.delenv(skill_bundle.REF_ENV_VAR) - gen("cursor").install([], "x") - assert fetched[-1] == REF - - def test_hint_only_install_returns_empty_and_never_raises(self, monkeypatch): - def boom(ref=None): - raise AssertionError("fetched") - - monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", boom) - assert gen("amazonq").install([], "x") == [] - assert gen("aider").install([], "x") == [] - rule = Path.home() / ".amazonq" / "rules" / "deepctl.md" - write_state({"installed_skills": {"amazonq": {"paths": [str(rule)]}}}) - assert gen("amazonq").install([], "x") == [rule] # Keeps 0.3.x paths. - for bad in ({"installed_skills": {"amazonq": "x"}}, {"skill_folders": 1}): - write_state(bad) # Corrupt: [] so plugin's loop goes on. - assert gen("amazonq").install([], "x") == [] - def test_remove_e21_keeps_records(self, tmp_path, monkeypatch): install(make_bundle(tmp_path)) saved = state_bytes() @@ -2441,18 +2165,24 @@ def denied(*a, **k): assert state_bytes() == saved assert (root() / "api").is_dir() - def test_shim_surface_exists(self): + +class TestToolTable: + def test_generator_has_no_install_shim(self): + assert not hasattr(sg.SkillGenerator, "install") for name in ( - "detect_ai_clis", - "get_all_generators", - "get_skills_state", "save_skills_state", "collect_command_metadata", "_commands_hash", + "skills_need_update", + "render_developer_guide", + "render_skill_content", + "CommandMetadata", ): + assert not hasattr(sg, name), name + for name in ("detect_ai_clis", "get_all_generators", "get_skills_state"): assert callable(getattr(sg, name)) for g in get_all_generators(): - assert g.cli_name and g.display_name and callable(g.install) + assert g.cli_name and g.display_name assert [g.cli_name for g in get_all_generators()] == [ "claude", "codex", @@ -2499,6 +2229,166 @@ def test_tool_table_roots_and_detection( assert g.detect() is (binary is not None) +# --------------------------------------------------------------------------- +# install_for: the one path for 'dg skills', login and plugin (B3) +# --------------------------------------------------------------------------- + + +class TestInstallFor: + def _fetch(self, monkeypatch, skills): + fetched = [] + monkeypatch.setattr( + skill_bundle, + "fetch_skill_bundle", + lambda ref=None: fetched.append(ref) or skills, + ) + return fetched + + def test_install_for_fetches_each_ref_once_and_preflights_every_tool( + self, tmp_path, monkeypatch + ): + fetched = self._fetch(monkeypatch, make_bundle(tmp_path)) + mine = root("cursor") / "api" + mine.mkdir(parents=True) + (mine / "notes.md").write_bytes(b"mine") + before = sha_tree(mine) + plan = [(gen("claude"), REF), (gen("cursor"), REF)] + with pytest.raises(SkillOwnershipError) as exc: + list(sg.install_for(plan)) + assert str(exc.value) == _msg("E1", paths=str(mine)) + assert fetched == [REF] + assert not root("claude").exists() + assert sha_tree(mine) == before + assert state_bytes() is None + + def test_install_for_fetches_each_distinct_ref_once(self, tmp_path, monkeypatch): + fetched = self._fetch(monkeypatch, make_bundle(tmp_path)) + plan = [(gen("claude"), REF), (gen("cursor"), "b"), (gen("cline"), REF)] + done = [g.cli_name for g, _, _ in sg.install_for(plan)] + assert done == ["claude", "cursor", "cline"] + assert fetched == [REF, "b"] + assert disk_state()["skill_folders"]["cursor"]["skills_ref"] == "b" + + def test_install_for_skips_hint_only_tools_without_fetching(self, monkeypatch): + def boom(ref=None): + raise AssertionError("fetched") + + monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", boom) + assert list(sg.install_for([(gen("amazonq"), REF), (gen("aider"), REF)])) == [] + assert state_bytes() is None + + def test_install_for_second_tool_failure_keeps_first_recorded( + self, tmp_path, monkeypatch + ): + skills = make_bundle(tmp_path) + self._fetch(monkeypatch, skills) + + def fail_cursor(g, *a, **k): + if g.cli_name == "cursor": + raise sg._err("E5", g, reason="disk full") + + wrap(monkeypatch, sg, "install_tool", fail_cursor) + done = [] + plan = [(gen("claude"), REF), (gen("cursor"), REF), (gen("cline"), REF)] + with pytest.raises(SkillInstallError) as exc: + for g, placed, _ in sg.install_for(plan): + done.append((g.cli_name, len(placed))) + assert str(exc.value) == _msg("E5", gen("cursor"), reason="disk full") + assert done == [("claude", 2)] + assert {n: r["state"] for n, r in records().items()} == { + "api": "installed", + "docs": "installed", + } + assert install_conflicts([gen("claude")], skills) == ([], []) + assert set(disk_state()["skill_folders"]) == {"claude"} + assert not root("cline").exists() + + def test_skills_json_has_one_writer(self, tmp_path, monkeypatch): + self._fetch(monkeypatch, make_bundle(tmp_path)) + writes = [] + wrap(monkeypatch, sg, "_write_state", lambda s: writes.append(1) and None) + updates = [] + wrap(monkeypatch, sg, "_update_state", lambda *a, **k: updates.append(1) and None) + list(sg.install_for([(gen("claude"), REF), (gen("cursor"), REF)])) + assert len(writes) == len(updates) == 4 # mark + settle per tool + + @pytest.mark.parametrize("agentic", [False, True]) + def test_warn_install_failure_is_one_stderr_line(self, capsys, agentic): + from deepctl_core import output + + output._output_config["agentic"] = agentic + retry = "run 'dg x' to try again" + sg.warn_install_failure("Step did not finish", SkillInstallError("[b]x[/b]."), retry) + sg.warn_install_failure("Step did not finish", RuntimeError(), retry) + out, err = capsys.readouterr() + assert out == "" + lines = err.splitlines() + assert len(lines) == 2 + assert lines[0].endswith("Step did not finish: [b]x[/b]; run 'dg x' to try again.") + assert lines[1].endswith("Step did not finish: RuntimeError; run 'dg x' to try again.") + + def test_warn_install_failure_names_the_retry_command_not_a_rerun(self, capsys): + e1 = _msg("E1", paths="/p") + sg.warn_install_failure("Step", SkillInstallError(e1), "run 'dg x' to try again") + _, err = capsys.readouterr() + assert "run the command again" not in err + assert err.splitlines() == [ + "WARN: Step: deepctl cannot prove it installed /p, so it will not replace" + " anything there; move or rename what is there, then run 'dg x' to try again." + ] + + def test_warn_install_failure_names_the_retry_in_every_sentence( + self, capsys, monkeypatch + ): + from deepctl_core import output + + monkeypatch.setattr(output.stderr_console, "_width", 2000) + home = Path.home() + p1, p2, p3 = home / "a", home / "b", home / "c" + sg.warn_install_failure("Step", SkillOwnershipError([p1], [p2, p3]), "retry") + _, err = capsys.readouterr() + [line] = err.splitlines() + assert "run the command again" not in line + assert line.count(", then retry.") == 3 + for p in (p1, p2, p3): + assert str(p) in line + assert line.endswith(", then retry.") + + def test_warn_install_failure_e27_says_stopped_not_changed_nothing(self, capsys): + # Earlier tools may already be installed, so "changed nothing" is not true. + sg.warn_install_failure("Step", SkillInstallError(_msg("E27")), "retry") + _, err = capsys.readouterr() + assert "changed nothing" not in err + assert err.splitlines() == [ + "WARN: Step: " + + _msg("E27") + .replace("changed nothing", "stopped") + .replace(", then run the command again.", ", then retry.") + ] + + def test_warn_install_failure_e9b_has_no_not_updated_prefix( + self, capsys, monkeypatch + ): + from deepctl_core import output + + monkeypatch.setattr(output.stderr_console, "_width", 2000) + e9b = sg._err("E9b", gen("claude"), reason="disk full") + sg.warn_install_failure("Step did not finish", e9b, "retry") + _, err = capsys.readouterr() + assert err.splitlines() == ["WARN: " + str(e9b).rstrip(".") + "; retry."] + + def test_warn_install_failure_reports_leftover_staging_first(self, capsys): + exc = SkillInstallError("boom.") + exc.leftover = Path.home() / ".deepctl-staging-1" + sg.warn_install_failure("Step did not finish", exc, "retry") + out, err = capsys.readouterr() + assert out == "" + assert err.splitlines() == [ + "WARN: " + _msg("E12", staging=exc.leftover), + "WARN: Step did not finish: boom; retry.", + ] + + # --------------------------------------------------------------------------- # Concurrency # --------------------------------------------------------------------------- @@ -2529,17 +2419,6 @@ def other_run_first(src, dest): assert _ownership(root() / n, "claude", n, records()[n]) == "ok" assert staging_dirs(root()) == [] - def test_login_save_during_install_keeps_records(self, tmp_path, monkeypatch): - stale = get_skills_state() - - def login_save(*a, **k): - save_skills_state(stale) - - wrap(monkeypatch, sg, "_swap", login_save) - install(make_bundle(tmp_path)) - assert set(records()) == {"api", "docs"} - assert all(r["state"] == "installed" for r in records().values()) - def test_root_alias_second_tool_refuses_without_writing(self, tmp_path): root("claude").mkdir(parents=True) (Path.home() / ".cursor").mkdir() @@ -2791,7 +2670,7 @@ def test_lock_held_by_another_process_gives_e27_and_changes_nothing( for op in ( lambda: install(make_bundle(tmp_path, body="v2")), lambda: remove_tool(gen("claude")), - lambda: save_skills_state(get_skills_state()), + lambda: sg._update_state(lambda state: None), ): with pytest.raises(SkillInstallError) as exc: op() @@ -2997,7 +2876,7 @@ def fetch(ref=None): return skills monkeypatch.setattr(skill_bundle, "fetch_skill_bundle", fetch) - gen("claude").install([], "x") + list(sg.install_for([(gen("claude"), "x")])) assert set(records()) == {"api", "docs"} def test_lock_messages_end_with_the_retry_phrase(self):