docs-kit 0.1.1__py3-none-any.whl
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.
- docs_kit/__init__.py +32 -0
- docs_kit/__main__.py +4 -0
- docs_kit/_version.py +1 -0
- docs_kit/agent.py +190 -0
- docs_kit/cli/__init__.py +0 -0
- docs_kit/cli/__main__.py +34 -0
- docs_kit/cli/commands.py +542 -0
- docs_kit/cli/help.py +140 -0
- docs_kit/connectors/__init__.py +0 -0
- docs_kit/connectors/embeddings/__init__.py +3 -0
- docs_kit/connectors/embeddings/base.py +9 -0
- docs_kit/connectors/embeddings/fastembed.py +30 -0
- docs_kit/connectors/fetchers/__init__.py +0 -0
- docs_kit/connectors/fetchers/base.py +8 -0
- docs_kit/connectors/fetchers/gitbook.py +7 -0
- docs_kit/connectors/fetchers/llms_txt.py +85 -0
- docs_kit/connectors/fetchers/mintlify.py +94 -0
- docs_kit/connectors/parsers/__init__.py +4 -0
- docs_kit/connectors/parsers/base.py +8 -0
- docs_kit/connectors/parsers/markdown.py +8 -0
- docs_kit/connectors/parsers/text.py +8 -0
- docs_kit/connectors/vector_stores/__init__.py +3 -0
- docs_kit/connectors/vector_stores/base.py +15 -0
- docs_kit/connectors/vector_stores/qdrant.py +279 -0
- docs_kit/core/__init__.py +0 -0
- docs_kit/core/chunking.py +227 -0
- docs_kit/core/config.py +67 -0
- docs_kit/core/html_utils.py +78 -0
- docs_kit/core/models.py +28 -0
- docs_kit/mcp/__init__.py +0 -0
- docs_kit/mcp/server.py +100 -0
- docs_kit/mcp/tools.py +10 -0
- docs_kit-0.1.1.dist-info/METADATA +268 -0
- docs_kit-0.1.1.dist-info/RECORD +37 -0
- docs_kit-0.1.1.dist-info/WHEEL +4 -0
- docs_kit-0.1.1.dist-info/entry_points.txt +2 -0
- docs_kit-0.1.1.dist-info/licenses/LICENSE +21 -0
docs_kit/cli/commands.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import tomli_w
|
|
11
|
+
|
|
12
|
+
from docs_kit.cli.help import DocsKitCommand, HELP_CONTEXT_SETTINGS, format_examples
|
|
13
|
+
|
|
14
|
+
INSTALL_TARGETS = [
|
|
15
|
+
"claude-code",
|
|
16
|
+
"claude-desktop",
|
|
17
|
+
"cursor",
|
|
18
|
+
"codex",
|
|
19
|
+
"codex-desktop",
|
|
20
|
+
"codex-app",
|
|
21
|
+
"chatgpt",
|
|
22
|
+
"chatgpt-desktop",
|
|
23
|
+
]
|
|
24
|
+
def _get_agent_class():
|
|
25
|
+
from docs_kit.agent import DocsKitAgent
|
|
26
|
+
|
|
27
|
+
return DocsKitAgent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_config_class():
|
|
31
|
+
from docs_kit.core.config import DocsKitConfig
|
|
32
|
+
|
|
33
|
+
return DocsKitConfig
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_mcp_config_class():
|
|
37
|
+
from docs_kit.core.config import McpConfig
|
|
38
|
+
|
|
39
|
+
return McpConfig
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_server_runners():
|
|
43
|
+
from docs_kit.mcp.server import run_stdio, run_sse
|
|
44
|
+
|
|
45
|
+
return run_stdio, run_sse
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_qdrant_client_class():
|
|
49
|
+
from qdrant_client import QdrantClient
|
|
50
|
+
|
|
51
|
+
return QdrantClient
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_config(config_path: str | None):
|
|
55
|
+
DocsKitConfig = _get_config_class()
|
|
56
|
+
if config_path:
|
|
57
|
+
return DocsKitConfig.from_yaml(config_path)
|
|
58
|
+
default_yaml = Path("docs-kit.yaml")
|
|
59
|
+
if default_yaml.exists():
|
|
60
|
+
return DocsKitConfig.from_yaml(default_yaml)
|
|
61
|
+
return DocsKitConfig()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _describe_vector_store(config) -> str:
|
|
65
|
+
if config.vector_store.url:
|
|
66
|
+
return f"remote Qdrant at {config.vector_store.url}"
|
|
67
|
+
return f"local embedded Qdrant at {config.vector_store.local_path}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.command(
|
|
71
|
+
cls=DocsKitCommand,
|
|
72
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
73
|
+
short_help="Create a docs-kit config file.",
|
|
74
|
+
epilog=format_examples(
|
|
75
|
+
"docs-kit init",
|
|
76
|
+
"docs-kit init --dir ./sandbox",
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
@click.option("--dir", "directory", default=".", help="Target directory for the config file.")
|
|
80
|
+
def init_cmd(directory: str):
|
|
81
|
+
"""Initialize a docs-kit project with a config file."""
|
|
82
|
+
import yaml as _yaml
|
|
83
|
+
|
|
84
|
+
dir_path = Path(directory)
|
|
85
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
config_path = dir_path / "docs-kit.yaml"
|
|
87
|
+
if config_path.exists():
|
|
88
|
+
click.echo(f"Config already exists at {config_path}")
|
|
89
|
+
return
|
|
90
|
+
DocsKitConfig = _get_config_class()
|
|
91
|
+
config = DocsKitConfig()
|
|
92
|
+
data = config.model_dump()
|
|
93
|
+
with open(config_path, "w") as f:
|
|
94
|
+
_yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
95
|
+
click.echo(f"Created config at {config_path}")
|
|
96
|
+
click.echo("No API keys required — embeddings run fully locally.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.command(
|
|
100
|
+
cls=DocsKitCommand,
|
|
101
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
102
|
+
short_help="Ingest docs from a path or URL.",
|
|
103
|
+
epilog=format_examples(
|
|
104
|
+
"docs-kit ingest ./docs",
|
|
105
|
+
"docs-kit ingest https://docs.example.com",
|
|
106
|
+
"docs-kit ingest ./docs --recreate",
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
@click.argument("path")
|
|
110
|
+
@click.option("--recreate", is_flag=True, help="Recreate the collection first.")
|
|
111
|
+
@click.option("--provider", default="auto", show_default=True,
|
|
112
|
+
type=click.Choice(["auto", "gitbook", "mintlify"], case_sensitive=False),
|
|
113
|
+
help="Docs platform. auto tries llms.txt then sitemap.xml.")
|
|
114
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
115
|
+
def ingest_cmd(path: str, recreate: bool, provider: str, config_path: str | None):
|
|
116
|
+
"""Ingest documents from a file, directory, or URL."""
|
|
117
|
+
import logging
|
|
118
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
119
|
+
|
|
120
|
+
config = _load_config(config_path)
|
|
121
|
+
agent = _get_agent_class()(config=config)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
125
|
+
click.echo(f"Fetching docs from {path}...")
|
|
126
|
+
total = agent.ingest_url(path, recreate=recreate, provider=provider if provider != "auto" else None)
|
|
127
|
+
else:
|
|
128
|
+
total = agent.ingest(path, recreate=recreate)
|
|
129
|
+
click.echo(f"Total: {total} chunks ingested")
|
|
130
|
+
except (FileNotFoundError, ValueError) as e:
|
|
131
|
+
click.echo(f"Error: {e}", err=True)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@click.command(
|
|
136
|
+
cls=DocsKitCommand,
|
|
137
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
138
|
+
short_help="Download GitBook docs to Markdown files.",
|
|
139
|
+
epilog=format_examples(
|
|
140
|
+
"docs-kit fetch https://docs.example.com",
|
|
141
|
+
"docs-kit fetch https://docs.example.com --output ./downloaded-docs",
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
@click.argument("url")
|
|
145
|
+
@click.option(
|
|
146
|
+
"--output",
|
|
147
|
+
"output_dir",
|
|
148
|
+
default="docs-kit-docs",
|
|
149
|
+
show_default=True,
|
|
150
|
+
help="Output directory for downloaded .md files.",
|
|
151
|
+
)
|
|
152
|
+
def fetch_cmd(url: str, output_dir: str):
|
|
153
|
+
"""Download docs from a GitBook URL to local .md files."""
|
|
154
|
+
from urllib.parse import urlparse
|
|
155
|
+
from docs_kit.connectors.fetchers.gitbook import GitBookFetcher
|
|
156
|
+
|
|
157
|
+
fetcher = GitBookFetcher()
|
|
158
|
+
click.echo(f"Fetching docs from {url}...")
|
|
159
|
+
try:
|
|
160
|
+
documents = fetcher.fetch(url)
|
|
161
|
+
except ValueError as e:
|
|
162
|
+
click.echo(f"Error: {e}", err=True)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
out_path = Path(output_dir)
|
|
166
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
for doc in documents:
|
|
169
|
+
# Derive a filename from the source URL
|
|
170
|
+
parsed = urlparse(doc.source)
|
|
171
|
+
slug = parsed.path.strip("/").replace("/", "_") or "index"
|
|
172
|
+
filename = f"{slug}.md"
|
|
173
|
+
file_path = out_path / filename
|
|
174
|
+
file_path.write_text(doc.content, encoding="utf-8")
|
|
175
|
+
click.echo(f" Saved {file_path}")
|
|
176
|
+
|
|
177
|
+
click.echo(f"Downloaded {len(documents)} document(s) to {output_dir}/")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@click.command(
|
|
181
|
+
cls=DocsKitCommand,
|
|
182
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
183
|
+
short_help="Run the MCP server.",
|
|
184
|
+
epilog=format_examples(
|
|
185
|
+
"docs-kit serve",
|
|
186
|
+
"docs-kit serve --transport sse --port 3001",
|
|
187
|
+
"docs-kit serve --config ./docs-kit.yaml",
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
@click.option("--transport", "transport", default=None, help="Transport to use: stdio or sse.")
|
|
191
|
+
@click.option("--port", default=None, type=int, help="SSE port override.")
|
|
192
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
193
|
+
def serve_cmd(transport: str | None, port: int | None, config_path: str | None):
|
|
194
|
+
"""Start the MCP server (stdio by default, --transport sse for HTTP)."""
|
|
195
|
+
config = _load_config(config_path)
|
|
196
|
+
run_stdio, run_sse = _get_server_runners()
|
|
197
|
+
|
|
198
|
+
effective_transport = transport or config.mcp.transport
|
|
199
|
+
effective_port = port or config.mcp.port
|
|
200
|
+
effective_host = config.mcp.host
|
|
201
|
+
|
|
202
|
+
if effective_transport == "sse":
|
|
203
|
+
click.echo(f"docs-kit MCP server (SSE) on {effective_host}:{effective_port}")
|
|
204
|
+
click.echo(f"Configure your agent to connect to: http://{effective_host}:{effective_port}/sse")
|
|
205
|
+
McpConfig = _get_mcp_config_class()
|
|
206
|
+
mcp_config = McpConfig(transport="sse", host=effective_host, port=effective_port)
|
|
207
|
+
overridden = config.model_copy(update={"mcp": mcp_config})
|
|
208
|
+
run_sse(overridden)
|
|
209
|
+
else:
|
|
210
|
+
# stdio: no banner — stdout is used for the MCP protocol
|
|
211
|
+
run_stdio(config)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@click.command(
|
|
215
|
+
cls=DocsKitCommand,
|
|
216
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
217
|
+
short_help="Show collection stats.",
|
|
218
|
+
epilog=format_examples(
|
|
219
|
+
"docs-kit inspect",
|
|
220
|
+
"docs-kit inspect --config ./docs-kit.yaml",
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
224
|
+
def inspect_cmd(config_path: str | None):
|
|
225
|
+
"""Show collection stats and configuration."""
|
|
226
|
+
config = _load_config(config_path)
|
|
227
|
+
agent = _get_agent_class()(config=config)
|
|
228
|
+
|
|
229
|
+
stats = agent.collection_stats()
|
|
230
|
+
click.echo(f"Collection: {config.vector_store.collection_name}")
|
|
231
|
+
click.echo(f"Exists: {stats.get('collection_exists', False)}")
|
|
232
|
+
click.echo(f"Points: {stats.get('points_count', 0)}")
|
|
233
|
+
click.echo(f"Embeddings: {config.embedding.provider} ({config.embedding.model})")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@click.command(
|
|
237
|
+
cls=DocsKitCommand,
|
|
238
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
239
|
+
short_help="Check config and connectivity.",
|
|
240
|
+
epilog=format_examples(
|
|
241
|
+
"docs-kit doctor",
|
|
242
|
+
"docs-kit doctor --config ./docs-kit.yaml",
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
246
|
+
def doctor_cmd(config_path: str | None):
|
|
247
|
+
"""Check environment and connectivity."""
|
|
248
|
+
config = _load_config(config_path)
|
|
249
|
+
checks = {
|
|
250
|
+
"EMBEDDING_MODEL": os.environ.get("EMBEDDING_MODEL"),
|
|
251
|
+
"VECTOR_STORE_URL": os.environ.get("VECTOR_STORE_URL"),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for key, value in checks.items():
|
|
255
|
+
if value:
|
|
256
|
+
click.echo(f" [OK] {key} is set")
|
|
257
|
+
else:
|
|
258
|
+
click.echo(f" [--] {key} not set")
|
|
259
|
+
|
|
260
|
+
click.echo(f" [OK] Vector store mode: {_describe_vector_store(config)}")
|
|
261
|
+
|
|
262
|
+
if config.vector_store.url:
|
|
263
|
+
try:
|
|
264
|
+
QdrantClient = _get_qdrant_client_class()
|
|
265
|
+
client = QdrantClient(url=config.vector_store.url, timeout=3)
|
|
266
|
+
client.get_collections()
|
|
267
|
+
click.echo(f" [OK] Qdrant reachable at {config.vector_store.url}")
|
|
268
|
+
except Exception:
|
|
269
|
+
click.echo(f" [--] Qdrant not reachable at {config.vector_store.url}")
|
|
270
|
+
|
|
271
|
+
resolved_config_path = Path(config_path) if config_path else Path("docs-kit.yaml")
|
|
272
|
+
if resolved_config_path.exists():
|
|
273
|
+
click.echo(f" [OK] Config file found: {resolved_config_path}")
|
|
274
|
+
else:
|
|
275
|
+
expected = "docs-kit.yaml" if config_path is None else config_path
|
|
276
|
+
click.echo(f" [--] No config file found at {expected} (run: docs-kit init)")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@click.command(
|
|
280
|
+
cls=DocsKitCommand,
|
|
281
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
282
|
+
short_help="Search ingested docs.",
|
|
283
|
+
epilog=format_examples(
|
|
284
|
+
'docs-kit query "How do I authenticate?"',
|
|
285
|
+
'docs-kit query "getting started" --limit 3',
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
@click.argument("text")
|
|
289
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
290
|
+
@click.option("--limit", default=None, type=int, help="Maximum chunks to return.")
|
|
291
|
+
def query_cmd(text: str, config_path: str | None, limit: int | None):
|
|
292
|
+
"""Run a retrieval query against the vector store (no server required)."""
|
|
293
|
+
config = _load_config(config_path)
|
|
294
|
+
agent = _get_agent_class()(config=config)
|
|
295
|
+
chunks = agent.query(text, limit=limit)
|
|
296
|
+
|
|
297
|
+
if not chunks:
|
|
298
|
+
click.echo("No results found.")
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
|
|
301
|
+
for i, chunk in enumerate(chunks, start=1):
|
|
302
|
+
preview = chunk.text[:300] + "..." if len(chunk.text) > 300 else chunk.text
|
|
303
|
+
click.echo(f"[{i}] score={chunk.score:.2f} source={chunk.source}")
|
|
304
|
+
click.echo(preview)
|
|
305
|
+
click.echo("---")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@click.command(
|
|
309
|
+
cls=DocsKitCommand,
|
|
310
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
311
|
+
short_help="Remove an ingested source.",
|
|
312
|
+
epilog=format_examples(
|
|
313
|
+
"docs-kit remove https://docs.example.com/page",
|
|
314
|
+
"docs-kit remove ./docs/getting-started.md",
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
@click.argument("source")
|
|
318
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
319
|
+
def remove_cmd(source: str, config_path: str | None):
|
|
320
|
+
"""Remove an ingested source (URL or file path) from the knowledge base."""
|
|
321
|
+
config = _load_config(config_path)
|
|
322
|
+
agent = _get_agent_class()(config=config)
|
|
323
|
+
deleted = agent.remove_source(source)
|
|
324
|
+
if deleted:
|
|
325
|
+
click.echo(f"Removed source: {source}")
|
|
326
|
+
else:
|
|
327
|
+
click.echo(f"No data found for source: {source}", err=True)
|
|
328
|
+
sys.exit(1)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@click.command(
|
|
332
|
+
cls=DocsKitCommand,
|
|
333
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
334
|
+
short_help="List ingested sources.",
|
|
335
|
+
epilog=format_examples(
|
|
336
|
+
"docs-kit list",
|
|
337
|
+
"docs-kit list --config ./docs-kit.yaml",
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
341
|
+
def list_cmd(config_path: str | None):
|
|
342
|
+
"""List all ingested sources with their ingestion dates."""
|
|
343
|
+
config = _load_config(config_path)
|
|
344
|
+
agent = _get_agent_class()(config=config)
|
|
345
|
+
entries = agent.list_sources_with_dates()
|
|
346
|
+
if not entries:
|
|
347
|
+
click.echo("No sources ingested yet.")
|
|
348
|
+
return
|
|
349
|
+
for entry in entries:
|
|
350
|
+
click.echo(f"{entry['ingested_at']} {entry['source']}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _resolve_agent_settings_path(agent: str, project: bool = False) -> Path | None:
|
|
354
|
+
import platform
|
|
355
|
+
home = Path.home()
|
|
356
|
+
system = platform.system()
|
|
357
|
+
|
|
358
|
+
if agent == "claude-code":
|
|
359
|
+
if project:
|
|
360
|
+
return Path(".claude") / "settings.json"
|
|
361
|
+
return home / ".claude" / "settings.json"
|
|
362
|
+
|
|
363
|
+
elif agent == "claude-desktop":
|
|
364
|
+
if system == "Darwin":
|
|
365
|
+
return home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
366
|
+
elif system == "Windows":
|
|
367
|
+
appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
|
|
368
|
+
return Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
369
|
+
else: # Linux
|
|
370
|
+
return home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
371
|
+
|
|
372
|
+
elif agent == "cursor":
|
|
373
|
+
if system == "Darwin":
|
|
374
|
+
return home / ".cursor" / "mcp.json"
|
|
375
|
+
elif system == "Windows":
|
|
376
|
+
appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
|
|
377
|
+
return Path(appdata) / "Cursor" / "mcp.json"
|
|
378
|
+
else: # Linux
|
|
379
|
+
return home / ".config" / "Cursor" / "mcp.json"
|
|
380
|
+
|
|
381
|
+
elif agent in {"codex", "codex-desktop", "codex-app"}:
|
|
382
|
+
return home / ".codex" / "config.toml"
|
|
383
|
+
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _install_json_mcp_server(settings_path: Path, server_name: str, command: str, args: list[str]) -> None:
|
|
388
|
+
import json
|
|
389
|
+
import tempfile
|
|
390
|
+
|
|
391
|
+
if settings_path.exists():
|
|
392
|
+
with open(settings_path) as f:
|
|
393
|
+
try:
|
|
394
|
+
settings = json.load(f)
|
|
395
|
+
except json.JSONDecodeError:
|
|
396
|
+
click.echo(f"Warning: {settings_path} contains invalid JSON. Creating backup and overwriting.", err=True)
|
|
397
|
+
backup = settings_path.with_suffix(".json.bak")
|
|
398
|
+
settings_path.rename(backup)
|
|
399
|
+
settings = {}
|
|
400
|
+
else:
|
|
401
|
+
settings = {}
|
|
402
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
|
|
404
|
+
if "mcpServers" not in settings:
|
|
405
|
+
settings["mcpServers"] = {}
|
|
406
|
+
|
|
407
|
+
settings["mcpServers"][server_name] = {
|
|
408
|
+
"command": command,
|
|
409
|
+
"args": args,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=settings_path.parent, suffix=".tmp")
|
|
413
|
+
try:
|
|
414
|
+
with os.fdopen(tmp_fd, "w") as f:
|
|
415
|
+
json.dump(settings, f, indent=2)
|
|
416
|
+
f.write("\n")
|
|
417
|
+
Path(tmp_path).rename(settings_path)
|
|
418
|
+
except Exception:
|
|
419
|
+
os.unlink(tmp_path)
|
|
420
|
+
raise
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _install_codex_mcp_server(settings_path: Path, server_name: str, command: str, args: list[str]) -> None:
|
|
424
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
425
|
+
|
|
426
|
+
if settings_path.exists():
|
|
427
|
+
with open(settings_path, "rb") as f:
|
|
428
|
+
settings = tomllib.load(f)
|
|
429
|
+
else:
|
|
430
|
+
settings = {}
|
|
431
|
+
|
|
432
|
+
settings.setdefault("mcp_servers", {})
|
|
433
|
+
settings["mcp_servers"][server_name] = {
|
|
434
|
+
"command": command,
|
|
435
|
+
"args": args,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
settings_path.write_text(tomli_w.dumps(settings), encoding="utf-8")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _print_chatgpt_remote_mcp_instructions() -> None:
|
|
442
|
+
click.echo(
|
|
443
|
+
"ChatGPT does not use a local stdio MCP config file here. "
|
|
444
|
+
"OpenAI's current docs route ChatGPT through remote MCP apps/connectors in Settings."
|
|
445
|
+
)
|
|
446
|
+
click.echo("To use docs-kit with ChatGPT, first run a remote MCP server:")
|
|
447
|
+
click.echo(" docs-kit serve --transport sse --port 3001")
|
|
448
|
+
click.echo("Then expose that server remotely and connect it from ChatGPT Settings > Apps/Connectors.")
|
|
449
|
+
click.echo("ChatGPT custom MCP apps currently require remote MCP plus search/fetch-compatible tools.")
|
|
450
|
+
sys.exit(1)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _resolve_command() -> tuple[str, list[str]]:
|
|
454
|
+
"""Return (command, prefix_args) to use in the MCP server config entry.
|
|
455
|
+
|
|
456
|
+
Prefers the absolute path of the docs-kit binary on PATH so the entry
|
|
457
|
+
works regardless of which directory Claude Code opens. Falls back to
|
|
458
|
+
invoking the current Python interpreter as a module, which always works
|
|
459
|
+
as long as the venv Python is accessible.
|
|
460
|
+
"""
|
|
461
|
+
resolved = shutil.which("docs-kit")
|
|
462
|
+
if resolved:
|
|
463
|
+
return (str(Path(resolved).resolve()), [])
|
|
464
|
+
# Fallback: python -m docs_kit (uses __main__.py in the package)
|
|
465
|
+
return (sys.executable, ["-m", "docs_kit"])
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _print_restart_instructions(agent: str) -> None:
|
|
469
|
+
if agent == "claude-code":
|
|
470
|
+
click.echo(" Restart Claude Code or run: /mcp in the Claude Code prompt")
|
|
471
|
+
elif agent == "claude-desktop":
|
|
472
|
+
click.echo(" Restart Claude Desktop for changes to take effect.")
|
|
473
|
+
elif agent == "cursor":
|
|
474
|
+
click.echo(" Restart Cursor for changes to take effect.")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@click.command(
|
|
478
|
+
cls=DocsKitCommand,
|
|
479
|
+
context_settings=HELP_CONTEXT_SETTINGS,
|
|
480
|
+
short_help="Install the MCP server into an agent config.",
|
|
481
|
+
epilog=format_examples(
|
|
482
|
+
"docs-kit install claude-code",
|
|
483
|
+
"docs-kit install codex",
|
|
484
|
+
"docs-kit install claude-code --project",
|
|
485
|
+
"docs-kit install cursor --config ./docs-kit.yaml",
|
|
486
|
+
"docs-kit install chatgpt",
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
@click.argument("agent", type=click.Choice(INSTALL_TARGETS, case_sensitive=False))
|
|
490
|
+
@click.option("--project", is_flag=True, default=False,
|
|
491
|
+
help="Install in project-level settings (claude-code only).")
|
|
492
|
+
@click.option("--config", "config_path", default=None, help="Path to docs-kit.yaml.")
|
|
493
|
+
def install_cmd(agent: str, project: bool, config_path: str | None):
|
|
494
|
+
"""Install docs-kit MCP server into an AI agent's settings or config.
|
|
495
|
+
|
|
496
|
+
AGENT must be one of: claude-code, claude-desktop, cursor, codex,
|
|
497
|
+
codex-desktop, codex-app, chatgpt, chatgpt-desktop
|
|
498
|
+
"""
|
|
499
|
+
normalized_agent = agent.lower()
|
|
500
|
+
|
|
501
|
+
if normalized_agent in {"chatgpt", "chatgpt-desktop"}:
|
|
502
|
+
_print_chatgpt_remote_mcp_instructions()
|
|
503
|
+
|
|
504
|
+
settings_path = _resolve_agent_settings_path(normalized_agent, project=project)
|
|
505
|
+
if settings_path is None:
|
|
506
|
+
click.echo(f"Error: Could not determine settings path for {normalized_agent!r} on this platform.", err=True)
|
|
507
|
+
sys.exit(1)
|
|
508
|
+
|
|
509
|
+
# Resolve the binary to an absolute path so the entry works from any CWD.
|
|
510
|
+
command, prefix_args = _resolve_command()
|
|
511
|
+
|
|
512
|
+
# 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.
|
|
516
|
+
if config_path:
|
|
517
|
+
config_path = str(Path(config_path).resolve())
|
|
518
|
+
else:
|
|
519
|
+
default_yaml = Path("docs-kit.yaml")
|
|
520
|
+
if default_yaml.exists():
|
|
521
|
+
config_path = str(default_yaml.resolve())
|
|
522
|
+
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
|
+
)
|
|
529
|
+
|
|
530
|
+
args = prefix_args + ["serve"]
|
|
531
|
+
if config_path:
|
|
532
|
+
args.extend(["--config", config_path])
|
|
533
|
+
|
|
534
|
+
if normalized_agent in {"codex", "codex-desktop", "codex-app"}:
|
|
535
|
+
_install_codex_mcp_server(settings_path, "docs-kit", command, args)
|
|
536
|
+
click.echo(f"✓ Installed docs-kit MCP server into {settings_path}")
|
|
537
|
+
click.echo(" Codex CLI and the Codex IDE extension share this config.")
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
_install_json_mcp_server(settings_path, "docs-kit", command, args)
|
|
541
|
+
click.echo(f"✓ Installed docs-kit MCP server into {settings_path}")
|
|
542
|
+
_print_restart_instructions(normalized_agent)
|
docs_kit/cli/help.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TERM_WIDTH = 100
|
|
12
|
+
HELP_CONTEXT_SETTINGS = {
|
|
13
|
+
"help_option_names": ["--help"],
|
|
14
|
+
"max_content_width": TERM_WIDTH,
|
|
15
|
+
"terminal_width": TERM_WIDTH,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_examples(*lines: str) -> str:
|
|
20
|
+
return "Examples:\n" + "\n".join(f" {line}" for line in lines)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FormattedHelpMixin:
|
|
24
|
+
_term_width = 20
|
|
25
|
+
_desc_start = 22
|
|
26
|
+
_rule_width = TERM_WIDTH
|
|
27
|
+
# FIGlet font "slant" for "docs-kit" (hardcoded; no runtime figlet dependency).
|
|
28
|
+
_banner_lines = [
|
|
29
|
+
" __ __ _ __ ",
|
|
30
|
+
" ____/ /___ __________ / /__(_) /_",
|
|
31
|
+
" / __ / __ \\/ ___/ ___/_____/ //_/ / __/",
|
|
32
|
+
"/ /_/ / /_/ / /__(__ )_____/ ,< / / /_ ",
|
|
33
|
+
"\\__,_/\\____/\\___/____/ /_/|_/_/\\__/ ",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def _color_enabled(self) -> bool:
|
|
37
|
+
if os.getenv("NO_COLOR"):
|
|
38
|
+
return False
|
|
39
|
+
if os.getenv("CLICOLOR_FORCE") not in {None, "", "0"}:
|
|
40
|
+
return True
|
|
41
|
+
if os.getenv("FORCE_COLOR") not in {None, "", "0"}:
|
|
42
|
+
return True
|
|
43
|
+
return sys.stdout.isatty()
|
|
44
|
+
|
|
45
|
+
def _style(self, ctx: click.Context, text: str, **styles: str | bool) -> str:
|
|
46
|
+
if self._color_enabled():
|
|
47
|
+
return click.style(text, **styles)
|
|
48
|
+
return text
|
|
49
|
+
|
|
50
|
+
def _rule(self, ctx: click.Context, char: str = "─") -> str:
|
|
51
|
+
text = char * self._rule_width
|
|
52
|
+
if self._color_enabled():
|
|
53
|
+
return click.style(text, fg="bright_black")
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
def _write_banner(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
57
|
+
formatter.write(f"{self._rule(ctx)}\n")
|
|
58
|
+
for line in self._banner_lines:
|
|
59
|
+
formatter.write(f"{self._style(ctx, line, fg='magenta', bold=True)}\n")
|
|
60
|
+
tagline = "Turn docs into agent-ready context."
|
|
61
|
+
formatter.write(f"{self._style(ctx, tagline, fg='bright_black', italic=True)}\n")
|
|
62
|
+
formatter.write(f"{self._rule(ctx)}\n")
|
|
63
|
+
|
|
64
|
+
def _write_section(self, ctx: click.Context, formatter: click.HelpFormatter, heading: str) -> None:
|
|
65
|
+
formatter.write_paragraph()
|
|
66
|
+
label = f"◆ {heading}"
|
|
67
|
+
formatter.write(f"{self._style(ctx, label, fg='cyan', bold=True)}\n")
|
|
68
|
+
|
|
69
|
+
def _write_definition_rows(
|
|
70
|
+
self,
|
|
71
|
+
ctx: click.Context,
|
|
72
|
+
formatter: click.HelpFormatter,
|
|
73
|
+
rows: list[tuple[str, str]],
|
|
74
|
+
) -> None:
|
|
75
|
+
description_width = max(20, formatter.width - self._desc_start)
|
|
76
|
+
for term, description in rows:
|
|
77
|
+
wrapped = textwrap.wrap(
|
|
78
|
+
description,
|
|
79
|
+
width=description_width,
|
|
80
|
+
break_long_words=False,
|
|
81
|
+
break_on_hyphens=False,
|
|
82
|
+
) or [""]
|
|
83
|
+
styled_term = self._style(ctx, term.ljust(self._term_width), fg="bright_green", bold=True)
|
|
84
|
+
formatter.write(f" {styled_term}{wrapped[0]}\n")
|
|
85
|
+
for line in wrapped[1:]:
|
|
86
|
+
formatter.write(" " * self._desc_start + f"{line}\n")
|
|
87
|
+
|
|
88
|
+
def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
89
|
+
if self.help:
|
|
90
|
+
self._write_section(ctx, formatter, "Description")
|
|
91
|
+
formatter.write_text(self._style(ctx, inspect.cleandoc(self.help), fg="white"))
|
|
92
|
+
|
|
93
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
94
|
+
self._write_banner(ctx, formatter)
|
|
95
|
+
formatter.write_paragraph()
|
|
96
|
+
self.format_usage(ctx, formatter)
|
|
97
|
+
self.format_help_text(ctx, formatter)
|
|
98
|
+
self.format_options(ctx, formatter)
|
|
99
|
+
if isinstance(self, click.Group):
|
|
100
|
+
self.format_commands(ctx, formatter)
|
|
101
|
+
self.format_epilog(ctx, formatter)
|
|
102
|
+
|
|
103
|
+
def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
104
|
+
records = []
|
|
105
|
+
for param in self.get_params(ctx):
|
|
106
|
+
record = param.get_help_record(ctx)
|
|
107
|
+
if record is not None:
|
|
108
|
+
records.append(record)
|
|
109
|
+
|
|
110
|
+
if records:
|
|
111
|
+
self._write_section(ctx, formatter, "Options")
|
|
112
|
+
self._write_definition_rows(ctx, formatter, records)
|
|
113
|
+
|
|
114
|
+
def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
115
|
+
if self.epilog:
|
|
116
|
+
formatter.write_paragraph()
|
|
117
|
+
lines = self.epilog.rstrip().splitlines()
|
|
118
|
+
if not lines:
|
|
119
|
+
return
|
|
120
|
+
formatter.write(f"{self._style(ctx, f'◆ {lines[0]}', fg='yellow', bold=True)}\n")
|
|
121
|
+
for line in lines[1:]:
|
|
122
|
+
formatter.write(f"{self._style(ctx, line, fg='bright_yellow')}\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class DocsKitCommand(_FormattedHelpMixin, click.Command):
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class DocsKitGroup(_FormattedHelpMixin, click.Group):
|
|
130
|
+
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
131
|
+
rows = []
|
|
132
|
+
for subcommand in self.list_commands(ctx):
|
|
133
|
+
command = self.get_command(ctx, subcommand)
|
|
134
|
+
if command is None or command.hidden:
|
|
135
|
+
continue
|
|
136
|
+
rows.append((subcommand, command.get_short_help_str()))
|
|
137
|
+
|
|
138
|
+
if rows:
|
|
139
|
+
self._write_section(ctx, formatter, "Commands")
|
|
140
|
+
self._write_definition_rows(ctx, formatter, rows)
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
from qdrant_client.models import SparseVector
|
|
4
|
+
|
|
5
|
+
class DenseEmbeddingClient(Protocol):
|
|
6
|
+
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
7
|
+
|
|
8
|
+
class SparseEmbeddingClient(Protocol):
|
|
9
|
+
def embed(self, texts: list[str]) -> list[SparseVector]: ...
|