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.
- cli_web/codewiki/__init__.py +3 -0
- cli_web/codewiki/__main__.py +6 -0
- cli_web/codewiki/codewiki_cli.py +142 -0
- cli_web/codewiki/commands/__init__.py +0 -0
- cli_web/codewiki/commands/chat.py +46 -0
- cli_web/codewiki/commands/repos.py +90 -0
- cli_web/codewiki/commands/wiki.py +267 -0
- cli_web/codewiki/core/__init__.py +0 -0
- cli_web/codewiki/core/client.py +224 -0
- cli_web/codewiki/core/exceptions.py +74 -0
- cli_web/codewiki/core/models.py +91 -0
- cli_web/codewiki/core/rpc/__init__.py +0 -0
- cli_web/codewiki/core/rpc/decoder.py +86 -0
- cli_web/codewiki/core/rpc/encoder.py +32 -0
- cli_web/codewiki/core/rpc/types.py +27 -0
- cli_web/codewiki/tests/__init__.py +0 -0
- cli_web/codewiki/tests/test_core.py +725 -0
- cli_web/codewiki/tests/test_e2e.py +411 -0
- cli_web/codewiki/utils/__init__.py +0 -0
- cli_web/codewiki/utils/config.py +14 -0
- cli_web/codewiki/utils/doctor.py +188 -0
- cli_web/codewiki/utils/helpers.py +67 -0
- cli_web/codewiki/utils/mcp_server.py +290 -0
- cli_web/codewiki/utils/output.py +11 -0
- cli_web/codewiki/utils/repl_skin.py +486 -0
- cli_web_codewiki-0.1.0.dist-info/METADATA +14 -0
- cli_web_codewiki-0.1.0.dist-info/RECORD +30 -0
- cli_web_codewiki-0.1.0.dist-info/WHEEL +5 -0
- cli_web_codewiki-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_codewiki-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""E2E and subprocess tests for cli-web-codewiki.
|
|
2
|
+
|
|
3
|
+
All live tests hit the real codewiki.google API.
|
|
4
|
+
Mark: @pytest.mark.e2e for live network tests.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
pytest cli_web/codewiki/tests/test_e2e.py -v -s -m e2e
|
|
8
|
+
CLI_WEB_FORCE_INSTALLED=1 pytest cli_web/codewiki/tests/test_e2e.py -v -s
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
from cli_web.codewiki.core.client import CodeWikiClient
|
|
21
|
+
from cli_web.codewiki.core.models import ChatResponse, Repository, WikiPage, WikiSection
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_cli(name: str = "cli-web-codewiki") -> str:
|
|
29
|
+
"""Return the CLI entry-point path, falling back to module mode via sys.executable."""
|
|
30
|
+
if os.environ.get("CLI_WEB_FORCE_INSTALLED"):
|
|
31
|
+
path = shutil.which(name)
|
|
32
|
+
if path:
|
|
33
|
+
return path
|
|
34
|
+
raise FileNotFoundError(f"{name} not found in PATH")
|
|
35
|
+
path = shutil.which(name)
|
|
36
|
+
if path:
|
|
37
|
+
return path
|
|
38
|
+
return sys.executable
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# TestLiveRepos
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.e2e
|
|
47
|
+
class TestLiveRepos:
|
|
48
|
+
"""Live tests for repository listing and search."""
|
|
49
|
+
|
|
50
|
+
def test_featured_repos_returns_data(self):
|
|
51
|
+
"""Live: featured repos has at least 1 result with slug and stars."""
|
|
52
|
+
client = CodeWikiClient()
|
|
53
|
+
try:
|
|
54
|
+
repos = client.featured_repos()
|
|
55
|
+
finally:
|
|
56
|
+
client.close()
|
|
57
|
+
|
|
58
|
+
assert len(repos) > 0, "Expected at least one featured repo"
|
|
59
|
+
first = repos[0]
|
|
60
|
+
assert isinstance(first, Repository)
|
|
61
|
+
assert first.slug, "slug must be non-empty"
|
|
62
|
+
assert first.stars > 0, "stars must be positive"
|
|
63
|
+
|
|
64
|
+
def test_search_returns_results(self):
|
|
65
|
+
"""Live: search for 'react' returns results."""
|
|
66
|
+
client = CodeWikiClient()
|
|
67
|
+
try:
|
|
68
|
+
repos = client.search_repos("react")
|
|
69
|
+
finally:
|
|
70
|
+
client.close()
|
|
71
|
+
|
|
72
|
+
assert len(repos) > 0, "Expected at least one search result for 'react'"
|
|
73
|
+
assert any("react" in r.slug.lower() for r in repos), (
|
|
74
|
+
"At least one slug should contain 'react'"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_search_empty_query_returns_empty(self):
|
|
78
|
+
"""Live: search with nonsense query returns empty list."""
|
|
79
|
+
client = CodeWikiClient()
|
|
80
|
+
try:
|
|
81
|
+
repos = client.search_repos("zzzzznonexistent99999")
|
|
82
|
+
finally:
|
|
83
|
+
client.close()
|
|
84
|
+
|
|
85
|
+
assert isinstance(repos, list), "Result must be a list"
|
|
86
|
+
|
|
87
|
+
def test_search_with_limit(self):
|
|
88
|
+
"""Live: search respects the limit parameter."""
|
|
89
|
+
client = CodeWikiClient()
|
|
90
|
+
try:
|
|
91
|
+
repos = client.search_repos("python", limit=5)
|
|
92
|
+
finally:
|
|
93
|
+
client.close()
|
|
94
|
+
|
|
95
|
+
assert isinstance(repos, list)
|
|
96
|
+
assert len(repos) <= 5, "Result count must not exceed requested limit"
|
|
97
|
+
|
|
98
|
+
def test_featured_repo_slugs_have_org_and_name(self):
|
|
99
|
+
"""Live: each featured repo slug is in org/name format."""
|
|
100
|
+
client = CodeWikiClient()
|
|
101
|
+
try:
|
|
102
|
+
repos = client.featured_repos()
|
|
103
|
+
finally:
|
|
104
|
+
client.close()
|
|
105
|
+
|
|
106
|
+
assert len(repos) > 0
|
|
107
|
+
for repo in repos[:5]:
|
|
108
|
+
assert "/" in repo.slug, f"slug '{repo.slug}' missing org/name separator"
|
|
109
|
+
assert repo.org, f"org must be non-empty for slug '{repo.slug}'"
|
|
110
|
+
assert repo.name, f"name must be non-empty for slug '{repo.slug}'"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# TestLiveWiki
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.e2e
|
|
119
|
+
class TestLiveWiki:
|
|
120
|
+
"""Live tests for wiki page fetching."""
|
|
121
|
+
|
|
122
|
+
def test_wiki_get_returns_sections(self):
|
|
123
|
+
"""Live: wiki get for facebook/react returns sections."""
|
|
124
|
+
client = CodeWikiClient()
|
|
125
|
+
try:
|
|
126
|
+
wiki = client.get_wiki("facebook/react")
|
|
127
|
+
finally:
|
|
128
|
+
client.close()
|
|
129
|
+
|
|
130
|
+
assert isinstance(wiki, WikiPage)
|
|
131
|
+
assert len(wiki.sections) > 0, "Expected at least one wiki section"
|
|
132
|
+
assert wiki.repo.slug == "facebook/react"
|
|
133
|
+
assert wiki.repo.commit_hash, "commit_hash must be present"
|
|
134
|
+
|
|
135
|
+
def test_wiki_sections_have_content(self):
|
|
136
|
+
"""Live: first section has markdown content."""
|
|
137
|
+
client = CodeWikiClient()
|
|
138
|
+
try:
|
|
139
|
+
wiki = client.get_wiki("excalidraw/excalidraw")
|
|
140
|
+
finally:
|
|
141
|
+
client.close()
|
|
142
|
+
|
|
143
|
+
overview = wiki.sections[0]
|
|
144
|
+
assert isinstance(overview, WikiSection)
|
|
145
|
+
assert overview.title, "section title must be non-empty"
|
|
146
|
+
assert overview.level >= 1, "section level must be >= 1"
|
|
147
|
+
assert len(overview.content) > 50, (
|
|
148
|
+
f"Expected content > 50 chars, got {len(overview.content)}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def test_wiki_not_found(self):
|
|
152
|
+
"""Live: nonexistent repo raises NotFoundError."""
|
|
153
|
+
from cli_web.codewiki.core.exceptions import NotFoundError
|
|
154
|
+
|
|
155
|
+
client = CodeWikiClient()
|
|
156
|
+
try:
|
|
157
|
+
with pytest.raises(NotFoundError):
|
|
158
|
+
client.get_wiki("nonexistent-org-xyz/nonexistent-repo-abc")
|
|
159
|
+
finally:
|
|
160
|
+
client.close()
|
|
161
|
+
|
|
162
|
+
def test_wiki_sections_structure(self):
|
|
163
|
+
"""Live: all wiki sections have required fields."""
|
|
164
|
+
client = CodeWikiClient()
|
|
165
|
+
try:
|
|
166
|
+
wiki = client.get_wiki("facebook/react")
|
|
167
|
+
finally:
|
|
168
|
+
client.close()
|
|
169
|
+
|
|
170
|
+
for section in wiki.sections:
|
|
171
|
+
assert isinstance(section, WikiSection)
|
|
172
|
+
assert isinstance(section.title, str)
|
|
173
|
+
assert isinstance(section.level, int)
|
|
174
|
+
assert section.level >= 1
|
|
175
|
+
assert isinstance(section.content, str)
|
|
176
|
+
assert isinstance(section.code_refs, list)
|
|
177
|
+
|
|
178
|
+
def test_wiki_repo_metadata(self):
|
|
179
|
+
"""Live: wiki page contains repo metadata including github_url."""
|
|
180
|
+
client = CodeWikiClient()
|
|
181
|
+
try:
|
|
182
|
+
wiki = client.get_wiki("excalidraw/excalidraw")
|
|
183
|
+
finally:
|
|
184
|
+
client.close()
|
|
185
|
+
|
|
186
|
+
assert wiki.repo.github_url == "https://github.com/excalidraw/excalidraw"
|
|
187
|
+
assert wiki.repo.slug == "excalidraw/excalidraw"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# TestLiveChat
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.e2e
|
|
196
|
+
class TestLiveChat:
|
|
197
|
+
"""Live tests for the Gemini chat endpoint."""
|
|
198
|
+
|
|
199
|
+
def test_chat_returns_answer(self):
|
|
200
|
+
"""Live: chat returns a markdown answer."""
|
|
201
|
+
client = CodeWikiClient()
|
|
202
|
+
try:
|
|
203
|
+
response = client.chat("What is this project about?", "facebook/react")
|
|
204
|
+
finally:
|
|
205
|
+
client.close()
|
|
206
|
+
|
|
207
|
+
assert isinstance(response, ChatResponse)
|
|
208
|
+
assert len(response.answer) > 50, f"Expected answer > 50 chars, got {len(response.answer)}"
|
|
209
|
+
assert response.repo_slug == "facebook/react"
|
|
210
|
+
|
|
211
|
+
def test_chat_no_rpc_leak(self):
|
|
212
|
+
"""Live: chat answer must not contain raw RPC data."""
|
|
213
|
+
client = CodeWikiClient()
|
|
214
|
+
try:
|
|
215
|
+
response = client.chat("Describe the architecture", "excalidraw/excalidraw")
|
|
216
|
+
finally:
|
|
217
|
+
client.close()
|
|
218
|
+
|
|
219
|
+
assert "wrb.fr" not in response.answer, "RPC frame prefix leaked into answer"
|
|
220
|
+
assert "af.httprm" not in response.answer, "RPC metadata leaked into answer"
|
|
221
|
+
|
|
222
|
+
def test_chat_answer_is_string(self):
|
|
223
|
+
"""Live: chat answer field is a plain string (not nested structure)."""
|
|
224
|
+
client = CodeWikiClient()
|
|
225
|
+
try:
|
|
226
|
+
response = client.chat("What programming language is used?", "facebook/react")
|
|
227
|
+
finally:
|
|
228
|
+
client.close()
|
|
229
|
+
|
|
230
|
+
assert isinstance(response.answer, str), "answer must be a plain string"
|
|
231
|
+
assert response.answer.strip(), "answer must not be blank"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# TestCLISubprocess
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TestCLISubprocess:
|
|
240
|
+
"""Subprocess tests — exercise the installed CLI binary end-to-end."""
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def setup_class(cls):
|
|
244
|
+
cls.cli = _resolve_cli()
|
|
245
|
+
# If cli is sys.executable, use module mode
|
|
246
|
+
cls.use_module = cls.cli == sys.executable
|
|
247
|
+
|
|
248
|
+
def _run(self, args: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
|
249
|
+
if self.use_module:
|
|
250
|
+
cmd = [self.cli, "-m", "cli_web.codewiki"] + args
|
|
251
|
+
else:
|
|
252
|
+
cmd = [self.cli] + args
|
|
253
|
+
return subprocess.run(
|
|
254
|
+
cmd,
|
|
255
|
+
capture_output=True,
|
|
256
|
+
text=True,
|
|
257
|
+
encoding="utf-8",
|
|
258
|
+
errors="replace",
|
|
259
|
+
timeout=timeout,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def test_help_loads(self):
|
|
263
|
+
"""Subprocess: --help exits 0 and mentions all command groups."""
|
|
264
|
+
result = self._run(["--help"])
|
|
265
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
266
|
+
assert "repos" in result.stdout
|
|
267
|
+
assert "wiki" in result.stdout
|
|
268
|
+
assert "chat" in result.stdout
|
|
269
|
+
|
|
270
|
+
def test_repos_help(self):
|
|
271
|
+
"""Subprocess: repos --help lists featured and search subcommands."""
|
|
272
|
+
result = self._run(["repos", "--help"])
|
|
273
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
274
|
+
assert "featured" in result.stdout
|
|
275
|
+
assert "search" in result.stdout
|
|
276
|
+
|
|
277
|
+
def test_wiki_help(self):
|
|
278
|
+
"""Subprocess: wiki --help lists get, sections, section subcommands."""
|
|
279
|
+
result = self._run(["wiki", "--help"])
|
|
280
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
281
|
+
assert "get" in result.stdout
|
|
282
|
+
assert "sections" in result.stdout
|
|
283
|
+
assert "section" in result.stdout
|
|
284
|
+
|
|
285
|
+
@pytest.mark.e2e
|
|
286
|
+
def test_repos_featured_json(self):
|
|
287
|
+
"""Subprocess/live: repos featured --json returns valid structured output."""
|
|
288
|
+
result = self._run(["repos", "featured", "--json"])
|
|
289
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
290
|
+
data = json.loads(result.stdout)
|
|
291
|
+
assert data["success"] is True
|
|
292
|
+
assert isinstance(data["data"], list)
|
|
293
|
+
assert len(data["data"]) > 0
|
|
294
|
+
first = data["data"][0]
|
|
295
|
+
assert "slug" in first
|
|
296
|
+
assert "stars" in first
|
|
297
|
+
|
|
298
|
+
@pytest.mark.e2e
|
|
299
|
+
def test_repos_search_json(self):
|
|
300
|
+
"""Subprocess/live: repos search returns valid JSON."""
|
|
301
|
+
result = self._run(["repos", "search", "kubernetes", "--json"])
|
|
302
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
303
|
+
data = json.loads(result.stdout)
|
|
304
|
+
assert data["success"] is True
|
|
305
|
+
assert isinstance(data["data"], list)
|
|
306
|
+
|
|
307
|
+
@pytest.mark.e2e
|
|
308
|
+
def test_repos_search_limit(self):
|
|
309
|
+
"""Subprocess/live: repos search --limit is respected."""
|
|
310
|
+
result = self._run(["repos", "search", "python", "--limit", "3", "--json"])
|
|
311
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
312
|
+
data = json.loads(result.stdout)
|
|
313
|
+
assert data["success"] is True
|
|
314
|
+
assert len(data["data"]) <= 3
|
|
315
|
+
|
|
316
|
+
@pytest.mark.e2e
|
|
317
|
+
def test_wiki_sections_json(self):
|
|
318
|
+
"""Subprocess/live: wiki sections returns a list with title fields."""
|
|
319
|
+
result = self._run(["wiki", "sections", "facebook/react", "--json"])
|
|
320
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
321
|
+
data = json.loads(result.stdout)
|
|
322
|
+
assert data["success"] is True
|
|
323
|
+
sections = data["data"]
|
|
324
|
+
assert isinstance(sections, list)
|
|
325
|
+
assert len(sections) > 0
|
|
326
|
+
assert "title" in sections[0]
|
|
327
|
+
assert "level" in sections[0]
|
|
328
|
+
|
|
329
|
+
@pytest.mark.e2e
|
|
330
|
+
def test_wiki_get_json(self):
|
|
331
|
+
"""Subprocess/live: wiki get returns full page structure."""
|
|
332
|
+
result = self._run(["wiki", "get", "excalidraw/excalidraw", "--json"])
|
|
333
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
334
|
+
data = json.loads(result.stdout)
|
|
335
|
+
assert data["success"] is True
|
|
336
|
+
wiki = data["data"]
|
|
337
|
+
assert "repo" in wiki
|
|
338
|
+
assert "sections" in wiki
|
|
339
|
+
assert wiki["repo"]["slug"] == "excalidraw/excalidraw"
|
|
340
|
+
assert len(wiki["sections"]) > 0
|
|
341
|
+
|
|
342
|
+
@pytest.mark.e2e
|
|
343
|
+
def test_chat_ask_json(self):
|
|
344
|
+
"""Subprocess/live: chat ask returns answer without RPC leaks."""
|
|
345
|
+
result = self._run(
|
|
346
|
+
["chat", "ask", "What is this?", "--repo", "facebook/react", "--json"],
|
|
347
|
+
timeout=90,
|
|
348
|
+
)
|
|
349
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
350
|
+
data = json.loads(result.stdout)
|
|
351
|
+
assert data["success"] is True
|
|
352
|
+
answer = data["data"]["answer"]
|
|
353
|
+
assert len(answer) > 20, f"Expected longer answer, got: {answer!r}"
|
|
354
|
+
assert "wrb.fr" not in answer, "RPC frame prefix leaked into CLI output"
|
|
355
|
+
|
|
356
|
+
@pytest.mark.e2e
|
|
357
|
+
def test_wiki_not_found_returns_error_json(self):
|
|
358
|
+
"""Subprocess/live: wiki get for nonexistent repo exits non-zero with error JSON."""
|
|
359
|
+
result = self._run(["wiki", "get", "nonexistent-org-xyz/nonexistent-repo-abc", "--json"])
|
|
360
|
+
assert result.returncode != 0
|
|
361
|
+
data = json.loads(result.stdout)
|
|
362
|
+
assert data.get("error") is True
|
|
363
|
+
assert data.get("code") == "NOT_FOUND"
|
|
364
|
+
|
|
365
|
+
@pytest.mark.e2e
|
|
366
|
+
def test_repos_featured_no_json_exits_zero(self):
|
|
367
|
+
"""Subprocess/live: repos featured (no --json) prints table and exits 0."""
|
|
368
|
+
result = self._run(["repos", "featured"])
|
|
369
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
370
|
+
# Should contain at least one org/repo slug pattern
|
|
371
|
+
assert "/" in result.stdout
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# TestReadOnlyRoundTrip
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@pytest.mark.e2e
|
|
380
|
+
class TestReadOnlyRoundTrip:
|
|
381
|
+
"""Round-trip consistency tests across API calls."""
|
|
382
|
+
|
|
383
|
+
def test_list_detail_consistency(self):
|
|
384
|
+
"""Round-trip: search result slug matches wiki page repo info."""
|
|
385
|
+
client = CodeWikiClient()
|
|
386
|
+
try:
|
|
387
|
+
results = client.search_repos("excalidraw")
|
|
388
|
+
assert len(results) > 0, "Expected at least one search result"
|
|
389
|
+
slug = results[0].slug
|
|
390
|
+
wiki = client.get_wiki(slug)
|
|
391
|
+
finally:
|
|
392
|
+
client.close()
|
|
393
|
+
|
|
394
|
+
assert wiki.repo.slug == slug, f"wiki.repo.slug '{wiki.repo.slug}' != search slug '{slug}'"
|
|
395
|
+
assert len(wiki.sections) > 0, "Expected at least one section in wiki page"
|
|
396
|
+
|
|
397
|
+
def test_featured_to_wiki_round_trip(self):
|
|
398
|
+
"""Round-trip: first featured repo can have its wiki fetched."""
|
|
399
|
+
client = CodeWikiClient()
|
|
400
|
+
try:
|
|
401
|
+
repos = client.featured_repos()
|
|
402
|
+
assert len(repos) > 0
|
|
403
|
+
slug = repos[0].slug
|
|
404
|
+
wiki = client.get_wiki(slug)
|
|
405
|
+
finally:
|
|
406
|
+
client.close()
|
|
407
|
+
|
|
408
|
+
assert wiki.repo.slug == slug
|
|
409
|
+
# The wiki page must belong to the same org/repo
|
|
410
|
+
assert wiki.repo.org == repos[0].org
|
|
411
|
+
assert wiki.repo.name == repos[0].name
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Configuration management for cli-web-codewiki."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
APP_NAME = "cli-web-codewiki"
|
|
8
|
+
CONFIG_DIR = Path.home() / ".config" / APP_NAME
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_config_dir() -> Path:
|
|
12
|
+
"""Create config directory if it doesn't exist."""
|
|
13
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
return CONFIG_DIR
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``doctor`` — self-diagnosis for cli-web-* CLIs.
|
|
2
|
+
|
|
3
|
+
CANONICAL SOURCE: cli-web-core/cli_web_core/doctor.py
|
|
4
|
+
Vendored into every generated CLI at cli_web/<app>/utils/doctor.py by
|
|
5
|
+
`cli-web-devkit resync`. Do not edit vendored copies by hand.
|
|
6
|
+
|
|
7
|
+
Checks the local environment a support thread would ask about first:
|
|
8
|
+
installation, Python version, config directory, auth material (when the
|
|
9
|
+
CLI has an auth module), and optional dependencies. Read-only — never
|
|
10
|
+
mutates state, never touches the network.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DoctorCheck:
|
|
28
|
+
name: str
|
|
29
|
+
status: str # "ok" | "warn" | "fail"
|
|
30
|
+
detail: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_entry_point(app_name: str) -> DoctorCheck:
|
|
34
|
+
binary = f"cli-web-{app_name}"
|
|
35
|
+
path = shutil.which(binary)
|
|
36
|
+
if path:
|
|
37
|
+
return DoctorCheck("entry point", "ok", path)
|
|
38
|
+
return DoctorCheck(
|
|
39
|
+
"entry point",
|
|
40
|
+
"warn",
|
|
41
|
+
f"{binary} not on PATH — run `pip install -e .` in agent-harness/ "
|
|
42
|
+
f"(python -m fallback still works)",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_python() -> DoctorCheck:
|
|
47
|
+
# Intentional runtime guard: direct-source runs bypass pip's
|
|
48
|
+
# python_requires, so the interpreter check must live here.
|
|
49
|
+
if sys.version_info >= (3, 10): # noqa: UP036
|
|
50
|
+
return DoctorCheck("python", "ok", sys.version.split()[0])
|
|
51
|
+
return DoctorCheck("python", "fail", f"{sys.version.split()[0]} < 3.10 (unsupported)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _config_dir(app_name: str) -> Path:
|
|
55
|
+
return Path.home() / ".config" / f"cli-web-{app_name}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_config_dir(app_name: str) -> DoctorCheck:
|
|
59
|
+
cfg = _config_dir(app_name)
|
|
60
|
+
if not cfg.exists():
|
|
61
|
+
return DoctorCheck("config dir", "ok", f"{cfg} (not created yet — created on first use)")
|
|
62
|
+
if os.access(cfg, os.W_OK):
|
|
63
|
+
return DoctorCheck("config dir", "ok", str(cfg))
|
|
64
|
+
return DoctorCheck("config dir", "fail", f"{cfg} is not writable")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_auth_module(pkg: str) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
return importlib.util.find_spec(f"cli_web.{pkg}.core.auth") is not None
|
|
70
|
+
except (ImportError, ModuleNotFoundError, ValueError):
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_auth(app_name: str, pkg: str) -> list[DoctorCheck]:
|
|
75
|
+
if not _has_auth_module(pkg):
|
|
76
|
+
return [DoctorCheck("auth", "ok", "no auth module — public site, nothing to configure")]
|
|
77
|
+
|
|
78
|
+
checks: list[DoctorCheck] = []
|
|
79
|
+
if importlib.util.find_spec("playwright") is None:
|
|
80
|
+
checks.append(
|
|
81
|
+
DoctorCheck(
|
|
82
|
+
"playwright",
|
|
83
|
+
"warn",
|
|
84
|
+
"not installed — `auth login` (browser flow) unavailable; "
|
|
85
|
+
"pip install playwright && playwright install chromium",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
checks.append(DoctorCheck("playwright", "ok", "installed"))
|
|
90
|
+
|
|
91
|
+
env_var = f"CLI_WEB_{app_name.upper().replace('-', '_')}_AUTH_JSON"
|
|
92
|
+
if os.environ.get(env_var):
|
|
93
|
+
checks.append(DoctorCheck("auth source", "ok", f"using env var {env_var}"))
|
|
94
|
+
return checks
|
|
95
|
+
|
|
96
|
+
auth_file = _config_dir(app_name) / "auth.json"
|
|
97
|
+
if not auth_file.is_file():
|
|
98
|
+
checks.append(
|
|
99
|
+
DoctorCheck(
|
|
100
|
+
"auth file",
|
|
101
|
+
"warn",
|
|
102
|
+
f"{auth_file} missing — run: cli-web-{app_name} auth login (or set {env_var})",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return checks
|
|
106
|
+
|
|
107
|
+
checks.append(DoctorCheck("auth file", "ok", str(auth_file)))
|
|
108
|
+
if os.name == "posix": # st_mode permission bits are meaningless on Windows
|
|
109
|
+
mode = stat.S_IMODE(auth_file.stat().st_mode)
|
|
110
|
+
if mode & 0o077:
|
|
111
|
+
checks.append(
|
|
112
|
+
DoctorCheck(
|
|
113
|
+
"auth file permissions",
|
|
114
|
+
"warn",
|
|
115
|
+
f"{oct(mode)} — should be 600; run: chmod 600 {auth_file}",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
checks.append(DoctorCheck("auth file permissions", "ok", oct(mode)))
|
|
120
|
+
try:
|
|
121
|
+
json.loads(auth_file.read_text(encoding="utf-8"))
|
|
122
|
+
checks.append(DoctorCheck("auth file format", "ok", "valid JSON"))
|
|
123
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
124
|
+
checks.append(DoctorCheck("auth file format", "fail", f"unreadable: {exc}"))
|
|
125
|
+
|
|
126
|
+
return checks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_optional_deps() -> list[DoctorCheck]:
|
|
130
|
+
checks = []
|
|
131
|
+
if importlib.util.find_spec("prompt_toolkit") is None:
|
|
132
|
+
checks.append(
|
|
133
|
+
DoctorCheck("prompt_toolkit", "ok", "not installed — REPL uses plain input()")
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
checks.append(DoctorCheck("prompt_toolkit", "ok", "installed (REPL autocomplete on)"))
|
|
137
|
+
return checks
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run_doctor(app_name: str, pkg: str) -> list[DoctorCheck]:
|
|
141
|
+
checks = [
|
|
142
|
+
_check_python(),
|
|
143
|
+
_check_entry_point(app_name),
|
|
144
|
+
_check_config_dir(app_name),
|
|
145
|
+
*_check_auth(app_name, pkg),
|
|
146
|
+
*_check_optional_deps(),
|
|
147
|
+
]
|
|
148
|
+
return checks
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def register_doctor_command(cli: Any, app_name: str, pkg: str | None = None) -> None:
|
|
152
|
+
"""Attach a ``doctor`` command to a cli-web-* Click group."""
|
|
153
|
+
import click
|
|
154
|
+
|
|
155
|
+
resolved_pkg = pkg or app_name.replace("-", "_")
|
|
156
|
+
|
|
157
|
+
@cli.command("doctor")
|
|
158
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
159
|
+
@click.pass_context
|
|
160
|
+
def doctor(ctx: Any, json_mode: bool) -> None:
|
|
161
|
+
"""Diagnose this CLI's local setup (install, auth, dependencies)."""
|
|
162
|
+
if not json_mode: # honor the group-level --json flag (ctx.obj["json"])
|
|
163
|
+
obj = ctx.find_root().obj
|
|
164
|
+
json_mode = bool(obj.get("json")) if isinstance(obj, dict) else False
|
|
165
|
+
checks = run_doctor(app_name, resolved_pkg)
|
|
166
|
+
failed = [c for c in checks if c.status == "fail"]
|
|
167
|
+
if json_mode:
|
|
168
|
+
click.echo(
|
|
169
|
+
json.dumps(
|
|
170
|
+
{
|
|
171
|
+
"success": not failed,
|
|
172
|
+
"data": {
|
|
173
|
+
"checks": [asdict(c) for c in checks],
|
|
174
|
+
"ok": not failed,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
indent=2,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
marks = {"ok": "✓", "warn": "⚠", "fail": "✗"}
|
|
182
|
+
for c in checks:
|
|
183
|
+
detail = f" {c.detail}" if c.detail else ""
|
|
184
|
+
click.echo(f" {marks[c.status]} {c.name}:{detail}")
|
|
185
|
+
click.echo()
|
|
186
|
+
click.echo("all good" if not failed else f"{len(failed)} problem(s) found")
|
|
187
|
+
if failed:
|
|
188
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared helpers for cli-web-codewiki."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from ..core.exceptions import CodeWikiError, RateLimitError, error_code_for
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextlib.contextmanager
|
|
17
|
+
def handle_errors(json_mode: bool = False):
|
|
18
|
+
"""Context manager for consistent error handling in commands."""
|
|
19
|
+
try:
|
|
20
|
+
yield
|
|
21
|
+
except KeyboardInterrupt:
|
|
22
|
+
if not json_mode:
|
|
23
|
+
click.echo("\nInterrupted.", err=True)
|
|
24
|
+
sys.exit(130)
|
|
25
|
+
except click.exceptions.Exit:
|
|
26
|
+
raise
|
|
27
|
+
except click.UsageError:
|
|
28
|
+
raise
|
|
29
|
+
except CodeWikiError as exc:
|
|
30
|
+
code = error_code_for(exc)
|
|
31
|
+
if json_mode:
|
|
32
|
+
err = {"error": True, "code": code, "message": str(exc)}
|
|
33
|
+
if isinstance(exc, RateLimitError) and exc.retry_after is not None:
|
|
34
|
+
err["retry_after"] = exc.retry_after
|
|
35
|
+
click.echo(json.dumps(err))
|
|
36
|
+
else:
|
|
37
|
+
click.echo(f"Error: {exc}", err=True)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
if json_mode:
|
|
41
|
+
click.echo(
|
|
42
|
+
json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"error": True,
|
|
45
|
+
"code": "INTERNAL_ERROR",
|
|
46
|
+
"message": str(exc),
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
click.echo(f"Error: {exc}", err=True)
|
|
52
|
+
sys.exit(2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_cli(name: str = "cli-web-codewiki") -> str:
|
|
56
|
+
"""Find the CLI binary path for subprocess tests."""
|
|
57
|
+
if os.environ.get("CLI_WEB_FORCE_INSTALLED"):
|
|
58
|
+
path = shutil.which(name)
|
|
59
|
+
if path:
|
|
60
|
+
return path
|
|
61
|
+
raise FileNotFoundError(f"{name} not found in PATH")
|
|
62
|
+
|
|
63
|
+
path = shutil.which(name)
|
|
64
|
+
if path:
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
return sys.executable
|