drydock-cli 3.0.60__tar.gz → 3.0.62__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 (79) hide show
  1. {drydock_cli-3.0.60/drydock_cli.egg-info → drydock_cli-3.0.62}/PKG-INFO +50 -1
  2. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/README.md +47 -0
  3. drydock_cli-3.0.62/drydock/extract.py +94 -0
  4. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/graphrag.py +13 -5
  5. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/skills.py +19 -0
  6. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/app.py +30 -7
  7. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tuning.py +25 -1
  8. {drydock_cli-3.0.60 → drydock_cli-3.0.62/drydock_cli.egg-info}/PKG-INFO +50 -1
  9. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/SOURCES.txt +3 -0
  10. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/requires.txt +3 -0
  11. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/pyproject.toml +4 -1
  12. drydock_cli-3.0.62/tests/test_extract.py +71 -0
  13. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_skills.py +25 -0
  14. drydock_cli-3.0.62/tests/test_system_prompt_help.py +15 -0
  15. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/LICENSE +0 -0
  16. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/NOTICE +0 -0
  17. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/__init__.py +0 -0
  18. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/__main__.py +0 -0
  19. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/agent.py +0 -0
  20. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/bash_safety.py +0 -0
  21. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/cli.py +0 -0
  22. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/compaction.py +0 -0
  23. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/config.py +0 -0
  24. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/detect.py +0 -0
  25. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/gittools.py +0 -0
  26. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/guards.py +0 -0
  27. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/loop_detect.py +0 -0
  28. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/mcp.py +0 -0
  29. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/providers.py +0 -0
  30. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tool_registry.py +0 -0
  31. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tools/__init__.py +0 -0
  32. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/__init__.py +0 -0
  33. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/approval.py +0 -0
  34. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/messages.py +0 -0
  35. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/widgets.py +0 -0
  36. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/web.py +0 -0
  37. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/dependency_links.txt +0 -0
  38. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/entry_points.txt +0 -0
  39. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/top_level.txt +0 -0
  40. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/setup.cfg +0 -0
  41. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_approval.py +0 -0
  42. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_back_command.py +0 -0
  43. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_output_bounding.py +0 -0
  44. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_process_group.py +0 -0
  45. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_safety.py +0 -0
  46. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_timeout_network.py +0 -0
  47. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_cli_agents.py +0 -0
  48. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_compact_command.py +0 -0
  49. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_compaction.py +0 -0
  50. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_config.py +0 -0
  51. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_config_migration.py +0 -0
  52. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_context_limit_config.py +0 -0
  53. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_detect.py +0 -0
  54. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_dispatch.py +0 -0
  55. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_empty_response.py +0 -0
  56. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_failure_loop.py +0 -0
  57. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_first_run_setup.py +0 -0
  58. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_gittools.py +0 -0
  59. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_graphrag.py +0 -0
  60. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_guards_and_tools.py +0 -0
  61. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_hallucinated_tools.py +0 -0
  62. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_leaked_tool_call.py +0 -0
  63. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_loop_detect.py +0 -0
  64. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_mcp.py +0 -0
  65. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_oneshot_unreachable.py +0 -0
  66. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_plan_autocontinue.py +0 -0
  67. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_providers_unreachable.py +0 -0
  68. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_read_index.py +0 -0
  69. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_runaway_repetition.py +0 -0
  70. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_stop.py +0 -0
  71. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_streaming_newlines.py +0 -0
  72. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_subagent.py +0 -0
  73. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_todo.py +0 -0
  74. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tool_arg_parsing.py +0 -0
  75. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tools_undo.py +0 -0
  76. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tui.py +0 -0
  77. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tuning.py +0 -0
  78. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_vision_input.py +0 -0
  79. {drydock_cli-3.0.60 → drydock_cli-3.0.62}/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.60
3
+ Version: 3.0.62
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
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-timeout; extra == "dev"
19
19
  Requires-Dist: ruff; extra == "dev"
20
20
  Requires-Dist: pyright; extra == "dev"
21
+ Provides-Extra: pdf
22
+ Requires-Dist: pypdf>=4; extra == "pdf"
21
23
  Dynamic: license-file
22
24
 
23
25
  # ⚓ Drydock
@@ -71,6 +73,53 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
71
73
  `~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
72
74
  - **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
73
75
 
76
+ ## Slash commands
77
+
78
+ Typed into the prompt. The agent also knows these, so you can just **ask it**
79
+ ("how do I add my own docs?") and it'll point you to the right one.
80
+
81
+ | Command | What it does |
82
+ | --- | --- |
83
+ | `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
84
+ | `/graphrag add <path>` | Incrementally add more documents to the base |
85
+ | `/graphrag query <q>` | Test what the base returns (no model) |
86
+ | `/graphrag status` · `clear` | List indexed sources · wipe the base |
87
+ | `/skills` | List your skills |
88
+ | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
89
+ | `/<name>` | Run a skill |
90
+ | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
91
+ | `/mcp` | List connected MCP servers + their tools |
92
+ | `/model` · `/cwd` | Show/set model & endpoint · working directory |
93
+ | `/undo` · `/back` | Revert the last write · rewind the last turn |
94
+ | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
95
+ | `/help` · `/quit` | Help · exit |
96
+
97
+ ### Knowledge base (GraphRAG) — ingesting your documents
98
+
99
+ ```
100
+ /graphrag build ./docs # index a file or a whole folder
101
+ /graphrag add ./more_docs # add more later, incrementally
102
+ /graphrag query "how are refunds handled?" # check retrieval
103
+ /graphrag status # what's indexed
104
+ ```
105
+
106
+ Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
107
+ tool) when a question touches your material. Ingests text formats
108
+ (`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
109
+ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
110
+ `pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
111
+ `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
112
+
113
+ ### Custom skills
114
+
115
+ ```
116
+ /skills new commitmsg Write a concise conventional-commit message for: $ARGS
117
+ /commitmsg the staged auth changes # runs the skill with $ARGS substituted
118
+ ```
119
+
120
+ Skills are markdown files in `~/.drydock/skills/` (personal) or
121
+ `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
122
+
74
123
  ## Install
75
124
 
76
125
  ```bash
@@ -49,6 +49,53 @@ 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 …`) **plus PDF and Word (`.docx`)**. `.docx`
85
+ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
86
+ `pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
87
+ `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
88
+
89
+ ### Custom skills
90
+
91
+ ```
92
+ /skills new commitmsg Write a concise conventional-commit message for: $ARGS
93
+ /commitmsg the staged auth changes # runs the skill with $ARGS substituted
94
+ ```
95
+
96
+ Skills are markdown files in `~/.drydock/skills/` (personal) or
97
+ `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
98
+
52
99
  ## Install
53
100
 
54
101
  ```bash
@@ -0,0 +1,94 @@
1
+ """Document text extraction for GraphRAG ingestion — PDF + Word (.docx).
2
+
3
+ Kept clean-room and dependency-light:
4
+ • .docx is a zip of XML, so we read it with STDLIB only (zipfile + ElementTree)
5
+ — no dependency.
6
+ • .pdf uses whatever the system already has: the `pdftotext` binary (poppler)
7
+ if present, else the optional `pypdf` package (`pip install drydock-cli[pdf]`).
8
+
9
+ Returns None when a document can't be read (unsupported, corrupt, or no PDF
10
+ backend available) so ingestion skips it cleanly instead of failing.
11
+
12
+ All logic original to Drydock.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import xml.etree.ElementTree as ET
18
+ import zipfile
19
+ from pathlib import Path
20
+
21
+ # Extensions we can pull text out of (beyond the plain-text formats GraphRAG
22
+ # already reads directly).
23
+ EXTRACTABLE_EXT = {".pdf", ".docx"}
24
+
25
+ _W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
26
+
27
+
28
+ def _extract_docx(path: str | Path) -> str | None:
29
+ """Pull paragraph text from a .docx (stdlib only). Each <w:p> is a paragraph;
30
+ its text lives in <w:t> runs."""
31
+ try:
32
+ with zipfile.ZipFile(path) as z:
33
+ xml = z.read("word/document.xml")
34
+ except (zipfile.BadZipFile, KeyError, OSError):
35
+ return None
36
+ try:
37
+ root = ET.fromstring(xml)
38
+ except ET.ParseError:
39
+ return None
40
+ paras: list[str] = []
41
+ for p in root.iter(f"{_W}p"):
42
+ runs = [t.text for t in p.iter(f"{_W}t") if t.text]
43
+ if runs:
44
+ paras.append("".join(runs))
45
+ text = "\n\n".join(paras).strip()
46
+ return text or None
47
+
48
+
49
+ def _extract_pdf(path: str | Path) -> str | None:
50
+ """Extract text from a PDF: try the `pdftotext` binary first, then `pypdf`.
51
+ Returns None if neither is available or the PDF has no extractable text."""
52
+ # 1) pdftotext (poppler) — fast and common on Linux/macOS.
53
+ try:
54
+ r = subprocess.run(
55
+ ["pdftotext", "-q", str(path), "-"],
56
+ capture_output=True, text=True, timeout=120,
57
+ )
58
+ if r.returncode == 0 and r.stdout.strip():
59
+ return r.stdout
60
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
61
+ pass
62
+ # 2) pypdf (optional dependency: drydock-cli[pdf]).
63
+ try:
64
+ import pypdf
65
+
66
+ reader = pypdf.PdfReader(str(path))
67
+ text = "\n\n".join((pg.extract_text() or "") for pg in reader.pages).strip()
68
+ return text or None
69
+ except Exception: # noqa: BLE001 — optional dep missing / unreadable PDF
70
+ return None
71
+
72
+
73
+ def extract_document(path: str | Path) -> str | None:
74
+ """Extract text from a supported document (.pdf/.docx), or None."""
75
+ ext = Path(path).suffix.lower()
76
+ if ext == ".docx":
77
+ return _extract_docx(path)
78
+ if ext == ".pdf":
79
+ return _extract_pdf(path)
80
+ return None
81
+
82
+
83
+ def pdf_backend_available() -> bool:
84
+ """Whether a PDF can be extracted at all (pdftotext binary or pypdf)."""
85
+ from shutil import which
86
+
87
+ if which("pdftotext"):
88
+ return True
89
+ try:
90
+ import pypdf # noqa: F401
91
+
92
+ return True
93
+ except ImportError:
94
+ return False
@@ -26,6 +26,8 @@ import json
26
26
  import os
27
27
  import re
28
28
  from collections import Counter, defaultdict
29
+
30
+ from drydock import extract
29
31
  from pathlib import Path
30
32
 
31
33
  # Files we ingest as text. Everything else (binaries, images) is skipped.
@@ -106,7 +108,8 @@ def _iter_text_files(paths: list[str]):
106
108
  dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
107
109
  for f in files:
108
110
  fp = Path(root) / f
109
- if fp.suffix.lower() in _TEXT_EXT or fp.suffix == "":
111
+ ext = fp.suffix.lower()
112
+ if ext in _TEXT_EXT or ext in extract.EXTRACTABLE_EXT or fp.suffix == "":
110
113
  yield fp
111
114
 
112
115
 
@@ -119,10 +122,15 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
119
122
  rel = os.path.relpath(str(fp), cwd)
120
123
  if rel in skip_sources:
121
124
  continue
122
- try:
123
- text = fp.read_text("utf-8", "ignore")
124
- except OSError:
125
- continue
125
+ if fp.suffix.lower() in extract.EXTRACTABLE_EXT:
126
+ text = extract.extract_document(fp) # PDF/Word → text (or None)
127
+ if not text:
128
+ continue # unreadable / no PDF backend — skip cleanly
129
+ else:
130
+ try:
131
+ text = fp.read_text("utf-8", "ignore")
132
+ except OSError:
133
+ continue
126
134
  if not text.strip():
127
135
  continue
128
136
  added += 1
@@ -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] = {}
@@ -492,7 +492,7 @@ class DrydockApp(App):
492
492
  " /compact shrink old context to free up the window\n"
493
493
  " /graphrag ingest docs into a knowledge base the agent can use\n"
494
494
  " build <path> · add <path> · query <q> · status · clear\n"
495
- " /skills list your reusable /<name> skills\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:
@@ -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/…) plus PDF and Word (.docx) directly.\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.60
3
+ Version: 3.0.62
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
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-timeout; extra == "dev"
19
19
  Requires-Dist: ruff; extra == "dev"
20
20
  Requires-Dist: pyright; extra == "dev"
21
+ Provides-Extra: pdf
22
+ Requires-Dist: pypdf>=4; extra == "pdf"
21
23
  Dynamic: license-file
22
24
 
23
25
  # ⚓ Drydock
@@ -71,6 +73,53 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
71
73
  `~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
72
74
  - **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
73
75
 
76
+ ## Slash commands
77
+
78
+ Typed into the prompt. The agent also knows these, so you can just **ask it**
79
+ ("how do I add my own docs?") and it'll point you to the right one.
80
+
81
+ | Command | What it does |
82
+ | --- | --- |
83
+ | `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
84
+ | `/graphrag add <path>` | Incrementally add more documents to the base |
85
+ | `/graphrag query <q>` | Test what the base returns (no model) |
86
+ | `/graphrag status` · `clear` | List indexed sources · wipe the base |
87
+ | `/skills` | List your skills |
88
+ | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
89
+ | `/<name>` | Run a skill |
90
+ | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
91
+ | `/mcp` | List connected MCP servers + their tools |
92
+ | `/model` · `/cwd` | Show/set model & endpoint · working directory |
93
+ | `/undo` · `/back` | Revert the last write · rewind the last turn |
94
+ | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
95
+ | `/help` · `/quit` | Help · exit |
96
+
97
+ ### Knowledge base (GraphRAG) — ingesting your documents
98
+
99
+ ```
100
+ /graphrag build ./docs # index a file or a whole folder
101
+ /graphrag add ./more_docs # add more later, incrementally
102
+ /graphrag query "how are refunds handled?" # check retrieval
103
+ /graphrag status # what's indexed
104
+ ```
105
+
106
+ Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
107
+ tool) when a question touches your material. Ingests text formats
108
+ (`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
109
+ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
110
+ `pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
111
+ `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
112
+
113
+ ### Custom skills
114
+
115
+ ```
116
+ /skills new commitmsg Write a concise conventional-commit message for: $ARGS
117
+ /commitmsg the staged auth changes # runs the skill with $ARGS substituted
118
+ ```
119
+
120
+ Skills are markdown files in `~/.drydock/skills/` (personal) or
121
+ `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
122
+
74
123
  ## Install
75
124
 
76
125
  ```bash
@@ -10,6 +10,7 @@ drydock/cli.py
10
10
  drydock/compaction.py
11
11
  drydock/config.py
12
12
  drydock/detect.py
13
+ drydock/extract.py
13
14
  drydock/gittools.py
14
15
  drydock/graphrag.py
15
16
  drydock/guards.py
@@ -47,6 +48,7 @@ tests/test_context_limit_config.py
47
48
  tests/test_detect.py
48
49
  tests/test_dispatch.py
49
50
  tests/test_empty_response.py
51
+ tests/test_extract.py
50
52
  tests/test_failure_loop.py
51
53
  tests/test_first_run_setup.py
52
54
  tests/test_gittools.py
@@ -65,6 +67,7 @@ tests/test_skills.py
65
67
  tests/test_stop.py
66
68
  tests/test_streaming_newlines.py
67
69
  tests/test_subagent.py
70
+ tests/test_system_prompt_help.py
68
71
  tests/test_todo.py
69
72
  tests/test_tool_arg_parsing.py
70
73
  tests/test_tools_undo.py
@@ -6,3 +6,6 @@ pytest
6
6
  pytest-timeout
7
7
  ruff
8
8
  pyright
9
+
10
+ [pdf]
11
+ pypdf>=4
@@ -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.60"
10
+ version = "3.0.62"
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"
@@ -29,6 +29,9 @@ drydock = "drydock.cli:main"
29
29
 
30
30
  [project.optional-dependencies]
31
31
  dev = ["pytest", "pytest-timeout", "ruff", "pyright"]
32
+ # PDF ingestion fallback when the `pdftotext` binary (poppler) isn't installed.
33
+ # .docx needs nothing extra (stdlib zip/xml). `pip install drydock-cli[pdf]`.
34
+ pdf = ["pypdf>=4"]
32
35
 
33
36
  [tool.ruff]
34
37
  target-version = "py312"
@@ -0,0 +1,71 @@
1
+ """PDF + Word (.docx) document extraction for GraphRAG ingestion."""
2
+ from __future__ import annotations
3
+
4
+ import zipfile
5
+
6
+ import pytest
7
+
8
+ from drydock import extract, graphrag
9
+
10
+
11
+ def _make_docx(path, text):
12
+ doc = ('<?xml version="1.0"?><w:document xmlns:w='
13
+ '"http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
14
+ f'<w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>')
15
+ with zipfile.ZipFile(path, "w") as z:
16
+ z.writestr("word/document.xml", doc)
17
+
18
+
19
+ def _make_pdf(path, text):
20
+ objs = [b"<< /Type /Catalog /Pages 2 0 R >>",
21
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
22
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
23
+ b"/Resources << /Font << /F1 5 0 R >> >> >>"]
24
+ stream = b"BT /F1 18 Tf 72 700 Td (" + text.encode() + b") Tj ET"
25
+ objs.append(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
26
+ objs.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
27
+ out = b"%PDF-1.4\n"; offs = []
28
+ for i, o in enumerate(objs, 1):
29
+ offs.append(len(out)); out += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
30
+ x = len(out); out += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
31
+ for o in offs:
32
+ out += ("%010d 00000 n \n" % o).encode()
33
+ out += (b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\n"
34
+ b"startxref\n" + str(x).encode() + b"\n%%EOF")
35
+ path.write_bytes(out)
36
+
37
+
38
+ def test_docx_extraction(tmp_path):
39
+ p = tmp_path / "d.docx"
40
+ _make_docx(p, "The Nimbus service uses the Falcon-Token header.")
41
+ assert extract.extract_document(p) == "The Nimbus service uses the Falcon-Token header."
42
+
43
+
44
+ def test_pdf_extraction(tmp_path):
45
+ if not extract.pdf_backend_available():
46
+ pytest.skip("no PDF backend (pdftotext/pypdf) available")
47
+ p = tmp_path / "m.pdf"
48
+ _make_pdf(p, "Refunds via the Zephyr gateway.")
49
+ out = extract.extract_document(p)
50
+ assert out and "Zephyr gateway" in out
51
+
52
+
53
+ def test_unsupported_and_corrupt_return_none(tmp_path):
54
+ assert extract.extract_document(tmp_path / "x.png") is None
55
+ bad = tmp_path / "bad.docx"; bad.write_text("not a zip")
56
+ assert extract.extract_document(bad) is None
57
+
58
+
59
+ def test_graphrag_ingests_docx_and_pdf(tmp_path):
60
+ _make_docx(tmp_path / "spec.docx", "The Nimbus API uses the Falcon-Token header for auth.")
61
+ if extract.pdf_backend_available():
62
+ _make_pdf(tmp_path / "manual.pdf", "Refunds are issued via the Zephyr gateway.")
63
+ store = tmp_path / ".drydock" / "graphrag.json"
64
+ graphrag.build_index(["."], store, cwd=str(tmp_path))
65
+ idx = graphrag.load_index(store)
66
+ srcs = graphrag.sources(idx)
67
+ assert "spec.docx" in srcs
68
+ assert graphrag.query_index(idx, "Falcon-Token header")["chunks"][0]["source"] == "spec.docx"
69
+ if extract.pdf_backend_available():
70
+ assert "manual.pdf" in srcs
71
+ assert graphrag.query_index(idx, "refunds Zephyr gateway")["chunks"][0]["source"] == "manual.pdf"
@@ -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