cli-web-codewiki 0.1.0__tar.gz
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.
- cli_web_codewiki-0.1.0/PKG-INFO +14 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/__init__.py +3 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/__main__.py +6 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/codewiki_cli.py +142 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/commands/__init__.py +0 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/commands/chat.py +46 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/commands/repos.py +90 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/commands/wiki.py +267 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/__init__.py +0 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/client.py +224 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/exceptions.py +74 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/models.py +91 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/rpc/__init__.py +0 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/rpc/decoder.py +86 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/rpc/encoder.py +32 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/core/rpc/types.py +27 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/tests/__init__.py +0 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/tests/test_core.py +725 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/tests/test_e2e.py +411 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/__init__.py +0 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/config.py +14 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/doctor.py +188 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/helpers.py +67 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/mcp_server.py +290 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/output.py +11 -0
- cli_web_codewiki-0.1.0/cli_web/codewiki/utils/repl_skin.py +486 -0
- cli_web_codewiki-0.1.0/cli_web_codewiki.egg-info/PKG-INFO +14 -0
- cli_web_codewiki-0.1.0/cli_web_codewiki.egg-info/SOURCES.txt +32 -0
- cli_web_codewiki-0.1.0/cli_web_codewiki.egg-info/dependency_links.txt +1 -0
- cli_web_codewiki-0.1.0/cli_web_codewiki.egg-info/entry_points.txt +2 -0
- cli_web_codewiki-0.1.0/cli_web_codewiki.egg-info/requires.txt +6 -0
- cli_web_codewiki-0.1.0/cli_web_codewiki.egg-info/top_level.txt +1 -0
- cli_web_codewiki-0.1.0/setup.cfg +4 -0
- cli_web_codewiki-0.1.0/setup.py +24 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-web-codewiki
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Google Code Wiki — AI-generated documentation for open source repos
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0
|
|
7
|
+
Requires-Dist: httpx>=0.24
|
|
8
|
+
Requires-Dist: rich>=13.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Dynamic: provides-extra
|
|
12
|
+
Dynamic: requires-dist
|
|
13
|
+
Dynamic: requires-python
|
|
14
|
+
Dynamic: summary
|
|
@@ -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
|