drydock-cli 3.0.98__tar.gz → 3.0.101__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.98/drydock_cli.egg-info → drydock_cli-3.0.101}/PKG-INFO +1 -1
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tools/__init__.py +65 -18
- {drydock_cli-3.0.98 → drydock_cli-3.0.101/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock_cli.egg-info/SOURCES.txt +3 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/pyproject.toml +1 -1
- drydock_cli-3.0.101/tests/test_bash_stop_partial.py +50 -0
- drydock_cli-3.0.101/tests/test_edit_replace_all.py +37 -0
- drydock_cli-3.0.101/tests/test_tool_arg_coercion.py +49 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/LICENSE +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/NOTICE +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/README.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/agent.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/cci.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/cli.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/config.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/detect.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/extract.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/guards.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/poam.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/providers.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/skills.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/stig.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock/web.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/setup.cfg +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_config.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.98 → drydock_cli-3.0.101}/tests/test_xccdf.py +0 -0
|
@@ -26,6 +26,36 @@ _DEFAULT_TIMEOUT = 120
|
|
|
26
26
|
_MAX_TIMEOUT = 1800 # 30 min hard ceiling — a single command shouldn't hang longer
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def _as_text(v, default: str = "") -> str:
|
|
30
|
+
"""Coerce a text-body arg (file content, replacement text) to a string. Local
|
|
31
|
+
models sometimes send it as a JSON array of lines → newline-join; None →
|
|
32
|
+
default; other scalars → str()."""
|
|
33
|
+
if isinstance(v, (list, tuple)):
|
|
34
|
+
return "\n".join(str(x) for x in v)
|
|
35
|
+
if v is None:
|
|
36
|
+
return default
|
|
37
|
+
return v if isinstance(v, str) else str(v)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_str_arg(v, default: str = "") -> str:
|
|
41
|
+
"""Coerce a scalar string arg (a path, a pattern) to a string. A single-value
|
|
42
|
+
list is unwrapped (models sometimes wrap a lone path/pattern in an array)."""
|
|
43
|
+
if isinstance(v, (list, tuple)):
|
|
44
|
+
return str(v[0]) if v else default
|
|
45
|
+
if v is None:
|
|
46
|
+
return default
|
|
47
|
+
return v if isinstance(v, str) else str(v)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _coerce_int(value, default: int):
|
|
51
|
+
"""Coerce a model-supplied int arg (offset/limit) that may arrive as a string
|
|
52
|
+
("5") or junk. Falls back to default on anything non-numeric."""
|
|
53
|
+
try:
|
|
54
|
+
return int(float(value))
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return default
|
|
57
|
+
|
|
58
|
+
|
|
29
59
|
def _coerce_timeout(value) -> int:
|
|
30
60
|
"""Make the model-supplied timeout robust: local models often send it as a
|
|
31
61
|
string ("10"), and a 0/negative/absurd value would make EVERY command time
|
|
@@ -162,13 +192,16 @@ SCHEMAS = [
|
|
|
162
192
|
},
|
|
163
193
|
{
|
|
164
194
|
"name": "Edit",
|
|
165
|
-
"description": "Replace exact text in a file. old_string must match exactly."
|
|
195
|
+
"description": "Replace exact text in a file. old_string must match exactly. "
|
|
196
|
+
"By default old_string must be unique; pass replace_all:true "
|
|
197
|
+
"to replace every occurrence.",
|
|
166
198
|
"input_schema": {
|
|
167
199
|
"type": "object",
|
|
168
200
|
"properties": {
|
|
169
201
|
"file_path": {"type": "string"},
|
|
170
202
|
"old_string": {"type": "string", "description": "Exact text to find"},
|
|
171
203
|
"new_string": {"type": "string", "description": "Replacement text"},
|
|
204
|
+
"replace_all": {"type": "boolean", "description": "Replace ALL occurrences (default false)"},
|
|
172
205
|
},
|
|
173
206
|
"required": ["file_path", "old_string", "new_string"],
|
|
174
207
|
},
|
|
@@ -585,13 +618,13 @@ def tool_viewimage(params: dict, config: dict) -> str:
|
|
|
585
618
|
|
|
586
619
|
|
|
587
620
|
def tool_read(params: dict, config: dict) -> str:
|
|
588
|
-
fp = _resolve_path(params
|
|
621
|
+
fp = _resolve_path(_as_str_arg(params.get("file_path")), config)
|
|
589
622
|
# Reading an image as text yields binary garbage — point at the vision tool.
|
|
590
623
|
if os.path.splitext(fp)[1].lower() in _IMAGE_EXTS and os.path.isfile(fp):
|
|
591
624
|
return (f"{params['file_path']} is an image — Read would return binary "
|
|
592
625
|
"garbage. Use the ViewImage tool to actually SEE it.")
|
|
593
|
-
limit = params.get("limit")
|
|
594
|
-
offset = params.get("offset", 0)
|
|
626
|
+
limit = None if params.get("limit") is None else _coerce_int(params.get("limit"), 2000)
|
|
627
|
+
offset = _coerce_int(params.get("offset", 0), 0)
|
|
595
628
|
try:
|
|
596
629
|
with open(fp, "r", encoding="utf-8", errors="replace") as f:
|
|
597
630
|
lines = f.readlines()
|
|
@@ -670,14 +703,10 @@ def tool_write(params: dict, config: dict) -> str:
|
|
|
670
703
|
fp = _resolve_path(str(fp).strip(), config)
|
|
671
704
|
if Path(fp).is_dir():
|
|
672
705
|
return f"Error: {fp} is a directory, not a file. Pass a file path."
|
|
673
|
-
content = params.get("content", "")
|
|
674
706
|
# Local models sometimes send content as a JSON array of lines (or a number),
|
|
675
|
-
# which f.write() can't take — coerce instead of crashing
|
|
676
|
-
#
|
|
677
|
-
|
|
678
|
-
content = "\n".join(str(x) for x in content)
|
|
679
|
-
elif not isinstance(content, str):
|
|
680
|
-
content = "" if content is None else str(content)
|
|
707
|
+
# which f.write() can't take — coerce instead of crashing (tools must return
|
|
708
|
+
# errors, never raise). A list → newline-joined lines.
|
|
709
|
+
content = _as_text(params.get("content", ""))
|
|
681
710
|
if has_conflict_markers(content):
|
|
682
711
|
return conflict_marker_refusal(fp)
|
|
683
712
|
prev = _snapshot(fp)
|
|
@@ -800,8 +829,10 @@ def tool_edit(params: dict, config: dict) -> str:
|
|
|
800
829
|
if not fp or not str(fp).strip():
|
|
801
830
|
return "Error: Edit needs a real file_path (the path was empty or blank)."
|
|
802
831
|
fp = _resolve_path(str(fp).strip(), config)
|
|
803
|
-
old
|
|
804
|
-
|
|
832
|
+
# Coerce non-string old/new (a JSON array of lines, a number) instead of
|
|
833
|
+
# crashing with a TypeError in the substring search / replace.
|
|
834
|
+
old = _as_text(params.get("old_string", ""))
|
|
835
|
+
new = _as_text(params.get("new_string", ""))
|
|
805
836
|
if has_conflict_markers(new):
|
|
806
837
|
return conflict_marker_refusal(fp)
|
|
807
838
|
# If a directory was passed, try to infer the intended file (avoids a
|
|
@@ -847,9 +878,15 @@ def tool_edit(params: dict, config: dict) -> str:
|
|
|
847
878
|
f"to copy the exact text (including indentation)."
|
|
848
879
|
)
|
|
849
880
|
count = content.count(old)
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
881
|
+
# replace_all (models expect it, like the standard Edit tool): replace
|
|
882
|
+
# EVERY occurrence. Without it, multiple matches are ambiguous → error.
|
|
883
|
+
replace_all = bool(params.get("replace_all", False))
|
|
884
|
+
if count > 1 and not replace_all:
|
|
885
|
+
return (
|
|
886
|
+
f"Error: old_string found {count} times in {fp}. Add more context "
|
|
887
|
+
f"to make it unique, or pass replace_all: true to replace all."
|
|
888
|
+
)
|
|
889
|
+
updated = content.replace(old, new) if replace_all else content.replace(old, new, 1)
|
|
853
890
|
if updated == content:
|
|
854
891
|
# No-op edit (old_string == new_string, or the replacement changes
|
|
855
892
|
# nothing). Reporting "Edited" here is a false success that invites
|
|
@@ -865,6 +902,8 @@ def tool_edit(params: dict, config: dict) -> str:
|
|
|
865
902
|
Path(fp).write_text(updated, encoding="utf-8")
|
|
866
903
|
_record_undo(config, fp, content)
|
|
867
904
|
result = f"Edited {fp}: replaced {len(old)} chars with {len(new)} chars"
|
|
905
|
+
if replace_all and count > 1:
|
|
906
|
+
result += f" ({count} occurrences)"
|
|
868
907
|
# Advisory post-edit syntax check.
|
|
869
908
|
warn = python_syntax_warning(fp, updated)
|
|
870
909
|
if warn:
|
|
@@ -1023,6 +1062,14 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
1023
1062
|
if cancel is not None and cancel.is_set():
|
|
1024
1063
|
kill_process_group(proc)
|
|
1025
1064
|
proc.wait()
|
|
1065
|
+
partial = _sanitize_bash_output(
|
|
1066
|
+
_collapse_repeated_lines("".join(list(chunks)))
|
|
1067
|
+
).rstrip()
|
|
1068
|
+
if partial:
|
|
1069
|
+
tail = partial[-_PARTIAL_TAIL:]
|
|
1070
|
+
if len(partial) > _PARTIAL_TAIL:
|
|
1071
|
+
tail = "[... earlier output truncated ...]\n" + tail
|
|
1072
|
+
return f"[stopped by user]\n\n--- output before stop ---\n{tail}"
|
|
1026
1073
|
return "[stopped by user]"
|
|
1027
1074
|
# The SHELL has exited but the pipe is still open → the command
|
|
1028
1075
|
# backgrounded a child (`cmd &`, a server the task wants to keep
|
|
@@ -1105,8 +1152,8 @@ def tool_glob(params: dict, config: dict) -> str:
|
|
|
1105
1152
|
|
|
1106
1153
|
|
|
1107
1154
|
def tool_grep(params: dict, config: dict) -> str:
|
|
1108
|
-
pattern = params
|
|
1109
|
-
path = params.get("path") or config.get("cwd") or "."
|
|
1155
|
+
pattern = _as_str_arg(params.get("pattern"))
|
|
1156
|
+
path = _as_str_arg(params.get("path")) or config.get("cwd") or "."
|
|
1110
1157
|
include = params.get("include", "")
|
|
1111
1158
|
try:
|
|
1112
1159
|
cmd = ["grep", "-rn", "--color=never"]
|
|
@@ -57,6 +57,7 @@ tests/test_bash_safety.py
|
|
|
57
57
|
tests/test_bash_sanitize.py
|
|
58
58
|
tests/test_bash_shell.py
|
|
59
59
|
tests/test_bash_stdin.py
|
|
60
|
+
tests/test_bash_stop_partial.py
|
|
60
61
|
tests/test_bash_timeout_network.py
|
|
61
62
|
tests/test_bash_timeout_param.py
|
|
62
63
|
tests/test_cci.py
|
|
@@ -69,6 +70,7 @@ tests/test_context_limit_config.py
|
|
|
69
70
|
tests/test_detect.py
|
|
70
71
|
tests/test_dispatch.py
|
|
71
72
|
tests/test_e2e_connected.py
|
|
73
|
+
tests/test_edit_replace_all.py
|
|
72
74
|
tests/test_empty_response.py
|
|
73
75
|
tests/test_extract.py
|
|
74
76
|
tests/test_failure_loop.py
|
|
@@ -99,6 +101,7 @@ tests/test_streaming_newlines.py
|
|
|
99
101
|
tests/test_subagent.py
|
|
100
102
|
tests/test_system_prompt_help.py
|
|
101
103
|
tests/test_todo.py
|
|
104
|
+
tests/test_tool_arg_coercion.py
|
|
102
105
|
tests/test_tool_arg_parsing.py
|
|
103
106
|
tests/test_tools_undo.py
|
|
104
107
|
tests/test_tui.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.101"
|
|
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,50 @@
|
|
|
1
|
+
"""tool_bash must preserve partial output when the user presses STOP.
|
|
2
|
+
Without this fix, cancel.is_set() triggered a bare "[stopped by user]" return
|
|
3
|
+
that discarded all chunks accumulated before the stop — losing useful diagnostics
|
|
4
|
+
(test-run results, build output, etc.) that the model needs to continue.
|
|
5
|
+
|
|
6
|
+
Mirrors the timeout-partial-output behaviour already in test_bash_background.py.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from drydock.tools import tool_bash
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_stop_preserves_partial_output():
|
|
17
|
+
"""Partial output printed before the cancel Event fires must appear in the
|
|
18
|
+
tool result alongside the '[stopped by user]' marker."""
|
|
19
|
+
cancel = threading.Event()
|
|
20
|
+
|
|
21
|
+
# Fire cancel after a short delay so the command has time to emit output.
|
|
22
|
+
def _fire():
|
|
23
|
+
time.sleep(0.5)
|
|
24
|
+
cancel.set()
|
|
25
|
+
|
|
26
|
+
threading.Thread(target=_fire, daemon=True).start()
|
|
27
|
+
out = tool_bash(
|
|
28
|
+
{"command": "echo PARTIAL_LINE; echo second_line; sleep 30", "timeout": 20},
|
|
29
|
+
{"cwd": "/tmp", "_cancel": cancel},
|
|
30
|
+
)
|
|
31
|
+
assert "stopped by user" in out
|
|
32
|
+
assert "PARTIAL_LINE" in out, f"partial output missing from stop result: {out!r}"
|
|
33
|
+
assert "output before stop" in out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_stop_with_no_output_returns_plain_marker():
|
|
37
|
+
"""If the command produces no output before the stop, the result is just
|
|
38
|
+
'[stopped by user]' without a spurious empty block."""
|
|
39
|
+
cancel = threading.Event()
|
|
40
|
+
|
|
41
|
+
def _fire():
|
|
42
|
+
time.sleep(0.05)
|
|
43
|
+
cancel.set()
|
|
44
|
+
|
|
45
|
+
threading.Thread(target=_fire, daemon=True).start()
|
|
46
|
+
out = tool_bash(
|
|
47
|
+
{"command": "sleep 30", "timeout": 20},
|
|
48
|
+
{"cwd": "/tmp", "_cancel": cancel},
|
|
49
|
+
)
|
|
50
|
+
assert out == "[stopped by user]"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The Edit tool honors replace_all (models expect it, like the standard Edit
|
|
2
|
+
tool). Without it, multiple matches are an error; with it, every occurrence is
|
|
3
|
+
replaced. Found by probing Edit edge cases."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from drydock.tools import tool_edit
|
|
7
|
+
from drydock import tool_registry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _edit(tmp_path, setup, **kw):
|
|
11
|
+
f = tmp_path / "f.txt"; f.write_text(setup)
|
|
12
|
+
out = tool_edit({"file_path": str(f), **kw}, {"cwd": str(tmp_path)})
|
|
13
|
+
return out, f.read_text()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_replace_all_replaces_every_occurrence(tmp_path):
|
|
17
|
+
out, text = _edit(tmp_path, "a a a\n", old_string="a", new_string="b", replace_all=True)
|
|
18
|
+
assert "Error" not in out and text == "b b b\n" and "3 occurrences" in out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_default_still_errors_on_multiple(tmp_path):
|
|
22
|
+
out, text = _edit(tmp_path, "a a a\n", old_string="a", new_string="b")
|
|
23
|
+
assert "found 3 times" in out and text == "a a a\n" # unchanged
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_replace_all_single_occurrence(tmp_path):
|
|
27
|
+
out, text = _edit(tmp_path, "just one\n", old_string="one", new_string="two", replace_all=True)
|
|
28
|
+
assert text == "just two\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_replace_all_no_match_errors(tmp_path):
|
|
32
|
+
out, text = _edit(tmp_path, "hello\n", old_string="zzz", new_string="b", replace_all=True)
|
|
33
|
+
assert "not found" in out and text == "hello\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_replace_all_in_schema():
|
|
37
|
+
assert "replace_all" in tool_registry.get("Edit").schema["input_schema"]["properties"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Model-supplied tool args that arrive as the wrong type (a JSON array of lines,
|
|
2
|
+
a number, a stringified int, a single-element-list-wrapped path) must be coerced,
|
|
3
|
+
never crash — tools return results, never raise. Systematic across the file tools."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from drydock.tools import (
|
|
7
|
+
_as_text, _as_str_arg, _coerce_int,
|
|
8
|
+
tool_edit, tool_read, tool_grep, tool_write,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_helpers():
|
|
13
|
+
assert _as_text(["a", "b"]) == "a\nb"
|
|
14
|
+
assert _as_text(None) == "" and _as_text(42) == "42" and _as_text("x") == "x"
|
|
15
|
+
assert _as_str_arg(["/p"]) == "/p" and _as_str_arg(None) == "" and _as_str_arg(5) == "5"
|
|
16
|
+
assert _coerce_int("5", 0) == 5 and _coerce_int("junk", 7) == 7 and _coerce_int(None, 3) == 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cfg(tmp): return {"cwd": str(tmp)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_edit_non_string_new(tmp_path):
|
|
23
|
+
f = tmp_path / "e.txt"; f.write_text("alpha beta\n")
|
|
24
|
+
out = tool_edit({"file_path": str(f), "old_string": "alpha", "new_string": ["x", "y"]}, _cfg(tmp_path))
|
|
25
|
+
assert "Error" not in out and f.read_text().startswith("x\ny")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_edit_int_new(tmp_path):
|
|
29
|
+
f = tmp_path / "e.txt"; f.write_text("beta\n")
|
|
30
|
+
out = tool_edit({"file_path": str(f), "old_string": "beta", "new_string": 99}, _cfg(tmp_path))
|
|
31
|
+
assert "99" in f.read_text() and "Error" not in out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_read_list_path_and_str_offsets(tmp_path):
|
|
35
|
+
f = tmp_path / "r.txt"; f.write_text("line1\nline2\n")
|
|
36
|
+
out = tool_read({"file_path": [str(f)], "offset": "0", "limit": "5"}, _cfg(tmp_path))
|
|
37
|
+
assert "line1" in out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_grep_list_pattern(tmp_path):
|
|
41
|
+
f = tmp_path / "g.txt"; f.write_text("needle here\n")
|
|
42
|
+
out = tool_grep({"pattern": ["needle"], "path": str(f)}, _cfg(tmp_path))
|
|
43
|
+
assert "needle" in out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_write_list_content(tmp_path):
|
|
47
|
+
f = tmp_path / "w.txt"
|
|
48
|
+
tool_write({"file_path": str(f), "content": ["a", "b"]}, _cfg(tmp_path))
|
|
49
|
+
assert f.read_text() == "a\nb"
|
|
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
|
|
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
|