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.
Files changed (54) hide show
  1. {drydock_cli-3.0.25/drydock_cli.egg-info → drydock_cli-3.0.27}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/cli.py +43 -11
  4. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/providers.py +6 -1
  5. {drydock_cli-3.0.25 → drydock_cli-3.0.27/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/SOURCES.txt +2 -0
  7. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/pyproject.toml +1 -1
  8. drydock_cli-3.0.27/tests/test_first_run_setup.py +34 -0
  9. drydock_cli-3.0.27/tests/test_streaming_newlines.py +70 -0
  10. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/LICENSE +0 -0
  11. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/NOTICE +0 -0
  12. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/README.md +0 -0
  13. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/__main__.py +0 -0
  14. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/agent.py +0 -0
  15. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/bash_safety.py +0 -0
  16. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/compaction.py +0 -0
  17. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/config.py +0 -0
  18. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/detect.py +0 -0
  19. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/guards.py +0 -0
  20. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/loop_detect.py +0 -0
  21. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tool_registry.py +0 -0
  22. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tools/__init__.py +0 -0
  23. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/__init__.py +0 -0
  24. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/app.py +0 -0
  25. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/approval.py +0 -0
  26. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/messages.py +0 -0
  27. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tui/widgets.py +0 -0
  28. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock/tuning.py +0 -0
  29. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/dependency_links.txt +0 -0
  30. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/entry_points.txt +0 -0
  31. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/requires.txt +0 -0
  32. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/drydock_cli.egg-info/top_level.txt +0 -0
  33. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/setup.cfg +0 -0
  34. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_approval.py +0 -0
  35. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_back_command.py +0 -0
  36. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_bash_safety.py +0 -0
  37. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_cli_agents.py +0 -0
  38. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_compaction.py +0 -0
  39. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_config.py +0 -0
  40. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_detect.py +0 -0
  41. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_empty_response.py +0 -0
  42. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_guards_and_tools.py +0 -0
  43. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_hallucinated_tools.py +0 -0
  44. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_leaked_tool_call.py +0 -0
  45. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_loop_detect.py +0 -0
  46. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_plan_autocontinue.py +0 -0
  47. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_providers_unreachable.py +0 -0
  48. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_stop.py +0 -0
  49. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_subagent.py +0 -0
  50. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_todo.py +0 -0
  51. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tool_arg_parsing.py +0 -0
  52. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tools_undo.py +0 -0
  53. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tui.py +0 -0
  54. {drydock_cli-3.0.25 → drydock_cli-3.0.27}/tests/test_tuning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.25
3
+ Version: 3.0.27
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
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.25"
10
+ __version__ = "3.0.27"
@@ -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-launch autodetect: if no config existed and the user didn't pin an
202
- # endpoint, probe localhost for a running local LLM and wire up the first
203
- # one found (persisted, so subsequent runs skip detection).
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
- from drydock import detect
207
-
208
- found = detect.detect_local_llms()
209
- onboarding = detect.onboarding_message(found)
210
- if found:
211
- cfg["provider"] = found[0]["provider"]
212
- cfg["base_url"] = found[0]["base_url"]
213
- cfgmod.save_file(cfg, cfg_path)
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
- if chunk_text.strip():
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.25
3
+ Version: 3.0.27
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
@@ -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.25"
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