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.
@@ -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,3 @@
1
+ from docs_kit.connectors.embeddings.fastembed import FastEmbedDenseEmbedding, FastEmbedSparseEmbedding
2
+
3
+ __all__ = ["FastEmbedDenseEmbedding", "FastEmbedSparseEmbedding"]
@@ -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]: ...