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,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