cli-web-codewiki 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,3 @@
1
+ """cli-web-codewiki — CLI for Google Code Wiki."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m cli_web.codewiki"""
2
+
3
+ from .codewiki_cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,142 @@
1
+ """cli-web-codewiki — CLI for Google Code Wiki.
2
+
3
+ Browse Gemini-generated documentation for open source repositories,
4
+ search for repos, explore wiki sections, and chat with Gemini.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shlex
10
+ import sys
11
+
12
+ # Windows UTF-8 fix — must be before any output
13
+ if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
14
+ try:
15
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
16
+ except AttributeError:
17
+ pass
18
+ if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"):
19
+ try:
20
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
21
+ except AttributeError:
22
+ pass
23
+
24
+ import click
25
+
26
+ from .commands.chat import chat_group
27
+ from .commands.repos import repos
28
+ from .commands.wiki import wiki_group
29
+ from .utils.repl_skin import ReplSkin
30
+
31
+ _skin = ReplSkin("codewiki", version="0.1.0")
32
+
33
+
34
+ @click.group(invoke_without_command=True)
35
+ @click.version_option("0.1.0", prog_name="cli-web-codewiki")
36
+ @click.option(
37
+ "--json",
38
+ "as_json",
39
+ is_flag=True,
40
+ default=False,
41
+ hidden=True,
42
+ help="Output as JSON (pass to subcommands).",
43
+ )
44
+ @click.pass_context
45
+ def cli(ctx: click.Context, as_json: bool) -> None:
46
+ """cli-web-codewiki — Browse AI-generated code documentation."""
47
+ ctx.ensure_object(dict)
48
+ ctx.obj["json"] = as_json
49
+ if ctx.invoked_subcommand is None:
50
+ _run_repl(ctx)
51
+
52
+
53
+ cli.add_command(repos)
54
+ cli.add_command(wiki_group, "wiki")
55
+ cli.add_command(chat_group, "chat")
56
+
57
+
58
+ def _print_repl_help() -> None:
59
+ """Print REPL help matching actual command surface."""
60
+ _skin.info("Available commands:")
61
+ print()
62
+ print(" repos featured List featured repositories")
63
+ print(" repos search <query> Search repos by name")
64
+ print(" --limit N Max results (default 25)")
65
+ print(" --offset N Pagination offset")
66
+ print()
67
+ print(" wiki get <org/repo> Get full wiki content")
68
+ print(" wiki sections <org/repo> List wiki sections (TOC)")
69
+ print(" wiki section <org/repo> <title> Get a specific section")
70
+ print(" wiki download <org/repo> Download wiki as .md files")
71
+ print(" -o <dir> Output directory")
72
+ print()
73
+ print(" chat ask <question> --repo <org/repo>")
74
+ print(" Ask Gemini about a repo")
75
+ print()
76
+ print(" All commands support --json for structured output.")
77
+ print()
78
+ print(" help / ? Show this help")
79
+ print(" quit / exit / q Exit REPL")
80
+ print()
81
+
82
+
83
+ def _run_repl(ctx: click.Context) -> None:
84
+ """Enter interactive REPL mode."""
85
+ _skin.print_banner()
86
+ _skin.info("Type 'help' for available commands, 'quit' to exit.")
87
+ print()
88
+
89
+ pt_session = _skin.create_prompt_session()
90
+
91
+ while True:
92
+ try:
93
+ line = _skin.get_input(pt_session)
94
+ except (EOFError, KeyboardInterrupt):
95
+ _skin.print_goodbye()
96
+ sys.exit(0)
97
+
98
+ if not line:
99
+ continue
100
+ if line in ("quit", "exit", "q"):
101
+ _skin.print_goodbye()
102
+ sys.exit(0)
103
+ if line in ("help", "?"):
104
+ _print_repl_help()
105
+ continue
106
+
107
+ try:
108
+ args = shlex.split(line)
109
+ except ValueError as exc:
110
+ _skin.error(f"Parse error: {exc}")
111
+ continue
112
+
113
+ # Propagate --json if set at top level
114
+ repl_args = ["--json"] + args if ctx.obj.get("json") else args
115
+
116
+ try:
117
+ cli.main(args=repl_args, standalone_mode=False, prog_name="cli-web-codewiki")
118
+ except SystemExit:
119
+ pass
120
+ except click.UsageError as exc:
121
+ _skin.error(str(exc))
122
+ except Exception as exc:
123
+ _skin.error(str(exc))
124
+
125
+
126
+ def main() -> None:
127
+ """Entry point for console_scripts."""
128
+ cli()
129
+
130
+
131
+ # MCP server mode — exposes every command as an MCP tool over stdio.
132
+ # Canonical adapter: cli-web-core/cli_web_core/mcp_server.py (vendored copy).
133
+ from cli_web.codewiki import __version__ as _pkg_version # noqa: E402
134
+ from cli_web.codewiki.utils.doctor import register_doctor_command # noqa: E402
135
+ from cli_web.codewiki.utils.mcp_server import register_mcp_command # noqa: E402
136
+
137
+ register_mcp_command(cli, app_name="codewiki", version=_pkg_version)
138
+ register_doctor_command(cli, app_name="codewiki", pkg="codewiki")
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
File without changes
@@ -0,0 +1,46 @@
1
+ """Chat command — ask Gemini about a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import CodeWikiClient
8
+ from ..utils.helpers import handle_errors
9
+ from ..utils.output import print_json
10
+
11
+ try:
12
+ from rich.console import Console
13
+ from rich.markdown import Markdown
14
+
15
+ _RICH_AVAILABLE = True
16
+ except ImportError:
17
+ _RICH_AVAILABLE = False
18
+
19
+
20
+ @click.group("chat")
21
+ def chat_group():
22
+ """Ask Gemini questions about a repository."""
23
+
24
+
25
+ @chat_group.command("ask")
26
+ @click.argument("question")
27
+ @click.option("--repo", required=True, help='Repository slug, e.g. "excalidraw/excalidraw".')
28
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
29
+ def ask(question: str, repo: str, as_json: bool) -> None:
30
+ """Ask Gemini about a repository."""
31
+ with handle_errors(json_mode=as_json):
32
+ client = CodeWikiClient()
33
+ try:
34
+ response = client.chat(question=question, repo_slug=repo)
35
+ finally:
36
+ client.close()
37
+
38
+ if as_json:
39
+ print_json({"success": True, "data": response.to_dict()})
40
+ else:
41
+ header = f"Gemini — {repo}:"
42
+ click.echo(f"\n{header}\n")
43
+ if _RICH_AVAILABLE:
44
+ Console().print(Markdown(response.answer))
45
+ else:
46
+ click.echo(response.answer)
@@ -0,0 +1,90 @@
1
+ """Repository commands: featured, search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ..core.client import CodeWikiClient
8
+ from ..utils.helpers import handle_errors
9
+ from ..utils.output import print_json
10
+
11
+ _SEP = "\u2500"
12
+ _SLUG_W = 34
13
+ _STARS_W = 8
14
+ _DESC_W = 60
15
+
16
+
17
+ def _truncate(text: str, max_len: int) -> str:
18
+ if len(text) <= max_len:
19
+ return text
20
+ return text[: max_len - 3] + "..."
21
+
22
+
23
+ def _format_stars(stars: int) -> str:
24
+ if stars >= 1_000:
25
+ return f"{stars / 1_000:.1f}k"
26
+ return str(stars)
27
+
28
+
29
+ def _print_repo_table(repos) -> None:
30
+ header_slug = "Slug"
31
+ header_stars = "Stars"
32
+ header_desc = "Description"
33
+
34
+ click.echo(f"{header_slug:<{_SLUG_W}} {header_stars:<{_STARS_W}} {header_desc}")
35
+ click.echo(_SEP * (_SLUG_W + 2 + _STARS_W + 1 + _DESC_W))
36
+
37
+ for repo in repos:
38
+ slug = _truncate(repo.slug, _SLUG_W)
39
+ stars = _format_stars(repo.stars)
40
+ desc = _truncate(repo.description, _DESC_W)
41
+ click.echo(f"{slug:<{_SLUG_W}} {stars:<{_STARS_W}} {desc}")
42
+
43
+
44
+ @click.group("repos")
45
+ def repos():
46
+ """List and search repositories on Code Wiki."""
47
+ pass
48
+
49
+
50
+ @repos.command("featured")
51
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
52
+ def featured(as_json: bool) -> None:
53
+ """List featured repositories."""
54
+ with handle_errors(json_mode=as_json):
55
+ client = CodeWikiClient()
56
+ try:
57
+ result = client.featured_repos()
58
+ finally:
59
+ client.close()
60
+
61
+ if as_json:
62
+ print_json({"success": True, "data": [repo.to_dict() for repo in result]})
63
+ else:
64
+ if not result:
65
+ click.echo("No featured repositories found.")
66
+ else:
67
+ _print_repo_table(result)
68
+
69
+
70
+ @repos.command("search")
71
+ @click.argument("query")
72
+ @click.option("--limit", default=25, show_default=True, help="Maximum number of results.")
73
+ @click.option("--offset", default=0, show_default=True, help="Result offset for pagination.")
74
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
75
+ def search(query: str, limit: int, offset: int, as_json: bool) -> None:
76
+ """Search repositories by QUERY."""
77
+ with handle_errors(json_mode=as_json):
78
+ client = CodeWikiClient()
79
+ try:
80
+ result = client.search_repos(query, limit=limit, offset=offset)
81
+ finally:
82
+ client.close()
83
+
84
+ if as_json:
85
+ print_json({"success": True, "data": [repo.to_dict() for repo in result]})
86
+ else:
87
+ if not result:
88
+ click.echo(f"No repositories found for '{query}'.")
89
+ else:
90
+ _print_repo_table(result)
@@ -0,0 +1,267 @@
1
+ """Wiki command group — get wiki pages and sections for a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from ..core.client import CodeWikiClient
11
+ from ..core.exceptions import NotFoundError
12
+ from ..utils.helpers import handle_errors
13
+ from ..utils.output import print_json
14
+
15
+
16
+ # Indent prefix per heading level (level 1 = no indent, level 2 = 2 spaces, etc.)
17
+ def _indent(level: int) -> str:
18
+ if level <= 1:
19
+ return ""
20
+ return " " * (level - 1)
21
+
22
+
23
+ def _heading(level: int, title: str) -> str:
24
+ """Return a markdown-style heading for a given level."""
25
+ hashes = "#" * max(1, level + 1)
26
+ return f"{hashes} {title}"
27
+
28
+
29
+ def _truncate(text: str, width: int = 50) -> str:
30
+ if len(text) <= width:
31
+ return text
32
+ return text[: width - 1] + "…"
33
+
34
+
35
+ @click.group("wiki")
36
+ def wiki_group():
37
+ """Get wiki pages and sections for a GitHub repository."""
38
+
39
+
40
+ @wiki_group.command("get")
41
+ @click.argument("repo")
42
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
43
+ def wiki_get(repo: str, as_json: bool) -> None:
44
+ """Get full wiki content for a repo (e.g. excalidraw/excalidraw)."""
45
+ with handle_errors(json_mode=as_json):
46
+ client = CodeWikiClient()
47
+ try:
48
+ wiki = client.get_wiki(repo)
49
+ finally:
50
+ client.close()
51
+
52
+ if as_json:
53
+ print_json({"success": True, "data": wiki.to_dict()})
54
+ else:
55
+ commit = wiki.repo.commit_hash or "unknown"
56
+ click.echo(f"\n{wiki.repo.slug} @{commit}\n")
57
+ for section in wiki.sections:
58
+ heading = _heading(section.level, section.title)
59
+ click.echo(heading)
60
+ if section.content:
61
+ click.echo(section.content)
62
+ click.echo()
63
+
64
+
65
+ @wiki_group.command("sections")
66
+ @click.argument("repo")
67
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
68
+ def wiki_sections(repo: str, as_json: bool) -> None:
69
+ """List wiki sections / table of contents for a repo."""
70
+ with handle_errors(json_mode=as_json):
71
+ client = CodeWikiClient()
72
+ try:
73
+ wiki = client.get_wiki(repo)
74
+ finally:
75
+ client.close()
76
+
77
+ if as_json:
78
+ print_json({"success": True, "data": [s.to_dict() for s in wiki.sections]})
79
+ else:
80
+ click.echo(f"\n{wiki.repo.slug} — {len(wiki.sections)} sections\n")
81
+
82
+ col_title = 45
83
+ col_desc = 52
84
+
85
+ header = f" {'Section':<{col_title}} {'Description':<{col_desc}}"
86
+ click.echo(header)
87
+ click.echo(" " + "-" * (col_title + col_desc + 2))
88
+
89
+ for section in wiki.sections:
90
+ indent = _indent(section.level)
91
+ display_title = indent + section.title
92
+ display_desc = _truncate(section.description, 50) if section.description else ""
93
+ click.echo(f" {display_title:<{col_title}} {display_desc:<{col_desc}}")
94
+
95
+ click.echo()
96
+
97
+
98
+ @wiki_group.command("section")
99
+ @click.argument("repo")
100
+ @click.argument("title")
101
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
102
+ def wiki_section(repo: str, title: str, as_json: bool) -> None:
103
+ """Get content of a specific section by title (case-insensitive partial match)."""
104
+ with handle_errors(json_mode=as_json):
105
+ client = CodeWikiClient()
106
+ try:
107
+ wiki = client.get_wiki(repo)
108
+ finally:
109
+ client.close()
110
+
111
+ query = title.lower()
112
+ matches = [s for s in wiki.sections if query in s.title.lower()]
113
+
114
+ if not matches:
115
+ raise NotFoundError(f"No section matching '{title}' in {repo}")
116
+
117
+ if len(matches) > 1:
118
+ candidates = [s.title for s in matches]
119
+ if as_json:
120
+ print_json(
121
+ {
122
+ "error": True,
123
+ "code": "AMBIGUOUS_MATCH",
124
+ "message": f"Multiple sections match '{title}'",
125
+ "candidates": candidates,
126
+ }
127
+ )
128
+ else:
129
+ click.echo(
130
+ f"Multiple sections match '{title}'. Please be more specific:\n", err=True
131
+ )
132
+ for s in matches:
133
+ click.echo(f" {_indent(s.level)}{s.title}", err=True)
134
+ raise SystemExit(1)
135
+
136
+ section = matches[0]
137
+
138
+ if as_json:
139
+ print_json({"success": True, "data": section.to_dict()})
140
+ else:
141
+ heading = _heading(section.level, section.title)
142
+ click.echo(f"\n{heading}\n")
143
+ if section.description:
144
+ click.echo(section.description)
145
+ click.echo()
146
+ if section.content:
147
+ click.echo(section.content)
148
+ click.echo()
149
+
150
+
151
+ def _slugify(text: str) -> str:
152
+ """Convert a section title to a safe filename slug."""
153
+ s = text.lower().strip()
154
+ s = re.sub(r"[^\w\s-]", "", s)
155
+ s = re.sub(r"[\s_]+", "-", s)
156
+ s = re.sub(r"-+", "-", s).strip("-")
157
+ return s[:80] or "untitled"
158
+
159
+
160
+ @wiki_group.command("download")
161
+ @click.argument("repo")
162
+ @click.option(
163
+ "--output", "-o", default=None, help="Output directory (default: <org>-<repo>-wiki/)."
164
+ )
165
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
166
+ def wiki_download(repo: str, output: str | None, as_json: bool) -> None:
167
+ """Download full wiki as organized markdown files into a folder."""
168
+ with handle_errors(json_mode=as_json):
169
+ client = CodeWikiClient()
170
+ try:
171
+ wiki = client.get_wiki(repo)
172
+ finally:
173
+ client.close()
174
+
175
+ if not wiki.sections:
176
+ raise NotFoundError(f"No wiki content found for {repo}")
177
+
178
+ # Determine output directory
179
+ if output:
180
+ out_dir = Path(output)
181
+ else:
182
+ safe_name = repo.replace("/", "-")
183
+ out_dir = Path(f"{safe_name}-wiki")
184
+
185
+ out_dir.mkdir(parents=True, exist_ok=True)
186
+
187
+ # Determine chapter split level: use level 1 if there are multiple,
188
+ # otherwise split at level 2 (common for wikis with a single overview root)
189
+ level_1_count = sum(1 for s in wiki.sections if s.level == 1)
190
+ split_level = 1 if level_1_count > 1 else 2
191
+
192
+ # Group sections into chapters at the split level
193
+ chapters: list[tuple[int, str, list]] = [] # (index, title, sections)
194
+ current_chapter_title = None
195
+ current_sections: list = []
196
+ chapter_idx = 0
197
+
198
+ for sec in wiki.sections:
199
+ if sec.level <= split_level:
200
+ if current_chapter_title is not None:
201
+ chapters.append((chapter_idx, current_chapter_title, current_sections))
202
+ chapter_idx += 1
203
+ current_chapter_title = sec.title
204
+ current_sections = [sec]
205
+ else:
206
+ current_sections.append(sec)
207
+
208
+ if current_chapter_title is not None:
209
+ chapters.append((chapter_idx, current_chapter_title, current_sections))
210
+
211
+ # Write index.md
212
+ index_lines = [
213
+ f"# {wiki.repo.slug}",
214
+ "",
215
+ f"Commit: `{wiki.repo.commit_hash or 'unknown'}`",
216
+ f"Sections: {len(wiki.sections)}",
217
+ "",
218
+ "## Table of Contents",
219
+ "",
220
+ ]
221
+ files_written = []
222
+
223
+ for idx, chapter_title, sections in chapters:
224
+ filename = f"{idx:02d}-{_slugify(chapter_title)}.md"
225
+ index_lines.append(f"{idx + 1}. [{chapter_title}]({filename})")
226
+ files_written.append(filename)
227
+
228
+ # Write chapter file
229
+ chapter_path = out_dir / filename
230
+ lines = []
231
+ for sec in sections:
232
+ heading = "#" * max(1, sec.level)
233
+ lines.append(f"{heading} {sec.title}")
234
+ lines.append("")
235
+ if sec.description and sec.description != sec.content:
236
+ lines.append(f"*{sec.description}*")
237
+ lines.append("")
238
+ if sec.content:
239
+ lines.append(sec.content)
240
+ lines.append("")
241
+
242
+ chapter_path.write_text("\n".join(lines), encoding="utf-8")
243
+
244
+ index_path = out_dir / "index.md"
245
+ index_path.write_text("\n".join(index_lines) + "\n", encoding="utf-8")
246
+ files_written.insert(0, "index.md")
247
+
248
+ if as_json:
249
+ print_json(
250
+ {
251
+ "success": True,
252
+ "data": {
253
+ "repo": repo,
254
+ "output_dir": str(out_dir),
255
+ "files": files_written,
256
+ "chapters": len(chapters),
257
+ "total_sections": len(wiki.sections),
258
+ },
259
+ }
260
+ )
261
+ else:
262
+ click.echo(f"\nDownloaded wiki for {wiki.repo.slug} to {out_dir}/")
263
+ click.echo(f" {len(chapters)} chapters, {len(wiki.sections)} sections\n")
264
+ click.echo(" Files:")
265
+ for f in files_written:
266
+ click.echo(f" {f}")
267
+ click.echo()
File without changes