drydock-cli 3.0.51__tar.gz → 3.0.52__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {drydock_cli-3.0.51/drydock_cli.egg-info → drydock_cli-3.0.52}/PKG-INFO +1 -1
- drydock_cli-3.0.52/drydock/skills.py +90 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tui/app.py +34 -1
- {drydock_cli-3.0.51 → drydock_cli-3.0.52/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/pyproject.toml +1 -1
- drydock_cli-3.0.52/tests/test_skills.py +56 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/LICENSE +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/NOTICE +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/README.md +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/agent.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/cli.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/config.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/guards.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/providers.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock/web.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/setup.cfg +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_config.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.52}/tests/test_web_tools.py +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Skills — user-defined reusable slash commands.
|
|
2
|
+
|
|
3
|
+
A skill is a markdown file with simple frontmatter; its body is a prompt the
|
|
4
|
+
user invokes as ``/<name>`` in the TUI (or --cli). It saves retyping common
|
|
5
|
+
instructions ("/review", "/explain", "/testgen") and ships project conventions
|
|
6
|
+
alongside the code.
|
|
7
|
+
|
|
8
|
+
Discovery (project overrides user on a name clash):
|
|
9
|
+
• ~/.drydock/skills/*.md (personal, all projects)
|
|
10
|
+
• <cwd>/.drydock/skills/*.md (this project)
|
|
11
|
+
|
|
12
|
+
File format (frontmatter optional; name defaults to the filename):
|
|
13
|
+
---
|
|
14
|
+
name: review
|
|
15
|
+
description: Review the current diff for bugs
|
|
16
|
+
---
|
|
17
|
+
Run GitDiff, then review the changes for bugs and suggest fixes.
|
|
18
|
+
|
|
19
|
+
Invocation:
|
|
20
|
+
• ``/review`` → the body becomes the user turn.
|
|
21
|
+
• ``/review focus on perf`` → trailing text replaces ``$ARGS`` in the body,
|
|
22
|
+
or is appended if the body has no ``$ARGS`` placeholder.
|
|
23
|
+
|
|
24
|
+
All logic original to Drydock.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
_FRONTMATTER = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.S)
|
|
33
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$", re.I)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Skill:
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
body: str
|
|
41
|
+
source: str # file path, for /skills listing
|
|
42
|
+
|
|
43
|
+
def render(self, args: str = "") -> str:
|
|
44
|
+
"""Expand the skill into a prompt, substituting/append args."""
|
|
45
|
+
args = (args or "").strip()
|
|
46
|
+
if "$ARGS" in self.body:
|
|
47
|
+
return self.body.replace("$ARGS", args)
|
|
48
|
+
return f"{self.body}\n\n{args}".rstrip() if args else self.body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse(path: Path) -> Skill | None:
|
|
52
|
+
try:
|
|
53
|
+
text = path.read_text("utf-8", "ignore")
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
name = path.stem.lower()
|
|
57
|
+
description = ""
|
|
58
|
+
body = text
|
|
59
|
+
m = _FRONTMATTER.match(text)
|
|
60
|
+
if m:
|
|
61
|
+
meta, body = m.group(1), m.group(2)
|
|
62
|
+
for line in meta.splitlines():
|
|
63
|
+
if ":" in line:
|
|
64
|
+
key, _, val = line.partition(":")
|
|
65
|
+
key, val = key.strip().lower(), val.strip()
|
|
66
|
+
if key == "name" and val:
|
|
67
|
+
name = val.lower()
|
|
68
|
+
elif key == "description":
|
|
69
|
+
description = val
|
|
70
|
+
body = body.strip()
|
|
71
|
+
if not body or not _NAME_RE.match(name):
|
|
72
|
+
return None
|
|
73
|
+
return Skill(name=name, description=description, body=body, source=str(path))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def skills_dirs(cwd: str) -> list[Path]:
|
|
77
|
+
return [Path.home() / ".drydock" / "skills", Path(cwd) / ".drydock" / "skills"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_skills(cwd: str) -> dict[str, Skill]:
|
|
81
|
+
"""Load all skills; later dirs (project) override earlier (user) by name."""
|
|
82
|
+
skills: dict[str, Skill] = {}
|
|
83
|
+
for d in skills_dirs(cwd):
|
|
84
|
+
if not d.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
for f in sorted(d.glob("*.md")):
|
|
87
|
+
sk = _parse(f)
|
|
88
|
+
if sk:
|
|
89
|
+
skills[sk.name] = sk
|
|
90
|
+
return skills
|
|
@@ -236,6 +236,8 @@ class DrydockApp(App):
|
|
|
236
236
|
self.config = config
|
|
237
237
|
self.state = AgentState()
|
|
238
238
|
self.system = self._build_system(config.get("model"))
|
|
239
|
+
from drydock.skills import load_skills
|
|
240
|
+
self._skills = load_skills(config.get("cwd") or ".")
|
|
239
241
|
self._current_assistant: AssistantMessage | None = None
|
|
240
242
|
self._last_card: ToolCard | None = None
|
|
241
243
|
self._busy = False
|
|
@@ -375,7 +377,8 @@ class DrydockApp(App):
|
|
|
375
377
|
return
|
|
376
378
|
hint = ""
|
|
377
379
|
if text.startswith("/") and " " not in text and "\n" not in text:
|
|
378
|
-
|
|
380
|
+
commands = SLASH_COMMANDS + ["/skills"] + [f"/{n}" for n in sorted(self._skills)]
|
|
381
|
+
matches = [c for c in commands if c.startswith(text.lower())]
|
|
379
382
|
if matches:
|
|
380
383
|
hint = " " + " ".join(matches) + " · Tab to complete"
|
|
381
384
|
self.query_one("#working", Static).update(hint)
|
|
@@ -485,13 +488,43 @@ class DrydockApp(App):
|
|
|
485
488
|
" /compact shrink old context to free up the window\n"
|
|
486
489
|
" /graphrag build/query a knowledge base from your docs\n"
|
|
487
490
|
" /graphrag build <path> · /graphrag status · /graphrag clear\n"
|
|
491
|
+
" /skills list your reusable /<name> skills\n"
|
|
488
492
|
" /clear reset the conversation\n"
|
|
489
493
|
" /quit exit\n"
|
|
490
494
|
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
491
495
|
)
|
|
496
|
+
elif cmd == "/skills":
|
|
497
|
+
self._cmd_skills()
|
|
498
|
+
elif cmd[1:] in self._skills:
|
|
499
|
+
self._run_skill(self._skills[cmd[1:]], arg)
|
|
492
500
|
else:
|
|
493
501
|
self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
|
|
494
502
|
|
|
503
|
+
def _cmd_skills(self) -> None:
|
|
504
|
+
if not self._skills:
|
|
505
|
+
self._info(
|
|
506
|
+
"No skills yet. Create one as a markdown file in "
|
|
507
|
+
"~/.drydock/skills/<name>.md (or <project>/.drydock/skills/), "
|
|
508
|
+
"then invoke it as /<name>. The body is the prompt; use $ARGS for "
|
|
509
|
+
"trailing input."
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
lines = ["Skills (invoke as /<name>):"]
|
|
513
|
+
for name in sorted(self._skills):
|
|
514
|
+
sk = self._skills[name]
|
|
515
|
+
lines.append(f" /{name}" + (f" — {sk.description}" if sk.description else ""))
|
|
516
|
+
self._info("\n".join(lines))
|
|
517
|
+
|
|
518
|
+
def _run_skill(self, skill, arg: str) -> None:
|
|
519
|
+
"""Expand a skill into a prompt and run it as a normal user turn."""
|
|
520
|
+
prompt = skill.render(arg)
|
|
521
|
+
self._mount(UserMessage(f"/{skill.name}" + (f" {arg}" if arg else "")))
|
|
522
|
+
if self._busy:
|
|
523
|
+
self._queue.append(prompt)
|
|
524
|
+
self._refresh_status()
|
|
525
|
+
else:
|
|
526
|
+
self._begin(prompt)
|
|
527
|
+
|
|
495
528
|
def _cmd_compact(self) -> None:
|
|
496
529
|
"""Manually compact the conversation to reclaim context NOW, without
|
|
497
530
|
waiting for the automatic 60%-of-window threshold (agent.maybe_compact).
|
|
@@ -15,6 +15,7 @@ drydock/graphrag.py
|
|
|
15
15
|
drydock/guards.py
|
|
16
16
|
drydock/loop_detect.py
|
|
17
17
|
drydock/providers.py
|
|
18
|
+
drydock/skills.py
|
|
18
19
|
drydock/tool_registry.py
|
|
19
20
|
drydock/tuning.py
|
|
20
21
|
drydock/web.py
|
|
@@ -55,6 +56,7 @@ tests/test_oneshot_unreachable.py
|
|
|
55
56
|
tests/test_plan_autocontinue.py
|
|
56
57
|
tests/test_providers_unreachable.py
|
|
57
58
|
tests/test_runaway_repetition.py
|
|
59
|
+
tests/test_skills.py
|
|
58
60
|
tests/test_stop.py
|
|
59
61
|
tests/test_streaming_newlines.py
|
|
60
62
|
tests/test_subagent.py
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.52"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Skills: user-defined reusable /<name> slash commands loaded from markdown."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from drydock import skills
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write(d, name, content):
|
|
8
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
9
|
+
(d / name).write_text(content)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_load_parses_frontmatter_and_body(tmp_path):
|
|
13
|
+
_write(tmp_path / ".drydock" / "skills", "review.md",
|
|
14
|
+
"---\nname: review\ndescription: Review the diff\n---\nRun GitDiff and review it.")
|
|
15
|
+
sk = skills.load_skills(str(tmp_path))
|
|
16
|
+
assert "review" in sk
|
|
17
|
+
assert sk["review"].description == "Review the diff"
|
|
18
|
+
assert sk["review"].body == "Run GitDiff and review it."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_name_defaults_to_filename(tmp_path):
|
|
22
|
+
_write(tmp_path / ".drydock" / "skills", "explain.md", "Explain the selected code.")
|
|
23
|
+
sk = skills.load_skills(str(tmp_path))
|
|
24
|
+
assert "explain" in sk and sk["explain"].body == "Explain the selected code."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_render_appends_args_without_placeholder(tmp_path):
|
|
28
|
+
_write(tmp_path / ".drydock" / "skills", "t.md", "Do the thing.")
|
|
29
|
+
sk = skills.load_skills(str(tmp_path))["t"]
|
|
30
|
+
assert sk.render("on auth.py") == "Do the thing.\n\non auth.py"
|
|
31
|
+
assert sk.render("") == "Do the thing."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_render_substitutes_args_placeholder(tmp_path):
|
|
35
|
+
_write(tmp_path / ".drydock" / "skills", "t.md", "Focus on $ARGS and nothing else.")
|
|
36
|
+
sk = skills.load_skills(str(tmp_path))["t"]
|
|
37
|
+
assert sk.render("performance") == "Focus on performance and nothing else."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_project_overrides_user(tmp_path, monkeypatch):
|
|
41
|
+
home = tmp_path / "home"
|
|
42
|
+
proj = tmp_path / "proj"
|
|
43
|
+
monkeypatch.setattr(skills.Path, "home", lambda: home)
|
|
44
|
+
_write(home / ".drydock" / "skills", "x.md", "USER version")
|
|
45
|
+
_write(proj / ".drydock" / "skills", "x.md", "PROJECT version")
|
|
46
|
+
sk = skills.load_skills(str(proj))
|
|
47
|
+
assert sk["x"].body == "PROJECT version"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_empty_body_skipped(tmp_path):
|
|
51
|
+
_write(tmp_path / ".drydock" / "skills", "empty.md", "---\nname: empty\n---\n")
|
|
52
|
+
assert "empty" not in skills.load_skills(str(tmp_path))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_no_skills_dir_is_empty(tmp_path):
|
|
56
|
+
assert skills.load_skills(str(tmp_path)) == {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|