docs-kit 0.1.1__tar.gz → 0.1.2__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 (63) hide show
  1. {docs_kit-0.1.1 → docs_kit-0.1.2}/.gitignore +1 -0
  2. {docs_kit-0.1.1 → docs_kit-0.1.2}/PKG-INFO +44 -13
  3. {docs_kit-0.1.1 → docs_kit-0.1.2}/README.md +42 -11
  4. docs_kit-0.1.2/docs_kit/_version.py +1 -0
  5. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/cli/commands.py +46 -10
  6. {docs_kit-0.1.1 → docs_kit-0.1.2}/pyproject.toml +1 -1
  7. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_cli.py +79 -0
  8. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_install_cmd.py +90 -20
  9. docs_kit-0.1.1/docs_kit/_version.py +0 -1
  10. {docs_kit-0.1.1 → docs_kit-0.1.2}/.github/workflows/ci.yml +0 -0
  11. {docs_kit-0.1.1 → docs_kit-0.1.2}/.github/workflows/publish.yml +0 -0
  12. {docs_kit-0.1.1 → docs_kit-0.1.2}/AGENTS.md +0 -0
  13. {docs_kit-0.1.1 → docs_kit-0.1.2}/CHANGELOG.md +0 -0
  14. {docs_kit-0.1.1 → docs_kit-0.1.2}/CLAUDE.md +0 -0
  15. {docs_kit-0.1.1 → docs_kit-0.1.2}/CONTRIBUTING.md +0 -0
  16. {docs_kit-0.1.1 → docs_kit-0.1.2}/LICENSE +0 -0
  17. {docs_kit-0.1.1 → docs_kit-0.1.2}/data/sample_docs/claude-code-changelog.md +0 -0
  18. {docs_kit-0.1.1 → docs_kit-0.1.2}/data/sample_docs/the-adventure-of-the-speckled-band.md +0 -0
  19. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs-kit.yaml +0 -0
  20. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/__init__.py +0 -0
  21. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/__main__.py +0 -0
  22. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/agent.py +0 -0
  23. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/cli/__init__.py +0 -0
  24. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/cli/__main__.py +0 -0
  25. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/cli/help.py +0 -0
  26. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/__init__.py +0 -0
  27. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/embeddings/__init__.py +0 -0
  28. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/embeddings/base.py +0 -0
  29. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/embeddings/fastembed.py +0 -0
  30. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/fetchers/__init__.py +0 -0
  31. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/fetchers/base.py +0 -0
  32. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/fetchers/gitbook.py +0 -0
  33. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/fetchers/llms_txt.py +0 -0
  34. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/fetchers/mintlify.py +0 -0
  35. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/parsers/__init__.py +0 -0
  36. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/parsers/base.py +0 -0
  37. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/parsers/markdown.py +0 -0
  38. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/parsers/text.py +0 -0
  39. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/vector_stores/__init__.py +0 -0
  40. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/vector_stores/base.py +0 -0
  41. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/connectors/vector_stores/qdrant.py +0 -0
  42. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/core/__init__.py +0 -0
  43. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/core/chunking.py +0 -0
  44. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/core/config.py +0 -0
  45. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/core/html_utils.py +0 -0
  46. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/core/models.py +0 -0
  47. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/mcp/__init__.py +0 -0
  48. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/mcp/server.py +0 -0
  49. {docs_kit-0.1.1 → docs_kit-0.1.2}/docs_kit/mcp/tools.py +0 -0
  50. {docs_kit-0.1.1 → docs_kit-0.1.2}/npx-wrapper/bin/docs-kit.js +0 -0
  51. {docs_kit-0.1.1 → docs_kit-0.1.2}/npx-wrapper/package.json +0 -0
  52. {docs_kit-0.1.1 → docs_kit-0.1.2}/scripts/smoke_test.sh +0 -0
  53. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/__init__.py +0 -0
  54. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_agent.py +0 -0
  55. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_chunking.py +0 -0
  56. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_config.py +0 -0
  57. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_embeddings_fastembed.py +0 -0
  58. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_fetcher_gitbook.py +0 -0
  59. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_fetcher_mintlify.py +0 -0
  60. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_mcp_tools.py +0 -0
  61. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_models.py +0 -0
  62. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_parsers.py +0 -0
  63. {docs_kit-0.1.1 → docs_kit-0.1.2}/tests/test_vector_store_qdrant.py +0 -0
@@ -12,4 +12,5 @@ coverage/
12
12
  .docs-kit/
13
13
  downloaded-docs/
14
14
  /release-patch-pypi.sh
15
+ /repush-release-tag.sh
15
16
  docs/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: docs-kit
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Fetch docs, embed locally, expose via MCP for AI agents.
5
5
  License: MIT License
6
6
 
@@ -23,7 +23,7 @@ License: MIT License
23
23
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
24
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
25
  SOFTWARE.
26
- Requires-Python: >=3.11
26
+ Requires-Python: <3.14,>=3.11
27
27
  Requires-Dist: click>=8.0.0
28
28
  Requires-Dist: fastembed>=0.6.0
29
29
  Requires-Dist: httpx>=0.27.0
@@ -65,37 +65,54 @@ No API keys are required for the default local embedding path.
65
65
 
66
66
  ## Install
67
67
 
68
+ The recommended install method is **`pipx`** — it puts `docs-kit` on your global PATH without touching your system Python or any project venv:
69
+
68
70
  ```bash
69
- pip install docs-kit
71
+ pipx install docs-kit
70
72
  ```
71
73
 
72
- Or with `npx`:
74
+ Install `pipx` first if you don't have it:
73
75
 
74
76
  ```bash
75
- npx docs-kit ingest https://docs.example.com
77
+ brew install pipx
78
+ pipx ensurepath # adds ~/.local/bin to PATH
79
+ ```
80
+
81
+ This matters for MCP installs. When `docs-kit` is on PATH, `docs-kit install claude-code` writes the absolute binary path into the agent config (e.g. `/Users/you/.local/pipx/venvs/docs-kit/bin/docs-kit`) so the MCP server works from any directory, not just your project folder.
82
+
83
+ If you install globally with `pipx` and do not have a project yet, `docs-kit install <agent>` will bootstrap a user config at `~/.docs-kit/docs-kit.yaml` and point the MCP entry at it automatically.
84
+
85
+ Or with plain `pip` (works, but binary won't be on global PATH unless you're in the right venv):
86
+
87
+ ```bash
88
+ pip install docs-kit
76
89
  ```
77
90
 
78
91
  ## Quickstart
79
92
 
80
93
  ```bash
81
- # 1. Create a config file
82
- docs-kit init
94
+ # 0. Install (once)
95
+ pipx install docs-kit
96
+
97
+ # 1. Install into your client
98
+ docs-kit install codex
99
+ docs-kit install claude-code
83
100
 
84
- # 2. Ingest docs (GitBook or Mintlify auto-detected)
101
+ # 2. Ingest docs (uses ~/.docs-kit/docs-kit.yaml if no local config exists)
85
102
  docs-kit ingest https://docs.elevenlabs.io
86
103
 
87
104
  # 3. Check the collection
88
105
  docs-kit inspect
89
106
 
90
- # 4. Install into your client
91
- docs-kit install claude-code
107
+ # 4. Optional: create a project-local config instead
108
+ docs-kit init
92
109
  ```
93
110
 
94
111
  ## Commands
95
112
 
96
113
  ### `docs-kit init`
97
114
 
98
- Create `docs-kit.yaml`.
115
+ Create a project-local `docs-kit.yaml`.
99
116
 
100
117
  ```bash
101
118
  docs-kit init
@@ -135,6 +152,10 @@ docs-kit serve --transport sse --port 3001
135
152
  docs-kit serve --config ./docs-kit.yaml
136
153
  ```
137
154
 
155
+ Without `--config`, `docs-kit` uses this precedence:
156
+ 1. `./docs-kit.yaml`
157
+ 2. `~/.docs-kit/docs-kit.yaml` (auto-created on first global use)
158
+
138
159
  ### `docs-kit install <agent>`
139
160
 
140
161
  Install docs-kit into a supported client config.
@@ -146,6 +167,8 @@ docs-kit install claude-code --project
146
167
  docs-kit install cursor --config ./docs-kit.yaml
147
168
  ```
148
169
 
170
+ If you run `docs-kit install <agent>` outside a project and omit `--config`, docs-kit creates `~/.docs-kit/docs-kit.yaml` and installs the MCP server with an absolute `--config` pointing there.
171
+
149
172
  ### `docs-kit query <text>`
150
173
 
151
174
  Run retrieval directly from the CLI.
@@ -227,7 +250,7 @@ ChatGPT aliases are accepted for guidance only:
227
250
 
228
251
  ## Configuration
229
252
 
230
- `docs-kit.yaml` created by `docs-kit init`:
253
+ Project-local `docs-kit.yaml` created by `docs-kit init`:
231
254
 
232
255
  ```yaml
233
256
  embedding:
@@ -250,6 +273,8 @@ mcp:
250
273
  port: 3001
251
274
  ```
252
275
 
276
+ Global installs can also use a user config at `~/.docs-kit/docs-kit.yaml`, with data stored under `~/.docs-kit/qdrant`.
277
+
253
278
  ## Supported Sources
254
279
 
255
280
  | Source | Strategy |
@@ -263,6 +288,12 @@ Both GitBook and Mintlify support the [`llms.txt` standard](https://llmstxt.org)
263
288
 
264
289
  ## Requirements
265
290
 
266
- - Python 3.11+
291
+ - Python 3.11–3.13 (3.14+ not yet supported — `onnxruntime` wheels are unavailable for 3.14)
267
292
  - Disk space for the local embedding model download
268
293
  - Local Qdrant storage under `.docs-kit/` by default
294
+
295
+ If your system Python is 3.14, pass an explicit version to pipx:
296
+
297
+ ```bash
298
+ pipx install docs-kit --python python3.13
299
+ ```
@@ -20,37 +20,54 @@ No API keys are required for the default local embedding path.
20
20
 
21
21
  ## Install
22
22
 
23
+ The recommended install method is **`pipx`** — it puts `docs-kit` on your global PATH without touching your system Python or any project venv:
24
+
23
25
  ```bash
24
- pip install docs-kit
26
+ pipx install docs-kit
25
27
  ```
26
28
 
27
- Or with `npx`:
29
+ Install `pipx` first if you don't have it:
28
30
 
29
31
  ```bash
30
- npx docs-kit ingest https://docs.example.com
32
+ brew install pipx
33
+ pipx ensurepath # adds ~/.local/bin to PATH
34
+ ```
35
+
36
+ This matters for MCP installs. When `docs-kit` is on PATH, `docs-kit install claude-code` writes the absolute binary path into the agent config (e.g. `/Users/you/.local/pipx/venvs/docs-kit/bin/docs-kit`) so the MCP server works from any directory, not just your project folder.
37
+
38
+ If you install globally with `pipx` and do not have a project yet, `docs-kit install <agent>` will bootstrap a user config at `~/.docs-kit/docs-kit.yaml` and point the MCP entry at it automatically.
39
+
40
+ Or with plain `pip` (works, but binary won't be on global PATH unless you're in the right venv):
41
+
42
+ ```bash
43
+ pip install docs-kit
31
44
  ```
32
45
 
33
46
  ## Quickstart
34
47
 
35
48
  ```bash
36
- # 1. Create a config file
37
- docs-kit init
49
+ # 0. Install (once)
50
+ pipx install docs-kit
51
+
52
+ # 1. Install into your client
53
+ docs-kit install codex
54
+ docs-kit install claude-code
38
55
 
39
- # 2. Ingest docs (GitBook or Mintlify auto-detected)
56
+ # 2. Ingest docs (uses ~/.docs-kit/docs-kit.yaml if no local config exists)
40
57
  docs-kit ingest https://docs.elevenlabs.io
41
58
 
42
59
  # 3. Check the collection
43
60
  docs-kit inspect
44
61
 
45
- # 4. Install into your client
46
- docs-kit install claude-code
62
+ # 4. Optional: create a project-local config instead
63
+ docs-kit init
47
64
  ```
48
65
 
49
66
  ## Commands
50
67
 
51
68
  ### `docs-kit init`
52
69
 
53
- Create `docs-kit.yaml`.
70
+ Create a project-local `docs-kit.yaml`.
54
71
 
55
72
  ```bash
56
73
  docs-kit init
@@ -90,6 +107,10 @@ docs-kit serve --transport sse --port 3001
90
107
  docs-kit serve --config ./docs-kit.yaml
91
108
  ```
92
109
 
110
+ Without `--config`, `docs-kit` uses this precedence:
111
+ 1. `./docs-kit.yaml`
112
+ 2. `~/.docs-kit/docs-kit.yaml` (auto-created on first global use)
113
+
93
114
  ### `docs-kit install <agent>`
94
115
 
95
116
  Install docs-kit into a supported client config.
@@ -101,6 +122,8 @@ docs-kit install claude-code --project
101
122
  docs-kit install cursor --config ./docs-kit.yaml
102
123
  ```
103
124
 
125
+ If you run `docs-kit install <agent>` outside a project and omit `--config`, docs-kit creates `~/.docs-kit/docs-kit.yaml` and installs the MCP server with an absolute `--config` pointing there.
126
+
104
127
  ### `docs-kit query <text>`
105
128
 
106
129
  Run retrieval directly from the CLI.
@@ -182,7 +205,7 @@ ChatGPT aliases are accepted for guidance only:
182
205
 
183
206
  ## Configuration
184
207
 
185
- `docs-kit.yaml` created by `docs-kit init`:
208
+ Project-local `docs-kit.yaml` created by `docs-kit init`:
186
209
 
187
210
  ```yaml
188
211
  embedding:
@@ -205,6 +228,8 @@ mcp:
205
228
  port: 3001
206
229
  ```
207
230
 
231
+ Global installs can also use a user config at `~/.docs-kit/docs-kit.yaml`, with data stored under `~/.docs-kit/qdrant`.
232
+
208
233
  ## Supported Sources
209
234
 
210
235
  | Source | Strategy |
@@ -218,6 +243,12 @@ Both GitBook and Mintlify support the [`llms.txt` standard](https://llmstxt.org)
218
243
 
219
244
  ## Requirements
220
245
 
221
- - Python 3.11+
246
+ - Python 3.11–3.13 (3.14+ not yet supported — `onnxruntime` wheels are unavailable for 3.14)
222
247
  - Disk space for the local embedding model download
223
248
  - Local Qdrant storage under `.docs-kit/` by default
249
+
250
+ If your system Python is 3.14, pass an explicit version to pipx:
251
+
252
+ ```bash
253
+ pipx install docs-kit --python python3.13
254
+ ```
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -21,6 +21,8 @@ INSTALL_TARGETS = [
21
21
  "chatgpt",
22
22
  "chatgpt-desktop",
23
23
  ]
24
+
25
+
24
26
  def _get_agent_class():
25
27
  from docs_kit.agent import DocsKitAgent
26
28
 
@@ -51,6 +53,35 @@ def _get_qdrant_client_class():
51
53
  return QdrantClient
52
54
 
53
55
 
56
+ def _get_user_config_dir() -> Path:
57
+ return Path.home() / ".docs-kit"
58
+
59
+
60
+ def _get_user_config_path() -> Path:
61
+ return _get_user_config_dir() / "docs-kit.yaml"
62
+
63
+
64
+ def _build_user_bootstrap_config():
65
+ DocsKitConfig = _get_config_class()
66
+ config = DocsKitConfig()
67
+ config.vector_store.local_path = str((_get_user_config_dir() / "qdrant").resolve())
68
+ return config
69
+
70
+
71
+ def _ensure_user_config() -> Path:
72
+ import yaml as _yaml
73
+
74
+ config_path = _get_user_config_path()
75
+ if config_path.exists():
76
+ return config_path
77
+
78
+ config_path.parent.mkdir(parents=True, exist_ok=True)
79
+ config = _build_user_bootstrap_config()
80
+ with open(config_path, "w") as f:
81
+ _yaml.dump(config.model_dump(), f, default_flow_style=False, sort_keys=False)
82
+ return config_path
83
+
84
+
54
85
  def _load_config(config_path: str | None):
55
86
  DocsKitConfig = _get_config_class()
56
87
  if config_path:
@@ -58,7 +89,7 @@ def _load_config(config_path: str | None):
58
89
  default_yaml = Path("docs-kit.yaml")
59
90
  if default_yaml.exists():
60
91
  return DocsKitConfig.from_yaml(default_yaml)
61
- return DocsKitConfig()
92
+ return DocsKitConfig.from_yaml(_ensure_user_config())
62
93
 
63
94
 
64
95
  def _describe_vector_store(config) -> str:
@@ -509,10 +540,11 @@ def install_cmd(agent: str, project: bool, config_path: str | None):
509
540
  # Resolve the binary to an absolute path so the entry works from any CWD.
510
541
  command, prefix_args = _resolve_command()
511
542
 
543
+ created_user_config = False
544
+
512
545
  # Resolve --config to an absolute path. If not passed, auto-discover
513
- # docs-kit.yaml in the current directory. Warn if neither is found on a
514
- # global install, because the server will fall back to default config and
515
- # may not find the user's ingested data.
546
+ # docs-kit.yaml in the current directory. For global installs with no
547
+ # project config, bootstrap a stable user-level config automatically.
516
548
  if config_path:
517
549
  config_path = str(Path(config_path).resolve())
518
550
  else:
@@ -520,12 +552,10 @@ def install_cmd(agent: str, project: bool, config_path: str | None):
520
552
  if default_yaml.exists():
521
553
  config_path = str(default_yaml.resolve())
522
554
  elif not project:
523
- click.echo(
524
- "Warning: No docs-kit.yaml found in current directory and --config not set.\n"
525
- "The MCP server will use default config and may not find your ingested data.\n"
526
- "Run from your project directory or pass --config /absolute/path/to/docs-kit.yaml",
527
- err=True,
528
- )
555
+ user_config_already_exists = _get_user_config_path().exists()
556
+ user_config_path = _ensure_user_config()
557
+ config_path = str(user_config_path.resolve())
558
+ created_user_config = not user_config_already_exists
529
559
 
530
560
  args = prefix_args + ["serve"]
531
561
  if config_path:
@@ -535,8 +565,14 @@ def install_cmd(agent: str, project: bool, config_path: str | None):
535
565
  _install_codex_mcp_server(settings_path, "docs-kit", command, args)
536
566
  click.echo(f"✓ Installed docs-kit MCP server into {settings_path}")
537
567
  click.echo(" Codex CLI and the Codex IDE extension share this config.")
568
+ if created_user_config:
569
+ click.echo(f" Created user config at {config_path}")
570
+ click.echo(f" Local data will be stored under {_get_user_config_dir() / 'qdrant'}")
538
571
  return
539
572
 
540
573
  _install_json_mcp_server(settings_path, "docs-kit", command, args)
541
574
  click.echo(f"✓ Installed docs-kit MCP server into {settings_path}")
575
+ if created_user_config:
576
+ click.echo(f" Created user config at {config_path}")
577
+ click.echo(f" Local data will be stored under {_get_user_config_dir() / 'qdrant'}")
542
578
  _print_restart_instructions(normalized_agent)
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "Fetch docs, embed locally, expose via MCP for AI agents."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
11
- requires-python = ">=3.11"
11
+ requires-python = ">=3.11,<3.14"
12
12
  dependencies = [
13
13
  "pydantic>=2.0.0",
14
14
  "pydantic-settings>=2.2.1",
@@ -1,9 +1,44 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import yaml
1
5
  from click.testing import CliRunner
2
6
  from unittest.mock import MagicMock, patch
3
7
 
4
8
  from docs_kit.cli.__main__ import cli
5
9
 
6
10
 
11
+ class FakeDocsKitConfig:
12
+ def __init__(self, data=None):
13
+ self._data = data or {
14
+ "embedding": {"provider": "fastembed", "model": "BAAI/bge-small-en-v1.5"},
15
+ "vector_store": {
16
+ "provider": "qdrant",
17
+ "url": "",
18
+ "collection_name": "knowledge_base",
19
+ "local_path": ".docs-kit/qdrant",
20
+ },
21
+ "ingestion": {"chunk_size": 800, "chunk_overlap": 120, "bm25_model": "Qdrant/bm25"},
22
+ "mcp": {"transport": "stdio", "host": "localhost", "port": 3001},
23
+ }
24
+ vector_store = type("VectorStore", (), {})()
25
+ vector_store.local_path = self._data["vector_store"]["local_path"]
26
+ self.vector_store = vector_store
27
+
28
+ def model_dump(self):
29
+ self._data["vector_store"]["local_path"] = self.vector_store.local_path
30
+ return self._data
31
+
32
+ @classmethod
33
+ def from_yaml(cls, path):
34
+ with open(path) as f:
35
+ data = yaml.safe_load(f) or {}
36
+ local_path = data["vector_store"]["local_path"]
37
+ if not Path(local_path).is_absolute():
38
+ data["vector_store"]["local_path"] = str((Path(path).parent / local_path).resolve())
39
+ return cls(data)
40
+
41
+
7
42
  PUBLIC_COMMAND_HELP_CASES = [
8
43
  ("init", ["docs-kit init --dir ./sandbox"]),
9
44
  ("ingest", ["docs-kit ingest https://docs.example.com"]),
@@ -68,6 +103,50 @@ def test_cli_init_creates_config(tmp_path):
68
103
  assert "local_path" in config_file.read_text()
69
104
 
70
105
 
106
+ def test_load_config_bootstraps_user_config_when_no_local_config(tmp_path):
107
+ fake_home = tmp_path / "home"
108
+ fake_home.mkdir()
109
+
110
+ with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
111
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
112
+ from docs_kit.cli.commands import _load_config
113
+
114
+ cwd = Path.cwd()
115
+ try:
116
+ os.chdir(tmp_path)
117
+ config = _load_config(None)
118
+ finally:
119
+ os.chdir(cwd)
120
+
121
+ user_config = fake_home / ".docs-kit" / "docs-kit.yaml"
122
+ assert user_config.exists()
123
+ assert config.vector_store.local_path == str((fake_home / ".docs-kit" / "qdrant").resolve())
124
+
125
+ data = yaml.safe_load(user_config.read_text())
126
+ assert data["vector_store"]["local_path"] == str((fake_home / ".docs-kit" / "qdrant").resolve())
127
+
128
+
129
+ def test_load_config_prefers_local_docs_kit_yaml(tmp_path):
130
+ fake_home = tmp_path / "home"
131
+ fake_home.mkdir()
132
+ local_config = tmp_path / "docs-kit.yaml"
133
+ local_config.write_text("vector_store:\n local_path: .docs-kit/qdrant\n")
134
+
135
+ with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
136
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
137
+ from docs_kit.cli.commands import _load_config
138
+
139
+ cwd = Path.cwd()
140
+ try:
141
+ os.chdir(tmp_path)
142
+ config = _load_config(None)
143
+ finally:
144
+ os.chdir(cwd)
145
+
146
+ assert config.vector_store.local_path == str((tmp_path / ".docs-kit" / "qdrant").resolve())
147
+ assert not (fake_home / ".docs-kit" / "docs-kit.yaml").exists()
148
+
149
+
71
150
  def test_ingest_shows_total_and_calls_agent(tmp_path):
72
151
  """ingest_cmd should call agent.ingest() and print total chunk count."""
73
152
  runner = CliRunner()
@@ -10,6 +10,36 @@ from docs_kit.cli.__main__ import cli
10
10
  FAKE_DOCS_KIT_BIN = "/usr/local/bin/docs-kit"
11
11
 
12
12
 
13
+ class FakeDocsKitConfig:
14
+ def __init__(self, data=None):
15
+ self._data = data or {
16
+ "embedding": {"provider": "fastembed", "model": "BAAI/bge-small-en-v1.5"},
17
+ "vector_store": {
18
+ "provider": "qdrant",
19
+ "url": "",
20
+ "collection_name": "knowledge_base",
21
+ "local_path": ".docs-kit/qdrant",
22
+ },
23
+ "ingestion": {"chunk_size": 800, "chunk_overlap": 120, "bm25_model": "Qdrant/bm25"},
24
+ "mcp": {"transport": "stdio", "host": "localhost", "port": 3001},
25
+ }
26
+ vector_store = type("VectorStore", (), {})()
27
+ vector_store.local_path = self._data["vector_store"]["local_path"]
28
+ self.vector_store = vector_store
29
+
30
+ def model_dump(self):
31
+ self._data["vector_store"]["local_path"] = self.vector_store.local_path
32
+ return self._data
33
+
34
+ @classmethod
35
+ def from_yaml(cls, path):
36
+ import yaml
37
+
38
+ with open(path) as f:
39
+ data = yaml.safe_load(f) or {}
40
+ return cls(data)
41
+
42
+
13
43
  class TestInstallCmd(unittest.TestCase):
14
44
 
15
45
  def test_install_claude_code_creates_settings(self):
@@ -18,7 +48,8 @@ class TestInstallCmd(unittest.TestCase):
18
48
  fake_home = Path(tmp_dir) / "home"
19
49
  fake_home.mkdir()
20
50
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
21
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
51
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
52
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
22
53
  result = runner.invoke(cli, ["install", "claude-code"])
23
54
  self.assertEqual(result.exit_code, 0, result.output)
24
55
  self.assertIn("Installed docs-kit MCP server", result.output)
@@ -30,7 +61,8 @@ class TestInstallCmd(unittest.TestCase):
30
61
  fake_home = Path(tmp_dir) / "home"
31
62
  fake_home.mkdir()
32
63
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
33
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
64
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
65
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
34
66
  result = runner.invoke(cli, ["install", "claude-code"])
35
67
  self.assertEqual(result.exit_code, 0, result.output)
36
68
  settings_file = fake_home / ".claude" / "settings.json"
@@ -41,7 +73,10 @@ class TestInstallCmd(unittest.TestCase):
41
73
  self.assertIn("docs-kit", data["mcpServers"])
42
74
  entry = data["mcpServers"]["docs-kit"]
43
75
  self.assertEqual(entry["command"], str(Path(FAKE_DOCS_KIT_BIN).resolve()))
44
- self.assertEqual(entry["args"], ["serve"])
76
+ self.assertEqual(
77
+ entry["args"],
78
+ ["serve", "--config", str((fake_home / ".docs-kit" / "docs-kit.yaml").resolve())],
79
+ )
45
80
 
46
81
  def test_install_python_module_fallback_when_no_binary(self):
47
82
  """Falls back to sys.executable -m docs_kit when docs-kit is not on PATH."""
@@ -50,7 +85,8 @@ class TestInstallCmd(unittest.TestCase):
50
85
  fake_home = Path(tmp_dir) / "home"
51
86
  fake_home.mkdir()
52
87
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
53
- patch("docs_kit.cli.commands.shutil.which", return_value=None):
88
+ patch("docs_kit.cli.commands.shutil.which", return_value=None), \
89
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
54
90
  result = runner.invoke(cli, ["install", "claude-code"])
55
91
  self.assertEqual(result.exit_code, 0, result.output)
56
92
  settings_file = fake_home / ".claude" / "settings.json"
@@ -58,7 +94,10 @@ class TestInstallCmd(unittest.TestCase):
58
94
  data = json.load(f)
59
95
  entry = data["mcpServers"]["docs-kit"]
60
96
  self.assertEqual(entry["command"], sys.executable)
61
- self.assertEqual(entry["args"], ["-m", "docs_kit", "serve"])
97
+ self.assertEqual(
98
+ entry["args"],
99
+ ["-m", "docs_kit", "serve", "--config", str((fake_home / ".docs-kit" / "docs-kit.yaml").resolve())],
100
+ )
62
101
 
63
102
  def test_install_with_config_writes_absolute_config_path(self):
64
103
  """--config is resolved to an absolute path in the written settings."""
@@ -70,7 +109,8 @@ class TestInstallCmd(unittest.TestCase):
70
109
  config_file = Path(tmp_dir) / "custom.yaml"
71
110
  config_file.write_text("vector_store:\n local_path: .docs-kit/qdrant\n")
72
111
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
73
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
112
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
113
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
74
114
  result = runner.invoke(cli, ["install", "claude-code", "--config", str(config_file)])
75
115
  self.assertEqual(result.exit_code, 0, result.output)
76
116
  settings_file = fake_home / ".claude" / "settings.json"
@@ -93,7 +133,8 @@ class TestInstallCmd(unittest.TestCase):
93
133
  config_file = Path(tmp_dir) / "docs-kit.yaml"
94
134
  config_file.write_text("vector_store:\n local_path: .docs-kit/qdrant\n")
95
135
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
96
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
136
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
137
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
97
138
  result = runner.invoke(cli, ["install", "claude-code"], catch_exceptions=False)
98
139
  self.assertEqual(result.exit_code, 0, result.output)
99
140
  settings_file = fake_home / ".claude" / "settings.json"
@@ -105,24 +146,33 @@ class TestInstallCmd(unittest.TestCase):
105
146
  self.assertTrue(Path(config_arg).is_absolute())
106
147
 
107
148
  def test_install_warns_when_no_config_on_global_install(self):
108
- """Prints a warning when no config found on a global install."""
149
+ """Global install bootstraps a user config when no config is present."""
109
150
  runner = CliRunner()
110
151
  with runner.isolated_filesystem() as tmp_dir:
111
152
  fake_home = Path(tmp_dir) / "home"
112
153
  fake_home.mkdir()
113
- # No docs-kit.yaml in CWD
114
154
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
115
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
155
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
156
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
116
157
  result = runner.invoke(cli, ["install", "claude-code"])
117
158
  self.assertEqual(result.exit_code, 0, result.output)
118
- self.assertIn("Warning", result.output)
119
- self.assertIn("docs-kit.yaml", result.output)
159
+ self.assertNotIn("Warning", result.output)
160
+ self.assertIn("Created user config", result.output)
161
+ settings_file = fake_home / ".claude" / "settings.json"
162
+ user_config = fake_home / ".docs-kit" / "docs-kit.yaml"
163
+ self.assertTrue(user_config.exists())
164
+ with open(settings_file) as f:
165
+ data = json.load(f)
166
+ args = data["mcpServers"]["docs-kit"]["args"]
167
+ self.assertIn("--config", args)
168
+ self.assertEqual(args[args.index("--config") + 1], str(user_config.resolve()))
120
169
 
121
170
  def test_install_no_warning_on_project_install_without_config(self):
122
171
  """--project install does not warn about missing config (project-local is expected)."""
123
172
  runner = CliRunner()
124
173
  with runner.isolated_filesystem() as tmp_dir:
125
- with patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
174
+ with patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
175
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
126
176
  result = runner.invoke(cli, ["install", "claude-code", "--project"])
127
177
  self.assertEqual(result.exit_code, 0, result.output)
128
178
  self.assertNotIn("Warning", result.output)
@@ -140,7 +190,8 @@ class TestInstallCmd(unittest.TestCase):
140
190
  "mcpServers": {"other-tool": {"command": "other", "args": []}}
141
191
  }))
142
192
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
143
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
193
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
194
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
144
195
  result = runner.invoke(cli, ["install", "claude-code"])
145
196
  self.assertEqual(result.exit_code, 0, result.output)
146
197
  with open(settings_file) as f:
@@ -152,7 +203,8 @@ class TestInstallCmd(unittest.TestCase):
152
203
  """--project flag writes to .claude/settings.json in current dir."""
153
204
  runner = CliRunner()
154
205
  with runner.isolated_filesystem() as tmp_dir:
155
- with patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
206
+ with patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
207
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
156
208
  result = runner.invoke(cli, ["install", "claude-code", "--project"])
157
209
  self.assertEqual(result.exit_code, 0, result.output)
158
210
  settings_file = Path(tmp_dir) / ".claude" / "settings.json"
@@ -164,13 +216,14 @@ class TestInstallCmd(unittest.TestCase):
164
216
  self.assertNotEqual(result.exit_code, 0)
165
217
 
166
218
  def test_install_codex_writes_toml_config(self):
167
- """Codex install writes absolute command path and serve args to TOML."""
219
+ """Codex install writes bootstrap config path when no local config exists."""
168
220
  runner = CliRunner()
169
221
  with runner.isolated_filesystem() as tmp_dir:
170
222
  fake_home = Path(tmp_dir) / "home"
171
223
  fake_home.mkdir()
172
224
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
173
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
225
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
226
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
174
227
  result = runner.invoke(cli, ["install", "codex"])
175
228
  self.assertEqual(result.exit_code, 0, result.output)
176
229
  settings_file = fake_home / ".codex" / "config.toml"
@@ -181,7 +234,10 @@ class TestInstallCmd(unittest.TestCase):
181
234
  self.assertIn("docs-kit", data["mcp_servers"])
182
235
  entry = data["mcp_servers"]["docs-kit"]
183
236
  self.assertEqual(entry["command"], str(Path(FAKE_DOCS_KIT_BIN).resolve()))
184
- self.assertEqual(entry["args"], ["serve"])
237
+ self.assertEqual(
238
+ entry["args"],
239
+ ["serve", "--config", str((fake_home / ".docs-kit" / "docs-kit.yaml").resolve())],
240
+ )
185
241
 
186
242
  def test_install_codex_with_config_uses_absolute_path(self):
187
243
  """Codex install resolves --config to an absolute path."""
@@ -192,7 +248,8 @@ class TestInstallCmd(unittest.TestCase):
192
248
  config_file = Path(tmp_dir) / "custom.yaml"
193
249
  config_file.write_text("vector_store:\n local_path: .docs-kit/qdrant\n")
194
250
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
195
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
251
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
252
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
196
253
  result = runner.invoke(cli, ["install", "codex", "--config", str(config_file)])
197
254
  self.assertEqual(result.exit_code, 0, result.output)
198
255
  settings_file = fake_home / ".codex" / "config.toml"
@@ -209,11 +266,24 @@ class TestInstallCmd(unittest.TestCase):
209
266
  fake_home = Path(tmp_dir) / "home"
210
267
  fake_home.mkdir()
211
268
  with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
212
- patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN):
269
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
270
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
213
271
  result = runner.invoke(cli, ["install", "codex-desktop"])
214
272
  self.assertEqual(result.exit_code, 0, result.output)
215
273
  self.assertIn(".codex/config.toml", result.output)
216
274
 
275
+ def test_install_project_does_not_bootstrap_user_config(self):
276
+ runner = CliRunner()
277
+ with runner.isolated_filesystem() as tmp_dir:
278
+ fake_home = Path(tmp_dir) / "home"
279
+ fake_home.mkdir()
280
+ with patch("docs_kit.cli.commands.Path.home", return_value=fake_home), \
281
+ patch("docs_kit.cli.commands.shutil.which", return_value=FAKE_DOCS_KIT_BIN), \
282
+ patch("docs_kit.cli.commands._get_config_class", return_value=FakeDocsKitConfig):
283
+ result = runner.invoke(cli, ["install", "claude-code", "--project"])
284
+ self.assertEqual(result.exit_code, 0, result.output)
285
+ self.assertFalse((fake_home / ".docs-kit" / "docs-kit.yaml").exists())
286
+
217
287
  def test_install_chatgpt_shows_remote_mcp_guidance(self):
218
288
  runner = CliRunner()
219
289
  result = runner.invoke(cli, ["install", "chatgpt"])
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes