drydock-cli 3.0.25__tar.gz → 3.0.27__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.25/drydock_cli.egg-info → drydock_cli-3.0.27}/PKG-INFO +1 -1
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/cli.py +43 -11
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/providers.py +6 -1
- {drydock_cli-3.0.25 → drydock_cli-3.0.27/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/pyproject.toml +1 -1
- drydock_cli-3.0.27/tests/test_first_run_setup.py +34 -0
- drydock_cli-3.0.27/tests/test_streaming_newlines.py +70 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/LICENSE +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/NOTICE +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/README.md +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/agent.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/config.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/detect.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/guards.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/setup.cfg +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_config.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tuning.py +0 -0
|
@@ -168,6 +168,35 @@ def _summarize(inputs: dict) -> str:
|
|
|
168
168
|
|
|
169
169
|
# ── Entry point ───────────────────────────────────────────────────────────
|
|
170
170
|
|
|
171
|
+
def _first_run_setup(cfg: dict, cfg_path, cfgmod) -> tuple[dict, str]:
|
|
172
|
+
"""Interactive first-launch: ask for the model server URL + model name and
|
|
173
|
+
persist them to config.toml. Autodetect (if anything is listening) supplies
|
|
174
|
+
the suggested defaults. Returns (updated cfg, onboarding line)."""
|
|
175
|
+
from drydock import detect
|
|
176
|
+
|
|
177
|
+
found = detect.detect_local_llms()
|
|
178
|
+
default_url = found[0]["base_url"] if found else "http://127.0.0.1:8000/v1"
|
|
179
|
+
default_model = (
|
|
180
|
+
found[0]["models"][0]
|
|
181
|
+
if found and found[0].get("models")
|
|
182
|
+
else (cfg.get("model") or "gemma4")
|
|
183
|
+
)
|
|
184
|
+
print("\n ⚓ Drydock — first-time setup")
|
|
185
|
+
print(" Point Drydock at your local model server (any OpenAI-compatible URL).\n")
|
|
186
|
+
try:
|
|
187
|
+
url = input(f" Model server URL [{default_url}]: ").strip() or default_url
|
|
188
|
+
model = input(f" Model name [{default_model}]: ").strip() or default_model
|
|
189
|
+
except (EOFError, KeyboardInterrupt):
|
|
190
|
+
url, model = default_url, default_model
|
|
191
|
+
print()
|
|
192
|
+
cfg["base_url"] = url
|
|
193
|
+
cfg["model"] = model
|
|
194
|
+
cfg.setdefault("provider", found[0]["provider"] if found else "vllm")
|
|
195
|
+
cfgmod.save_file(cfg, cfg_path)
|
|
196
|
+
print(f"\n Saved to {cfg_path} — change it anytime with /model or by editing that file.\n")
|
|
197
|
+
return cfg, f"⚓ Using {model} at {url}"
|
|
198
|
+
|
|
199
|
+
|
|
171
200
|
def main():
|
|
172
201
|
parser = argparse.ArgumentParser(description="DryDock — local coding agent")
|
|
173
202
|
parser.add_argument(
|
|
@@ -198,19 +227,22 @@ def main():
|
|
|
198
227
|
"temperature": args.temperature,
|
|
199
228
|
}, cfg_path)
|
|
200
229
|
|
|
201
|
-
# First
|
|
202
|
-
#
|
|
203
|
-
#
|
|
230
|
+
# First launch: ask the user for their model server URL + model name and
|
|
231
|
+
# persist them to config.toml. Interactive only — when stdin isn't a TTY
|
|
232
|
+
# (piped / -p / cron) we fall back to silent autodetect so nothing blocks.
|
|
204
233
|
onboarding = ""
|
|
205
234
|
if first_run and not args.provider and not args.base_url:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
235
|
+
if sys.stdin.isatty() and not args.prompt:
|
|
236
|
+
cfg, onboarding = _first_run_setup(cfg, cfg_path, cfgmod)
|
|
237
|
+
else:
|
|
238
|
+
from drydock import detect
|
|
239
|
+
|
|
240
|
+
found = detect.detect_local_llms()
|
|
241
|
+
onboarding = detect.onboarding_message(found)
|
|
242
|
+
if found:
|
|
243
|
+
cfg["provider"] = found[0]["provider"]
|
|
244
|
+
cfg["base_url"] = found[0]["base_url"]
|
|
245
|
+
cfgmod.save_file(cfg, cfg_path)
|
|
214
246
|
|
|
215
247
|
config = {
|
|
216
248
|
**cfg,
|
|
@@ -253,7 +253,12 @@ def stream(
|
|
|
253
253
|
if delta.content:
|
|
254
254
|
# Strip any leaked thinking-token markers (best-effort per chunk).
|
|
255
255
|
chunk_text = strip_thinking_tokens(delta.content)
|
|
256
|
-
|
|
256
|
+
# Keep ANY non-empty chunk — including whitespace-only ones. A
|
|
257
|
+
# `.strip()` guard here used to DROP newline-only chunks (the blank
|
|
258
|
+
# line a model streams between markdown blocks arrives as its own
|
|
259
|
+
# chunk), mashing "para### Header" together. `if chunk_text:` still
|
|
260
|
+
# skips chunks that stripping emptied (e.g. a lone thinking marker).
|
|
261
|
+
if chunk_text:
|
|
257
262
|
text += chunk_text
|
|
258
263
|
yield TextChunk(chunk_text)
|
|
259
264
|
|
|
@@ -35,6 +35,7 @@ tests/test_compaction.py
|
|
|
35
35
|
tests/test_config.py
|
|
36
36
|
tests/test_detect.py
|
|
37
37
|
tests/test_empty_response.py
|
|
38
|
+
tests/test_first_run_setup.py
|
|
38
39
|
tests/test_guards_and_tools.py
|
|
39
40
|
tests/test_hallucinated_tools.py
|
|
40
41
|
tests/test_leaked_tool_call.py
|
|
@@ -42,6 +43,7 @@ tests/test_loop_detect.py
|
|
|
42
43
|
tests/test_plan_autocontinue.py
|
|
43
44
|
tests/test_providers_unreachable.py
|
|
44
45
|
tests/test_stop.py
|
|
46
|
+
tests/test_streaming_newlines.py
|
|
45
47
|
tests/test_subagent.py
|
|
46
48
|
tests/test_todo.py
|
|
47
49
|
tests/test_tool_arg_parsing.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.27"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.12"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""First-launch setup: prompt for model server URL + name, persist to config."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from drydock import cli
|
|
5
|
+
from drydock import config as cfgmod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_first_run_setup_persists_url_and_model(tmp_path, monkeypatch):
|
|
9
|
+
# No server running → defaults; user types a custom URL + model.
|
|
10
|
+
monkeypatch.setattr(cli, "_first_run_setup", cli._first_run_setup) # ensure present
|
|
11
|
+
import drydock.detect as detect
|
|
12
|
+
monkeypatch.setattr(detect, "detect_local_llms", lambda: [])
|
|
13
|
+
answers = iter(["http://127.0.0.1:8000/v1", "gemma4"])
|
|
14
|
+
monkeypatch.setattr("builtins.input", lambda *_: next(answers))
|
|
15
|
+
|
|
16
|
+
cfg_path = tmp_path / "config.toml"
|
|
17
|
+
cfg, onboarding = cli._first_run_setup(dict(cfgmod.DEFAULTS), cfg_path, cfgmod)
|
|
18
|
+
|
|
19
|
+
assert cfg["base_url"] == "http://127.0.0.1:8000/v1"
|
|
20
|
+
assert cfg["model"] == "gemma4"
|
|
21
|
+
# Persisted to disk.
|
|
22
|
+
on_disk = cfgmod.load_file(cfg_path)
|
|
23
|
+
assert on_disk["base_url"] == "http://127.0.0.1:8000/v1"
|
|
24
|
+
assert on_disk["model"] == "gemma4"
|
|
25
|
+
assert "gemma4" in onboarding and "127.0.0.1:8000" in onboarding
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_first_run_setup_uses_defaults_on_empty_input(tmp_path, monkeypatch):
|
|
29
|
+
import drydock.detect as detect
|
|
30
|
+
monkeypatch.setattr(detect, "detect_local_llms", lambda: [])
|
|
31
|
+
monkeypatch.setattr("builtins.input", lambda *_: "") # user just hits Enter
|
|
32
|
+
cfg, _ = cli._first_run_setup(dict(cfgmod.DEFAULTS), tmp_path / "c.toml", cfgmod)
|
|
33
|
+
assert cfg["base_url"] == "http://127.0.0.1:8000/v1" # the documented default
|
|
34
|
+
assert cfg["model"] == "gemma4"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Streaming must preserve whitespace-only (newline) chunks.
|
|
2
|
+
|
|
3
|
+
A `.strip()` guard in the streaming loop used to DROP a chunk that was only
|
|
4
|
+
"\n\n" — the blank line a model streams between markdown blocks — so a long
|
|
5
|
+
response rendered as "paragraph### Header" mashed together (operator-reported).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import drydock.providers as P
|
|
10
|
+
from drydock.providers import AssistantTurn, TextChunk, stream
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Delta:
|
|
14
|
+
def __init__(self, c):
|
|
15
|
+
self.content = c
|
|
16
|
+
self.tool_calls = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Choice:
|
|
20
|
+
def __init__(self, c):
|
|
21
|
+
self.delta = _Delta(c)
|
|
22
|
+
self.finish_reason = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Chunk:
|
|
26
|
+
def __init__(self, c):
|
|
27
|
+
self.choices = [_Choice(c)]
|
|
28
|
+
self.usage = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_streaming_preserves_newline_only_chunks(monkeypatch):
|
|
32
|
+
chunks = [
|
|
33
|
+
_Chunk("Intro paragraph."),
|
|
34
|
+
_Chunk("\n\n"), # ← blank line between blocks (was dropped)
|
|
35
|
+
_Chunk("### Header"),
|
|
36
|
+
_Chunk("\n\n"),
|
|
37
|
+
_Chunk("body text"),
|
|
38
|
+
]
|
|
39
|
+
monkeypatch.setattr(P, "_safe_create", lambda *a, **k: iter(chunks))
|
|
40
|
+
|
|
41
|
+
full = ""
|
|
42
|
+
streamed = []
|
|
43
|
+
for ev in stream(
|
|
44
|
+
model="gemma4", system="s",
|
|
45
|
+
messages=[{"role": "user", "content": "review"}],
|
|
46
|
+
tool_schemas=[], # no tools → text-only → streaming path even for gemma
|
|
47
|
+
config={"model": "gemma4", "provider": "vllm"},
|
|
48
|
+
):
|
|
49
|
+
if isinstance(ev, TextChunk):
|
|
50
|
+
streamed.append(ev.text)
|
|
51
|
+
elif isinstance(ev, AssistantTurn):
|
|
52
|
+
full = ev.text
|
|
53
|
+
|
|
54
|
+
assert "\n\n" in "".join(streamed) # newline chunks survived streaming
|
|
55
|
+
assert "Intro paragraph.\n\n### Header" in full # blocks not mashed
|
|
56
|
+
assert "### Header\n\nbody text" in full
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_streaming_still_drops_chunk_emptied_by_stripping(monkeypatch):
|
|
60
|
+
# A chunk that is ONLY a thinking marker strips to "" and is skipped (so we
|
|
61
|
+
# don't emit empty text), but real whitespace is kept.
|
|
62
|
+
chunks = [_Chunk("hello"), _Chunk("<|channel>x<channel|>"), _Chunk("\n"), _Chunk("world")]
|
|
63
|
+
monkeypatch.setattr(P, "_safe_create", lambda *a, **k: iter(chunks))
|
|
64
|
+
full = ""
|
|
65
|
+
for ev in stream(model="gemma4", system="s",
|
|
66
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
67
|
+
tool_schemas=[], config={"model": "gemma4", "provider": "vllm"}):
|
|
68
|
+
if isinstance(ev, AssistantTurn):
|
|
69
|
+
full = ev.text
|
|
70
|
+
assert full == "hello\nworld" # marker-only chunk gone, newline kept
|
|
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
|