drydock-cli 3.0.134__tar.gz → 3.0.136__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 (147) hide show
  1. {drydock_cli-3.0.134/drydock_cli.egg-info → drydock_cli-3.0.136}/PKG-INFO +19 -4
  2. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/README.md +18 -3
  3. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/cli.py +24 -14
  4. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/graphrag.py +227 -19
  5. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tui/app.py +25 -5
  6. {drydock_cli-3.0.134 → drydock_cli-3.0.136/drydock_cli.egg-info}/PKG-INFO +19 -4
  7. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock_cli.egg-info/SOURCES.txt +1 -0
  8. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/pyproject.toml +1 -1
  9. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_config_migration.py +3 -3
  10. drydock_cli-3.0.136/tests/test_first_run_setup.py +81 -0
  11. drydock_cli-3.0.136/tests/test_graphrag_sqlite.py +70 -0
  12. drydock_cli-3.0.134/tests/test_first_run_setup.py +0 -34
  13. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/LICENSE +0 -0
  14. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/NOTICE +0 -0
  15. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/advisor.py +0 -0
  18. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/agent.py +0 -0
  19. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/bash_safety.py +0 -0
  20. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/__init__.py +0 -0
  21. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-data.md +0 -0
  22. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-debug.md +0 -0
  23. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-finetune.md +0 -0
  24. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-metrics.md +0 -0
  25. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-rl.md +0 -0
  26. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-train.md +0 -0
  27. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  28. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/nist-csf.md +0 -0
  29. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-categorize.md +0 -0
  30. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-control.md +0 -0
  31. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-poam.md +0 -0
  32. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-review.md +0 -0
  33. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/stig-assess.md +0 -0
  34. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/builtin_skills/stig-remediate.md +0 -0
  35. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/cci.py +0 -0
  36. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/compaction.py +0 -0
  37. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/config.py +0 -0
  38. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/detect.py +0 -0
  39. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/events.py +0 -0
  40. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/extract.py +0 -0
  41. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/gittools.py +0 -0
  42. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/guards.py +0 -0
  43. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/loop_detect.py +0 -0
  44. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/mcp.py +0 -0
  45. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/poam.py +0 -0
  46. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/providers.py +0 -0
  47. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/recipes.py +0 -0
  48. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/rmf.py +0 -0
  49. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/rmf_graph.py +0 -0
  50. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/skills.py +0 -0
  51. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/stig.py +0 -0
  52. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/suggest.py +0 -0
  53. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/task_state.py +0 -0
  54. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tool_registry.py +0 -0
  55. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tool_result.py +0 -0
  56. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tools/__init__.py +0 -0
  57. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tui/__init__.py +0 -0
  58. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tui/approval.py +0 -0
  59. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tui/messages.py +0 -0
  60. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tui/widgets.py +0 -0
  61. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/tuning.py +0 -0
  62. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/verification.py +0 -0
  63. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock/web.py +0 -0
  64. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock_cli.egg-info/dependency_links.txt +0 -0
  65. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock_cli.egg-info/entry_points.txt +0 -0
  66. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock_cli.egg-info/requires.txt +0 -0
  67. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/drydock_cli.egg-info/top_level.txt +0 -0
  68. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/setup.cfg +0 -0
  69. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_advisor.py +0 -0
  70. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_approval.py +0 -0
  71. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_back_command.py +0 -0
  72. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_background.py +0 -0
  73. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_binary_output.py +0 -0
  74. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_crossplatform.py +0 -0
  75. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_output_bounding.py +0 -0
  76. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_process_group.py +0 -0
  77. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_safety.py +0 -0
  78. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_sanitize.py +0 -0
  79. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_shell.py +0 -0
  80. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_stdin.py +0 -0
  81. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_stop_partial.py +0 -0
  82. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_timeout_network.py +0 -0
  83. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_bash_timeout_param.py +0 -0
  84. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_cci.py +0 -0
  85. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_cli_agents.py +0 -0
  86. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_compact_command.py +0 -0
  87. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_compaction.py +0 -0
  88. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_config.py +0 -0
  89. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_context_limit_config.py +0 -0
  90. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_detect.py +0 -0
  91. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_dispatch.py +0 -0
  92. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_e2e_connected.py +0 -0
  93. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_edit_replace_all.py +0 -0
  94. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_empty_response.py +0 -0
  95. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_events.py +0 -0
  96. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_extract.py +0 -0
  97. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_failure_loop.py +0 -0
  98. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_gittools.py +0 -0
  99. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_glob_edit_edges.py +0 -0
  100. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_graphify_example.py +0 -0
  101. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_graphrag.py +0 -0
  102. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_graphrag_quoted_path.py +0 -0
  103. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_grep_and_read_robust.py +0 -0
  104. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_guards_and_tools.py +0 -0
  105. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_hallucinated_tools.py +0 -0
  106. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_leaked_tool_call.py +0 -0
  107. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_loop_detect.py +0 -0
  108. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_mcp.py +0 -0
  109. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_oneshot_unreachable.py +0 -0
  110. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_overthink_interrupt.py +0 -0
  111. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_plan_autocontinue.py +0 -0
  112. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_poam.py +0 -0
  113. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_providers_unreachable.py +0 -0
  114. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_read_index.py +0 -0
  115. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_recipes.py +0 -0
  116. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_repetition_interrupt.py +0 -0
  117. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_rmf.py +0 -0
  118. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_rmf_graph.py +0 -0
  119. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_rmf_stig_graph.py +0 -0
  120. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_rolling_plan.py +0 -0
  121. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_runaway_repetition.py +0 -0
  122. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_screenshot.py +0 -0
  123. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_server_probe.py +0 -0
  124. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_skills.py +0 -0
  125. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_stall_retry.py +0 -0
  126. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_stig.py +0 -0
  127. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_stop.py +0 -0
  128. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_streaming_newlines.py +0 -0
  129. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_subagent.py +0 -0
  130. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_suggest.py +0 -0
  131. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_system_prompt_help.py +0 -0
  132. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_task_state.py +0 -0
  133. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_todo.py +0 -0
  134. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tool_arg_coercion.py +0 -0
  135. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tool_arg_coercion_more.py +0 -0
  136. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tool_arg_parsing.py +0 -0
  137. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tool_result.py +0 -0
  138. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tools_undo.py +0 -0
  139. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tui.py +0 -0
  140. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_tuning.py +0 -0
  141. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_verification_gate.py +0 -0
  142. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_viewimage.py +0 -0
  143. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_vision_input.py +0 -0
  144. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_web_tools.py +0 -0
  145. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_windows_shell.py +0 -0
  146. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_write_content_coerce.py +0 -0
  147. {drydock_cli-3.0.134 → drydock_cli-3.0.136}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.134
3
+ Version: 3.0.136
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
@@ -107,6 +107,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
107
107
  | `/graphrag add <path>` | Incrementally add more documents to the base |
108
108
  | `/graphrag query <q>` | Test what the base returns (no model) |
109
109
  | `/graphrag status` · `clear` | List indexed sources · wipe the base |
110
+ | `/graphrag migrate` | Convert a legacy JSON index to the fast SQLite store |
110
111
  | `/skills` | List your skills |
111
112
  | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
112
113
  | `/<name>` | Run a skill |
@@ -142,8 +143,8 @@ tool) when a question touches your material. Ingests text formats
142
143
  checklists (`.ckl`/`.cklb`)** — checklists are flattened to per-rule findings so
143
144
  you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
144
145
  uses the `pdftotext` binary (poppler) if present, else `pip install
145
- drydock-cli[pdf]` (pypdf). The index is a single JSON at
146
- `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
146
+ drydock-cli[pdf]` (pypdf). The index is a SQLite database at
147
+ `<project>/.drydock/graphrag.db` (FTS5-indexed, so queries stay fast at multi-GB scale) — clean-room, no embeddings.
147
148
 
148
149
  ### Custom skills
149
150
 
@@ -253,13 +254,27 @@ drydock
253
254
  ```
254
255
 
255
256
  Requires Python 3.11+. From source instead:
256
- `git clone https://github.com/fbobe321/drydock-v3.git && cd drydock-v3 && pip install -e .`
257
+ `git clone https://github.com/fbobe321/drydock.git && cd drydock && pip install -e .`
257
258
 
258
259
  On first launch with no config, Drydock probes localhost for a running local
259
260
  LLM (llama.cpp/vLLM `:8000`, Ollama `:11434`, LM Studio `:1234`) and wires up
260
261
  the first one it finds — no account or API-key prompt. Override anytime with
261
262
  `--model` / `--provider` / `--base-url` or `~/.drydock/config.toml`.
262
263
 
264
+ ### Docker
265
+
266
+ A prebuilt image is on Docker Hub. Drydock is just the agent — point it at your
267
+ own OpenAI-compatible model server (e.g. one running on the host):
268
+
269
+ ```
270
+ docker run -it --add-host=host.docker.internal:host-gateway \
271
+ -v "$PWD:/work" fbobe3/drydock \
272
+ --base-url http://host.docker.internal:8000/v1 --model gemma4
273
+ ```
274
+
275
+ `-v "$PWD:/work"` mounts your project so the agent can read/edit it. Tags:
276
+ `fbobe3/drydock:latest` and `fbobe3/drydock:<version>` (e.g. `:3.0.135`).
277
+
263
278
  ## Using it
264
279
 
265
280
  Type a task and press **Enter**. Drydock reads/writes/edits files and runs
@@ -83,6 +83,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
83
83
  | `/graphrag add <path>` | Incrementally add more documents to the base |
84
84
  | `/graphrag query <q>` | Test what the base returns (no model) |
85
85
  | `/graphrag status` · `clear` | List indexed sources · wipe the base |
86
+ | `/graphrag migrate` | Convert a legacy JSON index to the fast SQLite store |
86
87
  | `/skills` | List your skills |
87
88
  | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
88
89
  | `/<name>` | Run a skill |
@@ -118,8 +119,8 @@ tool) when a question touches your material. Ingests text formats
118
119
  checklists (`.ckl`/`.cklb`)** — checklists are flattened to per-rule findings so
119
120
  you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
120
121
  uses the `pdftotext` binary (poppler) if present, else `pip install
121
- drydock-cli[pdf]` (pypdf). The index is a single JSON at
122
- `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
122
+ drydock-cli[pdf]` (pypdf). The index is a SQLite database at
123
+ `<project>/.drydock/graphrag.db` (FTS5-indexed, so queries stay fast at multi-GB scale) — clean-room, no embeddings.
123
124
 
124
125
  ### Custom skills
125
126
 
@@ -229,13 +230,27 @@ drydock
229
230
  ```
230
231
 
231
232
  Requires Python 3.11+. From source instead:
232
- `git clone https://github.com/fbobe321/drydock-v3.git && cd drydock-v3 && pip install -e .`
233
+ `git clone https://github.com/fbobe321/drydock.git && cd drydock && pip install -e .`
233
234
 
234
235
  On first launch with no config, Drydock probes localhost for a running local
235
236
  LLM (llama.cpp/vLLM `:8000`, Ollama `:11434`, LM Studio `:1234`) and wires up
236
237
  the first one it finds — no account or API-key prompt. Override anytime with
237
238
  `--model` / `--provider` / `--base-url` or `~/.drydock/config.toml`.
238
239
 
240
+ ### Docker
241
+
242
+ A prebuilt image is on Docker Hub. Drydock is just the agent — point it at your
243
+ own OpenAI-compatible model server (e.g. one running on the host):
244
+
245
+ ```
246
+ docker run -it --add-host=host.docker.internal:host-gateway \
247
+ -v "$PWD:/work" fbobe3/drydock \
248
+ --base-url http://host.docker.internal:8000/v1 --model gemma4
249
+ ```
250
+
251
+ `-v "$PWD:/work"` mounts your project so the agent can read/edit it. Tags:
252
+ `fbobe3/drydock:latest` and `fbobe3/drydock:<version>` (e.g. `:3.0.135`).
253
+
239
254
  ## Using it
240
255
 
241
256
  Type a task and press **Enter**. Drydock reads/writes/edits files and runs
@@ -248,6 +248,27 @@ def _summarize(inputs: dict) -> str:
248
248
 
249
249
  # ── Entry point ───────────────────────────────────────────────────────────
250
250
 
251
+ def _resolve_first_run(cfg: dict, args, cfg_path, cfgmod, interactive: bool) -> tuple[dict, str]:
252
+ """First-launch model-server resolution. AUTODETECT a live local server and
253
+ wire it up with ZERO prompts (the frictionless path). Only when nothing is
254
+ detected AND we're interactive do we ask the user to enter their own URL — so
255
+ the easy install (server already running) needs no input, while a custom
256
+ endpoint is still one prompt (or `--base-url`, or `/model url` later)."""
257
+ from drydock import detect
258
+
259
+ found = detect.detect_local_llms()
260
+ if found:
261
+ cfg["provider"] = found[0]["provider"]
262
+ cfg["base_url"] = found[0]["base_url"]
263
+ if found[0].get("models") and not getattr(args, "model", None):
264
+ cfg["model"] = found[0]["models"][0]
265
+ cfgmod.save_file(cfg, cfg_path)
266
+ return cfg, detect.onboarding_message(found)
267
+ if interactive:
268
+ return _first_run_setup(cfg, cfg_path, cfgmod)
269
+ return cfg, detect.onboarding_message(found)
270
+
271
+
251
272
  def _first_run_setup(cfg: dict, cfg_path, cfgmod) -> tuple[dict, str]:
252
273
  """Interactive first-launch: ask for the model server URL + model name and
253
274
  persist them to config.toml. Autodetect (if anything is listening) supplies
@@ -346,22 +367,11 @@ def main():
346
367
  "context_limit": args.context_limit,
347
368
  }, cfg_path)
348
369
 
349
- # First launch: ask the user for their model server URL + model name and
350
- # persist them to config.toml. Interactive only — when stdin isn't a TTY
351
- # (piped / -p / cron) we fall back to silent autodetect so nothing blocks.
370
+ # First launch: autodetect a local server and wire it up (see _resolve_first_run).
352
371
  onboarding = ""
353
372
  if first_run and not args.provider and not args.base_url:
354
- if sys.stdin.isatty() and not args.prompt:
355
- cfg, onboarding = _first_run_setup(cfg, cfg_path, cfgmod)
356
- else:
357
- from drydock import detect
358
-
359
- found = detect.detect_local_llms()
360
- onboarding = detect.onboarding_message(found)
361
- if found:
362
- cfg["provider"] = found[0]["provider"]
363
- cfg["base_url"] = found[0]["base_url"]
364
- cfgmod.save_file(cfg, cfg_path)
373
+ interactive = sys.stdin.isatty() and not args.prompt
374
+ cfg, onboarding = _resolve_first_run(cfg, args, cfg_path, cfgmod, interactive)
365
375
 
366
376
  config = {
367
377
  # context_limit now comes from cfg (DEFAULTS < config.toml < --context-limit)
@@ -25,10 +25,11 @@ from __future__ import annotations
25
25
  import json
26
26
  import os
27
27
  import re
28
+ import sqlite3
28
29
  from collections import Counter, defaultdict
30
+ from pathlib import Path
29
31
 
30
32
  from drydock import extract
31
- from pathlib import Path
32
33
 
33
34
  # Files we ingest as text. Everything else (binaries, images) is skipped.
34
35
  _TEXT_EXT = {
@@ -59,8 +60,150 @@ _RE_WORD = re.compile(r"[a-zA-Z0-9_]+")
59
60
 
60
61
 
61
62
  def default_store_path(cwd: str) -> Path:
62
- """Project-local index (travels with the project, easy to .gitignore)."""
63
- return Path(cwd) / ".drydock" / "graphrag.json"
63
+ """Project-local index. SQLite so queries touch only matching rows (fast even
64
+ at multi-GB scale) instead of parsing a giant JSON on every query."""
65
+ return Path(cwd) / ".drydock" / "graphrag.db"
66
+
67
+
68
+ def _resolve_store(store_path) -> Path:
69
+ """Given a requested store path, return the one that actually exists — a
70
+ ``.db`` (SQLite) if present, else a legacy ``.json`` sibling, else the path
71
+ as given (a fresh build will create it)."""
72
+ p = Path(store_path)
73
+ if p.exists():
74
+ return p
75
+ db = p.with_suffix(".db")
76
+ if db.exists():
77
+ return db
78
+ js = p.with_suffix(".json")
79
+ if js.exists():
80
+ return js
81
+ return p
82
+
83
+
84
+ # ── SQLite + FTS5 backend (the scalable store) ────────────────────────────
85
+
86
+ _SCHEMA = """
87
+ CREATE TABLE IF NOT EXISTS chunks(id INTEGER PRIMARY KEY, source TEXT, text TEXT);
88
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='chunks', content_rowid='id');
89
+ CREATE TABLE IF NOT EXISTS entities(entity TEXT, chunk_id INTEGER);
90
+ CREATE INDEX IF NOT EXISTS idx_entity ON entities(entity);
91
+ CREATE TABLE IF NOT EXISTS edges(a TEXT, b TEXT, weight INTEGER);
92
+ CREATE INDEX IF NOT EXISTS idx_edge_a ON edges(a);
93
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
94
+ """
95
+
96
+ _FTS_STRIP = re.compile(r'[^A-Za-z0-9_ ]+')
97
+
98
+
99
+ def _is_sqlite(path) -> bool:
100
+ return str(path).endswith(".db")
101
+
102
+
103
+ def _write_sqlite(store_path, chunks, entity_chunks, edges) -> None:
104
+ """(Re)build the SQLite store from the accumulated chunks/entities/edges."""
105
+ sp = Path(store_path)
106
+ sp.parent.mkdir(parents=True, exist_ok=True)
107
+ if sp.exists():
108
+ sp.unlink()
109
+ con = sqlite3.connect(str(sp))
110
+ try:
111
+ con.executescript(_SCHEMA)
112
+ con.executemany("INSERT INTO chunks(id,source,text) VALUES(?,?,?)",
113
+ ((c["id"], c["source"], c["text"]) for c in chunks))
114
+ con.executemany("INSERT INTO chunks_fts(rowid,text) VALUES(?,?)",
115
+ ((c["id"], c["text"]) for c in chunks))
116
+ con.executemany("INSERT INTO entities(entity,chunk_id) VALUES(?,?)",
117
+ ((e, cid) for e, cids in entity_chunks.items() for cid in cids))
118
+ con.executemany("INSERT INTO edges(a,b,weight) VALUES(?,?,?)",
119
+ ((a, b, w) for a, nbrs in edges.items() for b, w in nbrs.items()))
120
+ con.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('version','2')")
121
+ con.commit()
122
+ finally:
123
+ con.close()
124
+
125
+
126
+ def _fts_query(words) -> str:
127
+ """Build a safe FTS5 MATCH expression (OR of quoted terms)."""
128
+ terms = []
129
+ for w in words:
130
+ w = _FTS_STRIP.sub(" ", w).strip()
131
+ if w:
132
+ terms.append('"' + w + '"')
133
+ return " OR ".join(terms)
134
+
135
+
136
+ def _query_sqlite(store_path, query: str, k: int, hops: bool) -> dict:
137
+ """Fast query: FTS5 full-text over chunk text + indexed entity/graph lookups.
138
+ Loads only the matching rows — no full-file parse."""
139
+ con = sqlite3.connect(str(store_path))
140
+ con.row_factory = sqlite3.Row
141
+ cur = con.cursor()
142
+ try:
143
+ q_entities = set(extract_entities(query))
144
+ q_words = {w for w in (w.lower() for w in _RE_WORD.findall(query))
145
+ if len(w) > 2 and w not in _STOPWORDS}
146
+ scores: dict[int, float] = defaultdict(float)
147
+
148
+ # 1) FTS5 keyword retrieval (indexed, bm25-ranked) — the main recall path.
149
+ expr = _fts_query(q_words)
150
+ if expr:
151
+ try:
152
+ rows = cur.execute(
153
+ "SELECT rowid, rank FROM chunks_fts WHERE chunks_fts MATCH ? "
154
+ "ORDER BY rank LIMIT 400", (expr,)).fetchall()
155
+ for r in rows:
156
+ scores[r["rowid"]] += 0.5 + min(2.0, -float(r["rank"]) / 4.0)
157
+ except sqlite3.OperationalError:
158
+ pass
159
+
160
+ # 2) Exact entity hits (indexed) weigh most; collect matched entities.
161
+ matched: set[str] = set()
162
+ cand = {e.lower() for e in q_entities} | q_words
163
+ for qe in cand:
164
+ for r in cur.execute("SELECT chunk_id, entity FROM entities WHERE entity=?", (qe,)):
165
+ scores[r["chunk_id"]] += 3.0
166
+ matched.add(r["entity"])
167
+
168
+ # 3) 1-hop graph expansion over the strongest neighbors of matched entities.
169
+ related: list[str] = []
170
+ if hops and matched:
171
+ for e in list(matched):
172
+ nbrs = cur.execute("SELECT b, weight FROM edges WHERE a=? ORDER BY weight DESC LIMIT 5",
173
+ (e,)).fetchall()
174
+ for nb in nbrs:
175
+ if nb["b"] not in matched:
176
+ related.append(nb["b"])
177
+ for r in cur.execute("SELECT chunk_id FROM entities WHERE entity=?", (nb["b"],)):
178
+ scores[r["chunk_id"]] += 1.0
179
+
180
+ if not scores:
181
+ return {"chunks": [], "related": []}
182
+ ranked = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
183
+ out = []
184
+ for cid, sc in ranked:
185
+ if sc <= 0:
186
+ continue
187
+ row = cur.execute("SELECT source, text FROM chunks WHERE id=?", (cid,)).fetchone()
188
+ if row:
189
+ out.append({"source": row["source"], "text": row["text"], "score": round(sc, 1)})
190
+ seen: set[str] = set()
191
+ rel_unique = [r for r in related if not (r in seen or seen.add(r))][:12]
192
+ return {"chunks": out, "related": rel_unique}
193
+ finally:
194
+ con.close()
195
+
196
+
197
+ def migrate_json_to_sqlite(json_path, db_path) -> dict:
198
+ """One-time conversion of a legacy JSON index to the SQLite store. Loads the
199
+ JSON once (slow for a huge file, but one-time), then queries are fast."""
200
+ data = json.loads(Path(json_path).read_text("utf-8"))
201
+ chunks = data.get("chunks", [])
202
+ entity_chunks = {e: list(cids) for e, cids in data.get("entities", {}).items()}
203
+ edges = {a: dict(nbrs) for a, nbrs in data.get("edges", {}).items()}
204
+ _write_sqlite(db_path, chunks, entity_chunks, edges)
205
+ return {"chunks": len(chunks), "entities": len(entity_chunks),
206
+ "edges": sum(len(n) for n in edges.values())}
64
207
 
65
208
 
66
209
  def extract_entities(text: str) -> list[str]:
@@ -167,15 +310,22 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
167
310
 
168
311
 
169
312
  def _save(store_path, chunks, entity_chunks, edges, files_added):
170
- index = {
171
- "version": 1,
172
- "chunks": chunks,
173
- "entities": {e: sorted(cids) for e, cids in entity_chunks.items()},
174
- "edges": {a: dict(nbrs) for a, nbrs in edges.items()},
175
- }
176
- sp = Path(store_path)
177
- sp.parent.mkdir(parents=True, exist_ok=True)
178
- sp.write_text(json.dumps(index), encoding="utf-8")
313
+ # SQLite by default (scalable). A caller can still target a .json path
314
+ # explicitly (small/portable indexes, tests) and get the legacy format.
315
+ if _is_sqlite(store_path):
316
+ _write_sqlite(store_path, chunks,
317
+ {e: sorted(cids) for e, cids in entity_chunks.items()},
318
+ {a: dict(nbrs) for a, nbrs in edges.items()})
319
+ else:
320
+ index = {
321
+ "version": 1,
322
+ "chunks": chunks,
323
+ "entities": {e: sorted(cids) for e, cids in entity_chunks.items()},
324
+ "edges": {a: dict(nbrs) for a, nbrs in edges.items()},
325
+ }
326
+ sp = Path(store_path)
327
+ sp.parent.mkdir(parents=True, exist_ok=True)
328
+ sp.write_text(json.dumps(index), encoding="utf-8")
179
329
  return {
180
330
  "files": files_added,
181
331
  "chunks": len(chunks),
@@ -194,13 +344,36 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
194
344
  return _save(store_path, chunks, entity_chunks, edges, added)
195
345
 
196
346
 
347
+ def _read_all(store_path) -> dict:
348
+ """Read a whole index into a dict (chunks/entities/edges) — used by add_to_index
349
+ to append. Handles both the SQLite store and a legacy JSON file."""
350
+ sp = _resolve_store(store_path)
351
+ if _is_sqlite(sp):
352
+ con = sqlite3.connect(str(sp))
353
+ con.row_factory = sqlite3.Row
354
+ try:
355
+ chunks = [{"id": r["id"], "source": r["source"], "text": r["text"]}
356
+ for r in con.execute("SELECT id,source,text FROM chunks")]
357
+ entities: dict[str, list[int]] = defaultdict(list)
358
+ for r in con.execute("SELECT entity,chunk_id FROM entities"):
359
+ entities[r["entity"]].append(r["chunk_id"])
360
+ edges: dict[str, dict] = defaultdict(dict)
361
+ for r in con.execute("SELECT a,b,weight FROM edges"):
362
+ edges[r["a"]][r["b"]] = r["weight"]
363
+ return {"chunks": chunks, "entities": entities, "edges": edges}
364
+ finally:
365
+ con.close()
366
+ return json.loads(Path(sp).read_text("utf-8"))
367
+
368
+
197
369
  def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
198
370
  """Incrementally ADD documents to an existing index (build it if none yet).
199
371
  Files already indexed (by relative path) are skipped — clear+build to refresh
200
372
  changed files. Returns {files (added), chunks, entities, edges} totals."""
201
- existing = load_index(store_path)
202
- if existing is None:
373
+ sp = _resolve_store(store_path)
374
+ if not sp.exists():
203
375
  return build_index(paths, store_path, cwd=cwd)
376
+ existing = _read_all(sp)
204
377
  chunks: list[dict] = list(existing.get("chunks", []))
205
378
  entity_chunks: dict[str, set[int]] = defaultdict(set)
206
379
  for e, cids in existing.get("entities", {}).items():
@@ -210,20 +383,50 @@ def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
210
383
  edges[a] = Counter(nbrs)
211
384
  skip = {c["source"] for c in chunks}
212
385
  added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip)
213
- return _save(store_path, chunks, entity_chunks, edges, added)
386
+ return _save(sp, chunks, entity_chunks, edges, added)
214
387
 
215
388
 
216
389
  def sources(index: dict) -> list[str]:
217
- """The distinct source files in an index, sorted."""
390
+ """The distinct source files in an index, sorted. Accepts a loaded JSON dict
391
+ OR a SQLite handle ({'_db': path})."""
392
+ db = index.get("_db")
393
+ if db:
394
+ con = sqlite3.connect(str(db))
395
+ try:
396
+ return sorted(r[0] for r in con.execute("SELECT DISTINCT source FROM chunks"))
397
+ finally:
398
+ con.close()
218
399
  return sorted({c["source"] for c in index.get("chunks", [])})
219
400
 
220
401
 
221
- def load_index(store_path: str | Path) -> dict | None:
222
- sp = Path(store_path)
402
+ def index_stats(index: dict) -> dict:
403
+ """{chunks, entities} counts for a loaded dict OR a SQLite handle — without
404
+ reading a large index into memory."""
405
+ db = index.get("_db")
406
+ if db:
407
+ con = sqlite3.connect(str(db))
408
+ try:
409
+ c = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
410
+ e = con.execute("SELECT COUNT(DISTINCT entity) FROM entities").fetchone()[0]
411
+ return {"chunks": c, "entities": e}
412
+ finally:
413
+ con.close()
414
+ return {"chunks": len(index.get("chunks", [])), "entities": len(index.get("entities", {}))}
415
+
416
+
417
+ def load_index(store_path: str | Path):
418
+ """Return a query handle for the index at store_path, or None if none exists.
419
+
420
+ For the SQLite store this is a tiny handle ({'_db': path}) — it does NOT read
421
+ the data (queries hit the DB directly, so a multi-GB index stays instant). For
422
+ a legacy JSON file it loads the dict (small/portable indexes)."""
423
+ sp = _resolve_store(store_path)
223
424
  if not sp.exists():
224
425
  return None
426
+ if _is_sqlite(sp):
427
+ return {"_db": str(sp)}
225
428
  try:
226
- return json.loads(sp.read_text("utf-8"))
429
+ return json.loads(Path(sp).read_text("utf-8"))
227
430
  except (OSError, ValueError):
228
431
  return None
229
432
 
@@ -233,6 +436,11 @@ def query_index(index: dict, query: str, *, k: int = 5, hops: bool = True) -> di
233
436
 
234
437
  Returns {chunks: [{source, text, score}], related: [entities]}.
235
438
  """
439
+ # SQLite handle → the fast, indexed path (no full-file load).
440
+ db = index.get("_db")
441
+ if db:
442
+ return _query_sqlite(db, query, k, hops)
443
+
236
444
  chunks = index.get("chunks", [])
237
445
  entities = index.get("entities", {})
238
446
  edges = index.get("edges", {})
@@ -1125,23 +1125,43 @@ class DrydockApp(App):
1125
1125
  self._info("No knowledge base yet. Build one: /graphrag build <path>")
1126
1126
  else:
1127
1127
  srcs = graphrag.sources(index)
1128
+ st = graphrag.index_stats(index)
1128
1129
  shown = "\n".join(f" · {s}" for s in srcs[:20])
1129
1130
  more = f"\n … +{len(srcs) - 20} more" if len(srcs) > 20 else ""
1131
+ resolved = graphrag._resolve_store(store)
1132
+ legacy = " ⚠ legacy JSON — run /graphrag migrate for fast queries" \
1133
+ if str(resolved).endswith(".json") else ""
1130
1134
  self._info(
1131
- f"Knowledge base: {len(index.get('chunks', []))} chunks · "
1132
- f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
1133
- f"({store}).\n{shown}{more}"
1135
+ f"Knowledge base: {st['chunks']} chunks · {st['entities']} entities · "
1136
+ f"{len(srcs)} sources ({resolved}).{legacy}\n{shown}{more}"
1134
1137
  )
1138
+ elif sub == "migrate":
1139
+ resolved = graphrag._resolve_store(store)
1140
+ if not str(resolved).endswith(".json"):
1141
+ self._info("Already using the fast SQLite store — nothing to migrate.")
1142
+ return
1143
+ db = resolved.with_suffix(".db")
1144
+ self._info(f"Migrating legacy index → {db} (one-time; loads the JSON once) …")
1145
+ try:
1146
+ m = graphrag.migrate_json_to_sqlite(resolved, db)
1147
+ except Exception as e: # noqa: BLE001
1148
+ self._mount(ErrorMessage(f"migrate failed: {e}"))
1149
+ return
1150
+ self._info(
1151
+ f"✓ Migrated: {m['chunks']} chunks · {m['entities']} entities → {db}. "
1152
+ f"Queries now hit the index directly (no full-file load). You can delete "
1153
+ f"the old {resolved.name} once you've confirmed queries work."
1154
+ )
1135
1155
  elif sub == "clear":
1136
1156
  try:
1137
- store.unlink(missing_ok=True)
1157
+ graphrag._resolve_store(store).unlink(missing_ok=True)
1138
1158
  self._info("Knowledge base cleared.")
1139
1159
  except OSError as e:
1140
1160
  self._mount(ErrorMessage(f"could not clear: {e}"))
1141
1161
  else:
1142
1162
  self._info(
1143
1163
  "usage: /graphrag build <path> · add <path> · query <question> "
1144
- "· status · clear"
1164
+ "· status · migrate · clear"
1145
1165
  )
1146
1166
 
1147
1167
  def _persist_config(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.134
3
+ Version: 3.0.136
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
@@ -107,6 +107,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
107
107
  | `/graphrag add <path>` | Incrementally add more documents to the base |
108
108
  | `/graphrag query <q>` | Test what the base returns (no model) |
109
109
  | `/graphrag status` · `clear` | List indexed sources · wipe the base |
110
+ | `/graphrag migrate` | Convert a legacy JSON index to the fast SQLite store |
110
111
  | `/skills` | List your skills |
111
112
  | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
112
113
  | `/<name>` | Run a skill |
@@ -142,8 +143,8 @@ tool) when a question touches your material. Ingests text formats
142
143
  checklists (`.ckl`/`.cklb`)** — checklists are flattened to per-rule findings so
143
144
  you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
144
145
  uses the `pdftotext` binary (poppler) if present, else `pip install
145
- drydock-cli[pdf]` (pypdf). The index is a single JSON at
146
- `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
146
+ drydock-cli[pdf]` (pypdf). The index is a SQLite database at
147
+ `<project>/.drydock/graphrag.db` (FTS5-indexed, so queries stay fast at multi-GB scale) — clean-room, no embeddings.
147
148
 
148
149
  ### Custom skills
149
150
 
@@ -253,13 +254,27 @@ drydock
253
254
  ```
254
255
 
255
256
  Requires Python 3.11+. From source instead:
256
- `git clone https://github.com/fbobe321/drydock-v3.git && cd drydock-v3 && pip install -e .`
257
+ `git clone https://github.com/fbobe321/drydock.git && cd drydock && pip install -e .`
257
258
 
258
259
  On first launch with no config, Drydock probes localhost for a running local
259
260
  LLM (llama.cpp/vLLM `:8000`, Ollama `:11434`, LM Studio `:1234`) and wires up
260
261
  the first one it finds — no account or API-key prompt. Override anytime with
261
262
  `--model` / `--provider` / `--base-url` or `~/.drydock/config.toml`.
262
263
 
264
+ ### Docker
265
+
266
+ A prebuilt image is on Docker Hub. Drydock is just the agent — point it at your
267
+ own OpenAI-compatible model server (e.g. one running on the host):
268
+
269
+ ```
270
+ docker run -it --add-host=host.docker.internal:host-gateway \
271
+ -v "$PWD:/work" fbobe3/drydock \
272
+ --base-url http://host.docker.internal:8000/v1 --model gemma4
273
+ ```
274
+
275
+ `-v "$PWD:/work"` mounts your project so the agent can read/edit it. Tags:
276
+ `fbobe3/drydock:latest` and `fbobe3/drydock:<version>` (e.g. `:3.0.135`).
277
+
263
278
  ## Using it
264
279
 
265
280
  Type a task and press **Enter**. Drydock reads/writes/edits files and runs
@@ -96,6 +96,7 @@ tests/test_glob_edit_edges.py
96
96
  tests/test_graphify_example.py
97
97
  tests/test_graphrag.py
98
98
  tests/test_graphrag_quoted_path.py
99
+ tests/test_graphrag_sqlite.py
99
100
  tests/test_grep_and_read_robust.py
100
101
  tests/test_guards_and_tools.py
101
102
  tests/test_hallucinated_tools.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.134"
10
+ version = "3.0.136"
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"
@@ -77,8 +77,8 @@ def test_partial_config_backfills_missing_keys(tmp_path):
77
77
 
78
78
 
79
79
  def test_explicit_user_values_are_respected(tmp_path):
80
- m = _load(tmp_path, 'base_url = "http://192.168.50.129:8000/v1"\nmodel = "gemma4"\n')
81
- assert m["base_url"] == "http://192.168.50.129:8000/v1"
80
+ m = _load(tmp_path, 'base_url = "http://192.0.2.10:8000/v1"\nmodel = "gemma4"\n')
81
+ assert m["base_url"] == "http://192.0.2.10:8000/v1"
82
82
 
83
83
 
84
84
  def test_default_base_url_is_concrete_not_empty():
@@ -108,7 +108,7 @@ def test_v2_file_is_migrated_and_backed_up(tmp_path):
108
108
  def test_real_v3_file_is_left_untouched(tmp_path):
109
109
  """A genuine v3 file (has a known key) must NOT be migrated/rewritten."""
110
110
  p = tmp_path / "config.toml"
111
- p.write_text('base_url = "http://192.168.50.21:8000/v1"\n# my hand edit\n')
111
+ p.write_text('base_url = "http://192.0.2.20:8000/v1"\n# my hand edit\n')
112
112
  cfg.resolve({}, path=p)
113
113
  assert not p.with_name("config.toml.bak").exists()
114
114
  assert "# my hand edit" in p.read_text() # untouched