drydock-cli 3.0.59__tar.gz → 3.0.61__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 (77) hide show
  1. {drydock_cli-3.0.59/drydock_cli.egg-info → drydock_cli-3.0.61}/PKG-INFO +47 -1
  2. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/README.md +46 -0
  3. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/cli.py +14 -6
  4. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/graphrag.py +47 -13
  5. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/skills.py +19 -0
  6. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/app.py +65 -20
  7. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tuning.py +25 -1
  8. {drydock_cli-3.0.59 → drydock_cli-3.0.61/drydock_cli.egg-info}/PKG-INFO +47 -1
  9. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/SOURCES.txt +1 -0
  10. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/pyproject.toml +1 -1
  11. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_graphrag.py +32 -0
  12. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_skills.py +25 -0
  13. drydock_cli-3.0.61/tests/test_system_prompt_help.py +15 -0
  14. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/LICENSE +0 -0
  15. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/NOTICE +0 -0
  16. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/__init__.py +0 -0
  17. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/__main__.py +0 -0
  18. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/agent.py +0 -0
  19. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/bash_safety.py +0 -0
  20. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/compaction.py +0 -0
  21. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/config.py +0 -0
  22. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/detect.py +0 -0
  23. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/gittools.py +0 -0
  24. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/guards.py +0 -0
  25. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/loop_detect.py +0 -0
  26. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/mcp.py +0 -0
  27. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/providers.py +0 -0
  28. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tool_registry.py +0 -0
  29. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tools/__init__.py +0 -0
  30. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/__init__.py +0 -0
  31. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/approval.py +0 -0
  32. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/messages.py +0 -0
  33. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/widgets.py +0 -0
  34. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/web.py +0 -0
  35. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/dependency_links.txt +0 -0
  36. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/entry_points.txt +0 -0
  37. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/requires.txt +0 -0
  38. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/top_level.txt +0 -0
  39. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/setup.cfg +0 -0
  40. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_approval.py +0 -0
  41. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_back_command.py +0 -0
  42. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_output_bounding.py +0 -0
  43. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_process_group.py +0 -0
  44. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_safety.py +0 -0
  45. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_timeout_network.py +0 -0
  46. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_cli_agents.py +0 -0
  47. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_compact_command.py +0 -0
  48. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_compaction.py +0 -0
  49. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_config.py +0 -0
  50. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_config_migration.py +0 -0
  51. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_context_limit_config.py +0 -0
  52. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_detect.py +0 -0
  53. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_dispatch.py +0 -0
  54. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_empty_response.py +0 -0
  55. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_failure_loop.py +0 -0
  56. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_first_run_setup.py +0 -0
  57. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_gittools.py +0 -0
  58. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_guards_and_tools.py +0 -0
  59. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_hallucinated_tools.py +0 -0
  60. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_leaked_tool_call.py +0 -0
  61. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_loop_detect.py +0 -0
  62. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_mcp.py +0 -0
  63. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_oneshot_unreachable.py +0 -0
  64. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_plan_autocontinue.py +0 -0
  65. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_providers_unreachable.py +0 -0
  66. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_read_index.py +0 -0
  67. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_runaway_repetition.py +0 -0
  68. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_stop.py +0 -0
  69. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_streaming_newlines.py +0 -0
  70. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_subagent.py +0 -0
  71. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_todo.py +0 -0
  72. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tool_arg_parsing.py +0 -0
  73. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tools_undo.py +0 -0
  74. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tui.py +0 -0
  75. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tuning.py +0 -0
  76. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_vision_input.py +0 -0
  77. {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.59
3
+ Version: 3.0.61
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
@@ -71,6 +71,52 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
71
71
  `~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
72
72
  - **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
73
73
 
74
+ ## Slash commands
75
+
76
+ Typed into the prompt. The agent also knows these, so you can just **ask it**
77
+ ("how do I add my own docs?") and it'll point you to the right one.
78
+
79
+ | Command | What it does |
80
+ | --- | --- |
81
+ | `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
82
+ | `/graphrag add <path>` | Incrementally add more documents to the base |
83
+ | `/graphrag query <q>` | Test what the base returns (no model) |
84
+ | `/graphrag status` · `clear` | List indexed sources · wipe the base |
85
+ | `/skills` | List your skills |
86
+ | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
87
+ | `/<name>` | Run a skill |
88
+ | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
89
+ | `/mcp` | List connected MCP servers + their tools |
90
+ | `/model` · `/cwd` | Show/set model & endpoint · working directory |
91
+ | `/undo` · `/back` | Revert the last write · rewind the last turn |
92
+ | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
93
+ | `/help` · `/quit` | Help · exit |
94
+
95
+ ### Knowledge base (GraphRAG) — ingesting your documents
96
+
97
+ ```
98
+ /graphrag build ./docs # index a file or a whole folder
99
+ /graphrag add ./more_docs # add more later, incrementally
100
+ /graphrag query "how are refunds handled?" # check retrieval
101
+ /graphrag status # what's indexed
102
+ ```
103
+
104
+ Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
105
+ tool) when a question touches your material. Ingests text formats
106
+ (`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
107
+ (e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
108
+ `<project>/.drydock/graphrag.json` — clean-room, stdlib-only, no embeddings.
109
+
110
+ ### Custom skills
111
+
112
+ ```
113
+ /skills new commitmsg Write a concise conventional-commit message for: $ARGS
114
+ /commitmsg the staged auth changes # runs the skill with $ARGS substituted
115
+ ```
116
+
117
+ Skills are markdown files in `~/.drydock/skills/` (personal) or
118
+ `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
119
+
74
120
  ## Install
75
121
 
76
122
  ```bash
@@ -49,6 +49,52 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
49
49
  `~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
50
50
  - **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
51
51
 
52
+ ## Slash commands
53
+
54
+ Typed into the prompt. The agent also knows these, so you can just **ask it**
55
+ ("how do I add my own docs?") and it'll point you to the right one.
56
+
57
+ | Command | What it does |
58
+ | --- | --- |
59
+ | `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
60
+ | `/graphrag add <path>` | Incrementally add more documents to the base |
61
+ | `/graphrag query <q>` | Test what the base returns (no model) |
62
+ | `/graphrag status` · `clear` | List indexed sources · wipe the base |
63
+ | `/skills` | List your skills |
64
+ | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
65
+ | `/<name>` | Run a skill |
66
+ | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
67
+ | `/mcp` | List connected MCP servers + their tools |
68
+ | `/model` · `/cwd` | Show/set model & endpoint · working directory |
69
+ | `/undo` · `/back` | Revert the last write · rewind the last turn |
70
+ | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
71
+ | `/help` · `/quit` | Help · exit |
72
+
73
+ ### Knowledge base (GraphRAG) — ingesting your documents
74
+
75
+ ```
76
+ /graphrag build ./docs # index a file or a whole folder
77
+ /graphrag add ./more_docs # add more later, incrementally
78
+ /graphrag query "how are refunds handled?" # check retrieval
79
+ /graphrag status # what's indexed
80
+ ```
81
+
82
+ Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
83
+ tool) when a question touches your material. Ingests text formats
84
+ (`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
85
+ (e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
86
+ `<project>/.drydock/graphrag.json` — clean-room, stdlib-only, no embeddings.
87
+
88
+ ### Custom skills
89
+
90
+ ```
91
+ /skills new commitmsg Write a concise conventional-commit message for: $ARGS
92
+ /commitmsg the staged auth changes # runs the skill with $ARGS substituted
93
+ ```
94
+
95
+ Skills are markdown files in `~/.drydock/skills/` (personal) or
96
+ `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
97
+
52
98
  ## Install
53
99
 
54
100
  ```bash
@@ -198,17 +198,23 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
198
198
  parts = raw.split() # raw = case-preserved, so paths survive
199
199
  sub = parts[1].lower() if len(parts) > 1 else ""
200
200
  rest = " ".join(parts[2:]).strip()
201
- if sub == "build" and rest:
202
- print_colored(f" Building knowledge base from {rest} …", "dim")
201
+ if sub in ("build", "add") and rest:
203
202
  try:
204
- stats = graphrag.build_index([rest], store, cwd=cwd)
203
+ fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
204
+ stats = fn([rest], store, cwd=cwd)
205
205
  print_colored(
206
- f" ✓ {stats['files']} files · {stats['chunks']} chunks · "
206
+ f" ✓ {sub}: +{stats['files']} files · {stats['chunks']} chunks · "
207
207
  f"{stats['entities']} entities · {stats['edges']} edges → {store}",
208
208
  "green",
209
209
  )
210
210
  except Exception as e: # noqa: BLE001
211
- print_colored(f" graphrag build failed: {e}", "red")
211
+ print_colored(f" graphrag {sub} failed: {e}", "red")
212
+ elif sub == "query" and rest:
213
+ index = graphrag.load_index(store)
214
+ if index is None:
215
+ print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
216
+ else:
217
+ print(graphrag.format_results(graphrag.query_index(index, rest, k=3), rest))
212
218
  elif sub == "clear":
213
219
  store.unlink(missing_ok=True)
214
220
  print_colored(" Knowledge base cleared.", "green")
@@ -217,9 +223,11 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
217
223
  if index is None:
218
224
  print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
219
225
  else:
226
+ srcs = graphrag.sources(index)
220
227
  print_colored(
221
228
  f" Knowledge base: {len(index.get('chunks', []))} chunks · "
222
- f"{len(index.get('entities', {}))} entities ({store})", "cyan")
229
+ f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
230
+ f"({store})", "cyan")
223
231
  return True
224
232
  elif cmd in ("/quit", "/exit"):
225
233
  raise KeyboardInterrupt
@@ -110,26 +110,23 @@ def _iter_text_files(paths: list[str]):
110
110
  yield fp
111
111
 
112
112
 
113
- def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
114
- """Build (or rebuild) the knowledge graph from paths; persist to store_path.
115
-
116
- Returns a stats dict: {files, chunks, entities, edges}.
117
- """
118
- chunks: list[dict] = []
119
- entity_chunks: dict[str, set[int]] = defaultdict(set)
120
- edges: dict[str, Counter] = defaultdict(Counter)
121
- files = 0
122
-
113
+ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
114
+ """Chunk + extract + graph every text file under paths into the (mutable)
115
+ accumulators, skipping any source already present. Returns files added."""
116
+ added = 0
123
117
  for fp in _iter_text_files([str(Path(cwd) / p) if not os.path.isabs(p) else p
124
118
  for p in paths]):
119
+ rel = os.path.relpath(str(fp), cwd)
120
+ if rel in skip_sources:
121
+ continue
125
122
  try:
126
123
  text = fp.read_text("utf-8", "ignore")
127
124
  except OSError:
128
125
  continue
129
126
  if not text.strip():
130
127
  continue
131
- files += 1
132
- rel = os.path.relpath(str(fp), cwd)
128
+ added += 1
129
+ skip_sources.add(rel) # don't double-ingest the same file in one call
133
130
  for body in _chunk_text(text):
134
131
  cid = len(chunks)
135
132
  ents = extract_entities(body)
@@ -140,7 +137,10 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
140
137
  for b in ents[i + 1:]:
141
138
  edges[a][b] += 1
142
139
  edges[b][a] += 1
140
+ return added
141
+
143
142
 
143
+ def _save(store_path, chunks, entity_chunks, edges, files_added):
144
144
  index = {
145
145
  "version": 1,
146
146
  "chunks": chunks,
@@ -151,13 +151,47 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
151
151
  sp.parent.mkdir(parents=True, exist_ok=True)
152
152
  sp.write_text(json.dumps(index), encoding="utf-8")
153
153
  return {
154
- "files": files,
154
+ "files": files_added,
155
155
  "chunks": len(chunks),
156
156
  "entities": len(entity_chunks),
157
157
  "edges": sum(len(v) for v in edges.values()) // 2,
158
158
  }
159
159
 
160
160
 
161
+ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
162
+ """Build (or REBUILD from scratch) the knowledge graph from paths; persist to
163
+ store_path. Returns {files, chunks, entities, edges}."""
164
+ chunks: list[dict] = []
165
+ entity_chunks: dict[str, set[int]] = defaultdict(set)
166
+ edges: dict[str, Counter] = defaultdict(Counter)
167
+ added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, set())
168
+ return _save(store_path, chunks, entity_chunks, edges, added)
169
+
170
+
171
+ def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
172
+ """Incrementally ADD documents to an existing index (build it if none yet).
173
+ Files already indexed (by relative path) are skipped — clear+build to refresh
174
+ changed files. Returns {files (added), chunks, entities, edges} totals."""
175
+ existing = load_index(store_path)
176
+ if existing is None:
177
+ return build_index(paths, store_path, cwd=cwd)
178
+ chunks: list[dict] = list(existing.get("chunks", []))
179
+ entity_chunks: dict[str, set[int]] = defaultdict(set)
180
+ for e, cids in existing.get("entities", {}).items():
181
+ entity_chunks[e] = set(cids)
182
+ edges: dict[str, Counter] = defaultdict(Counter)
183
+ for a, nbrs in existing.get("edges", {}).items():
184
+ edges[a] = Counter(nbrs)
185
+ skip = {c["source"] for c in chunks}
186
+ added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip)
187
+ return _save(store_path, chunks, entity_chunks, edges, added)
188
+
189
+
190
+ def sources(index: dict) -> list[str]:
191
+ """The distinct source files in an index, sorted."""
192
+ return sorted({c["source"] for c in index.get("chunks", [])})
193
+
194
+
161
195
  def load_index(store_path: str | Path) -> dict | None:
162
196
  sp = Path(store_path)
163
197
  if not sp.exists():
@@ -77,6 +77,25 @@ def skills_dirs(cwd: str) -> list[Path]:
77
77
  return [Path.home() / ".drydock" / "skills", Path(cwd) / ".drydock" / "skills"]
78
78
 
79
79
 
80
+ def create_skill(name: str, body: str, *, description: str = "", scope: str = "user",
81
+ cwd: str = ".") -> Path:
82
+ """Author a new skill markdown file and return its path. scope='user' writes
83
+ to ~/.drydock/skills (available everywhere); scope='project' writes to
84
+ <cwd>/.drydock/skills. Raises ValueError on a bad name / empty body."""
85
+ name = (name or "").strip().lower()
86
+ body = (body or "").strip()
87
+ if not _NAME_RE.match(name):
88
+ raise ValueError("skill name must be letters/digits/-/_ (e.g. 'review')")
89
+ if not body:
90
+ raise ValueError("a skill needs a prompt body")
91
+ d = (Path(cwd) if scope == "project" else Path.home()) / ".drydock" / "skills"
92
+ d.mkdir(parents=True, exist_ok=True)
93
+ path = d / f"{name}.md"
94
+ front = f"---\nname: {name}\ndescription: {description or 'user skill'}\n---\n"
95
+ path.write_text(front + body + "\n", encoding="utf-8")
96
+ return path
97
+
98
+
80
99
  def load_skills(cwd: str) -> dict[str, Skill]:
81
100
  """Load all skills; later dirs (project) override earlier (user) by name."""
82
101
  skills: dict[str, Skill] = {}
@@ -490,9 +490,9 @@ class DrydockApp(App):
490
490
  " /stop stop the running turn (or press Esc)\n"
491
491
  " /status session model, cwd, turns, tokens\n"
492
492
  " /compact shrink old context to free up the window\n"
493
- " /graphrag build/query a knowledge base from your docs\n"
494
- " /graphrag build <path> · /graphrag status · /graphrag clear\n"
495
- " /skills list your reusable /<name> skills\n"
493
+ " /graphrag ingest docs into a knowledge base the agent can use\n"
494
+ " build <path> · add <path> · query <q> · status · clear\n"
495
+ " /skills list skills · /skills new <name> <prompt> to create one\n"
496
496
  " /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
497
497
  " /mcp list connected MCP servers and their tools\n"
498
498
  " /clear reset the conversation\n"
@@ -504,7 +504,7 @@ class DrydockApp(App):
504
504
  elif cmd == "/loop":
505
505
  self._cmd_loop(arg)
506
506
  elif cmd == "/skills":
507
- self._cmd_skills()
507
+ self._cmd_skills(arg)
508
508
  elif cmd[1:] in self._skills:
509
509
  self._run_skill(self._skills[cmd[1:]], arg)
510
510
  else:
@@ -554,19 +554,42 @@ class DrydockApp(App):
554
554
  self._mount(UserMessage(prompt))
555
555
  self._begin(prompt)
556
556
 
557
- def _cmd_skills(self) -> None:
557
+ def _cmd_skills(self, arg: str = "") -> None:
558
+ """List skills, or create one: /skills new <name> <prompt…> (use $ARGS in
559
+ the prompt for trailing input). Created skills are usable as /<name>
560
+ immediately."""
561
+ from drydock import skills as skillsmod
562
+
563
+ parts = arg.split(maxsplit=2)
564
+ if parts and parts[0].lower() == "new":
565
+ if len(parts) < 3:
566
+ self._info(
567
+ "usage: /skills new <name> <prompt text>\n"
568
+ " e.g. /skills new review Run GitDiff, then review the changes "
569
+ "for bugs.\n (use $ARGS in the prompt for trailing input)"
570
+ )
571
+ return
572
+ name, body = parts[1], parts[2]
573
+ try:
574
+ path = skillsmod.create_skill(name, body, cwd=self.config.get("cwd") or ".")
575
+ except ValueError as e:
576
+ self._info(f"Couldn't create skill: {e}")
577
+ return
578
+ self._skills = skillsmod.load_skills(self.config.get("cwd") or ".") # reload
579
+ self._info(f"✓ Created skill /{name.lower()} ({path}). Invoke it as /{name.lower()}.")
580
+ return
558
581
  if not self._skills:
559
582
  self._info(
560
- "No skills yet. Create one as a markdown file in "
561
- "~/.drydock/skills/<name>.md (or <project>/.drydock/skills/), "
562
- "then invoke it as /<name>. The body is the prompt; use $ARGS for "
563
- "trailing input."
583
+ "No skills yet. Create one with: /skills new <name> <prompt text>\n"
584
+ "(or drop a markdown file in ~/.drydock/skills/<name>.md). Invoke as "
585
+ "/<name>; use $ARGS for trailing input."
564
586
  )
565
587
  return
566
588
  lines = ["Skills (invoke as /<name>):"]
567
589
  for name in sorted(self._skills):
568
590
  sk = self._skills[name]
569
591
  lines.append(f" /{name}" + (f" — {sk.description}" if sk.description else ""))
592
+ lines.append("Create one: /skills new <name> <prompt text>")
570
593
  self._info("\n".join(lines))
571
594
 
572
595
  def _run_skill(self, skill, arg: str) -> None:
@@ -634,33 +657,52 @@ class DrydockApp(App):
634
657
  sub = parts[0].lower() if parts else ""
635
658
  rest = parts[1].strip() if len(parts) > 1 else ""
636
659
 
637
- if sub == "build":
660
+ if sub in ("build", "add"):
638
661
  if not rest:
639
- self._info("usage: /graphrag build <path> (a file or directory of docs/code)")
662
+ self._info(f"usage: /graphrag {sub} <path> (a file or directory of docs/code)")
640
663
  return
641
- self._info(f"Building knowledge base from {rest} ")
664
+ verb = "Rebuilding" if sub == "build" else "Ingesting into"
665
+ self._info(f"{verb} knowledge base from {rest} …")
642
666
  try:
643
- stats = graphrag.build_index([rest], store, cwd=cwd)
667
+ fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
668
+ stats = fn([rest], store, cwd=cwd)
644
669
  except Exception as e: # noqa: BLE001 — surface, never crash the TUI
645
- self._mount(ErrorMessage(f"graphrag build failed: {e}"))
670
+ self._mount(ErrorMessage(f"graphrag {sub} failed: {e}"))
671
+ return
672
+ if sub == "add" and stats["files"] == 0:
673
+ self._info(f"No new documents under {rest} (already indexed, or no text found).")
646
674
  return
647
675
  if not stats["chunks"]:
648
676
  self._info(f"No text found under {rest}. Nothing was indexed.")
649
677
  return
678
+ verb2 = "built" if sub == "build" else f"updated (+{stats['files']} new files)"
650
679
  self._info(
651
- f"✓ Knowledge base built: {stats['files']} files · "
652
- f"{stats['chunks']} chunks · {stats['entities']} entities · "
653
- f"{stats['edges']} edges.\nStored at {store}. The agent will now "
654
- "use the Knowledge tool to draw on it."
680
+ f"✓ Knowledge base {verb2}: {stats['chunks']} chunks · "
681
+ f"{stats['entities']} entities · {stats['edges']} edges.\n"
682
+ f"Stored at {store}. The agent draws on it via the Knowledge tool."
655
683
  )
684
+ elif sub == "query":
685
+ if not rest:
686
+ self._info("usage: /graphrag query <question> (test what the KB returns)")
687
+ return
688
+ index = graphrag.load_index(store)
689
+ if index is None:
690
+ self._info("No knowledge base yet. Build one: /graphrag build <path>")
691
+ return
692
+ res = graphrag.query_index(index, rest, k=3)
693
+ self._info(graphrag.format_results(res, rest))
656
694
  elif sub in ("", "status"):
657
695
  index = graphrag.load_index(store)
658
696
  if index is None:
659
697
  self._info("No knowledge base yet. Build one: /graphrag build <path>")
660
698
  else:
699
+ srcs = graphrag.sources(index)
700
+ shown = "\n".join(f" · {s}" for s in srcs[:20])
701
+ more = f"\n … +{len(srcs) - 20} more" if len(srcs) > 20 else ""
661
702
  self._info(
662
703
  f"Knowledge base: {len(index.get('chunks', []))} chunks · "
663
- f"{len(index.get('entities', {}))} entities ({store})."
704
+ f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
705
+ f"({store}).\n{shown}{more}"
664
706
  )
665
707
  elif sub == "clear":
666
708
  try:
@@ -669,7 +711,10 @@ class DrydockApp(App):
669
711
  except OSError as e:
670
712
  self._mount(ErrorMessage(f"could not clear: {e}"))
671
713
  else:
672
- self._info("usage: /graphrag build <path> · /graphrag status · /graphrag clear")
714
+ self._info(
715
+ "usage: /graphrag build <path> · add <path> · query <question> "
716
+ "· status · clear"
717
+ )
673
718
 
674
719
  def _persist_config(self) -> None:
675
720
  """Save the persistable settings (model/provider/base_url/… — save_file
@@ -206,9 +206,33 @@ def filter_tool_schemas(tool_schemas: list, model: str | None) -> list:
206
206
  return [t for t in tool_schemas if t.get("name") not in GEMMA_DISABLED_TOOLS]
207
207
 
208
208
 
209
+ # Reference for the model so it can answer "how do I …" questions about Drydock
210
+ # itself. The user TYPES these slash commands; you (the model) do NOT run them —
211
+ # you just explain the right one when asked. Appended to every system prompt.
212
+ _DRYDOCK_COMMANDS_HELP = (
213
+ "\n\nAbout Drydock (the tool you run inside). If the user asks how to do "
214
+ "something with Drydock, tell them the slash command — they type it, you do "
215
+ "NOT run it:\n"
216
+ "- Knowledge base from their docs: `/graphrag build <path>` (a file or "
217
+ "folder), `/graphrag add <path>` to add more, `/graphrag query <q>` to test "
218
+ "it, `/graphrag status`, `/graphrag clear`. Once built, you automatically use "
219
+ "the `Knowledge` tool to draw on it. It ingests text formats (md/txt/code/"
220
+ "json/yaml/…); PDFs/Word must be converted to text first.\n"
221
+ "- Custom skills (reusable `/<name>` prompts): `/skills new <name> <prompt "
222
+ "text>` creates one (use $ARGS in the prompt for trailing input); `/skills` "
223
+ "lists them; then they run it as `/<name>`.\n"
224
+ "- Other: `/model` (model/endpoint), `/cwd`, `/undo` (revert last write), "
225
+ "`/back` (rewind a turn), `/compact` (shrink context), `/loop <n> <prompt>` "
226
+ "(repeat a prompt), `/mcp` (connected MCP servers), `/status`, `/clear`, "
227
+ "`/help`, `/quit`. Internet + git are tools you call yourself "
228
+ "(WebSearch/WebFetch, GitStatus/GitDiff/GitLog/GitCommit)."
229
+ )
230
+
231
+
209
232
  def system_prompt_for_model(model: str | None) -> str:
210
233
  """Return the system prompt best suited to the model."""
211
- return _GEMMA_SYSTEM_PROMPT if is_gemma(model) else _DEFAULT_SYSTEM_PROMPT
234
+ base = _GEMMA_SYSTEM_PROMPT if is_gemma(model) else _DEFAULT_SYSTEM_PROMPT
235
+ return base + _DRYDOCK_COMMANDS_HELP
212
236
 
213
237
 
214
238
  def thinking_level_for_turn(turn_count: int, is_user_turn: bool) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.59
3
+ Version: 3.0.61
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
@@ -71,6 +71,52 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
71
71
  `~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
72
72
  - **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
73
73
 
74
+ ## Slash commands
75
+
76
+ Typed into the prompt. The agent also knows these, so you can just **ask it**
77
+ ("how do I add my own docs?") and it'll point you to the right one.
78
+
79
+ | Command | What it does |
80
+ | --- | --- |
81
+ | `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
82
+ | `/graphrag add <path>` | Incrementally add more documents to the base |
83
+ | `/graphrag query <q>` | Test what the base returns (no model) |
84
+ | `/graphrag status` · `clear` | List indexed sources · wipe the base |
85
+ | `/skills` | List your skills |
86
+ | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
87
+ | `/<name>` | Run a skill |
88
+ | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
89
+ | `/mcp` | List connected MCP servers + their tools |
90
+ | `/model` · `/cwd` | Show/set model & endpoint · working directory |
91
+ | `/undo` · `/back` | Revert the last write · rewind the last turn |
92
+ | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
93
+ | `/help` · `/quit` | Help · exit |
94
+
95
+ ### Knowledge base (GraphRAG) — ingesting your documents
96
+
97
+ ```
98
+ /graphrag build ./docs # index a file or a whole folder
99
+ /graphrag add ./more_docs # add more later, incrementally
100
+ /graphrag query "how are refunds handled?" # check retrieval
101
+ /graphrag status # what's indexed
102
+ ```
103
+
104
+ Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
105
+ tool) when a question touches your material. Ingests text formats
106
+ (`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
107
+ (e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
108
+ `<project>/.drydock/graphrag.json` — clean-room, stdlib-only, no embeddings.
109
+
110
+ ### Custom skills
111
+
112
+ ```
113
+ /skills new commitmsg Write a concise conventional-commit message for: $ARGS
114
+ /commitmsg the staged auth changes # runs the skill with $ARGS substituted
115
+ ```
116
+
117
+ Skills are markdown files in `~/.drydock/skills/` (personal) or
118
+ `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
119
+
74
120
  ## Install
75
121
 
76
122
  ```bash
@@ -65,6 +65,7 @@ tests/test_skills.py
65
65
  tests/test_stop.py
66
66
  tests/test_streaming_newlines.py
67
67
  tests/test_subagent.py
68
+ tests/test_system_prompt_help.py
68
69
  tests/test_todo.py
69
70
  tests/test_tool_arg_parsing.py
70
71
  tests/test_tools_undo.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.59"
10
+ version = "3.0.61"
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"
@@ -73,3 +73,35 @@ import pytest # noqa: E402
73
73
  def _make_corpus(tmp_path):
74
74
  _corpus(tmp_path)
75
75
  yield
76
+
77
+
78
+ def test_add_to_index_is_incremental(tmp_path):
79
+ store = tmp_path / ".drydock" / "graphrag.json"
80
+ (tmp_path / "one.md").write_text("The Alpha service uses the Beacon token.")
81
+ s1 = graphrag.build_index(["one.md"], store, cwd=str(tmp_path))
82
+ assert s1["files"] == 1
83
+ # add a second doc — existing chunks preserved, new ones appended
84
+ (tmp_path / "two.md").write_text("The Gamma module writes to the Delta table.")
85
+ s2 = graphrag.add_to_index(["two.md"], store, cwd=str(tmp_path))
86
+ assert s2["files"] == 1 # only the NEW file counted
87
+ assert s2["chunks"] > s1["chunks"] # grew
88
+ idx = graphrag.load_index(store)
89
+ assert set(graphrag.sources(idx)) == {"one.md", "two.md"}
90
+ # both docs are queryable
91
+ assert graphrag.query_index(idx, "Beacon token")["chunks"][0]["source"] == "one.md"
92
+ assert graphrag.query_index(idx, "Delta table")["chunks"][0]["source"] == "two.md"
93
+
94
+
95
+ def test_add_skips_already_indexed(tmp_path):
96
+ store = tmp_path / ".drydock" / "graphrag.json"
97
+ (tmp_path / "one.md").write_text("Alpha Beacon content here.")
98
+ graphrag.build_index(["one.md"], store, cwd=str(tmp_path))
99
+ s = graphrag.add_to_index(["one.md"], store, cwd=str(tmp_path)) # same file again
100
+ assert s["files"] == 0 # nothing re-ingested
101
+
102
+
103
+ def test_add_builds_when_no_index_yet(tmp_path):
104
+ store = tmp_path / ".drydock" / "graphrag.json"
105
+ (tmp_path / "x.md").write_text("Some Echo content.")
106
+ s = graphrag.add_to_index(["x.md"], store, cwd=str(tmp_path))
107
+ assert s["files"] == 1 and graphrag.load_index(store) is not None
@@ -54,3 +54,28 @@ def test_empty_body_skipped(tmp_path):
54
54
 
55
55
  def test_no_skills_dir_is_empty(tmp_path):
56
56
  assert skills.load_skills(str(tmp_path)) == {}
57
+
58
+
59
+ def test_create_skill_user_scope(tmp_path, monkeypatch):
60
+ monkeypatch.setattr(skills.Path, "home", lambda: tmp_path)
61
+ path = skills.create_skill("review", "Run GitDiff then review for bugs.",
62
+ description="Review the diff")
63
+ assert path.exists()
64
+ loaded = skills.load_skills(str(tmp_path / "proj")) # user-scope visible anywhere
65
+ assert "review" in loaded and "review for bugs" in loaded["review"].body
66
+ assert loaded["review"].description == "Review the diff"
67
+
68
+
69
+ def test_create_skill_rejects_bad_name_and_empty_body(tmp_path, monkeypatch):
70
+ monkeypatch.setattr(skills.Path, "home", lambda: tmp_path)
71
+ import pytest
72
+ with pytest.raises(ValueError):
73
+ skills.create_skill("bad name!", "body")
74
+ with pytest.raises(ValueError):
75
+ skills.create_skill("ok", " ")
76
+
77
+
78
+ def test_create_skill_project_scope(tmp_path):
79
+ path = skills.create_skill("explain", "Explain $ARGS.", scope="project", cwd=str(tmp_path))
80
+ assert str(tmp_path) in str(path)
81
+ assert skills.load_skills(str(tmp_path))["explain"].render("auth.py") == "Explain auth.py."
@@ -0,0 +1,15 @@
1
+ """The system prompt must teach the model Drydock's own slash commands so it can
2
+ answer 'how do I add documents / make a skill' when a user asks."""
3
+ from __future__ import annotations
4
+
5
+ from drydock.tuning import system_prompt_for_model
6
+
7
+
8
+ def test_prompt_documents_graphrag_and_skills():
9
+ for model in ("gemma4", "some-other-model"):
10
+ p = system_prompt_for_model(model)
11
+ assert "/graphrag build" in p and "/graphrag add" in p
12
+ assert "/skills new" in p
13
+ assert "Knowledge" in p
14
+ # framed so the model explains, not runs, the command
15
+ assert "you do" in p.lower() and "not run" in p.lower()
File without changes
File without changes
File without changes