pyrepowiki-cli 0.1.0__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.
- pyrepowiki_cli-0.1.0.dist-info/METADATA +875 -0
- pyrepowiki_cli-0.1.0.dist-info/RECORD +28 -0
- pyrepowiki_cli-0.1.0.dist-info/WHEEL +4 -0
- pyrepowiki_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repowiki/__init__.py +3 -0
- repowiki/cli.py +43 -0
- repowiki/services/__init__.py +1 -0
- repowiki/services/codewiki/__init__.py +5 -0
- repowiki/services/codewiki/boq.py +57 -0
- repowiki/services/codewiki/cli.py +315 -0
- repowiki/services/codewiki/client.py +237 -0
- repowiki/services/codewiki/wiki.py +162 -0
- repowiki/services/deepwiki/__init__.py +5 -0
- repowiki/services/deepwiki/cli.py +851 -0
- repowiki/services/deepwiki/client.py +164 -0
- repowiki/services/deepwiki/codemap.py +74 -0
- repowiki/services/deepwiki/devin.py +355 -0
- repowiki/services/zread/__init__.py +5 -0
- repowiki/services/zread/cli.py +653 -0
- repowiki/services/zread/client.py +414 -0
- repowiki/services/zread/flight.py +116 -0
- repowiki/shared/__init__.py +1 -0
- repowiki/shared/async_.py +15 -0
- repowiki/shared/export.py +36 -0
- repowiki/shared/model.py +29 -0
- repowiki/shared/output.py +204 -0
- repowiki/shared/repo.py +45 -0
- repowiki/shared/save.py +27 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
"""CLI entry point for repowiki-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import NoReturn, Optional
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from typer.core import TyperCommand
|
|
16
|
+
|
|
17
|
+
from repowiki.services.deepwiki.client import (
|
|
18
|
+
ConnectionError,
|
|
19
|
+
DeepWikiClient,
|
|
20
|
+
DeepWikiError,
|
|
21
|
+
ToolError,
|
|
22
|
+
)
|
|
23
|
+
from repowiki.shared.async_ import run_async
|
|
24
|
+
from repowiki.services.deepwiki.codemap import codemap_to_mermaid
|
|
25
|
+
from repowiki.services.deepwiki.devin import DevinClient
|
|
26
|
+
from repowiki.shared.export import export_pages
|
|
27
|
+
from repowiki.shared.model import Answer
|
|
28
|
+
from repowiki.shared.output import (
|
|
29
|
+
filter_page,
|
|
30
|
+
format_answer,
|
|
31
|
+
format_answer_tail,
|
|
32
|
+
format_command_json,
|
|
33
|
+
format_error_json,
|
|
34
|
+
format_header,
|
|
35
|
+
format_json,
|
|
36
|
+
format_list,
|
|
37
|
+
format_result,
|
|
38
|
+
format_status,
|
|
39
|
+
format_warm,
|
|
40
|
+
render_markdown,
|
|
41
|
+
split_pages,
|
|
42
|
+
status,
|
|
43
|
+
)
|
|
44
|
+
from repowiki.shared.repo import normalize_repo
|
|
45
|
+
from repowiki.shared.save import append_entry, default_save_path
|
|
46
|
+
|
|
47
|
+
deepwiki_app = typer.Typer(add_completion=False, help="Query DeepWiki documentation.")
|
|
48
|
+
|
|
49
|
+
# Sentinel injected into argv for a bare ``--save`` (no value). ``save`` then
|
|
50
|
+
# resolves to an auto-generated filename instead of an explicit path.
|
|
51
|
+
_SAVE_AUTO = "\x00auto\x00"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_save(args: list[str]) -> list[str]:
|
|
55
|
+
"""Turn a bare ``--save`` into ``--save <sentinel>`` so typer accepts it.
|
|
56
|
+
|
|
57
|
+
Typer has no support for Click's optional-value flags (``flag_value``), so a
|
|
58
|
+
value-taking ``--save`` normally rejects a bare ``--save``. Rewriting the
|
|
59
|
+
bare form here lets a single option cover both ``--save`` and
|
|
60
|
+
``--save PATH``.
|
|
61
|
+
"""
|
|
62
|
+
out: list[str] = []
|
|
63
|
+
i = 0
|
|
64
|
+
while i < len(args):
|
|
65
|
+
tok = args[i]
|
|
66
|
+
out.append(tok)
|
|
67
|
+
if tok == "--save":
|
|
68
|
+
if i + 1 >= len(args) or args[i + 1].startswith("-"):
|
|
69
|
+
out.append(_SAVE_AUTO)
|
|
70
|
+
else:
|
|
71
|
+
out.append(args[i + 1])
|
|
72
|
+
i += 1
|
|
73
|
+
i += 1
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _AskCommand(TyperCommand):
|
|
78
|
+
def parse_args(self, ctx, args):
|
|
79
|
+
return super().parse_args(ctx, _normalize_save(args))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _error_message(exc: Exception) -> str:
|
|
83
|
+
if isinstance(exc, ConnectionError):
|
|
84
|
+
return str(exc) or "Could not connect to DeepWiki server. Check your connection and try again."
|
|
85
|
+
if isinstance(exc, ToolError):
|
|
86
|
+
return str(exc)
|
|
87
|
+
if isinstance(exc, DeepWikiError):
|
|
88
|
+
return str(exc)
|
|
89
|
+
return f"Unexpected error: {exc}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _error_kind(exc: Exception) -> str:
|
|
93
|
+
if isinstance(exc, ConnectionError):
|
|
94
|
+
return "connection"
|
|
95
|
+
if isinstance(exc, ToolError):
|
|
96
|
+
message = str(exc).lower()
|
|
97
|
+
if any(
|
|
98
|
+
token in message
|
|
99
|
+
for token in ("not indexed", "not_indexed", "not found", "to index")
|
|
100
|
+
):
|
|
101
|
+
return "not_indexed"
|
|
102
|
+
return "tool"
|
|
103
|
+
if isinstance(exc, DeepWikiError):
|
|
104
|
+
return "error"
|
|
105
|
+
return "unexpected"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _exit_code(kind: str) -> int:
|
|
109
|
+
return {"not_indexed": 2, "connection": 3}.get(kind, 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fail(message: str, kind: str, json_mode: bool) -> NoReturn:
|
|
113
|
+
"""Print an error (JSON on stderr when requested) and exit."""
|
|
114
|
+
if json_mode:
|
|
115
|
+
typer.echo(format_error_json(kind, message), err=True)
|
|
116
|
+
else:
|
|
117
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
118
|
+
raise typer.Exit(code=_exit_code(kind))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_error(exc: Exception) -> None:
|
|
122
|
+
typer.secho(f"Error: {_error_message(exc)}", fg=typer.colors.RED, err=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _handle_exception(exc: Exception, json_mode: bool = False) -> None:
|
|
126
|
+
_fail(_error_message(exc), _error_kind(exc), json_mode)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_repo(raw: str, json_mode: bool = False) -> str:
|
|
130
|
+
try:
|
|
131
|
+
return normalize_repo(raw)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
_fail(str(exc), "invalid_repo", json_mode)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _mock_text() -> Optional[str]:
|
|
137
|
+
return os.environ.get("REPOWIKI_MOCK_TEXT")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _repl_prompt() -> str:
|
|
141
|
+
"""Return the REPL input prompt, colored only on a terminal."""
|
|
142
|
+
if not sys.stdout.isatty():
|
|
143
|
+
return ">> "
|
|
144
|
+
return typer.style(">> ", fg=typer.colors.CYAN, bold=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _emit(
|
|
148
|
+
repo: str,
|
|
149
|
+
command: str,
|
|
150
|
+
text: str,
|
|
151
|
+
rich: bool,
|
|
152
|
+
json_mode: bool = False,
|
|
153
|
+
**json_fields: object,
|
|
154
|
+
) -> None:
|
|
155
|
+
if json_mode:
|
|
156
|
+
typer.echo(format_json(repo, command, **json_fields))
|
|
157
|
+
return
|
|
158
|
+
rendered = format_result("DeepWiki", repo, command, text)
|
|
159
|
+
if rich:
|
|
160
|
+
render_markdown(rendered)
|
|
161
|
+
else:
|
|
162
|
+
typer.echo(rendered)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _run_ask(
|
|
166
|
+
repos: list[str],
|
|
167
|
+
question: str,
|
|
168
|
+
mode: str | None,
|
|
169
|
+
query_id: str | None,
|
|
170
|
+
use_devin: bool,
|
|
171
|
+
context: str,
|
|
172
|
+
generate_summary: bool,
|
|
173
|
+
on_chunk=None,
|
|
174
|
+
timeout: float | None = None,
|
|
175
|
+
) -> Answer:
|
|
176
|
+
if use_devin:
|
|
177
|
+
if (mock := os.environ.get("REPOWIKI_DEVIN_MOCK")) is not None:
|
|
178
|
+
return Answer(body=mock)
|
|
179
|
+
resolved_mode = mode or "fast"
|
|
180
|
+
resolved_timeout = _answer_timeout(resolved_mode, timeout)
|
|
181
|
+
return run_async(
|
|
182
|
+
_ask_reverse(
|
|
183
|
+
DevinClient(),
|
|
184
|
+
repos,
|
|
185
|
+
question,
|
|
186
|
+
mode=resolved_mode,
|
|
187
|
+
query_id=query_id,
|
|
188
|
+
context=context,
|
|
189
|
+
generate_summary=generate_summary,
|
|
190
|
+
timeout=resolved_timeout,
|
|
191
|
+
on_chunk=on_chunk,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
if (mock := _mock_text()) is not None:
|
|
195
|
+
return Answer(body=mock)
|
|
196
|
+
return run_async(DeepWikiClient().ask_question(repos[0], question))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _stream_chunk(text: str) -> None:
|
|
200
|
+
sys.stdout.write(text)
|
|
201
|
+
sys.stdout.flush()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _emit_streamed_answer(answer: Answer, show_sources: bool) -> None:
|
|
205
|
+
tail = format_answer_tail(answer, show_sources=show_sources)
|
|
206
|
+
if tail:
|
|
207
|
+
typer.echo(f"\n\n{tail}")
|
|
208
|
+
else:
|
|
209
|
+
typer.echo()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _answer_json_fields(answer: Answer) -> dict[str, object]:
|
|
213
|
+
fields: dict[str, object] = {
|
|
214
|
+
"answer": answer.body,
|
|
215
|
+
}
|
|
216
|
+
if answer.summary:
|
|
217
|
+
fields["summary"] = answer.summary
|
|
218
|
+
if answer.references:
|
|
219
|
+
fields["references"] = [asdict(r) for r in answer.references]
|
|
220
|
+
if answer.sources:
|
|
221
|
+
fields["sources"] = [asdict(s) for s in answer.sources]
|
|
222
|
+
if answer.stats:
|
|
223
|
+
fields["stats"] = answer.stats
|
|
224
|
+
if answer.query_id:
|
|
225
|
+
fields["query_id"] = answer.query_id
|
|
226
|
+
return fields
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _emit_answer(
|
|
230
|
+
repo: str,
|
|
231
|
+
question: str,
|
|
232
|
+
answer: Answer,
|
|
233
|
+
rich: bool,
|
|
234
|
+
json_mode: bool,
|
|
235
|
+
show_sources: bool,
|
|
236
|
+
) -> None:
|
|
237
|
+
if json_mode:
|
|
238
|
+
fields = _answer_json_fields(answer)
|
|
239
|
+
fields = {"question": question, **fields}
|
|
240
|
+
typer.echo(format_json(repo, "ask", **fields))
|
|
241
|
+
return
|
|
242
|
+
_emit(repo, "ask", format_answer(answer, show_sources=show_sources), rich, False)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _append_save(path: str | None, repo: str, question: str, answer: str) -> None:
|
|
246
|
+
if path is None:
|
|
247
|
+
return
|
|
248
|
+
try:
|
|
249
|
+
append_entry(path, repo, question, answer)
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
typer.secho(
|
|
252
|
+
f"Warning: could not save to {path}: {exc}",
|
|
253
|
+
fg=typer.colors.YELLOW,
|
|
254
|
+
err=True,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def _repl(resolved: str, rich: bool, save_path: str | None) -> None:
|
|
259
|
+
prompt = _repl_prompt()
|
|
260
|
+
async with DeepWikiClient() as client:
|
|
261
|
+
while True:
|
|
262
|
+
try:
|
|
263
|
+
line = input(prompt)
|
|
264
|
+
except (EOFError, KeyboardInterrupt):
|
|
265
|
+
typer.echo()
|
|
266
|
+
break
|
|
267
|
+
q = line.strip()
|
|
268
|
+
if not q:
|
|
269
|
+
continue
|
|
270
|
+
if q in ("/exit", "/quit", "/q"):
|
|
271
|
+
break
|
|
272
|
+
try:
|
|
273
|
+
with status("Thinking..."):
|
|
274
|
+
answer = await client.ask_question(resolved, q)
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
_print_error(exc)
|
|
277
|
+
continue
|
|
278
|
+
_append_save(save_path, resolved, q, answer.body)
|
|
279
|
+
typer.echo()
|
|
280
|
+
if rich:
|
|
281
|
+
render_markdown(answer.body.strip())
|
|
282
|
+
else:
|
|
283
|
+
typer.echo(answer.body.strip())
|
|
284
|
+
typer.echo()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_REPL_RETRY_BASE = 1.0
|
|
288
|
+
_REPL_RETRY_JITTER = 0.5
|
|
289
|
+
_REPL_DEFAULT_ATTEMPTS = 4
|
|
290
|
+
|
|
291
|
+
_DEFAULT_TIMEOUT = 120.0
|
|
292
|
+
_DEEP_TIMEOUT = 300.0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _repl_attempts() -> int:
|
|
296
|
+
raw = os.environ.get("DEEPWIKI_REPL_RETRIES", "")
|
|
297
|
+
if raw.isdigit() and int(raw) > 0:
|
|
298
|
+
return int(raw)
|
|
299
|
+
return _REPL_DEFAULT_ATTEMPTS
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _answer_timeout(mode: str, explicit: float | None) -> float:
|
|
303
|
+
"""Resolve the answer timeout: explicit flag, then env, then mode default."""
|
|
304
|
+
if explicit is not None:
|
|
305
|
+
return explicit
|
|
306
|
+
raw = os.environ.get("DEEPWIKI_TIMEOUT", "").strip()
|
|
307
|
+
if raw:
|
|
308
|
+
try:
|
|
309
|
+
value = float(raw)
|
|
310
|
+
except ValueError:
|
|
311
|
+
value = -1.0
|
|
312
|
+
if value > 0:
|
|
313
|
+
return value
|
|
314
|
+
return _DEEP_TIMEOUT if mode == "deep" else _DEFAULT_TIMEOUT
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _retry_delay(attempt: int) -> float:
|
|
318
|
+
return _REPL_RETRY_BASE * (2 ** attempt) + random.uniform(0, _REPL_RETRY_JITTER)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _is_retryable(exc: Exception) -> bool:
|
|
322
|
+
"""True for transient failures worth retrying (connection / 5xx)."""
|
|
323
|
+
if isinstance(exc, ConnectionError):
|
|
324
|
+
return True
|
|
325
|
+
if isinstance(exc, ToolError):
|
|
326
|
+
return "HTTP 5" in str(exc)
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def _ask_reverse(
|
|
331
|
+
devin: DevinClient,
|
|
332
|
+
repos: list[str],
|
|
333
|
+
question: str,
|
|
334
|
+
*,
|
|
335
|
+
mode: str,
|
|
336
|
+
query_id: str | None,
|
|
337
|
+
context: str,
|
|
338
|
+
generate_summary: bool,
|
|
339
|
+
timeout: float,
|
|
340
|
+
on_chunk=None,
|
|
341
|
+
) -> Answer:
|
|
342
|
+
"""Ask the reverse backend with jittered backoff retry.
|
|
343
|
+
|
|
344
|
+
Retries transient failures (connection / HTTP 5xx) only while nothing has
|
|
345
|
+
streamed yet — once a partial answer has reached the terminal, a dropped
|
|
346
|
+
connection is reported rather than re-streaming garbled text.
|
|
347
|
+
"""
|
|
348
|
+
qid = query_id or str(uuid4())
|
|
349
|
+
emitted = 0
|
|
350
|
+
attempts = _repl_attempts()
|
|
351
|
+
for attempt in range(attempts):
|
|
352
|
+
if on_chunk is not None:
|
|
353
|
+
def wrapped(text: str) -> None:
|
|
354
|
+
nonlocal emitted
|
|
355
|
+
emitted += 1
|
|
356
|
+
on_chunk(text)
|
|
357
|
+
else:
|
|
358
|
+
wrapped = None
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
return await devin.ask(
|
|
362
|
+
repos,
|
|
363
|
+
question,
|
|
364
|
+
mode=mode,
|
|
365
|
+
query_id=qid,
|
|
366
|
+
context=context,
|
|
367
|
+
generate_summary=generate_summary,
|
|
368
|
+
timeout=timeout,
|
|
369
|
+
on_chunk=wrapped,
|
|
370
|
+
)
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
if not _is_retryable(exc) or emitted:
|
|
373
|
+
raise
|
|
374
|
+
if attempt == attempts - 1:
|
|
375
|
+
raise
|
|
376
|
+
delay = _retry_delay(attempt)
|
|
377
|
+
typer.secho(
|
|
378
|
+
f"Transient error ({_error_message(exc)}); retrying in {delay:.1f}s…",
|
|
379
|
+
fg=typer.colors.YELLOW,
|
|
380
|
+
err=True,
|
|
381
|
+
)
|
|
382
|
+
await asyncio.sleep(delay)
|
|
383
|
+
raise ToolError("retries exhausted")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def _repl_devin(
|
|
387
|
+
repos: list[str],
|
|
388
|
+
rich: bool,
|
|
389
|
+
save_path: str | None,
|
|
390
|
+
mode: str,
|
|
391
|
+
initial_query_id: str | None,
|
|
392
|
+
show_sources: bool,
|
|
393
|
+
context: str,
|
|
394
|
+
generate_summary: bool,
|
|
395
|
+
timeout: float,
|
|
396
|
+
) -> None:
|
|
397
|
+
prompt = _repl_prompt()
|
|
398
|
+
devin = DevinClient()
|
|
399
|
+
last_qid = initial_query_id
|
|
400
|
+
while True:
|
|
401
|
+
try:
|
|
402
|
+
line = input(prompt)
|
|
403
|
+
except (EOFError, KeyboardInterrupt):
|
|
404
|
+
typer.echo()
|
|
405
|
+
break
|
|
406
|
+
q = line.strip()
|
|
407
|
+
if not q:
|
|
408
|
+
continue
|
|
409
|
+
if q in ("/exit", "/quit", "/q"):
|
|
410
|
+
break
|
|
411
|
+
if q == "/new":
|
|
412
|
+
last_qid = None
|
|
413
|
+
typer.echo("(started a new thread)")
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
with status("Thinking..."):
|
|
417
|
+
answer = await _ask_reverse(
|
|
418
|
+
devin, repos, q, mode=mode, query_id=last_qid,
|
|
419
|
+
context=context, generate_summary=generate_summary,
|
|
420
|
+
timeout=timeout,
|
|
421
|
+
)
|
|
422
|
+
except Exception as exc:
|
|
423
|
+
_print_error(exc)
|
|
424
|
+
continue
|
|
425
|
+
last_qid = answer.query_id
|
|
426
|
+
_append_save(save_path, repos[0], q, answer.body)
|
|
427
|
+
rendered = format_answer(answer, show_sources=show_sources)
|
|
428
|
+
if rich:
|
|
429
|
+
render_markdown(rendered)
|
|
430
|
+
else:
|
|
431
|
+
typer.echo(f"\n\n{rendered}")
|
|
432
|
+
typer.echo()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@deepwiki_app.command()
|
|
436
|
+
def structure(
|
|
437
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
438
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Show the documentation table of contents for a repository."""
|
|
441
|
+
resolved = _resolve_repo(repo, json)
|
|
442
|
+
if (mock := _mock_text()) is not None:
|
|
443
|
+
_emit(resolved, "structure", mock, False, json, content=mock)
|
|
444
|
+
return
|
|
445
|
+
client = DeepWikiClient()
|
|
446
|
+
try:
|
|
447
|
+
with status("Fetching table of contents..."):
|
|
448
|
+
text = run_async(client.read_wiki_structure(resolved))
|
|
449
|
+
except Exception as exc:
|
|
450
|
+
_handle_exception(exc, json)
|
|
451
|
+
_emit(resolved, "structure", text, False, json, content=text)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@deepwiki_app.command()
|
|
455
|
+
def contents(
|
|
456
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
457
|
+
page: Optional[str] = typer.Option(None, "--page", help="Show only the page with this title"),
|
|
458
|
+
rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
|
|
459
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Show the full documentation for a repository."""
|
|
462
|
+
resolved = _resolve_repo(repo, json)
|
|
463
|
+
if (mock := _mock_text()) is not None:
|
|
464
|
+
text = mock
|
|
465
|
+
else:
|
|
466
|
+
client = DeepWikiClient()
|
|
467
|
+
try:
|
|
468
|
+
with status("Fetching documentation..."):
|
|
469
|
+
text = run_async(client.read_wiki_contents(resolved))
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
_handle_exception(exc, json)
|
|
472
|
+
if page is not None:
|
|
473
|
+
try:
|
|
474
|
+
text = filter_page(text, page)
|
|
475
|
+
except ValueError as exc:
|
|
476
|
+
_fail(str(exc), "page_not_found", json)
|
|
477
|
+
fields: dict[str, object] = {"content": text}
|
|
478
|
+
if page is not None:
|
|
479
|
+
fields["page"] = page
|
|
480
|
+
_emit(resolved, "contents", text, rich, json, **fields)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@deepwiki_app.command(cls=_AskCommand)
|
|
484
|
+
def ask(
|
|
485
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
486
|
+
question: Optional[str] = typer.Argument(None, help="Question (omit for interactive mode)"),
|
|
487
|
+
rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
|
|
488
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
489
|
+
save: Optional[str] = typer.Option(
|
|
490
|
+
None,
|
|
491
|
+
"--save",
|
|
492
|
+
help="Save answers to a Markdown file. Bare --save auto-names the file; "
|
|
493
|
+
"--save PATH writes/appends to PATH.",
|
|
494
|
+
),
|
|
495
|
+
mode: Optional[str] = typer.Option(
|
|
496
|
+
None, "--mode", help="Engine: fast, deep, or codemap (reverse backend)",
|
|
497
|
+
),
|
|
498
|
+
query_id: Optional[str] = typer.Option(
|
|
499
|
+
None, "--id", help="Continue a thread from a previous query id (reverse backend)",
|
|
500
|
+
),
|
|
501
|
+
sources: bool = typer.Option(
|
|
502
|
+
False, "--sources", help="Show source-code slices for citations (reverse backend)",
|
|
503
|
+
),
|
|
504
|
+
no_summary: bool = typer.Option(
|
|
505
|
+
False, "--no-summary", help="Skip summary generation (reverse backend)",
|
|
506
|
+
),
|
|
507
|
+
context: Optional[str] = typer.Option(
|
|
508
|
+
None, "--context", help="Additional context for the question (reverse backend)",
|
|
509
|
+
),
|
|
510
|
+
extra_repos: Optional[list[str]] = typer.Option(
|
|
511
|
+
None, "--repo", help="Additional repos to query (repeatable, reverse backend)",
|
|
512
|
+
),
|
|
513
|
+
mermaid: bool = typer.Option(
|
|
514
|
+
False, "--mermaid", help="Output a Mermaid diagram (codemap mode only)",
|
|
515
|
+
),
|
|
516
|
+
stream: bool = typer.Option(
|
|
517
|
+
False, "--stream", help="Stream answer chunks as they arrive (reverse backend)",
|
|
518
|
+
),
|
|
519
|
+
timeout: Optional[float] = typer.Option(
|
|
520
|
+
None, "--timeout", help="Answer timeout in seconds (reverse backend). "
|
|
521
|
+
"Default: 120s, or 300s for --mode deep.",
|
|
522
|
+
),
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Ask a question about a repository (single-shot or interactive)."""
|
|
525
|
+
resolved = _resolve_repo(repo, json)
|
|
526
|
+
save_path = default_save_path(resolved) if save == _SAVE_AUTO else save
|
|
527
|
+
|
|
528
|
+
if mode is not None and mode not in ("fast", "deep", "codemap"):
|
|
529
|
+
_fail(f"Invalid --mode: {mode!r} (expected fast, deep, or codemap).", "invalid_input", json)
|
|
530
|
+
if timeout is not None and timeout <= 0:
|
|
531
|
+
_fail("--timeout must be a positive number of seconds.", "invalid_input", json)
|
|
532
|
+
all_repos = [resolved] + [_resolve_repo(r, json) for r in (extra_repos or [])]
|
|
533
|
+
use_devin = bool(mode or query_id or sources or extra_repos
|
|
534
|
+
or (context is not None) or no_summary or stream)
|
|
535
|
+
generate_summary = not no_summary
|
|
536
|
+
context_value = context or ""
|
|
537
|
+
resolved_timeout = _answer_timeout(mode or "fast", timeout)
|
|
538
|
+
|
|
539
|
+
if stream and json:
|
|
540
|
+
typer.secho(
|
|
541
|
+
"Warning: --stream has no effect with --json.",
|
|
542
|
+
fg=typer.colors.YELLOW,
|
|
543
|
+
err=True,
|
|
544
|
+
)
|
|
545
|
+
if stream and rich:
|
|
546
|
+
typer.secho(
|
|
547
|
+
"Warning: --rich has no effect with --stream; the answer streams as plain text.",
|
|
548
|
+
fg=typer.colors.YELLOW,
|
|
549
|
+
err=True,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if question is not None:
|
|
553
|
+
if not question.strip():
|
|
554
|
+
_fail("Question must not be empty.", "invalid_input", json)
|
|
555
|
+
effective_qid = query_id or str(uuid4())
|
|
556
|
+
streamed = False
|
|
557
|
+
try:
|
|
558
|
+
if stream and not json:
|
|
559
|
+
answer = _run_ask(all_repos, question, mode, effective_qid, use_devin,
|
|
560
|
+
context_value, generate_summary, on_chunk=_stream_chunk,
|
|
561
|
+
timeout=timeout)
|
|
562
|
+
streamed = True
|
|
563
|
+
else:
|
|
564
|
+
with status("Thinking..."):
|
|
565
|
+
answer = _run_ask(all_repos, question, mode, effective_qid, use_devin,
|
|
566
|
+
context_value, generate_summary, timeout=timeout)
|
|
567
|
+
except Exception as exc:
|
|
568
|
+
if stream and not json and _is_retryable(exc):
|
|
569
|
+
typer.secho(
|
|
570
|
+
"\nStream dropped; retrieving the complete answer via polling…",
|
|
571
|
+
fg=typer.colors.YELLOW,
|
|
572
|
+
err=True,
|
|
573
|
+
)
|
|
574
|
+
try:
|
|
575
|
+
with status("Thinking..."):
|
|
576
|
+
answer = _run_ask(all_repos, question, mode, effective_qid, use_devin,
|
|
577
|
+
context_value, generate_summary, timeout=timeout)
|
|
578
|
+
except Exception as exc2:
|
|
579
|
+
_handle_exception(exc2, json)
|
|
580
|
+
streamed = False
|
|
581
|
+
else:
|
|
582
|
+
_handle_exception(exc, json)
|
|
583
|
+
if mermaid:
|
|
584
|
+
mermaid_text = codemap_to_mermaid(answer.body)
|
|
585
|
+
if mermaid_text is not None:
|
|
586
|
+
if json:
|
|
587
|
+
fields: dict[str, object] = {"question": question, "mermaid": mermaid_text}
|
|
588
|
+
if answer.query_id:
|
|
589
|
+
fields["query_id"] = answer.query_id
|
|
590
|
+
typer.echo(format_json(resolved, "ask", **fields))
|
|
591
|
+
else:
|
|
592
|
+
typer.echo(mermaid_text)
|
|
593
|
+
_append_save(save_path, resolved, question, mermaid_text)
|
|
594
|
+
return
|
|
595
|
+
typer.secho(
|
|
596
|
+
"Warning: --mermaid is set but the answer is not a codemap; showing as text.",
|
|
597
|
+
fg=typer.colors.YELLOW,
|
|
598
|
+
err=True,
|
|
599
|
+
)
|
|
600
|
+
if streamed:
|
|
601
|
+
_emit_streamed_answer(answer, sources)
|
|
602
|
+
else:
|
|
603
|
+
_emit_answer(resolved, question, answer, rich, json, sources)
|
|
604
|
+
_append_save(save_path, resolved, question, answer.body)
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
if json:
|
|
608
|
+
typer.secho(
|
|
609
|
+
"Warning: --json has no effect in interactive mode.",
|
|
610
|
+
fg=typer.colors.YELLOW,
|
|
611
|
+
err=True,
|
|
612
|
+
)
|
|
613
|
+
typer.echo(format_header("DeepWiki", resolved, "ask"))
|
|
614
|
+
typer.echo()
|
|
615
|
+
hint = "Ask a question, or /exit to quit."
|
|
616
|
+
if use_devin:
|
|
617
|
+
hint += " (/new starts a new thread)"
|
|
618
|
+
typer.echo(hint)
|
|
619
|
+
typer.echo()
|
|
620
|
+
try:
|
|
621
|
+
if use_devin:
|
|
622
|
+
run_async(_repl_devin(all_repos, rich, save_path, mode or "fast", query_id,
|
|
623
|
+
sources, context_value, generate_summary, resolved_timeout))
|
|
624
|
+
else:
|
|
625
|
+
run_async(_repl(resolved, rich, save_path))
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
_handle_exception(exc)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@deepwiki_app.command("list")
|
|
631
|
+
def list_indexes(
|
|
632
|
+
search: str = typer.Argument(..., help="Search term for indexed repos"),
|
|
633
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Search DeepWiki's indexed public repositories."""
|
|
636
|
+
client = DevinClient()
|
|
637
|
+
try:
|
|
638
|
+
with status("Searching..."):
|
|
639
|
+
result = run_async(client.list_public_indexes(search))
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
_handle_exception(exc, json)
|
|
642
|
+
if json:
|
|
643
|
+
typer.echo(format_command_json(
|
|
644
|
+
"list", search=search,
|
|
645
|
+
indices=result.get("indices") or [],
|
|
646
|
+
needs_reindex=result.get("needs_reindex") or [],
|
|
647
|
+
pending_repos=result.get("pending_repos") or [],
|
|
648
|
+
))
|
|
649
|
+
return
|
|
650
|
+
typer.echo(format_list(result))
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# Named ``status_cmd`` (not ``status``) because the module-level ``status``
|
|
654
|
+
# spinner contextmanager from ``repowiki.shared.output`` is referenced by every other
|
|
655
|
+
# command; a function named ``status`` would shadow it. The CLI name stays
|
|
656
|
+
# ``status`` via the explicit decorator argument, mirroring ``list``.
|
|
657
|
+
@deepwiki_app.command("status")
|
|
658
|
+
def status_cmd(
|
|
659
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
660
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
661
|
+
) -> None:
|
|
662
|
+
"""Check a repository's indexing status on DeepWiki."""
|
|
663
|
+
resolved = _resolve_repo(repo, json)
|
|
664
|
+
client = DevinClient()
|
|
665
|
+
try:
|
|
666
|
+
with status("Checking..."):
|
|
667
|
+
result = run_async(client.public_repo_indexing_status(resolved))
|
|
668
|
+
except Exception as exc:
|
|
669
|
+
_handle_exception(exc, json)
|
|
670
|
+
if json:
|
|
671
|
+
typer.echo(format_command_json("status", repo=resolved, status=result.get("status") or "unknown"))
|
|
672
|
+
return
|
|
673
|
+
typer.echo(format_status(resolved, result))
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@deepwiki_app.command()
|
|
677
|
+
def warm(
|
|
678
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
679
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
680
|
+
) -> None:
|
|
681
|
+
"""Pre-warm a repository's documentation cache on DeepWiki."""
|
|
682
|
+
resolved = _resolve_repo(repo, json)
|
|
683
|
+
client = DevinClient()
|
|
684
|
+
try:
|
|
685
|
+
with status("Warming..."):
|
|
686
|
+
result = run_async(client.warm_public_repo(resolved))
|
|
687
|
+
except Exception as exc:
|
|
688
|
+
_handle_exception(exc, json)
|
|
689
|
+
if json:
|
|
690
|
+
typer.echo(format_command_json("warm", repo=resolved, status=result.get("status") or "OK"))
|
|
691
|
+
return
|
|
692
|
+
typer.echo(format_warm(resolved, result))
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@deepwiki_app.command()
|
|
696
|
+
def get(
|
|
697
|
+
query_id: str = typer.Argument(..., help="Query id to retrieve"),
|
|
698
|
+
rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
|
|
699
|
+
sources: bool = typer.Option(False, "--sources", help="Show source-code slices"),
|
|
700
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
701
|
+
mermaid: bool = typer.Option(
|
|
702
|
+
False, "--mermaid", help="Output a Mermaid diagram (codemap queries only)",
|
|
703
|
+
),
|
|
704
|
+
) -> None:
|
|
705
|
+
"""Retrieve the result of a previous query by id."""
|
|
706
|
+
client = DevinClient()
|
|
707
|
+
try:
|
|
708
|
+
with status("Fetching..."):
|
|
709
|
+
answer = run_async(client.get_query(query_id))
|
|
710
|
+
except Exception as exc:
|
|
711
|
+
_handle_exception(exc, json)
|
|
712
|
+
if mermaid:
|
|
713
|
+
mermaid_text = codemap_to_mermaid(answer.body)
|
|
714
|
+
if mermaid_text is not None:
|
|
715
|
+
if json:
|
|
716
|
+
fields: dict[str, object] = {"mermaid": mermaid_text}
|
|
717
|
+
if answer.query_id:
|
|
718
|
+
fields["query_id"] = answer.query_id
|
|
719
|
+
typer.echo(format_command_json("get", **fields))
|
|
720
|
+
else:
|
|
721
|
+
typer.echo(mermaid_text)
|
|
722
|
+
return
|
|
723
|
+
typer.secho(
|
|
724
|
+
"Warning: --mermaid is set but the answer is not a codemap; showing as text.",
|
|
725
|
+
fg=typer.colors.YELLOW,
|
|
726
|
+
err=True,
|
|
727
|
+
)
|
|
728
|
+
if json:
|
|
729
|
+
typer.echo(format_command_json("get", **_answer_json_fields(answer)))
|
|
730
|
+
return
|
|
731
|
+
rendered = format_answer(answer, show_sources=sources)
|
|
732
|
+
if rich:
|
|
733
|
+
render_markdown(rendered)
|
|
734
|
+
else:
|
|
735
|
+
typer.echo(rendered)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _short_sha(index_id: str) -> str:
|
|
739
|
+
"""Extract the short commit sha from an index id (its last segment).
|
|
740
|
+
|
|
741
|
+
The last segment may carry a branch suffix (``59aff3e1::main``); keep only
|
|
742
|
+
the sha part before any ``::``.
|
|
743
|
+
"""
|
|
744
|
+
last = index_id.rsplit("/", 1)[-1] if index_id else ""
|
|
745
|
+
return last.split("::", 1)[0]
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _iso_to_local(value: str) -> str:
|
|
749
|
+
"""Render an ISO-8601 timestamp as the local timezone's human form."""
|
|
750
|
+
try:
|
|
751
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
752
|
+
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
753
|
+
except (ValueError, TypeError):
|
|
754
|
+
return value
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _format_stat(entry: dict, human: bool) -> str:
|
|
758
|
+
lines: list[str] = []
|
|
759
|
+
if entry.get("repo_name"):
|
|
760
|
+
lines.append(f"- repo: {entry['repo_name']}")
|
|
761
|
+
sha = _short_sha(entry.get("id") or "")
|
|
762
|
+
if sha:
|
|
763
|
+
lines.append(f"- commit: {sha}")
|
|
764
|
+
last_modified = entry.get("last_modified")
|
|
765
|
+
if last_modified:
|
|
766
|
+
if human:
|
|
767
|
+
lines.append(f"- last_indexed: {last_modified} ({_iso_to_local(last_modified)})")
|
|
768
|
+
else:
|
|
769
|
+
lines.append(f"- last_indexed: {last_modified}")
|
|
770
|
+
for key in ("language", "stargazers_count", "topics", "description"):
|
|
771
|
+
if entry.get(key) is not None:
|
|
772
|
+
lines.append(f"- {key}: {entry[key]}")
|
|
773
|
+
return "\n".join(lines) or "No data."
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _format_stale(info: dict) -> str:
|
|
777
|
+
wiki_sha = info.get("wiki_sha") or ""
|
|
778
|
+
github_sha = info.get("github_sha") or ""
|
|
779
|
+
when = info.get("github_when") or ""
|
|
780
|
+
if not github_sha:
|
|
781
|
+
return "Could not fetch GitHub HEAD."
|
|
782
|
+
if not wiki_sha:
|
|
783
|
+
return "No commit sha in the index entry."
|
|
784
|
+
if github_sha.startswith(wiki_sha):
|
|
785
|
+
return f"最新 (up-to-date): {wiki_sha}"
|
|
786
|
+
suffix = f" ({when})" if when else ""
|
|
787
|
+
return f"过期 (stale): wiki {wiki_sha[:7]} != github {github_sha[:7]}{suffix}"
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@deepwiki_app.command()
|
|
791
|
+
def stat(
|
|
792
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
793
|
+
human: bool = typer.Option(False, "--human", help="Show timestamps as human-readable local times"),
|
|
794
|
+
stale: bool = typer.Option(False, "--stale", help="Compare the indexed commit against GitHub HEAD"),
|
|
795
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
796
|
+
) -> None:
|
|
797
|
+
"""Show a repository's DeepWiki index metadata (last indexed time + commit)."""
|
|
798
|
+
resolved = _resolve_repo(repo, json)
|
|
799
|
+
client = DevinClient()
|
|
800
|
+
entry: dict | None = None
|
|
801
|
+
stale_info: dict | None = None
|
|
802
|
+
try:
|
|
803
|
+
with status("Fetching index info..."):
|
|
804
|
+
entry = run_async(client.repo_index(resolved))
|
|
805
|
+
if entry is not None and stale:
|
|
806
|
+
wiki_sha = _short_sha(entry.get("id") or "")
|
|
807
|
+
with status("Checking GitHub HEAD..."):
|
|
808
|
+
head = run_async(client.github_head(resolved))
|
|
809
|
+
stale_info = {
|
|
810
|
+
"wiki_sha": wiki_sha,
|
|
811
|
+
"github_sha": head.get("sha") or "",
|
|
812
|
+
"github_when": head.get("when") or "",
|
|
813
|
+
}
|
|
814
|
+
except Exception as exc:
|
|
815
|
+
_handle_exception(exc, json)
|
|
816
|
+
if entry is None:
|
|
817
|
+
_fail(f"No index entry found for {resolved}.", "not_indexed", json)
|
|
818
|
+
if json:
|
|
819
|
+
fields: dict[str, object] = {"repo": resolved, "data": entry}
|
|
820
|
+
if stale_info is not None:
|
|
821
|
+
fields["stale"] = stale_info
|
|
822
|
+
typer.echo(format_command_json("stat", **fields))
|
|
823
|
+
return
|
|
824
|
+
text = _format_stat(entry, human)
|
|
825
|
+
if stale_info is not None:
|
|
826
|
+
text += "\n\n" + _format_stale(stale_info)
|
|
827
|
+
typer.echo(format_result("DeepWiki", resolved, "stat", text))
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@deepwiki_app.command()
|
|
831
|
+
def cp(
|
|
832
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
833
|
+
output_dir: Optional[str] = typer.Argument(None, help="Output directory"),
|
|
834
|
+
) -> None:
|
|
835
|
+
"""Export the whole wiki as Markdown files plus llms.txt."""
|
|
836
|
+
resolved = _resolve_repo(repo, False)
|
|
837
|
+
client = DeepWikiClient()
|
|
838
|
+
out = output_dir or resolved.replace("/", "_")
|
|
839
|
+
try:
|
|
840
|
+
with status("Exporting wiki..."):
|
|
841
|
+
text = run_async(client.read_wiki_contents(resolved))
|
|
842
|
+
pages = split_pages(text)
|
|
843
|
+
count = export_pages(out, resolved, pages, text)
|
|
844
|
+
except Exception as exc:
|
|
845
|
+
_handle_exception(exc, False)
|
|
846
|
+
typer.echo(f"Exported {count} pages to {out}")
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def register(app: typer.Typer) -> None:
|
|
850
|
+
"""Mount the DeepWiki service commands on the root app."""
|
|
851
|
+
app.add_typer(deepwiki_app, name="deepwiki")
|