drydock-cli 3.0.51__tar.gz → 3.0.53__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.53}/PKG-INFO +1 -1
- drydock_cli-3.0.53/drydock/skills.py +90 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/app.py +73 -1
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/widgets.py +1 -1
- {drydock_cli-3.0.51 → drydock_cli-3.0.53/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/pyproject.toml +1 -1
- drydock_cli-3.0.53/tests/test_skills.py +56 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tui.py +37 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/LICENSE +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/NOTICE +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/README.md +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/agent.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/cli.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/config.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/guards.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/providers.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/web.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/setup.cfg +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_config.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.51 → drydock_cli-3.0.53}/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
|
|
@@ -153,7 +153,10 @@ class DrydockApp(App):
|
|
|
153
153
|
self._abort_inflight()
|
|
154
154
|
dropped = len(self._queue)
|
|
155
155
|
self._queue.clear()
|
|
156
|
+
looping = self._repeat is not None
|
|
157
|
+
self._repeat = None # Esc/stop also ends an active /loop
|
|
156
158
|
note = "⏹ stopped." + (f" (discarded {dropped} queued)" if dropped else "")
|
|
159
|
+
note += " loop ended." if looping else ""
|
|
157
160
|
self._info(note)
|
|
158
161
|
self._refresh_status()
|
|
159
162
|
|
|
@@ -236,6 +239,8 @@ class DrydockApp(App):
|
|
|
236
239
|
self.config = config
|
|
237
240
|
self.state = AgentState()
|
|
238
241
|
self.system = self._build_system(config.get("model"))
|
|
242
|
+
from drydock.skills import load_skills
|
|
243
|
+
self._skills = load_skills(config.get("cwd") or ".")
|
|
239
244
|
self._current_assistant: AssistantMessage | None = None
|
|
240
245
|
self._last_card: ToolCard | None = None
|
|
241
246
|
self._busy = False
|
|
@@ -249,6 +254,7 @@ class DrydockApp(App):
|
|
|
249
254
|
# A plain dict survives run()'s dict(config) shallow copy by reference.
|
|
250
255
|
self.config["_abort"] = {}
|
|
251
256
|
self._queue: list[str] = [] # prompts submitted while a turn is running
|
|
257
|
+
self._repeat: dict | None = None # active /loop: {prompt, remaining, total}
|
|
252
258
|
self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
|
|
253
259
|
self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
|
|
254
260
|
# Live "working" line state.
|
|
@@ -375,7 +381,8 @@ class DrydockApp(App):
|
|
|
375
381
|
return
|
|
376
382
|
hint = ""
|
|
377
383
|
if text.startswith("/") and " " not in text and "\n" not in text:
|
|
378
|
-
|
|
384
|
+
commands = SLASH_COMMANDS + ["/skills"] + [f"/{n}" for n in sorted(self._skills)]
|
|
385
|
+
matches = [c for c in commands if c.startswith(text.lower())]
|
|
379
386
|
if matches:
|
|
380
387
|
hint = " " + " ".join(matches) + " · Tab to complete"
|
|
381
388
|
self.query_one("#working", Static).update(hint)
|
|
@@ -485,13 +492,69 @@ class DrydockApp(App):
|
|
|
485
492
|
" /compact shrink old context to free up the window\n"
|
|
486
493
|
" /graphrag build/query a knowledge base from your docs\n"
|
|
487
494
|
" /graphrag build <path> · /graphrag status · /graphrag clear\n"
|
|
495
|
+
" /skills list your reusable /<name> skills\n"
|
|
496
|
+
" /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
|
|
488
497
|
" /clear reset the conversation\n"
|
|
489
498
|
" /quit exit\n"
|
|
490
499
|
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
491
500
|
)
|
|
501
|
+
elif cmd == "/loop":
|
|
502
|
+
self._cmd_loop(arg)
|
|
503
|
+
elif cmd == "/skills":
|
|
504
|
+
self._cmd_skills()
|
|
505
|
+
elif cmd[1:] in self._skills:
|
|
506
|
+
self._run_skill(self._skills[cmd[1:]], arg)
|
|
492
507
|
else:
|
|
493
508
|
self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
|
|
494
509
|
|
|
510
|
+
def _cmd_loop(self, arg: str) -> None:
|
|
511
|
+
"""/loop <count> <prompt> — run <prompt> up to <count> times (1–50),
|
|
512
|
+
re-submitting after each turn finishes. Esc (or /stop) ends the loop."""
|
|
513
|
+
parts = arg.split(maxsplit=1)
|
|
514
|
+
if len(parts) < 2 or not parts[0].isdigit():
|
|
515
|
+
self._info(
|
|
516
|
+
"usage: /loop <count> <prompt> e.g. /loop 5 fix the next "
|
|
517
|
+
"failing test and run pytest\n(count 1–50; Esc stops the loop)"
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
count = max(1, min(int(parts[0]), 50))
|
|
521
|
+
prompt = parts[1].strip()
|
|
522
|
+
if not prompt:
|
|
523
|
+
self._info("usage: /loop <count> <prompt>")
|
|
524
|
+
return
|
|
525
|
+
if self._busy:
|
|
526
|
+
self._info("A turn is already running — stop it (Esc) before starting a loop.")
|
|
527
|
+
return
|
|
528
|
+
self._repeat = {"prompt": prompt, "remaining": count, "total": count}
|
|
529
|
+
self._info(f"↻ looping {count}× (Esc to stop): {prompt}")
|
|
530
|
+
self._mount(UserMessage(prompt))
|
|
531
|
+
self._begin(prompt)
|
|
532
|
+
|
|
533
|
+
def _cmd_skills(self) -> None:
|
|
534
|
+
if not self._skills:
|
|
535
|
+
self._info(
|
|
536
|
+
"No skills yet. Create one as a markdown file in "
|
|
537
|
+
"~/.drydock/skills/<name>.md (or <project>/.drydock/skills/), "
|
|
538
|
+
"then invoke it as /<name>. The body is the prompt; use $ARGS for "
|
|
539
|
+
"trailing input."
|
|
540
|
+
)
|
|
541
|
+
return
|
|
542
|
+
lines = ["Skills (invoke as /<name>):"]
|
|
543
|
+
for name in sorted(self._skills):
|
|
544
|
+
sk = self._skills[name]
|
|
545
|
+
lines.append(f" /{name}" + (f" — {sk.description}" if sk.description else ""))
|
|
546
|
+
self._info("\n".join(lines))
|
|
547
|
+
|
|
548
|
+
def _run_skill(self, skill, arg: str) -> None:
|
|
549
|
+
"""Expand a skill into a prompt and run it as a normal user turn."""
|
|
550
|
+
prompt = skill.render(arg)
|
|
551
|
+
self._mount(UserMessage(f"/{skill.name}" + (f" {arg}" if arg else "")))
|
|
552
|
+
if self._busy:
|
|
553
|
+
self._queue.append(prompt)
|
|
554
|
+
self._refresh_status()
|
|
555
|
+
else:
|
|
556
|
+
self._begin(prompt)
|
|
557
|
+
|
|
495
558
|
def _cmd_compact(self) -> None:
|
|
496
559
|
"""Manually compact the conversation to reclaim context NOW, without
|
|
497
560
|
waiting for the automatic 60%-of-window threshold (agent.maybe_compact).
|
|
@@ -747,6 +810,15 @@ class DrydockApp(App):
|
|
|
747
810
|
if self._queue:
|
|
748
811
|
self._begin(self._queue.pop(0))
|
|
749
812
|
return
|
|
813
|
+
# /loop: re-run the prompt until the iteration count is exhausted (Esc/
|
|
814
|
+
# stop clears self._repeat). Queued user prompts take priority (above).
|
|
815
|
+
if self._repeat and self._repeat["remaining"] > 1 and not self._cancel.is_set():
|
|
816
|
+
self._repeat["remaining"] -= 1
|
|
817
|
+
done = self._repeat["total"] - self._repeat["remaining"] + 1
|
|
818
|
+
self._info(f"↻ loop iteration {done}/{self._repeat['total']}")
|
|
819
|
+
self._begin(self._repeat["prompt"])
|
|
820
|
+
return
|
|
821
|
+
self._repeat = None
|
|
750
822
|
self._refresh_status()
|
|
751
823
|
self.query_one("#prompt", PromptArea).focus()
|
|
752
824
|
|
|
@@ -153,7 +153,7 @@ class PromptHistory:
|
|
|
153
153
|
# completes the prefix.
|
|
154
154
|
SLASH_COMMANDS = [
|
|
155
155
|
"/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
|
|
156
|
-
"/compact", "/graphrag", "/clear", "/quit",
|
|
156
|
+
"/compact", "/graphrag", "/skills", "/loop", "/clear", "/quit",
|
|
157
157
|
]
|
|
158
158
|
|
|
159
159
|
|
|
@@ -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.53"
|
|
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)) == {}
|
|
@@ -348,3 +348,40 @@ def test_new_user_turn_clears_stale_plan():
|
|
|
348
348
|
assert str(panel.render()) == "" # panel cleared
|
|
349
349
|
|
|
350
350
|
asyncio.run(main())
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_loop_command_iterates_and_stops():
|
|
354
|
+
"""/loop <n> <prompt> re-runs the prompt n times, then clears; Esc ends it."""
|
|
355
|
+
async def main():
|
|
356
|
+
app = DrydockApp({"model": "gemma4", "provider": "vllm", "cwd": "/tmp"})
|
|
357
|
+
async with app.run_test() as pilot:
|
|
358
|
+
await pilot.pause()
|
|
359
|
+
begins = []
|
|
360
|
+
app._begin = lambda text: begins.append(text) # stub out the real agent
|
|
361
|
+
|
|
362
|
+
# bad usage → no loop
|
|
363
|
+
app._handle_slash("/loop notanumber")
|
|
364
|
+
assert app._repeat is None and not begins
|
|
365
|
+
|
|
366
|
+
# /loop 3 <prompt> → starts, first iteration begun
|
|
367
|
+
app._handle_slash("/loop 3 do the thing")
|
|
368
|
+
assert app._repeat and app._repeat["total"] == 3
|
|
369
|
+
assert begins == ["do the thing"]
|
|
370
|
+
|
|
371
|
+
# each finished turn re-begins until the count is exhausted
|
|
372
|
+
from drydock.tui.messages import AgentFinished
|
|
373
|
+
app.on_agent_finished(AgentFinished()) # iter 2
|
|
374
|
+
app.on_agent_finished(AgentFinished()) # iter 3
|
|
375
|
+
assert len(begins) == 3
|
|
376
|
+
app.on_agent_finished(AgentFinished()) # done → loop cleared
|
|
377
|
+
assert app._repeat is None
|
|
378
|
+
assert len(begins) == 3 # no 4th run
|
|
379
|
+
|
|
380
|
+
# Esc/stop ends an active loop
|
|
381
|
+
app._begin = lambda text: begins.append(text)
|
|
382
|
+
app._handle_slash("/loop 5 again")
|
|
383
|
+
app._busy = True
|
|
384
|
+
app.action_stop()
|
|
385
|
+
assert app._repeat is None
|
|
386
|
+
|
|
387
|
+
asyncio.run(main())
|
|
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
|