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,653 @@
|
|
|
1
|
+
"""CLI commands for the Zread service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import NoReturn, Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from repowiki.services.zread.client import (
|
|
16
|
+
ZreadChallengeError,
|
|
17
|
+
ZreadClient,
|
|
18
|
+
ZreadConnectionError,
|
|
19
|
+
ZreadError,
|
|
20
|
+
ZreadNotFoundError,
|
|
21
|
+
)
|
|
22
|
+
from repowiki.services.zread.flight import Page
|
|
23
|
+
from repowiki.shared.async_ import run_async
|
|
24
|
+
from repowiki.shared.model import Answer
|
|
25
|
+
from repowiki.shared.output import (
|
|
26
|
+
format_answer,
|
|
27
|
+
format_error_json,
|
|
28
|
+
format_header,
|
|
29
|
+
format_json,
|
|
30
|
+
format_result,
|
|
31
|
+
render_markdown as render_rich,
|
|
32
|
+
status,
|
|
33
|
+
)
|
|
34
|
+
from repowiki.shared.repo import normalize_repo
|
|
35
|
+
from repowiki.shared.save import append_entry
|
|
36
|
+
|
|
37
|
+
zread_app = typer.Typer(add_completion=False, help="Query zread.ai documentation.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register(app: typer.Typer) -> None:
|
|
41
|
+
"""Mount the Zread service commands on the root app."""
|
|
42
|
+
app.add_typer(zread_app, name="zread")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mock_text() -> Optional[str]:
|
|
46
|
+
return os.environ.get("REPOWIKI_ZREAD_MOCK") or None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_repo(raw: str, json_mode: bool) -> str:
|
|
50
|
+
try:
|
|
51
|
+
return normalize_repo(raw)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
_fail(str(exc), "invalid_repo", json_mode)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_BLOB_RE = re.compile(r"/blob/[^/]+/(.+)$")
|
|
57
|
+
_LINE_FRAG_RE = re.compile(r"#L(\d+)(?:-L(\d+))?$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_repo_arg(raw: str) -> tuple[str, str | None, int | None, int | None]:
|
|
61
|
+
"""Split a repo arg into ``(repo_ref, file_path, start, end)``.
|
|
62
|
+
|
|
63
|
+
Accepts GitHub blob URLs with an optional ``#L10`` / ``#L10-L20`` line
|
|
64
|
+
fragment, e.g. ``github.com/o/r/blob/main/src/a.py#L10-L20``.
|
|
65
|
+
"""
|
|
66
|
+
value = raw.strip()
|
|
67
|
+
start: int | None = None
|
|
68
|
+
end: int | None = None
|
|
69
|
+
m = _LINE_FRAG_RE.search(value)
|
|
70
|
+
if m:
|
|
71
|
+
start = int(m.group(1))
|
|
72
|
+
if m.group(2):
|
|
73
|
+
end = int(m.group(2))
|
|
74
|
+
value = value[:m.start()].rstrip("/")
|
|
75
|
+
file_path: str | None = None
|
|
76
|
+
bm = _BLOB_RE.search(value)
|
|
77
|
+
if bm:
|
|
78
|
+
file_path = bm.group(1)
|
|
79
|
+
value = value[:bm.start()]
|
|
80
|
+
return value, file_path, start, end
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _fail(message: str, kind: str, json_mode: bool) -> NoReturn:
|
|
84
|
+
if json_mode:
|
|
85
|
+
typer.echo(format_error_json(kind, message), err=True)
|
|
86
|
+
else:
|
|
87
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
88
|
+
raise typer.Exit(code={"connection": 3}.get(kind, 1))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _handle_exception(exc: Exception, json_mode: bool) -> NoReturn:
|
|
92
|
+
if isinstance(exc, ZreadConnectionError):
|
|
93
|
+
_fail(str(exc), "connection", json_mode)
|
|
94
|
+
elif isinstance(exc, ZreadNotFoundError):
|
|
95
|
+
_fail(str(exc), "not_found", json_mode)
|
|
96
|
+
elif isinstance(exc, ZreadChallengeError):
|
|
97
|
+
_fail(str(exc), "challenge", json_mode)
|
|
98
|
+
elif isinstance(exc, ZreadError):
|
|
99
|
+
_fail(str(exc), "error", json_mode)
|
|
100
|
+
_fail(f"Unexpected error: {exc}", "unexpected", json_mode)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _emit(
|
|
104
|
+
repo: str, command: str, text: str, rich: bool, json_mode: bool, **json_fields: object
|
|
105
|
+
) -> None:
|
|
106
|
+
if json_mode:
|
|
107
|
+
typer.echo(format_json(repo, command, **json_fields))
|
|
108
|
+
return
|
|
109
|
+
rendered = format_result("Zread", repo, command, text)
|
|
110
|
+
if rich:
|
|
111
|
+
render_rich(rendered)
|
|
112
|
+
else:
|
|
113
|
+
typer.echo(rendered)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_lang(lang: Optional[str]) -> str:
|
|
117
|
+
return lang or os.environ.get("ZREAD_LANG") or "en"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_model(model: Optional[str]) -> str:
|
|
121
|
+
return model or os.environ.get("ZREAD_MODEL") or "glm-5.1"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _make_client(
|
|
125
|
+
lang: Optional[str], model: Optional[str] = None, token: Optional[str] = None
|
|
126
|
+
) -> ZreadClient:
|
|
127
|
+
return ZreadClient(
|
|
128
|
+
lang=_resolve_lang(lang),
|
|
129
|
+
model=_resolve_model(model),
|
|
130
|
+
token=token or os.environ.get("ZREAD_TOKEN") or None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _repl_prompt() -> str:
|
|
135
|
+
if not sys.stdout.isatty():
|
|
136
|
+
return ">> "
|
|
137
|
+
return typer.style(">> ", fg=typer.colors.CYAN, bold=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _print_error(exc: Exception) -> None:
|
|
141
|
+
if isinstance(exc, ZreadError):
|
|
142
|
+
message = str(exc)
|
|
143
|
+
else:
|
|
144
|
+
message = f"Unexpected error: {exc}"
|
|
145
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _append_save(path: Optional[str], repo: str, question: str, answer: str) -> None:
|
|
149
|
+
if path is None:
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
append_entry(path, repo, question, answer)
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
typer.secho(f"Warning: could not save to {path}: {exc}", fg=typer.colors.YELLOW, err=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _repl(
|
|
158
|
+
resolved: str, rich: bool, save_path: Optional[str], model: Optional[str], lang: Optional[str]
|
|
159
|
+
) -> None:
|
|
160
|
+
prompt = _repl_prompt()
|
|
161
|
+
client = _make_client(lang, model)
|
|
162
|
+
while True:
|
|
163
|
+
try:
|
|
164
|
+
line = input(prompt)
|
|
165
|
+
except (EOFError, KeyboardInterrupt):
|
|
166
|
+
typer.echo()
|
|
167
|
+
break
|
|
168
|
+
q = line.strip()
|
|
169
|
+
if not q:
|
|
170
|
+
continue
|
|
171
|
+
if q in ("/exit", "/quit", "/q"):
|
|
172
|
+
break
|
|
173
|
+
try:
|
|
174
|
+
with status("Thinking..."):
|
|
175
|
+
answer = await client.ask(resolved, q)
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
_print_error(exc)
|
|
178
|
+
continue
|
|
179
|
+
_append_save(save_path, resolved, q, answer)
|
|
180
|
+
typer.echo()
|
|
181
|
+
if rich:
|
|
182
|
+
render_rich(answer.strip())
|
|
183
|
+
else:
|
|
184
|
+
typer.echo(answer.strip())
|
|
185
|
+
typer.echo()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _render_structure(pages: list[Page]) -> str:
|
|
189
|
+
lines = []
|
|
190
|
+
for p in pages:
|
|
191
|
+
parts = [x for x in (p.section, p.group, p.topic) if x]
|
|
192
|
+
title = "/".join(parts) if parts else p.slug
|
|
193
|
+
lines.append(f"{p.order:02d} {title} ({p.slug})")
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _repo_name(item: dict) -> str:
|
|
198
|
+
for key in ("name", "repo", "repo_name", "full_name", "slug"):
|
|
199
|
+
if item.get(key):
|
|
200
|
+
return str(item[key])
|
|
201
|
+
owner = item.get("owner")
|
|
202
|
+
if owner:
|
|
203
|
+
name = item.get("name")
|
|
204
|
+
return f"{owner}/{name}" if name else str(owner)
|
|
205
|
+
return str(item.get("id", "?"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _format_repos(items: list) -> str:
|
|
209
|
+
if not items:
|
|
210
|
+
return "No results."
|
|
211
|
+
lines = []
|
|
212
|
+
for it in items:
|
|
213
|
+
name = _repo_name(it)
|
|
214
|
+
bits = []
|
|
215
|
+
for key in ("language", "lang"):
|
|
216
|
+
if it.get(key):
|
|
217
|
+
bits.append(str(it[key]))
|
|
218
|
+
stars = it.get("stars", it.get("stargazers_count"))
|
|
219
|
+
if stars is not None:
|
|
220
|
+
bits.append(f"{stars} stars")
|
|
221
|
+
suffix = f" ({', '.join(bits)})" if bits else ""
|
|
222
|
+
lines.append(f"- {name}{suffix}")
|
|
223
|
+
return "\n".join(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _format_trending(groups: list) -> str:
|
|
227
|
+
if not groups:
|
|
228
|
+
return "No results."
|
|
229
|
+
lines = []
|
|
230
|
+
for g in groups:
|
|
231
|
+
lines.append(f"## {g.get('title') or g.get('time_span') or ''}")
|
|
232
|
+
for r in g.get("repos", []):
|
|
233
|
+
lines.append(f"- {_repo_name(r)}")
|
|
234
|
+
lines.append("")
|
|
235
|
+
return "\n".join(lines).strip()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _human_time(ts: object) -> str:
|
|
239
|
+
try:
|
|
240
|
+
return datetime.fromtimestamp(int(ts)).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
241
|
+
except (TypeError, ValueError, OSError):
|
|
242
|
+
return str(ts)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _format_last_commit(value: object, human: bool) -> str:
|
|
246
|
+
if not isinstance(value, dict):
|
|
247
|
+
return str(value)
|
|
248
|
+
parts = []
|
|
249
|
+
for k, v in value.items():
|
|
250
|
+
if human and k == "when":
|
|
251
|
+
parts.append(f"'when': {v} ({_human_time(v)})")
|
|
252
|
+
else:
|
|
253
|
+
parts.append(f"{k!r}: {v!r}")
|
|
254
|
+
return "{" + ", ".join(parts) + "}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _format_stat(data: dict, human: bool = False) -> str:
|
|
258
|
+
if not data:
|
|
259
|
+
return "No data."
|
|
260
|
+
lines = []
|
|
261
|
+
for k, v in data.items():
|
|
262
|
+
if human and k in ("created_at", "updated_at"):
|
|
263
|
+
lines.append(f"- {k}: {v} ({_human_time(v)})")
|
|
264
|
+
elif k == "last_commit":
|
|
265
|
+
lines.append(f"- {k}: {_format_last_commit(v, human)}")
|
|
266
|
+
else:
|
|
267
|
+
lines.append(f"- {k}: {v}")
|
|
268
|
+
return "\n".join(lines)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _format_stale(info: dict) -> str:
|
|
272
|
+
zread_sha = info.get("zread_sha") or ""
|
|
273
|
+
github_sha = info.get("github_sha") or ""
|
|
274
|
+
when = info.get("github_when") or ""
|
|
275
|
+
if not github_sha:
|
|
276
|
+
return "Could not fetch GitHub HEAD."
|
|
277
|
+
if not zread_sha:
|
|
278
|
+
return "No last_commit.hash in zread data."
|
|
279
|
+
if zread_sha == github_sha:
|
|
280
|
+
return f"最新 (up-to-date): {github_sha}"
|
|
281
|
+
suffix = f" ({when})" if when else ""
|
|
282
|
+
return f"过期 (stale): zread {zread_sha[:7]} != github {github_sha[:7]}{suffix}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _format_search(results: list) -> str:
|
|
286
|
+
if not results:
|
|
287
|
+
return "No results."
|
|
288
|
+
lines = []
|
|
289
|
+
for r in results:
|
|
290
|
+
title = str(r.get("title") or "").strip()
|
|
291
|
+
slug = str(r.get("slug") or "").strip()
|
|
292
|
+
heading = title or slug
|
|
293
|
+
if title and slug and slug != title:
|
|
294
|
+
heading = f"{title} ({slug})"
|
|
295
|
+
lines.append(f"## {heading}")
|
|
296
|
+
for m in r.get("matches", []):
|
|
297
|
+
if isinstance(m, dict):
|
|
298
|
+
text = m.get("highlight") or m.get("content") or ""
|
|
299
|
+
else:
|
|
300
|
+
text = m
|
|
301
|
+
text = re.sub(r"<[^>]+>", "", str(text))
|
|
302
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
303
|
+
if text:
|
|
304
|
+
lines.append(f"- {text}")
|
|
305
|
+
lines.append("")
|
|
306
|
+
return "\n".join(lines).strip()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _export(client: ZreadClient, repo: str, out_dir: str, concurrency: int) -> int:
|
|
310
|
+
_, pages = await client.outline(repo)
|
|
311
|
+
sem = asyncio.Semaphore(concurrency)
|
|
312
|
+
|
|
313
|
+
async def one(p: Page):
|
|
314
|
+
async with sem:
|
|
315
|
+
md = await client.page(repo, p.slug)
|
|
316
|
+
return p, md
|
|
317
|
+
|
|
318
|
+
results = await asyncio.gather(*(one(p) for p in pages))
|
|
319
|
+
out = Path(out_dir)
|
|
320
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
entries = []
|
|
322
|
+
for p, md in results:
|
|
323
|
+
fname = f"{p.slug}.md"
|
|
324
|
+
(out / fname).write_text(md, encoding="utf-8")
|
|
325
|
+
entries.append((p, fname))
|
|
326
|
+
index = ["# " + repo, ""]
|
|
327
|
+
for p, fname in entries:
|
|
328
|
+
index.append(f"- [{p.topic}]({fname})")
|
|
329
|
+
index_text = "\n".join(index) + "\n"
|
|
330
|
+
(out / "llms.txt").write_text(index_text, encoding="utf-8")
|
|
331
|
+
(out / "README.md").write_text(index_text, encoding="utf-8")
|
|
332
|
+
full = "\n\n".join(f"# {p.topic}\n\n{md}" for p, md in results)
|
|
333
|
+
(out / "llms-full.txt").write_text(full + "\n", encoding="utf-8")
|
|
334
|
+
return len(entries)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@zread_app.command()
|
|
338
|
+
def structure(
|
|
339
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
340
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
341
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Show the zread.ai table of contents for a repository."""
|
|
344
|
+
resolved = _resolve_repo(repo, json)
|
|
345
|
+
if (mock := _mock_text()) is not None:
|
|
346
|
+
_emit(resolved, "structure", mock, False, json, content=mock)
|
|
347
|
+
return
|
|
348
|
+
client = _make_client(lang)
|
|
349
|
+
try:
|
|
350
|
+
with status("Fetching structure..."):
|
|
351
|
+
_, pages = run_async(client.outline(resolved))
|
|
352
|
+
text = _render_structure(pages)
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
_handle_exception(exc, json)
|
|
355
|
+
_emit(resolved, "structure", text, False, json, content=text)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@zread_app.command()
|
|
359
|
+
def contents(
|
|
360
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo, GitHub URL, or a blob URL with #L lines)"),
|
|
361
|
+
slug: Optional[str] = typer.Argument(None, help="Page slug (default: overview)"),
|
|
362
|
+
file: Optional[str] = typer.Option(None, "--file", help="Read a source file instead"),
|
|
363
|
+
start: Optional[int] = typer.Option(None, "--start", help="Start line (with --file)"),
|
|
364
|
+
end: Optional[int] = typer.Option(None, "--end", help="End line (with --file)"),
|
|
365
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
366
|
+
rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
|
|
367
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
368
|
+
) -> None:
|
|
369
|
+
"""Show a single page of documentation (default: the overview page)."""
|
|
370
|
+
raw_repo, embedded_file, embedded_start, embedded_end = _parse_repo_arg(repo)
|
|
371
|
+
resolved = _resolve_repo(raw_repo, json)
|
|
372
|
+
if file is None:
|
|
373
|
+
file = embedded_file
|
|
374
|
+
if start is None:
|
|
375
|
+
start = embedded_start
|
|
376
|
+
if end is None:
|
|
377
|
+
end = embedded_end
|
|
378
|
+
if slug is not None and file is not None:
|
|
379
|
+
_fail("Specify either a slug or --file, not both.", "invalid_input", json)
|
|
380
|
+
client = _make_client(lang)
|
|
381
|
+
try:
|
|
382
|
+
if file is not None:
|
|
383
|
+
with status("Fetching file..."):
|
|
384
|
+
info = run_async(client.repo_info(resolved))
|
|
385
|
+
repo_id = str(info.get("repo_id") or "")
|
|
386
|
+
if not repo_id:
|
|
387
|
+
raise ZreadNotFoundError(f"no repo_id for {resolved}")
|
|
388
|
+
text = run_async(client.read_file(repo_id, file, start, end))
|
|
389
|
+
_emit(resolved, "contents", text, False, json, content=text, file=file)
|
|
390
|
+
return
|
|
391
|
+
if (mock := _mock_text()) is not None:
|
|
392
|
+
text = mock
|
|
393
|
+
elif slug is None:
|
|
394
|
+
with status("Fetching page..."):
|
|
395
|
+
_, pages = run_async(client.outline(resolved))
|
|
396
|
+
if not pages:
|
|
397
|
+
raise ZreadNotFoundError(f"no wiki pages for {resolved}")
|
|
398
|
+
text = run_async(client.page(resolved, pages[0].slug))
|
|
399
|
+
else:
|
|
400
|
+
with status("Fetching page..."):
|
|
401
|
+
text = run_async(client.page(resolved, slug))
|
|
402
|
+
except Exception as exc:
|
|
403
|
+
_handle_exception(exc, json)
|
|
404
|
+
fields: dict[str, object] = {"content": text}
|
|
405
|
+
if slug is not None:
|
|
406
|
+
fields["slug"] = slug
|
|
407
|
+
_emit(resolved, "contents", text, rich, json, **fields)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@zread_app.command()
|
|
411
|
+
def ask(
|
|
412
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
413
|
+
question: Optional[str] = typer.Argument(None, help="Question (omit for interactive mode)"),
|
|
414
|
+
model: Optional[str] = typer.Option(None, "--model", help="Model (glm-5.1|claude-sonnet-4.6)"),
|
|
415
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
416
|
+
rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
|
|
417
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
418
|
+
save: Optional[str] = typer.Option(None, "--save", help="Save the answer to a Markdown file."),
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Ask a question about a repository (single-shot or interactive)."""
|
|
421
|
+
resolved = _resolve_repo(repo, json)
|
|
422
|
+
if question is not None:
|
|
423
|
+
if not question.strip():
|
|
424
|
+
_fail("Question must not be empty.", "invalid_input", json)
|
|
425
|
+
if (mock := _mock_text()) is not None:
|
|
426
|
+
answer = Answer(body=mock)
|
|
427
|
+
else:
|
|
428
|
+
client = _make_client(lang, model)
|
|
429
|
+
try:
|
|
430
|
+
with status("Thinking..."):
|
|
431
|
+
answer = Answer(body=run_async(client.ask(resolved, question)))
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
_handle_exception(exc, json)
|
|
434
|
+
if json:
|
|
435
|
+
typer.echo(format_json(resolved, "ask", question=question, answer=answer.body))
|
|
436
|
+
else:
|
|
437
|
+
_emit(resolved, "ask", format_answer(answer), rich, False)
|
|
438
|
+
_append_save(save, resolved, question, answer.body)
|
|
439
|
+
return
|
|
440
|
+
if json:
|
|
441
|
+
typer.secho("Warning: --json has no effect in interactive mode.", fg=typer.colors.YELLOW, err=True)
|
|
442
|
+
typer.echo(format_header("Zread", resolved, "ask"))
|
|
443
|
+
typer.echo()
|
|
444
|
+
typer.echo("Ask a question, or /exit to quit.")
|
|
445
|
+
typer.echo()
|
|
446
|
+
try:
|
|
447
|
+
run_async(_repl(resolved, rich, save, model, lang))
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
_handle_exception(exc, False)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@zread_app.command()
|
|
453
|
+
def find(
|
|
454
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
455
|
+
limit: Optional[int] = typer.Option(None, "--limit", help="Max results"),
|
|
456
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
457
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Search zread.ai for repositories."""
|
|
460
|
+
client = _make_client(lang)
|
|
461
|
+
try:
|
|
462
|
+
with status("Searching..."):
|
|
463
|
+
items = run_async(client.search_repos(query))
|
|
464
|
+
if limit is not None:
|
|
465
|
+
items = items[:limit]
|
|
466
|
+
except Exception as exc:
|
|
467
|
+
_handle_exception(exc, json)
|
|
468
|
+
text = _format_repos(items)
|
|
469
|
+
if json:
|
|
470
|
+
typer.echo(format_json("", "find", results=items))
|
|
471
|
+
else:
|
|
472
|
+
typer.echo(format_result("Zread", query, "find", text))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@zread_app.command()
|
|
476
|
+
def search(
|
|
477
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
478
|
+
query: str = typer.Argument(..., help="Text to search for inside the wiki"),
|
|
479
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
480
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Search within a repository's wiki documentation."""
|
|
483
|
+
resolved = _resolve_repo(repo, json)
|
|
484
|
+
client = _make_client(lang)
|
|
485
|
+
try:
|
|
486
|
+
with status("Searching..."):
|
|
487
|
+
results = run_async(client.search_wiki(resolved, query))
|
|
488
|
+
except Exception as exc:
|
|
489
|
+
_handle_exception(exc, json)
|
|
490
|
+
if json:
|
|
491
|
+
typer.echo(format_json(resolved, "search", query=query, results=results))
|
|
492
|
+
else:
|
|
493
|
+
typer.echo(format_result("Zread", resolved, "search", _format_search(results)))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@zread_app.command()
|
|
497
|
+
def stat(
|
|
498
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
499
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
500
|
+
human: bool = typer.Option(False, "--human", help="Show timestamps as human-readable times"),
|
|
501
|
+
stale: bool = typer.Option(False, "--stale", help="Compare last_commit against GitHub HEAD"),
|
|
502
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Show repository info and index status on zread.ai."""
|
|
505
|
+
resolved = _resolve_repo(repo, json)
|
|
506
|
+
client = _make_client(lang)
|
|
507
|
+
try:
|
|
508
|
+
with status("Fetching status..."):
|
|
509
|
+
data = run_async(client.repo_info(resolved))
|
|
510
|
+
stale_info: dict | None = None
|
|
511
|
+
if stale:
|
|
512
|
+
last_commit = data.get("last_commit") if isinstance(data, dict) else None
|
|
513
|
+
zread_sha = (last_commit or {}).get("hash") if isinstance(last_commit, dict) else None
|
|
514
|
+
with status("Checking GitHub HEAD..."):
|
|
515
|
+
head = run_async(client.github_head(resolved))
|
|
516
|
+
stale_info = {
|
|
517
|
+
"zread_sha": zread_sha or "",
|
|
518
|
+
"github_sha": head.get("sha") or "",
|
|
519
|
+
"github_when": head.get("when") or "",
|
|
520
|
+
}
|
|
521
|
+
except Exception as exc:
|
|
522
|
+
_handle_exception(exc, json)
|
|
523
|
+
if json:
|
|
524
|
+
fields: dict[str, object] = {"data": data}
|
|
525
|
+
if stale_info is not None:
|
|
526
|
+
fields["stale"] = stale_info
|
|
527
|
+
typer.echo(format_json(resolved, "stat", **fields))
|
|
528
|
+
else:
|
|
529
|
+
text = _format_stat(data, human)
|
|
530
|
+
if stale_info is not None:
|
|
531
|
+
text += "\n\n" + _format_stale(stale_info)
|
|
532
|
+
typer.echo(format_result("Zread", resolved, "stat", text))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@zread_app.command()
|
|
536
|
+
def top(
|
|
537
|
+
weeks: Optional[int] = typer.Argument(None, help="Number of week-groups to show"),
|
|
538
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
539
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
540
|
+
) -> None:
|
|
541
|
+
"""Show the zread.ai trending list."""
|
|
542
|
+
client = _make_client(lang)
|
|
543
|
+
try:
|
|
544
|
+
with status("Fetching trending..."):
|
|
545
|
+
groups = run_async(client.trending())
|
|
546
|
+
if weeks is not None:
|
|
547
|
+
groups = groups[:weeks]
|
|
548
|
+
except Exception as exc:
|
|
549
|
+
_handle_exception(exc, json)
|
|
550
|
+
if json:
|
|
551
|
+
typer.echo(format_json("", "top", groups=groups))
|
|
552
|
+
else:
|
|
553
|
+
typer.echo(format_result("Zread", "", "top", _format_trending(groups)))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@zread_app.command()
|
|
557
|
+
def rand(
|
|
558
|
+
topic: Optional[str] = typer.Argument(None, help="GitHub topic to filter by"),
|
|
559
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
560
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
561
|
+
) -> None:
|
|
562
|
+
"""Get a random repository recommendation (optionally by topic)."""
|
|
563
|
+
client = _make_client(lang)
|
|
564
|
+
try:
|
|
565
|
+
with status("Fetching recommendation..."):
|
|
566
|
+
data = run_async(client.recommend(topic or ""))
|
|
567
|
+
except Exception as exc:
|
|
568
|
+
_handle_exception(exc, json)
|
|
569
|
+
if json:
|
|
570
|
+
typer.echo(format_json("", "rand", data=data))
|
|
571
|
+
else:
|
|
572
|
+
repos = data.get("repos", []) if isinstance(data, dict) else []
|
|
573
|
+
text = _format_repos(repos)
|
|
574
|
+
typer.echo(format_result("Zread", topic or "", "rand", text))
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@zread_app.command()
|
|
578
|
+
def cp(
|
|
579
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
580
|
+
output_dir: Optional[str] = typer.Argument(None, help="Output directory"),
|
|
581
|
+
concurrency: int = typer.Option(5, "--concurrency", help="Parallel page fetches"),
|
|
582
|
+
lang: Optional[str] = typer.Option(None, "--lang", help="Language (zh|en)"),
|
|
583
|
+
) -> None:
|
|
584
|
+
"""Export the whole wiki as Markdown files plus llms.txt."""
|
|
585
|
+
if concurrency < 1:
|
|
586
|
+
_fail("--concurrency must be at least 1", "invalid_input", False)
|
|
587
|
+
resolved = _resolve_repo(repo, False)
|
|
588
|
+
client = _make_client(lang)
|
|
589
|
+
out = output_dir or resolved.replace("/", "_")
|
|
590
|
+
try:
|
|
591
|
+
with status("Exporting wiki..."):
|
|
592
|
+
count = run_async(_export(client, resolved, out, concurrency))
|
|
593
|
+
except Exception as exc:
|
|
594
|
+
_handle_exception(exc, False)
|
|
595
|
+
typer.echo(f"Exported {count} pages to {out}")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@zread_app.command()
|
|
599
|
+
def submit(
|
|
600
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
601
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
602
|
+
) -> None:
|
|
603
|
+
"""Submit a repository for indexing on zread.ai (requires ZREAD_TOKEN)."""
|
|
604
|
+
resolved = _resolve_repo(repo, json)
|
|
605
|
+
if not os.environ.get("ZREAD_TOKEN"):
|
|
606
|
+
message = "ZREAD_TOKEN is not set; skipping submit. Set it to submit a repo for indexing."
|
|
607
|
+
if json:
|
|
608
|
+
typer.echo(format_error_json("missing_token", message), err=True)
|
|
609
|
+
else:
|
|
610
|
+
typer.secho(f"Warning: {message}", fg=typer.colors.YELLOW, err=True)
|
|
611
|
+
return
|
|
612
|
+
client = _make_client(None)
|
|
613
|
+
try:
|
|
614
|
+
with status("Submitting..."):
|
|
615
|
+
data = run_async(client.submit(resolved))
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
_handle_exception(exc, json)
|
|
618
|
+
eta: dict = {}
|
|
619
|
+
try:
|
|
620
|
+
with status("Checking queue..."):
|
|
621
|
+
eta = run_async(client.eta())
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
if json:
|
|
625
|
+
typer.echo(format_json(resolved, "submit", data=data, eta=eta))
|
|
626
|
+
else:
|
|
627
|
+
message = f"Submitted {resolved} for indexing."
|
|
628
|
+
if isinstance(eta, dict):
|
|
629
|
+
backlog = eta.get("backlog")
|
|
630
|
+
estimate = eta.get("estimate_minutes")
|
|
631
|
+
if backlog is not None:
|
|
632
|
+
message += f" Queue: {backlog} ahead"
|
|
633
|
+
message += f" (~{estimate} min ETA)" if estimate is not None else "."
|
|
634
|
+
typer.echo(message)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@zread_app.command()
|
|
638
|
+
def refresh(
|
|
639
|
+
repo: str = typer.Argument(..., help="Repository (owner/repo or GitHub URL)"),
|
|
640
|
+
json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
641
|
+
) -> None:
|
|
642
|
+
"""Request a re-index (refresh) of a repository's wiki."""
|
|
643
|
+
resolved = _resolve_repo(repo, json)
|
|
644
|
+
client = _make_client(None)
|
|
645
|
+
try:
|
|
646
|
+
with status("Refreshing..."):
|
|
647
|
+
data = run_async(client.refresh(resolved))
|
|
648
|
+
except Exception as exc:
|
|
649
|
+
_handle_exception(exc, json)
|
|
650
|
+
if json:
|
|
651
|
+
typer.echo(format_json(resolved, "refresh", data=data))
|
|
652
|
+
else:
|
|
653
|
+
typer.echo(f"Refreshed {resolved}.")
|