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.
@@ -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}.")