drydock-cli 3.0.50__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.
Files changed (71) hide show
  1. {drydock_cli-3.0.50/drydock_cli.egg-info → drydock_cli-3.0.52}/PKG-INFO +1 -1
  2. drydock_cli-3.0.52/drydock/gittools.py +96 -0
  3. drydock_cli-3.0.52/drydock/skills.py +90 -0
  4. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tools/__init__.py +107 -1
  5. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tui/app.py +34 -1
  6. {drydock_cli-3.0.50 → drydock_cli-3.0.52/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock_cli.egg-info/SOURCES.txt +4 -0
  8. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/pyproject.toml +1 -1
  9. drydock_cli-3.0.52/tests/test_gittools.py +78 -0
  10. drydock_cli-3.0.52/tests/test_skills.py +56 -0
  11. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/LICENSE +0 -0
  12. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/NOTICE +0 -0
  13. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/README.md +0 -0
  14. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/agent.py +0 -0
  17. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/cli.py +0 -0
  19. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/compaction.py +0 -0
  20. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/config.py +0 -0
  21. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/detect.py +0 -0
  22. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/graphrag.py +0 -0
  23. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/guards.py +0 -0
  24. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/loop_detect.py +0 -0
  25. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/providers.py +0 -0
  26. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tool_registry.py +0 -0
  27. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tui/__init__.py +0 -0
  28. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tui/approval.py +0 -0
  29. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tui/messages.py +0 -0
  30. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tui/widgets.py +0 -0
  31. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/tuning.py +0 -0
  32. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock/web.py +0 -0
  33. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock_cli.egg-info/dependency_links.txt +0 -0
  34. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock_cli.egg-info/entry_points.txt +0 -0
  35. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock_cli.egg-info/requires.txt +0 -0
  36. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/drydock_cli.egg-info/top_level.txt +0 -0
  37. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/setup.cfg +0 -0
  38. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_approval.py +0 -0
  39. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_back_command.py +0 -0
  40. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_bash_process_group.py +0 -0
  41. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_bash_safety.py +0 -0
  42. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_bash_timeout_network.py +0 -0
  43. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_cli_agents.py +0 -0
  44. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_compact_command.py +0 -0
  45. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_compaction.py +0 -0
  46. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_config.py +0 -0
  47. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_config_migration.py +0 -0
  48. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_context_limit_config.py +0 -0
  49. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_detect.py +0 -0
  50. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_empty_response.py +0 -0
  51. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_failure_loop.py +0 -0
  52. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_first_run_setup.py +0 -0
  53. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_graphrag.py +0 -0
  54. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_guards_and_tools.py +0 -0
  55. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_hallucinated_tools.py +0 -0
  56. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_leaked_tool_call.py +0 -0
  57. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_loop_detect.py +0 -0
  58. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_oneshot_unreachable.py +0 -0
  59. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_plan_autocontinue.py +0 -0
  60. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_providers_unreachable.py +0 -0
  61. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_runaway_repetition.py +0 -0
  62. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_stop.py +0 -0
  63. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_streaming_newlines.py +0 -0
  64. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_subagent.py +0 -0
  65. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_todo.py +0 -0
  66. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_tool_arg_parsing.py +0 -0
  67. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_tools_undo.py +0 -0
  68. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_tui.py +0 -0
  69. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_tuning.py +0 -0
  70. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_vision_input.py +0 -0
  71. {drydock_cli-3.0.50 → drydock_cli-3.0.52}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.50
3
+ Version: 3.0.52
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,96 @@
1
+ """Dedicated Version Control helpers — first-class git for the agent.
2
+
3
+ Git works through the generic Bash tool, but purpose-built git_* tools give the
4
+ model concise, structured, TRUNCATED output (a raw `git diff` can blow the
5
+ context window) and a safe commit path, without it having to remember exact git
6
+ invocations. Read ops (status/diff/log) are side-effect-free; commit is local
7
+ and reversible (push stays gated through Bash's approval modal).
8
+
9
+ All logic original to Drydock.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+
15
+
16
+ class GitError(RuntimeError):
17
+ """A git operation failed (not a repo, git missing, or a command error)."""
18
+
19
+
20
+ def _run_git(args: list[str], cwd: str, timeout: float = 30.0) -> str:
21
+ """Run `git <args>` in cwd, returning stdout. Raises GitError on failure."""
22
+ try:
23
+ p = subprocess.run(
24
+ ["git", *args], cwd=cwd, capture_output=True, text=True,
25
+ timeout=timeout,
26
+ )
27
+ except FileNotFoundError as e:
28
+ raise GitError("git is not installed or not on PATH") from e
29
+ except subprocess.TimeoutExpired as e:
30
+ raise GitError(f"git {args[0]} timed out after {timeout:.0f}s") from e
31
+ if p.returncode != 0:
32
+ err = (p.stderr or p.stdout or "").strip()
33
+ if "not a git repository" in err.lower():
34
+ raise GitError(f"not a git repository: {cwd}")
35
+ raise GitError(err or f"git {args[0]} failed (exit {p.returncode})")
36
+ return p.stdout
37
+
38
+
39
+ def _truncate(text: str, max_chars: int, *, tail: bool = False) -> str:
40
+ if len(text) <= max_chars:
41
+ return text
42
+ note = f"\n[... {len(text) - max_chars} chars truncated ...]\n"
43
+ return (note + text[-max_chars:]) if tail else (text[:max_chars] + note)
44
+
45
+
46
+ def status(cwd: str) -> str:
47
+ """Branch + concise porcelain status of the working tree."""
48
+ branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).strip()
49
+ porcelain = _run_git(["status", "--porcelain=v1", "--branch"], cwd).strip()
50
+ if len(porcelain.splitlines()) <= 1: # only the "## branch" header line
51
+ return f"On branch {branch}. Working tree clean."
52
+ return f"On branch {branch}:\n{_truncate(porcelain, 4000)}"
53
+
54
+
55
+ def diff(cwd: str, path: str | None = None, *, staged: bool = False,
56
+ max_chars: int = 8000) -> str:
57
+ """Working-tree (or --staged) diff, optionally scoped to a path. Truncated."""
58
+ args = ["diff"]
59
+ if staged:
60
+ args.append("--cached")
61
+ if path:
62
+ args += ["--", path]
63
+ out = _run_git(args, cwd)
64
+ if not out.strip():
65
+ scope = " (staged)" if staged else ""
66
+ return f"No{scope} changes" + (f" in {path}" if path else "") + "."
67
+ # Lead with a --stat summary so the model sees the shape even when truncated.
68
+ stat_args = ["diff", "--stat"] + (["--cached"] if staged else [])
69
+ if path:
70
+ stat_args += ["--", path]
71
+ stat = _run_git(stat_args, cwd).strip()
72
+ return f"{stat}\n\n{_truncate(out, max_chars)}"
73
+
74
+
75
+ def log(cwd: str, n: int = 10) -> str:
76
+ """The last n commits, one line each."""
77
+ n = max(1, min(n, 50))
78
+ out = _run_git(["log", f"-{n}", "--oneline", "--decorate"], cwd).strip()
79
+ return out or "No commits yet."
80
+
81
+
82
+ def commit(cwd: str, message: str, *, add_all: bool = True) -> str:
83
+ """Stage (all, by default) and commit. Local + reversible (git reset undoes
84
+ it). Refuses an empty message. Does NOT push."""
85
+ message = (message or "").strip()
86
+ if not message:
87
+ raise GitError("a commit needs a non-empty message")
88
+ if add_all:
89
+ _run_git(["add", "-A"], cwd)
90
+ # Nothing staged → say so instead of erroring opaquely.
91
+ staged = _run_git(["diff", "--cached", "--name-only"], cwd).strip()
92
+ if not staged:
93
+ return "Nothing to commit — the working tree has no staged changes."
94
+ out = _run_git(["commit", "-m", message], cwd)
95
+ head = _run_git(["rev-parse", "--short", "HEAD"], cwd).strip()
96
+ return f"Committed {head}:\n{out.strip()}"
@@ -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
@@ -161,6 +161,56 @@ SCHEMAS = [
161
161
  "required": ["prompt"],
162
162
  },
163
163
  },
164
+ {
165
+ "name": "GitStatus",
166
+ "description": (
167
+ "Show the current branch and a concise list of changed/staged/"
168
+ "untracked files (git status). Use it to understand the working tree "
169
+ "before editing or committing."
170
+ ),
171
+ "input_schema": {"type": "object", "properties": {}},
172
+ },
173
+ {
174
+ "name": "GitDiff",
175
+ "description": (
176
+ "Show the diff of changes (git diff), with a --stat summary first and "
177
+ "the body truncated to fit context. Set staged=true for staged "
178
+ "changes; pass a path to scope it to one file/dir."
179
+ ),
180
+ "input_schema": {
181
+ "type": "object",
182
+ "properties": {
183
+ "path": {"type": "string", "description": "Optional file/dir to scope the diff."},
184
+ "staged": {"type": "boolean", "description": "Diff staged changes instead of the working tree."},
185
+ },
186
+ },
187
+ },
188
+ {
189
+ "name": "GitLog",
190
+ "description": "Show the last n commits, one line each (git log --oneline).",
191
+ "input_schema": {
192
+ "type": "object",
193
+ "properties": {
194
+ "n": {"type": "integer", "description": "How many commits (default 10)."},
195
+ },
196
+ },
197
+ },
198
+ {
199
+ "name": "GitCommit",
200
+ "description": (
201
+ "Stage all changes and commit with a message (git add -A && git "
202
+ "commit). Local and reversible; does NOT push. Use it to package a "
203
+ "completed, verified change. Write a clear, specific message."
204
+ ),
205
+ "input_schema": {
206
+ "type": "object",
207
+ "properties": {
208
+ "message": {"type": "string", "description": "The commit message."},
209
+ "add_all": {"type": "boolean", "description": "Stage all changes first (default true)."},
210
+ },
211
+ "required": ["message"],
212
+ },
213
+ },
164
214
  {
165
215
  "name": "WebSearch",
166
216
  "description": (
@@ -769,6 +819,54 @@ def tool_task(params: dict, config: dict) -> str:
769
819
  return f"[sub-agent finished {steps} step(s) with no summary]"
770
820
 
771
821
 
822
+ def _git_cwd(config: dict) -> str:
823
+ return config.get("cwd") or os.getcwd()
824
+
825
+
826
+ def tool_gitstatus(params: dict, config: dict) -> str:
827
+ from drydock import gittools
828
+ try:
829
+ return gittools.status(_git_cwd(config))
830
+ except gittools.GitError as e:
831
+ return f"git status failed: {e}"
832
+
833
+
834
+ def tool_gitdiff(params: dict, config: dict) -> str:
835
+ from drydock import gittools
836
+ try:
837
+ return gittools.diff(
838
+ _git_cwd(config),
839
+ path=(params.get("path") or None),
840
+ staged=bool(params.get("staged")),
841
+ )
842
+ except gittools.GitError as e:
843
+ return f"git diff failed: {e}"
844
+
845
+
846
+ def tool_gitlog(params: dict, config: dict) -> str:
847
+ from drydock import gittools
848
+ try:
849
+ n = int(params.get("n") or 10)
850
+ except (TypeError, ValueError):
851
+ n = 10
852
+ try:
853
+ return gittools.log(_git_cwd(config), n=n)
854
+ except gittools.GitError as e:
855
+ return f"git log failed: {e}"
856
+
857
+
858
+ def tool_gitcommit(params: dict, config: dict) -> str:
859
+ from drydock import gittools
860
+ try:
861
+ return gittools.commit(
862
+ _git_cwd(config),
863
+ params.get("message") or "",
864
+ add_all=params.get("add_all", True) is not False,
865
+ )
866
+ except gittools.GitError as e:
867
+ return f"git commit failed: {e}"
868
+
869
+
772
870
  def tool_websearch(params: dict, config: dict) -> str:
773
871
  """Search the internet (DuckDuckGo). Read-only; clean message when offline."""
774
872
  from drydock import web
@@ -845,6 +943,10 @@ _TOOLS = [
845
943
  ("Knowledge", tool_knowledge, True),
846
944
  ("WebSearch", tool_websearch, True),
847
945
  ("WebFetch", tool_webfetch, True),
946
+ ("GitStatus", tool_gitstatus, True),
947
+ ("GitDiff", tool_gitdiff, True),
948
+ ("GitLog", tool_gitlog, True),
949
+ ("GitCommit", tool_gitcommit, False),
848
950
  ]
849
951
 
850
952
  def register_all():
@@ -855,10 +957,14 @@ def register_all():
855
957
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
856
958
  "todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
857
959
  "WebSearch": tool_websearch, "WebFetch": tool_webfetch,
960
+ "GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
961
+ "GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
858
962
  }[name]
859
- # These are read-only w.r.t. the parent's files.
963
+ # Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
964
+ # GitCommit writes a local, reversible commit).
860
965
  read_only = name in (
861
966
  "Read", "Glob", "Grep", "task", "Knowledge", "WebSearch", "WebFetch",
967
+ "GitStatus", "GitDiff", "GitLog",
862
968
  )
863
969
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
864
970
 
@@ -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
- matches = [c for c in SLASH_COMMANDS if c.startswith(text.lower())]
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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.50
3
+ Version: 3.0.52
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -10,10 +10,12 @@ drydock/cli.py
10
10
  drydock/compaction.py
11
11
  drydock/config.py
12
12
  drydock/detect.py
13
+ drydock/gittools.py
13
14
  drydock/graphrag.py
14
15
  drydock/guards.py
15
16
  drydock/loop_detect.py
16
17
  drydock/providers.py
18
+ drydock/skills.py
17
19
  drydock/tool_registry.py
18
20
  drydock/tuning.py
19
21
  drydock/web.py
@@ -44,6 +46,7 @@ tests/test_detect.py
44
46
  tests/test_empty_response.py
45
47
  tests/test_failure_loop.py
46
48
  tests/test_first_run_setup.py
49
+ tests/test_gittools.py
47
50
  tests/test_graphrag.py
48
51
  tests/test_guards_and_tools.py
49
52
  tests/test_hallucinated_tools.py
@@ -53,6 +56,7 @@ tests/test_oneshot_unreachable.py
53
56
  tests/test_plan_autocontinue.py
54
57
  tests/test_providers_unreachable.py
55
58
  tests/test_runaway_repetition.py
59
+ tests/test_skills.py
56
60
  tests/test_stop.py
57
61
  tests/test_streaming_newlines.py
58
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.50"
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,78 @@
1
+ """Dedicated Version Control tools (drydock/gittools.py): structured, truncated
2
+ git for the agent. Tests run in isolated temp repos."""
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+ import pytest
8
+
9
+ from drydock import gittools
10
+ from drydock.tools import (
11
+ tool_gitstatus, tool_gitdiff, tool_gitlog, tool_gitcommit,
12
+ )
13
+
14
+
15
+ def _git(args, cwd):
16
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
17
+
18
+
19
+ @pytest.fixture
20
+ def repo(tmp_path):
21
+ _git(["init", "-q"], tmp_path)
22
+ _git(["config", "user.email", "t@t.com"], tmp_path)
23
+ _git(["config", "user.name", "T"], tmp_path)
24
+ (tmp_path / "a.txt").write_text("hello\n")
25
+ _git(["add", "-A"], tmp_path)
26
+ _git(["commit", "-qm", "initial"], tmp_path)
27
+ return tmp_path
28
+
29
+
30
+ def test_status_clean_then_dirty(repo):
31
+ cfg = {"cwd": str(repo)}
32
+ assert "clean" in tool_gitstatus({}, cfg).lower()
33
+ (repo / "a.txt").write_text("hello\nworld\n")
34
+ out = tool_gitstatus({}, cfg)
35
+ assert "a.txt" in out and "clean" not in out.lower()
36
+
37
+
38
+ def test_diff_shows_stat_and_body(repo):
39
+ (repo / "a.txt").write_text("hello\nchanged\n")
40
+ out = tool_gitdiff({}, {"cwd": str(repo)})
41
+ assert "a.txt" in out and "changed" in out and "insertion" in out
42
+
43
+
44
+ def test_diff_no_changes(repo):
45
+ assert "No changes" in tool_gitdiff({}, {"cwd": str(repo)})
46
+
47
+
48
+ def test_log_lists_commits(repo):
49
+ out = tool_gitlog({"n": 5}, {"cwd": str(repo)})
50
+ assert "initial" in out
51
+
52
+
53
+ def test_commit_stages_and_commits(repo):
54
+ (repo / "b.txt").write_text("new file\n")
55
+ out = tool_gitcommit({"message": "add b"}, {"cwd": str(repo)})
56
+ assert "Committed" in out
57
+ assert "add b" in tool_gitlog({}, {"cwd": str(repo)})
58
+
59
+
60
+ def test_commit_empty_message_refused(repo):
61
+ (repo / "b.txt").write_text("x")
62
+ assert "non-empty message" in tool_gitcommit({"message": " "}, {"cwd": str(repo)})
63
+
64
+
65
+ def test_commit_nothing_to_commit(repo):
66
+ assert "Nothing to commit" in tool_gitcommit({"message": "noop"}, {"cwd": str(repo)})
67
+
68
+
69
+ def test_not_a_repo_is_graceful(tmp_path):
70
+ out = tool_gitstatus({}, {"cwd": str(tmp_path)})
71
+ assert "not a git repository" in out.lower()
72
+
73
+
74
+ def test_diff_truncates_large_body(repo):
75
+ # modify a TRACKED file so it appears in the working-tree diff
76
+ (repo / "a.txt").write_text("\n".join(f"line {i}" for i in range(5000)))
77
+ out = gittools.diff(str(repo), max_chars=1000)
78
+ assert "truncated" in out and len(out) < 4000
@@ -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