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,204 @@
|
|
|
1
|
+
"""Output formatting for CLI results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
|
|
14
|
+
from repowiki.shared.model import Answer, Reference, SourceFile
|
|
15
|
+
|
|
16
|
+
_PAGE_DELIMITER = re.compile(r"^# Page: (.*)$", re.MULTILINE)
|
|
17
|
+
_DETAILS_TAG = re.compile(r"</?details[^>]*>", re.IGNORECASE)
|
|
18
|
+
_SUMMARY_TAG = re.compile(r"<summary[^>]*>(.*?)</summary>", re.IGNORECASE | re.DOTALL)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_header(service: str, repo: str, command: str) -> str:
|
|
22
|
+
"""Return the Markdown header line identifying a result."""
|
|
23
|
+
return f"## {service}: {repo} ({command})"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_result(service: str, repo: str, command: str, text: str) -> str:
|
|
27
|
+
"""Wrap result text in a Markdown header, trimming surrounding whitespace."""
|
|
28
|
+
return f"{format_header(service, repo, command)}\n\n{text.strip()}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_json(repo: str, command: str, **fields: object) -> str:
|
|
32
|
+
"""Return a machine-readable JSON envelope for a result."""
|
|
33
|
+
return json.dumps(
|
|
34
|
+
{"repo": repo, "command": command, **fields},
|
|
35
|
+
indent=2,
|
|
36
|
+
ensure_ascii=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_error_json(kind: str, message: str) -> str:
|
|
41
|
+
"""Return a single-line JSON error object."""
|
|
42
|
+
return json.dumps({"error": message, "kind": kind}, ensure_ascii=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_command_json(command: str, **fields: object) -> str:
|
|
46
|
+
"""Return a JSON envelope without a repo field (for management commands)."""
|
|
47
|
+
return json.dumps({"command": command, **fields}, indent=2, ensure_ascii=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def list_page_titles(text: str) -> list[str]:
|
|
51
|
+
"""Return page titles found after each '# Page:' delimiter."""
|
|
52
|
+
return [m.group(1).strip() for m in _PAGE_DELIMITER.finditer(text)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def split_pages(text: str) -> list[tuple[str, str]]:
|
|
56
|
+
"""Split markdown on each ``# Page:`` delimiter into ``(title, content)``.
|
|
57
|
+
|
|
58
|
+
Content with no delimiter is returned as a single ``("Overview", text)`` page.
|
|
59
|
+
"""
|
|
60
|
+
matches = list(_PAGE_DELIMITER.finditer(text))
|
|
61
|
+
if not matches:
|
|
62
|
+
return [("Overview", text.strip())]
|
|
63
|
+
pages: list[tuple[str, str]] = []
|
|
64
|
+
for i, m in enumerate(matches):
|
|
65
|
+
title = m.group(1).strip()
|
|
66
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
67
|
+
pages.append((title, text[m.end():end].strip()))
|
|
68
|
+
return pages
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def filter_page(text: str, title: str) -> str:
|
|
72
|
+
"""Return the content of the page whose title matches (case-insensitive exact).
|
|
73
|
+
|
|
74
|
+
Raises ValueError if no page matches.
|
|
75
|
+
"""
|
|
76
|
+
matches = list(_PAGE_DELIMITER.finditer(text))
|
|
77
|
+
target = title.strip().lower()
|
|
78
|
+
available = []
|
|
79
|
+
for i, m in enumerate(matches):
|
|
80
|
+
page_title = m.group(1).strip()
|
|
81
|
+
available.append(page_title)
|
|
82
|
+
if page_title.lower() == target:
|
|
83
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
84
|
+
return text[m.end() : end].strip()
|
|
85
|
+
listing = "\n".join(f" - {t}" for t in available) or " - (none)"
|
|
86
|
+
raise ValueError(f"Page '{title}' not found. Available pages:\n{listing}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _prepare_markdown(text: str) -> str:
|
|
90
|
+
"""Convert HTML that rich's Markdown renderer would silently drop."""
|
|
91
|
+
text = _DETAILS_TAG.sub("", text)
|
|
92
|
+
text = _SUMMARY_TAG.sub(lambda m: f"**{m.group(1).strip()}**", text)
|
|
93
|
+
return text
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_markdown(text: str) -> None:
|
|
97
|
+
"""Render Markdown text to the terminal via rich."""
|
|
98
|
+
Console().print(Markdown(_prepare_markdown(text)))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def status(message: str) -> Iterator[None]:
|
|
103
|
+
"""Show a spinner on stderr while a slow call runs.
|
|
104
|
+
|
|
105
|
+
No-op when stderr is not a terminal, so piped output and tests stay clean.
|
|
106
|
+
"""
|
|
107
|
+
if not sys.stderr.isatty():
|
|
108
|
+
yield
|
|
109
|
+
return
|
|
110
|
+
with Console(stderr=True).status(message):
|
|
111
|
+
yield
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _clean_path(file_path: str) -> str:
|
|
115
|
+
"""Strip a leading 'Repo owner/repo: ' prefix from a reference path."""
|
|
116
|
+
if ": " in file_path:
|
|
117
|
+
return file_path.split(": ", 1)[1]
|
|
118
|
+
return file_path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _range_label(path: str, start: int | None, end: int | None) -> str:
|
|
122
|
+
"""Format a path with its line range, omitting the range when absent.
|
|
123
|
+
|
|
124
|
+
The reverse API uses ``None`` or ``0`` to mean "no specific lines", so
|
|
125
|
+
either renders as just the path.
|
|
126
|
+
"""
|
|
127
|
+
if not start and not end:
|
|
128
|
+
return path
|
|
129
|
+
return f"{path}:{start}-{end}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _render_slice(ref: Reference, sources: list[SourceFile]) -> str | None:
|
|
133
|
+
path = _clean_path(ref.file_path)
|
|
134
|
+
src = next((s for s in sources if s.path == path), None)
|
|
135
|
+
if src is None:
|
|
136
|
+
return None
|
|
137
|
+
lines = src.content.split("\n")
|
|
138
|
+
start = ref.range_start if ref.range_start else 1
|
|
139
|
+
end = ref.range_end if ref.range_end else len(lines)
|
|
140
|
+
lo = max(0, start - 1)
|
|
141
|
+
hi = min(len(lines), end)
|
|
142
|
+
numbered = "\n".join(f"{n:4d} {lines[n - 1]}" for n in range(lo + 1, hi + 1))
|
|
143
|
+
return f"```\n{_range_label(path, ref.range_start, ref.range_end)}\n{numbered}\n```"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _format_sources(references: list[Reference], sources: list[SourceFile], show_sources: bool) -> str:
|
|
147
|
+
lines = ["## Sources", ""]
|
|
148
|
+
for i, ref in enumerate(references, 1):
|
|
149
|
+
path = _clean_path(ref.file_path)
|
|
150
|
+
lines.append(f"{i}. {_range_label(path, ref.range_start, ref.range_end)}")
|
|
151
|
+
if show_sources:
|
|
152
|
+
slice_text = _render_slice(ref, sources)
|
|
153
|
+
if slice_text:
|
|
154
|
+
lines.extend(["", slice_text])
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def format_answer(answer: Answer, *, show_sources: bool = False) -> str:
|
|
159
|
+
"""Render an Answer as Markdown (no outer header)."""
|
|
160
|
+
body = answer.body
|
|
161
|
+
parts = [body.strip()]
|
|
162
|
+
if answer.summary:
|
|
163
|
+
parts.append(f"## Summary\n\n{answer.summary.strip()}")
|
|
164
|
+
if answer.references:
|
|
165
|
+
parts.append(_format_sources(answer.references, answer.sources, show_sources))
|
|
166
|
+
return "\n\n".join(parts)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def format_answer_tail(answer: Answer, *, show_sources: bool = False) -> str:
|
|
170
|
+
"""Render an Answer's summary and sources (no body) for streamed output."""
|
|
171
|
+
parts = []
|
|
172
|
+
if answer.summary:
|
|
173
|
+
parts.append(f"## Summary\n\n{answer.summary.strip()}")
|
|
174
|
+
if answer.references:
|
|
175
|
+
parts.append(_format_sources(answer.references, answer.sources, show_sources))
|
|
176
|
+
return "\n\n".join(parts)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def format_list(result: dict) -> str:
|
|
180
|
+
"""Render a list_public_indexes result as one line per indexed repo."""
|
|
181
|
+
indices = result.get("indices") or []
|
|
182
|
+
if not indices:
|
|
183
|
+
return "No matching indexed repos."
|
|
184
|
+
lines = []
|
|
185
|
+
for entry in indices:
|
|
186
|
+
repo = entry.get("repo_name", "")
|
|
187
|
+
bits = []
|
|
188
|
+
if entry.get("last_modified"):
|
|
189
|
+
bits.append(entry["last_modified"])
|
|
190
|
+
if entry.get("language"):
|
|
191
|
+
bits.append(entry["language"])
|
|
192
|
+
if entry.get("stargazers_count") is not None:
|
|
193
|
+
bits.append(f"{entry['stargazers_count']} stars")
|
|
194
|
+
suffix = f" ({', '.join(bits)})" if bits else ""
|
|
195
|
+
lines.append(f"- {repo}{suffix}")
|
|
196
|
+
return "\n".join(lines)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def format_status(repo: str, result: dict) -> str:
|
|
200
|
+
return f"{repo}: {result.get('status', 'unknown')}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def format_warm(repo: str, result: dict) -> str:
|
|
204
|
+
return f"warmed {repo} ({result.get('status', 'OK')})"
|
repowiki/shared/repo.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Repository name normalization and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_repo(raw: str) -> str:
|
|
7
|
+
"""Normalize a repo reference to ``owner/repo`` form.
|
|
8
|
+
|
|
9
|
+
Accepts ``owner/repo``, ``github.com/owner/repo``, and full GitHub URLs
|
|
10
|
+
(optionally with a trailing path such as ``/tree/main`` or a ``.git``
|
|
11
|
+
suffix). Raises ``ValueError`` for anything that is not a well-formed
|
|
12
|
+
repo reference.
|
|
13
|
+
"""
|
|
14
|
+
value = raw.strip()
|
|
15
|
+
if not value:
|
|
16
|
+
raise ValueError("repository reference is empty")
|
|
17
|
+
|
|
18
|
+
for scheme in ("https://", "http://"):
|
|
19
|
+
if value.lower().startswith(scheme):
|
|
20
|
+
value = value[len(scheme):]
|
|
21
|
+
break
|
|
22
|
+
|
|
23
|
+
lower = value.lower()
|
|
24
|
+
if lower.startswith("www.github.com/"):
|
|
25
|
+
value = value[len("www.github.com/"):]
|
|
26
|
+
elif lower.startswith("github.com/"):
|
|
27
|
+
value = value[len("github.com/"):]
|
|
28
|
+
|
|
29
|
+
segments = [seg for seg in value.split("/") if seg]
|
|
30
|
+
if len(segments) < 2:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"invalid repository reference: '{raw}'. "
|
|
33
|
+
"Expected owner/repo, github.com/owner/repo, or a GitHub URL."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
owner, repo = segments[0], segments[1]
|
|
37
|
+
if repo.endswith(".git"):
|
|
38
|
+
repo = repo[:-4]
|
|
39
|
+
|
|
40
|
+
if not owner or not repo:
|
|
41
|
+
raise ValueError(f"invalid repository reference: '{raw}'")
|
|
42
|
+
if any(" " in part or "\t" in part for part in (owner, repo)):
|
|
43
|
+
raise ValueError(f"invalid repository reference: '{raw}'")
|
|
44
|
+
|
|
45
|
+
return f"{owner}/{repo}"
|
repowiki/shared/save.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Persist ask Q&A records to Markdown files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def default_save_path(repo: str) -> str:
|
|
10
|
+
"""Return an auto-generated Markdown filename for a repo's Q&A session."""
|
|
11
|
+
slug = repo.replace("/", "-")
|
|
12
|
+
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
13
|
+
return f"repowiki-{slug}_{stamp}.md"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def append_entry(path: str, repo: str, question: str, answer: str) -> None:
|
|
17
|
+
"""Append a single Q&A record to *path*, creating parent directories."""
|
|
18
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
19
|
+
entry = (
|
|
20
|
+
f"# {repo} · {timestamp}\n\n"
|
|
21
|
+
f"> **Q:** {question}\n\n"
|
|
22
|
+
f"{answer.strip()}\n\n"
|
|
23
|
+
f"---\n\n"
|
|
24
|
+
)
|
|
25
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
27
|
+
fh.write(entry)
|