Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ebf9471
feat(skills): fetch the upstream skill list from the marketplace mani…
dg-coreylweathers Sep 20, 2026
a9be894
test(skills): cover the bundle fetcher, including every failure path
dg-coreylweathers Sep 20, 2026
cf1b004
fix(skills): install skills as folders, into the directory each tool …
dg-coreylweathers Sep 20, 2026
dbf1474
test(skills): rewrite the generator tests and add a real end-to-end i…
dg-coreylweathers Sep 20, 2026
de4be91
docs(readme): document where dg skills installs and how to pin the ref
dg-coreylweathers Sep 20, 2026
602eef1
fix(skills): describe the real behaviour in help, status and list
dg-coreylweathers Sep 20, 2026
2eb922b
chore: drop skills installed into the working tree by mistake
dg-coreylweathers Sep 20, 2026
b301766
fix(skills): only ever touch skill folders deepctl installed
dg-coreylweathers Sep 22, 2026
143a26e
test(skills): cover all six install destinations end to end, and owne…
dg-coreylweathers Sep 22, 2026
ae0d1c2
fix(skills): compare resolved paths when checking ownership
dg-coreylweathers Sep 22, 2026
2e87f31
test(skills): read the status table's own column instead of grepping …
dg-coreylweathers Sep 22, 2026
4821a2c
fix(skills): close the gaps a second pass over the ownership model found
dg-coreylweathers Sep 22, 2026
f1b6db5
fix(skills): stop the test suite touching the developer's own machine
dg-coreylweathers Sep 22, 2026
7d5c920
fix(skills): keep ownership through every failure path
dg-coreylweathers Sep 23, 2026
c04e205
fix(skills): close the holes a review of the ownership rewrite found
dg-coreylweathers Sep 23, 2026
18c2fde
test(skills): cover list, non-TTY setup, and the remove paths a revie…
dg-coreylweathers Sep 23, 2026
eb150b7
fix(skills): give a stranded record one verdict, not two
dg-coreylweathers Sep 23, 2026
ce0e939
docs(skills): name the status column the table actually prints
dg-coreylweathers Sep 23, 2026
d99e928
fix(skills): retire unsupported tools inside the run, and report hone…
dg-coreylweathers Sep 23, 2026
346e95c
docs(readme): say what login and plugin actually do on a skills failure
dg-coreylweathers Sep 23, 2026
bf084f3
test(skills): cover remove unlinking a recorded path that became a file
dg-coreylweathers Sep 23, 2026
9647fdd
fix(skills): stop legacy cleanup taking the install down with it
dg-coreylweathers Sep 23, 2026
97cfd91
fix(skills): harden the record file, the update path and the shared root
dg-coreylweathers Sep 23, 2026
02c57d1
fix(skills): stop legacy cleanup deleting through a symlinked directory
dg-coreylweathers Sep 23, 2026
44ef2a5
docs(skills): say what the legacy cleanup does in the cases it does not
dg-coreylweathers Sep 23, 2026
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
98 changes: 96 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,108 @@ Add to your editor's MCP config:

### AI Tool Integration

Automatically detect and configure AI coding assistants with Deepgram skills.
Install the Deepgram agent skills from
[`deepgram/skills`](https://github.com/deepgram/skills) into the AI coding
tools on this machine. Each skill is installed as a folder, into the
user-scope skills directory the tool's own documentation names.

```bash
dg skills status # Detect AI tools
dg skills status # Detect AI tools and show their skills directories
dg skills setup # Interactive setup wizard
dg skills install --all # Install for all detected tools
dg skills list # Show what is installed, and from which ref
dg skills update # Reinstall from upstream
dg skills remove --all # Uninstall (--cli NAME for one tool)
```

| Tool | Skills directory |
| --- | --- |
| Claude Code | `~/.claude/skills/` |
| OpenAI Codex | `~/.agents/skills/` |
| Gemini CLI | `~/.gemini/skills/` |
| Cursor | `~/.cursor/skills/` |
| OpenCode | `~/.config/opencode/skills/` |
| Cline | `~/.cline/skills/` |

Amazon Q Developer and Aider have no skills mechanism, so `dg skills` prints
`npx skills add deepgram/skills` for those rather than writing a file they
would not read.

Installs are pinned to a released `deepgram/skills` tag so the same deepctl
version always installs the same skills. Override with `--ref` or the
`DEEPCTL_SKILLS_REF` environment variable:

```bash
dg skills install --all --ref main # track the upstream default branch
```

A failed download, an unknown ref, or an upstream manifest that does not match
the directories it lists is a hard failure (exit 1) with nothing written — a
partial install is indistinguishable from a complete one once it is on disk.

**deepctl only ever touches skill folder paths it installed.** Those
directories are shared: your own skills and other publishers' skills live in
them too. So `dg skills` records every folder it writes in
`~/.deepctl/skills/skills.json` and works on that list alone.

- `install`, `update` and `setup` refuse to overwrite a folder that is not on
the list — if you already have a skill called `api`, the install exits 1 and
writes nothing, naming the folder so you can rename it.
- `remove` deletes only the recorded folders. An unrelated skill in the same
directory stays. A recorded folder it *could not* delete — a permission
error, a read-only mount — stays recorded and `remove` exits 1, so the next
`remove` or `update` can still reach it. Dropping the record there would
leave Deepgram's own folders behind with nothing able to touch them.
- `status` counts only the recorded folders, not everything with a `SKILL.md`.
- Those exit codes are for the `dg skills` subcommands. Two other commands
install skills. `dg login` offers the same install after a successful
login, but only at an interactive prompt and only while nothing is
recorded as installed yet. `dg plugin install/update/remove` refreshes what
is already installed, unless `auto_update` is set to `false` in
`skills.json`. Both go through the same ownership rules, but a collision
or a download failure there is a warning rather than a failure — a skills
problem never changes whether the login or the plugin operation succeeded.
Run `dg skills install` to see the error and get the exit code.
- If you delete `skills.json`, deepctl can no longer prove it installed
anything: `remove` deletes nothing and `install` reports the collision rather
than reclaiming the folders. Delete them by hand, then install again.
- The list holds *paths*, not fingerprints. Delete a folder deepctl installed
and put your own folder — or a file — at the same path without running
`dg skills remove --cli <tool>`, and deepctl still counts it as its own: the
next `update` replaces it and `remove` deletes it. Where the filesystem
ignores case, as macOS and Windows do by default, `API` and `api` are one path
for this purpose. So run `dg skills remove --cli <tool>` first, or drop the
entry from `skills.json`, before reusing a name deepctl installed under.
- A *symlink* is the exception: deepctl never writes or deletes through one.
Put a symlink where a recorded skill folder was and that path stops being
deepctl's — `install`, `update` and `setup` exit 1 naming it rather than
replacing it, and `remove` reports where it is, drops it from the list and
leaves it on disk rather than following it to whatever it points at. Delete
the symlink yourself to hand the name back; until you do, installing under
that name keeps failing.

#### Upgrading from deepctl 0.2.16 through 0.3.0

Those versions wrote to paths that are not skills directories, so `install`,
`update`, `setup` and `remove` clear them for the tools that run — a command
that exits early, such as an install that hits a collision or cannot download,
clears nothing. Otherwise four stale skills would sit next to fourteen fresh
ones. These paths are the only thing `dg skills` touches outside its own skill
folders and its own `~/.deepctl/` directory, and the list is scoped to what
0.3.0 wrote:

| Path | What happens |
| --- | --- |
| `~/.claude/commands/deepgram/` | Deletes `api.md`, `docs.md`, `setup-mcp.md`, `starters.md` and `deepgram.md` by name, whoever wrote them; a command you added under any other name stays, and the directory goes only if that empties it. If `deepgram` is itself a symlink — dotfiles kept in a repo — nothing is deleted through it |
| `~/.codex/instructions.md`, `~/.gemini/GEMINI.md`, `~/.opencode/agents.md` | Cuts out only the section between `<!-- BEGIN deepctl CLI Reference (auto-generated by deepctl) -->` and `<!-- END deepctl CLI Reference -->`; the rest of the file is yours and is kept. If the opening marker is there without the closing one — what a write cut short leaves behind — everything after it counts as that unfinished section and goes. These files are followed through a symlink, because only deepctl's own marked section is ever touched |
| `~/.cursor/rules/deepctl.mdc`, `~/.cline/rules/deepctl.md`, `~/.amazonq/rules/deepctl.md` | Deleted — 0.3.0 created these files and nothing else writes them |
| `~/.aider.conf.yml` | Drops the stale `read:` entry pointing at deepctl's old conventions file |

deepctl 0.2.15 and earlier wrote one combined file at
`~/.claude/commands/deepctl.md` instead, with no marker around it. Nothing
distinguishes it from a `/deepctl` slash command you wrote yourself, so the
cleanup leaves it alone. Delete it by hand if it is there.

### Starter Apps

Scaffold a new project from Deepgram templates.
Expand Down
66 changes: 49 additions & 17 deletions packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Login command for deepctl."""

from pathlib import Path
from typing import Any

from deepctl_core import (
Expand Down Expand Up @@ -237,41 +238,72 @@ def _maybe_prompt_skills_setup(self) -> None:
console.print("[dim]No tools selected.[/dim]")
return

# Install skills for selected tools
# Install skills for selected tools. The same shared helper
# 'dg skills install' uses, so login cannot drift from the
# ownership contract: one fetch for every tool, every
# destination preflighted before anything is written, and the
# record saved as each tool lands rather than at the end.
# Best-effort here only in that a failure is reported and the
# login still succeeds -- never in that a folder is written
# without deepctl recording that it owns it.
from deepctl_core.skill_bundle import SkillFetchError
from deepctl_core.skill_generator import (
_commands_hash,
collect_command_metadata,
install_skills_for,
save_skills_state,
)

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,
}
def announce(gen: Any, paths: list[Path]) -> None:
for p in paths:
console.print(f" [green]✓[/green] {gen.display_name} → {p}")

try:
report = install_skills_for(
selected,
state,
commands=collect_command_metadata(),
version=version,
on_installed=announce,
best_effort=True,
)
except SkillFetchError as exc:
# Nothing was written, so there is no ownership to save.
# Say so rather than leaving the banner above unanswered.
console.print(
f"[yellow] Could not download the Deepgram skills: "
f"{exc}. Run 'dg skills install' to retry.[/yellow]"
)
return
for gen in report.unsupported:
console.print(f"[yellow] {gen.manual_hint()}[/yellow]")
# Someone else's skill folder has one of these names, so the
# tool was skipped rather than have their work overwritten.
for display_name, path in report.conflicts:
console.print(
f"[yellow] Skipped {display_name}: {path} is not "
"deepctl's to replace.[/yellow]"
)
for display_name, failure in report.failures:
console.print(
f"[yellow] {display_name}: {failure}. Run "
"'dg skills install' to retry.[/yellow]"
)
save_skills_state(state)
console.print(
"\n[green]Skills installed![/green] "
"[dim]Run 'dg skills update' after plugin changes.[/dim]"
)

if report.total_written:
console.print(
"\n[green]Skills installed![/green] "
"[dim]Run 'dg skills update' after plugin changes.[/dim]"
)
except Exception:
pass # Best-effort — never fail the login

Expand Down
136 changes: 136 additions & 0 deletions packages/deepctl-cmd-login/tests/unit/test_login_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the login command."""

from pathlib import Path
from unittest.mock import MagicMock, Mock, call, patch

import pytest
Expand All @@ -11,6 +12,22 @@
from deepctl_cmd_login.models import LoginResult, LogoutResult
from deepctl_core import AuthManager, Config, DeepgramClient
from deepctl_core.models import ProfileInfo, ProfilesResult
from deepctl_core.skill_bundle import RepoSkill


@pytest.fixture(autouse=True)
def _no_real_skill_installs():
"""Keep the post-login skills prompt off this machine.

A successful login calls ``_maybe_prompt_skills_setup()``, whose only
guard is ``sys.stdout.isatty()``. Under ``pytest -s`` that is True, and
the prompt then downloads the deepgram/skills bundle and installs it
into the real ``~/.claude/skills`` and friends. Reporting no detected
tools stops it at the first branch; the tests that exercise the prompt
itself patch this same function and win over this fixture.
"""
with patch("deepctl_core.skill_generator.detect_ai_clis", return_value=[]):
yield


@pytest.fixture
Expand Down Expand Up @@ -475,3 +492,122 @@ def test_env_key_without_profile_labeled_env(
profile_key=None,
)
assert result.key_source == "DEEPGRAM_API_KEY (env)"


class TestLoginRecordsTheSameStateAsSkillsInstall:
"""`dg login` writes the record `dg skills list/update/remove` then read."""

def _generator(self, cli_name, display_name, root, paths):
gen = MagicMock()
gen.cli_name = cli_name
gen.display_name = display_name
gen.skills_root.return_value = root
gen.install_conflicts.return_value = []
gen.install_skills.return_value = paths
gen.prune_retired.return_value = []
gen.manual_hint.return_value = f"{display_name} has no skills directory."
return gen

def _run(self, generators, state, skills=("api", "docs")):
cmd = LoginCommand()
cmd._guided = True
bundle = [
RepoSkill(name=name, path=Path("/upstream") / name) for name in skills
]
with (
patch("sys.stdout") as mock_stdout,
patch(
"deepctl_core.skill_generator.detect_ai_clis", return_value=generators
),
patch(
"deepctl_core.skill_generator.get_skills_state", return_value=state
),
patch("deepctl_core.skill_generator.save_skills_state"),
patch(
"deepctl_core.skill_generator.collect_command_metadata",
return_value=[],
),
patch(
"deepctl_core.skill_generator.fetch_repo_skills", return_value=bundle
) as fetch,
patch("deepctl_cmd_login.command.Prompt.ask", return_value="all"),
):
mock_stdout.isatty.return_value = True
cmd._maybe_prompt_skills_setup()
self.fetch = fetch
return state

def test_it_records_the_upstream_ref_and_skill_names(self, tmp_path):
"""Without these, `dg skills list` prints '?' for the ref it pinned."""
from deepctl_core.skill_bundle import DEFAULT_SKILLS_REF

root = tmp_path / ".claude" / "skills"
gen = self._generator(
"claude", "Claude Code", root, [root / "api", root / "docs"]
)
state = self._run([gen], {"installed_skills": {}})

entry = state["installed_skills"]["claude"]
assert entry["skills_ref"] == DEFAULT_SKILLS_REF
assert entry["skills"] == ["api", "docs"]
assert [Path(p).name for p in entry["paths"]] == ["api", "docs"]

def test_a_tool_with_no_skills_directory_is_not_recorded(self):
"""Nothing was written for it, so nothing may claim it was."""
gen = self._generator("amazonq", "Amazon Q Developer", None, [])
with patch("deepctl_cmd_login.command.console") as printer:
state = self._run([gen], {"installed_skills": {}})

assert state["installed_skills"] == {}
# The empty map is also what this started as, so on its own it
# would pass if the whole block had thrown into login's bare
# `except`. The hint only prints from the far side of the
# install, which is what pins down that it ran and declined.
printed = " ".join(str(c) for c in printer.print.call_args_list)
assert "Amazon Q Developer has no skills directory." in printed
gen.install_skills.assert_not_called()

def test_a_second_tool_failing_leaves_the_first_recorded(self, tmp_path):
"""Login used to save state only after the whole loop.

It installed tool by tool, refetching the bundle each time, and a
later failure hit the bare `except` before `save_skills_state`.
Whatever the earlier tools had written was then folders deepctl
would neither update nor remove.
"""
root = tmp_path / ".claude" / "skills"
first = self._generator("claude", "Claude Code", root, [root / "api"])
second = self._generator(
"cursor", "Cursor", tmp_path / ".cursor" / "skills", []
)
second.install_skills.side_effect = OSError(30, "Read-only file system")

with patch("deepctl_cmd_login.command.console") as printer:
state = self._run(
[first, second], {"installed_skills": {}}, skills=("api",)
)

entry = state["installed_skills"]["claude"]
assert [Path(p).name for p in entry["paths"]] == ["api"]
assert "cursor" not in state["installed_skills"]
# And the user is told, rather than the login going quiet on it.
# Not just "Cursor" -- every detected tool is named in the menu
# printed before the install, so that would match either way.
printed = " ".join(str(c) for c in printer.print.call_args_list)
assert "Cursor: [Errno 30] Read-only file system" in printed
assert "Run 'dg skills install' to retry" in printed

def test_the_bundle_is_fetched_once_for_every_tool(self, tmp_path):
"""Two fetches could install two different revisions side by side."""
claude_root = tmp_path / ".claude" / "skills"
cursor_root = tmp_path / ".cursor" / "skills"
generators = [
self._generator(
"claude", "Claude Code", claude_root, [claude_root / "api"]
),
self._generator("cursor", "Cursor", cursor_root, [cursor_root / "api"]),
]

self._run(generators, {"installed_skills": {}}, skills=("api",))

assert self.fetch.call_count == 1
Loading
Loading