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,224 @@
1
+ """HTTP client for Code Wiki batchexecute API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .exceptions import (
11
+ AuthError,
12
+ CodeWikiError,
13
+ NetworkError,
14
+ NotFoundError,
15
+ RateLimitError,
16
+ ServerError,
17
+ )
18
+ from .models import ChatResponse, Repository, WikiPage, WikiSection
19
+ from .rpc.decoder import decode_response
20
+ from .rpc.encoder import build_url, encode_request
21
+ from .rpc.types import DEFAULT_HEADERS, RPCMethod
22
+
23
+
24
+ class CodeWikiClient:
25
+ """Client for Google Code Wiki batchexecute API."""
26
+
27
+ def __init__(self) -> None:
28
+ self._http = httpx.Client(
29
+ headers=DEFAULT_HEADERS,
30
+ timeout=30.0,
31
+ follow_redirects=True,
32
+ )
33
+
34
+ def close(self) -> None:
35
+ self._http.close()
36
+
37
+ def __enter__(self) -> CodeWikiClient:
38
+ return self
39
+
40
+ def __exit__(self, *exc) -> None:
41
+ self.close()
42
+
43
+ def _call(self, rpc_id: str, params: list) -> Any:
44
+ """Make a batchexecute RPC call."""
45
+ url = build_url(rpc_id)
46
+ body = encode_request(rpc_id, params)
47
+
48
+ try:
49
+ resp = self._http.post(
50
+ url,
51
+ content=body.encode("utf-8"),
52
+ )
53
+ except httpx.ConnectError as exc:
54
+ raise NetworkError(f"Connection failed: {exc}") from exc
55
+ except httpx.TimeoutException as exc:
56
+ raise NetworkError(f"Request timed out: {exc}") from exc
57
+
58
+ if resp.status_code in (401, 403):
59
+ raise AuthError("Authentication required", recoverable=False)
60
+ if resp.status_code == 404:
61
+ raise NotFoundError("Resource not found")
62
+ if resp.status_code == 429:
63
+ retry = resp.headers.get("Retry-After")
64
+ raise RateLimitError(
65
+ "Rate limited by Code Wiki",
66
+ retry_after=float(retry) if retry else None,
67
+ )
68
+ if resp.status_code >= 500:
69
+ raise ServerError(f"Server error: {resp.status_code}", status_code=resp.status_code)
70
+ if resp.status_code >= 400:
71
+ raise CodeWikiError(f"HTTP {resp.status_code}: {resp.text[:200]}")
72
+
73
+ return decode_response(resp.content, rpc_id)
74
+
75
+ # ── Repos ───────────────────────────────────────────────
76
+
77
+ def featured_repos(self) -> list[Repository]:
78
+ """Get featured repositories from the homepage."""
79
+ raw = self._call(RPCMethod.FEATURED_REPOS, [])
80
+ if not raw:
81
+ return []
82
+ # Response is [[repo1, repo2, ...]] — unwrap outer array
83
+ items = (
84
+ raw[0] if isinstance(raw[0], list) and raw[0] and isinstance(raw[0][0], list) else raw
85
+ )
86
+ repos = []
87
+ for item in items:
88
+ if not isinstance(item, list) or len(item) < 6:
89
+ continue
90
+ slug = item[0] or ""
91
+ meta = item[3] if len(item) > 3 and isinstance(item[3], list) else [None, ""]
92
+ info = item[5] if len(item) > 5 and isinstance(item[5], list) else []
93
+ repos.append(
94
+ Repository(
95
+ slug=slug,
96
+ github_url=meta[1] if len(meta) > 1 else "",
97
+ description=info[0] if len(info) > 0 else "",
98
+ avatar_url=info[1] if len(info) > 1 else "",
99
+ stars=info[2] if len(info) > 2 else 0,
100
+ )
101
+ )
102
+ return repos
103
+
104
+ def search_repos(self, query: str, limit: int = 25, offset: int = 0) -> list[Repository]:
105
+ """Search for repositories."""
106
+ raw = self._call(RPCMethod.SEARCH_REPOS, [query, limit, query, offset])
107
+ if not raw:
108
+ return []
109
+ # Response is [[repo1, repo2, ...]] — unwrap outer array
110
+ items = (
111
+ raw[0] if isinstance(raw[0], list) and raw[0] and isinstance(raw[0][0], list) else raw
112
+ )
113
+ repos = []
114
+ for item in items:
115
+ if not isinstance(item, list) or len(item) < 6:
116
+ continue
117
+ slug = item[0] or ""
118
+ meta = item[3] if isinstance(item[3], list) else [None, ""]
119
+ ts = item[4] if isinstance(item[4], list) else [None, None]
120
+ info = item[5] if isinstance(item[5], list) else []
121
+ updated = None
122
+ if ts and ts[0]:
123
+ try:
124
+ updated = datetime.fromtimestamp(ts[0])
125
+ except (ValueError, OSError, TypeError):
126
+ pass
127
+ repos.append(
128
+ Repository(
129
+ slug=slug,
130
+ github_url=meta[1] if len(meta) > 1 else "",
131
+ description=info[0] if len(info) > 0 else "",
132
+ avatar_url=info[1] if len(info) > 1 else "",
133
+ stars=info[2] if len(info) > 2 else 0,
134
+ updated_at=updated,
135
+ )
136
+ )
137
+ return repos
138
+
139
+ # ── Wiki ────────────────────────────────────────────────
140
+
141
+ def get_wiki(self, repo_slug: str) -> WikiPage:
142
+ """Get the full wiki page for a repository."""
143
+ github_url = f"https://github.com/{repo_slug}"
144
+ raw = self._call(RPCMethod.WIKI_PAGE, [github_url])
145
+ if not raw:
146
+ raise NotFoundError(f"Wiki not found for {repo_slug}")
147
+
148
+ top = raw[0] if isinstance(raw, list) and raw else []
149
+ if not isinstance(top, list) or len(top) < 2:
150
+ raise NotFoundError(f"Wiki not found for {repo_slug}")
151
+
152
+ repo_info = top[0] if isinstance(top[0], list) else []
153
+ sections_raw = top[1] if isinstance(top[1], list) else []
154
+ ts = top[4] if len(top) > 4 and isinstance(top[4], list) else [None, None]
155
+
156
+ commit_hash = repo_info[1] if len(repo_info) > 1 else ""
157
+ updated = None
158
+ if ts and ts[0]:
159
+ try:
160
+ updated = datetime.fromtimestamp(ts[0])
161
+ except (ValueError, OSError, TypeError):
162
+ pass
163
+
164
+ meta = raw[1] if len(raw) > 1 and isinstance(raw[1], list) else []
165
+ has_wiki = meta[1] if len(meta) > 1 else False
166
+
167
+ sections = []
168
+ for sec in sections_raw:
169
+ if not isinstance(sec, list) or len(sec) < 2:
170
+ continue
171
+ code_refs = []
172
+ if len(sec) > 3 and isinstance(sec[3], list):
173
+ for ref in sec[3]:
174
+ if isinstance(ref, list) and ref:
175
+ code_refs.append(ref[0])
176
+ sections.append(
177
+ WikiSection(
178
+ title=sec[0] or "",
179
+ level=sec[1] if len(sec) > 1 else 1,
180
+ description=sec[2] if len(sec) > 2 else "",
181
+ code_refs=code_refs,
182
+ content=sec[4] if len(sec) > 4 else "",
183
+ )
184
+ )
185
+
186
+ repo = Repository(
187
+ slug=repo_slug,
188
+ github_url=github_url,
189
+ commit_hash=commit_hash,
190
+ updated_at=updated,
191
+ )
192
+
193
+ return WikiPage(repo=repo, sections=sections, has_wiki=has_wiki)
194
+
195
+ # ── Chat ────────────────────────────────────────────────
196
+
197
+ def chat(
198
+ self,
199
+ question: str,
200
+ repo_slug: str,
201
+ history: list[tuple[str, str]] | None = None,
202
+ ) -> ChatResponse:
203
+ """Ask Gemini a question about a repository.
204
+
205
+ Args:
206
+ question: The user's question.
207
+ repo_slug: Repository slug (e.g. "excalidraw/excalidraw").
208
+ history: Optional conversation history as [(text, role), ...].
209
+ """
210
+ messages = []
211
+ if history:
212
+ for text, role in history:
213
+ messages.append([text, role])
214
+ messages.append([question, "user"])
215
+
216
+ github_url = f"https://github.com/{repo_slug}"
217
+ params = [messages, [None, github_url]]
218
+
219
+ raw = self._call(RPCMethod.CHAT, params)
220
+ if not raw:
221
+ raise NotFoundError(f"No response for {repo_slug}")
222
+
223
+ answer = raw[0] if isinstance(raw, list) and raw else str(raw)
224
+ return ChatResponse(answer=answer, repo_slug=repo_slug)
@@ -0,0 +1,74 @@
1
+ """Domain-specific exception hierarchy for cli-web-codewiki."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CodeWikiError(Exception):
7
+ """Base exception for all Code Wiki errors."""
8
+
9
+ def to_dict(self) -> dict:
10
+ return {"error": True, "code": error_code_for(self), "message": str(self)}
11
+
12
+
13
+ class AuthError(CodeWikiError):
14
+ """Authentication failure (unlikely for public API, but kept for completeness)."""
15
+
16
+ def __init__(self, message: str, recoverable: bool = True):
17
+ self.recoverable = recoverable
18
+ super().__init__(message)
19
+
20
+
21
+ class RateLimitError(CodeWikiError):
22
+ """Rate limited by the server."""
23
+
24
+ def __init__(self, message: str, retry_after: float | None = None):
25
+ self.retry_after = retry_after
26
+ super().__init__(message)
27
+
28
+ def to_dict(self) -> dict:
29
+ d = super().to_dict()
30
+ if self.retry_after is not None:
31
+ d["retry_after"] = self.retry_after
32
+ return d
33
+
34
+
35
+ class NetworkError(CodeWikiError):
36
+ """Network connectivity failure."""
37
+
38
+
39
+ class ServerError(CodeWikiError):
40
+ """Server returned 5xx."""
41
+
42
+ def __init__(self, message: str, status_code: int = 500):
43
+ self.status_code = status_code
44
+ super().__init__(message)
45
+
46
+
47
+ class NotFoundError(CodeWikiError):
48
+ """Requested resource not found (404 or empty RPC result)."""
49
+
50
+
51
+ class RPCError(CodeWikiError):
52
+ """Batchexecute RPC-level error."""
53
+
54
+ def __init__(self, message: str, code: int | None = None):
55
+ self.code = code
56
+ super().__init__(message)
57
+
58
+
59
+ EXCEPTION_CODE_MAP: dict[type, str] = {
60
+ AuthError: "AUTH_EXPIRED",
61
+ RateLimitError: "RATE_LIMITED",
62
+ NotFoundError: "NOT_FOUND",
63
+ ServerError: "SERVER_ERROR",
64
+ NetworkError: "NETWORK_ERROR",
65
+ RPCError: "RPC_ERROR",
66
+ }
67
+
68
+
69
+ def error_code_for(exc: Exception) -> str:
70
+ """Return standardized error code string for JSON output."""
71
+ for cls, code in EXCEPTION_CODE_MAP.items():
72
+ if isinstance(exc, cls):
73
+ return code
74
+ return "INTERNAL_ERROR"
@@ -0,0 +1,91 @@
1
+ """Data models for Code Wiki CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+
9
+ @dataclass
10
+ class Repository:
11
+ """A GitHub repository on Code Wiki."""
12
+
13
+ slug: str
14
+ github_url: str
15
+ description: str = ""
16
+ avatar_url: str = ""
17
+ stars: int = 0
18
+ commit_hash: str = ""
19
+ updated_at: datetime | None = None
20
+
21
+ @property
22
+ def org(self) -> str:
23
+ parts = self.slug.split("/")
24
+ return parts[0] if len(parts) >= 2 else ""
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ parts = self.slug.split("/")
29
+ return parts[1] if len(parts) >= 2 else self.slug
30
+
31
+ def to_dict(self) -> dict:
32
+ return {
33
+ "slug": self.slug,
34
+ "github_url": self.github_url,
35
+ "description": self.description,
36
+ "avatar_url": self.avatar_url,
37
+ "stars": self.stars,
38
+ "commit_hash": self.commit_hash,
39
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class WikiSection:
45
+ """A section in a Code Wiki page."""
46
+
47
+ title: str
48
+ level: int
49
+ description: str = ""
50
+ code_refs: list[str] = field(default_factory=list)
51
+ content: str = ""
52
+
53
+ def to_dict(self) -> dict:
54
+ return {
55
+ "title": self.title,
56
+ "level": self.level,
57
+ "description": self.description,
58
+ "code_refs": self.code_refs,
59
+ "content": self.content,
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class WikiPage:
65
+ """A full Code Wiki page for a repository."""
66
+
67
+ repo: Repository
68
+ sections: list[WikiSection] = field(default_factory=list)
69
+ has_wiki: bool = False
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "repo": self.repo.to_dict(),
74
+ "sections": [s.to_dict() for s in self.sections],
75
+ "section_count": len(self.sections),
76
+ "has_wiki": self.has_wiki,
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class ChatResponse:
82
+ """A Gemini chat response."""
83
+
84
+ answer: str
85
+ repo_slug: str
86
+
87
+ def to_dict(self) -> dict:
88
+ return {
89
+ "answer": self.answer,
90
+ "repo": self.repo_slug,
91
+ }
File without changes
@@ -0,0 +1,86 @@
1
+ """Decode batchexecute RPC responses from Code Wiki."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ..exceptions import RPCError
9
+
10
+
11
+ def _strip_prefix(raw: bytes | str) -> str:
12
+ """Remove the )]}' anti-XSSI prefix."""
13
+ text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
14
+ if text.startswith(")]}'"):
15
+ text = text[4:]
16
+ return text.lstrip("\n")
17
+
18
+
19
+ def _parse_chunks(text: str) -> list[list]:
20
+ """Extract JSON array chunks from the batchexecute response.
21
+
22
+ The response interleaves numeric length hints with JSON arrays.
23
+ We use raw_decode to reliably find array boundaries.
24
+ """
25
+ decoder = json.JSONDecoder()
26
+ chunks: list[list] = []
27
+ pos = 0
28
+ length = len(text)
29
+
30
+ while pos < length:
31
+ # Skip whitespace and numeric length lines
32
+ while pos < length and text[pos] in " \t\r\n":
33
+ pos += 1
34
+ if pos >= length:
35
+ break
36
+
37
+ # Skip numeric length hints
38
+ if text[pos].isdigit():
39
+ while pos < length and text[pos] not in "\n":
40
+ pos += 1
41
+ continue
42
+
43
+ # Try to decode a JSON array
44
+ if text[pos] == "[":
45
+ try:
46
+ obj, end = decoder.raw_decode(text, pos)
47
+ chunks.append(obj)
48
+ pos = end
49
+ except json.JSONDecodeError:
50
+ pos += 1
51
+ else:
52
+ pos += 1
53
+
54
+ return chunks
55
+
56
+
57
+ def decode_response(raw: bytes | str, rpc_id: str) -> Any:
58
+ """Decode a batchexecute response and extract the result for rpc_id.
59
+
60
+ Returns the parsed inner JSON, or None if the result is null.
61
+ Raises RPCError or AuthError on protocol-level errors.
62
+ """
63
+ text = _strip_prefix(raw)
64
+ chunks = _parse_chunks(text)
65
+
66
+ for chunk in chunks:
67
+ if not isinstance(chunk, list) or not chunk:
68
+ continue
69
+ for entry in chunk:
70
+ if not isinstance(entry, list) or len(entry) < 2:
71
+ continue
72
+
73
+ # Check for error entries
74
+ if entry[0] == "er" and len(entry) >= 2:
75
+ err_data = entry[1] if len(entry) > 1 else None
76
+ raise RPCError(f"RPC error: {err_data}", code=err_data)
77
+
78
+ # Look for the result matching our rpc_id
79
+ if entry[0] == "wrb.fr" and entry[1] == rpc_id:
80
+ raw_result = entry[2] if len(entry) > 2 else None
81
+ if raw_result is None:
82
+ return None
83
+ # Double-parse: result is a JSON string inside JSON
84
+ return json.loads(raw_result)
85
+
86
+ return None
@@ -0,0 +1,32 @@
1
+ """Encode batchexecute RPC requests for Code Wiki."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from urllib.parse import urlencode
7
+
8
+ from .types import BATCHEXECUTE_URL
9
+
10
+
11
+ def build_url(rpc_id: str) -> str:
12
+ """Build the full batchexecute URL with query parameters."""
13
+ params = {
14
+ "rpcids": rpc_id,
15
+ "source-path": "/",
16
+ "hl": "en",
17
+ "rt": "c",
18
+ }
19
+ return f"{BATCHEXECUTE_URL}?{urlencode(params)}"
20
+
21
+
22
+ def encode_request(rpc_id: str, params: list) -> str:
23
+ """Encode an RPC call into a form-encoded f.req body.
24
+
25
+ The batchexecute wire format wraps each call as:
26
+ [[[rpc_id, json_string_params, null, "generic"]]]
27
+
28
+ No CSRF token needed — Code Wiki is fully public.
29
+ """
30
+ inner = [rpc_id, json.dumps(params), None, "generic"]
31
+ freq = json.dumps([[inner]])
32
+ return urlencode({"f.req": freq})
@@ -0,0 +1,27 @@
1
+ """RPC method IDs and constants for Code Wiki batchexecute API."""
2
+
3
+
4
+ class RPCMethod:
5
+ """Batchexecute RPC method identifiers."""
6
+
7
+ FEATURED_REPOS = "nm8Fsb"
8
+ WIKI_PAGE = "VSX6ub"
9
+ SEARCH_REPOS = "vyWDAf"
10
+ CHAT = "EgIxfe"
11
+
12
+
13
+ BATCHEXECUTE_URL = "https://codewiki.google/_/BoqAngularSdlcAgentsUi/data/batchexecute"
14
+
15
+ BASE_URL = "https://codewiki.google"
16
+
17
+ DEFAULT_HEADERS = {
18
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
19
+ "x-same-domain": "1",
20
+ "Origin": BASE_URL,
21
+ "Referer": f"{BASE_URL}/",
22
+ "User-Agent": (
23
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
24
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
25
+ "Chrome/146.0.0.0 Safari/537.36"
26
+ ),
27
+ }
File without changes