cc-visual-walkthrough 0.2.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,22 @@
1
+ """cc-visual-walkthrough: spec-driven Playwright walkthroughs for Claude Code.
2
+
3
+ Records and captures browser tours of a live web app into shareable HTML +
4
+ Markdown reports (screenshots, per-group video, non-fatal regression
5
+ assertions). The supported workflow is through the Claude Code plugin
6
+ (slug: ccwalk); the `ccwalk` CLI can be driven by hand, but that path is
7
+ unsupported -- you are on your own.
8
+ """
9
+
10
+ from .specs import Assertion, AssertionResult, Step, StepResult, load_spec, validate_steps
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ __all__ = [
15
+ "Assertion",
16
+ "AssertionResult",
17
+ "Step",
18
+ "StepResult",
19
+ "load_spec",
20
+ "validate_steps",
21
+ "__version__",
22
+ ]
@@ -0,0 +1,241 @@
1
+ """Action registry: the verbs a spec's action dicts can use.
2
+
3
+ Every action takes the live ``Runner`` (``rt``), the current ``Step``, and
4
+ its own kwargs, and returns an info dict merged into the step's log
5
+ (``screenshot`` / ``progress_screenshots`` keys are collected into the
6
+ report). The registry is a plain dict, so app-specific actions can be
7
+ added from a custom module -- see ``load_custom_actions``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from .specs import Step
19
+
20
+
21
+ def _url_for(rt, path: str) -> str:
22
+ if path.startswith("http://") or path.startswith("https://"):
23
+ return path
24
+ if not path.startswith("/"):
25
+ path = "/" + path
26
+ return rt.base_url + path
27
+
28
+
29
+ def act_goto(rt, step: Step, url: str, apply_auth: bool = True, **_) -> dict:
30
+ """Navigate. On the first goto of a fresh browser context this also
31
+ establishes auth per the config (form login / localStorage injection);
32
+ cookie and header auth are applied at context creation instead."""
33
+ if apply_auth:
34
+ rt.ensure_pre_goto_auth()
35
+ rt.page.goto(_url_for(rt, url), wait_until="domcontentloaded")
36
+ if apply_auth:
37
+ rt.ensure_post_goto_auth()
38
+ return {}
39
+
40
+
41
+ def act_reload(rt, step: Step, **_) -> dict:
42
+ rt.page.reload(wait_until="domcontentloaded")
43
+ return {}
44
+
45
+
46
+ def act_click(rt, step: Step, selector: str, timeout_ms: int = 10000, **_) -> dict:
47
+ rt.page.click(selector, timeout=timeout_ms)
48
+ return {}
49
+
50
+
51
+ def act_fill(rt, step: Step, selector: str, value: str, **_) -> dict:
52
+ rt.page.fill(selector, value)
53
+ return {}
54
+
55
+
56
+ def act_press(rt, step: Step, selector: str, key: str, **_) -> dict:
57
+ rt.page.press(selector, key)
58
+ return {}
59
+
60
+
61
+ def act_hover(rt, step: Step, selector: str, **_) -> dict:
62
+ rt.page.hover(selector)
63
+ return {}
64
+
65
+
66
+ def act_select_option(
67
+ rt, step: Step, selector: str, value: str | None = None, label: str | None = None, **_
68
+ ) -> dict:
69
+ if label is not None:
70
+ rt.page.select_option(selector, label=label)
71
+ else:
72
+ rt.page.select_option(selector, value=value)
73
+ return {}
74
+
75
+
76
+ def act_upload_file(rt, step: Step, selector: str, path: str, **_) -> dict:
77
+ rt.page.set_input_files(selector, path)
78
+ return {}
79
+
80
+
81
+ def act_wait_for(
82
+ rt, step: Step, selector: str, state: str = "visible", timeout_ms: int = 15000, **_
83
+ ) -> dict:
84
+ rt.page.wait_for_selector(selector, state=state, timeout=timeout_ms)
85
+ return {}
86
+
87
+
88
+ def act_wait_ms(rt, step: Step, ms: int, **_) -> dict:
89
+ rt.page.wait_for_timeout(ms)
90
+ return {}
91
+
92
+
93
+ def act_set_viewport(rt, step: Step, width: int, height: int, **_) -> dict:
94
+ rt.page.set_viewport_size({"width": width, "height": height})
95
+ return {}
96
+
97
+
98
+ def act_screenshot(rt, step: Step, suffix: str = "", **_) -> dict:
99
+ path = rt.screenshot(step, suffix=suffix)
100
+ return {"screenshot": path}
101
+
102
+
103
+ def act_scroll_into_view(rt, step: Step, selector: str, **_) -> dict:
104
+ rt.page.locator(selector).first.scroll_into_view_if_needed()
105
+ return {}
106
+
107
+
108
+ def act_login_form(rt, step: Step, **_) -> dict:
109
+ """Perform the configured form login explicitly (useful as a group's
110
+ opening step when the login flow itself should appear in the tour)."""
111
+ rt.perform_form_login()
112
+ return {"logged_in": True}
113
+
114
+
115
+ def act_override_session(rt, step: Step, logical_name: str = "default", **_) -> dict:
116
+ sid = rt.override_session_id(logical_name)
117
+ return {"session_id": sid}
118
+
119
+
120
+ def act_wait_for_condition(
121
+ rt,
122
+ step: Step,
123
+ js: str,
124
+ timeout_ms: int = 90000,
125
+ capture_progress: bool = False,
126
+ poll_interval_ms: int = 2500,
127
+ progress_selector: str = "",
128
+ settle_ms: int = 400,
129
+ **_,
130
+ ) -> dict:
131
+ """Poll ``js`` (a no-arg JS function body evaluated via
132
+ ``() => (expr)``; return truthy when the wait should END) until it
133
+ holds or ``timeout_ms`` elapses. While waiting, if
134
+ ``progress_selector`` is visible and ``capture_progress`` is set, a
135
+ progress screenshot is taken every ``poll_interval_ms`` -- this is how
136
+ long async operations (spinners, progress labels, streaming
137
+ responses) get their wait-state captured in the report.
138
+
139
+ The internal poll granularity is finer (300ms) than
140
+ ``poll_interval_ms`` (which only paces progress screenshots), so a
141
+ near-instant completion is still detected promptly -- a lesson
142
+ inherited from the predecessor system, where gating on a
143
+ spinner-visible->hidden transition spun for the full timeout on
144
+ fast replies (see DESIGN.md).
145
+ """
146
+ shots: list[str] = []
147
+ deadline = time.monotonic() + (timeout_ms / 1000.0)
148
+ saw_progress = False
149
+ met = False
150
+ last_shot_at = 0.0
151
+ check_interval_ms = min(300, poll_interval_ms) if poll_interval_ms else 300
152
+ expr = f"() => ({js})"
153
+ while time.monotonic() < deadline:
154
+ if progress_selector and rt.page.is_visible(progress_selector):
155
+ saw_progress = True
156
+ now = time.monotonic()
157
+ if capture_progress and (now - last_shot_at) * 1000 >= poll_interval_ms:
158
+ shots.append(rt.screenshot(step, suffix=f"wait_{len(shots)}"))
159
+ last_shot_at = now
160
+ if rt.page.evaluate(expr):
161
+ met = True
162
+ break
163
+ rt.page.wait_for_timeout(check_interval_ms)
164
+ if not met and rt.page.evaluate(expr):
165
+ met = True # condition became true exactly at the deadline
166
+ if not met:
167
+ # A timed-out evidence wait must be VISIBLE: the runner turns this
168
+ # into a failed pseudo-assertion (step status warn), never a
169
+ # silent PASS.
170
+ return {
171
+ "progress_screenshots": shots,
172
+ "saw_progress": saw_progress,
173
+ "timed_out": True,
174
+ "timeout_detail": f"wait_for_condition {js!r} not met within {timeout_ms}ms",
175
+ }
176
+ if settle_ms:
177
+ rt.page.wait_for_timeout(settle_ms)
178
+ return {"progress_screenshots": shots, "saw_progress": saw_progress}
179
+
180
+
181
+ def act_mock_route(rt, step: Step, url_pattern: str, json_body: Any, status: int = 200, **_) -> dict:
182
+ """Intercept a network request with a canned JSON response (e.g. to
183
+ stop a wizard auto-skipping itself on an already-configured backend).
184
+ Registered on the page, so it survives a subsequent goto/reload."""
185
+ body = json.dumps(json_body)
186
+
187
+ def handler(route):
188
+ route.fulfill(status=status, content_type="application/json", body=body)
189
+
190
+ rt.page.route(url_pattern, handler)
191
+ return {}
192
+
193
+
194
+ ACTIONS: dict[str, Callable[..., dict]] = {
195
+ "goto": act_goto,
196
+ "reload": act_reload,
197
+ "click": act_click,
198
+ "fill": act_fill,
199
+ "press": act_press,
200
+ "hover": act_hover,
201
+ "select_option": act_select_option,
202
+ "upload_file": act_upload_file,
203
+ "wait_for": act_wait_for,
204
+ "wait_ms": act_wait_ms,
205
+ "set_viewport": act_set_viewport,
206
+ "screenshot": act_screenshot,
207
+ "scroll_into_view": act_scroll_into_view,
208
+ "login_form": act_login_form,
209
+ "override_session": act_override_session,
210
+ "wait_for_condition": act_wait_for_condition,
211
+ "mock_route": act_mock_route,
212
+ }
213
+
214
+
215
+ def run_action(rt, step: Step, action_dict: dict[str, Any]) -> dict:
216
+ kwargs = {k: v for k, v in action_dict.items() if k != "action"}
217
+ name = action_dict["action"]
218
+ fn = ACTIONS.get(name)
219
+ if fn is None:
220
+ raise ValueError(f"Unknown action {name!r} (known: {sorted(ACTIONS)})")
221
+ return fn(rt, step, **kwargs)
222
+
223
+
224
+ def load_custom_actions(module_path: str | Path) -> list[str]:
225
+ """Import a custom-actions module by file path and let it register
226
+ into ACTIONS. The module must expose ``register(actions: dict)``.
227
+ Returns the action names it added."""
228
+ path = Path(module_path)
229
+ if not path.exists():
230
+ raise ValueError(f"custom_actions module not found: {module_path}")
231
+ spec = importlib.util.spec_from_file_location(f"ccwalk_custom_{path.stem}", path)
232
+ if spec is None or spec.loader is None:
233
+ raise ValueError(f"Cannot import custom_actions module: {module_path}")
234
+ mod = importlib.util.module_from_spec(spec)
235
+ spec.loader.exec_module(mod)
236
+ register = getattr(mod, "register", None)
237
+ if not callable(register):
238
+ raise ValueError(f"{module_path} must define register(actions: dict)")
239
+ before = set(ACTIONS)
240
+ register(ACTIONS)
241
+ return sorted(set(ACTIONS) - before)
@@ -0,0 +1,38 @@
1
+ """Browser launch with a system-chromium fallback.
2
+
3
+ Some boxes cannot run `playwright install-deps` (no root), but already
4
+ have a shared-libs-satisfied system chromium. Try the Playwright-managed
5
+ browser first, then each known system path."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ _FALLBACK_CHROMIUM_PATHS = [
12
+ "/usr/bin/chromium-browser",
13
+ "/usr/bin/chromium",
14
+ "/snap/bin/chromium",
15
+ "/usr/bin/google-chrome",
16
+ "/usr/bin/google-chrome-stable",
17
+ ]
18
+
19
+
20
+ def launch_browser(playwright, headless: bool = True):
21
+ last_error: Exception | None = None
22
+ try:
23
+ return playwright.chromium.launch(headless=headless)
24
+ except Exception as e: # noqa: BLE001 -- any launch failure triggers fallback
25
+ last_error = e
26
+ for candidate in _FALLBACK_CHROMIUM_PATHS:
27
+ if not Path(candidate).exists():
28
+ continue
29
+ try:
30
+ return playwright.chromium.launch(headless=headless, executable_path=candidate)
31
+ except Exception as e: # noqa: BLE001
32
+ last_error = e
33
+ raise RuntimeError(
34
+ "Could not launch any chromium (playwright-managed or system fallback). "
35
+ f"Last error: {last_error}. Run `ccwalk install-browsers` to install "
36
+ "Playwright's chromium (if that fails on missing shared libraries, "
37
+ "install libnspr4/libnss3 system packages)."
38
+ )
@@ -0,0 +1,388 @@
1
+ """ccwalk CLI: init / run / doctor / report.
2
+
3
+ The supported workflow is through the Claude Code plugin (/ccwalk:setup
4
+ writes the config and spec, /ccwalk:run runs and triages). Driving this
5
+ CLI by hand works but is unsupported.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import shutil
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from . import __version__
17
+ from .actions import load_custom_actions
18
+ from .config import CONFIG_FILENAME, Config, load_config
19
+ from .helpers import AppProcess, app_ready, git_head, parse_viewports
20
+ from .reporters import results_from_run_meta, transcode_videos_mp4, write_report
21
+ from .runner import run_capture_only, run_walkthrough
22
+ from .specs import load_spec
23
+
24
+ _CONFIG_TEMPLATE = """\
25
+ # ccwalk.yaml -- cc-visual-walkthrough configuration.
26
+ # Generated by `ccwalk init`; the /ccwalk:setup Claude Code skill fills
27
+ # this in properly for your app. Full schema: see the config module docs.
28
+ version: 1
29
+ app:
30
+ name: "My App"
31
+ base_url: "http://localhost:8000"
32
+ # start_command: "npm run dev"
33
+ ready_check: { path: "/", timeout_s: 60 }
34
+ auth:
35
+ method: none # none | form | localstorage | cookie | header
36
+ capture:
37
+ video: true
38
+ default_viewport: { width: 1440, height: 900 }
39
+ report:
40
+ out_dir: "reports/walkthrough"
41
+ keep_runs: 10
42
+ specs_dir: "walkthrough/specs"
43
+ default_spec: "example_tour"
44
+ """
45
+
46
+ _EXAMPLE_SPEC = '''\
47
+ """Example walkthrough spec -- replace with your app's tour.
48
+
49
+ Rules the validator enforces:
50
+ - steps of a group must be consecutive in STEPS
51
+ - a group's first step must begin with a goto (fresh contexts start blank)
52
+ """
53
+
54
+ from cc_visual_walkthrough import Assertion, Step
55
+
56
+ STEPS = [
57
+ Step(
58
+ name="homepage",
59
+ description="Load the homepage and check the main heading renders",
60
+ group="smoke",
61
+ actions=[
62
+ {"action": "goto", "url": "/"},
63
+ {"action": "wait_for", "selector": "h1"},
64
+ ],
65
+ assertions=[Assertion(selector="h1", description="main heading visible")],
66
+ ),
67
+ ]
68
+ '''
69
+
70
+
71
+ def _resolve_spec(cfg: Config, spec_arg: str | None) -> str:
72
+ """Resolution order: an explicit path (slash or .py) passes through;
73
+ otherwise try {specs_dir}/{name}.py on disk FIRST -- so a spec named
74
+ 'tour.v2' finds tour.v2.py instead of misrouting into a dotted-module
75
+ import -- and only fall back to treating a dotted name as a module."""
76
+ spec = spec_arg or cfg.default_spec
77
+ if "/" in spec or "\\" in spec or spec.endswith(".py"):
78
+ return spec
79
+ candidate = Path(cfg.specs_dir) / f"{spec}.py"
80
+ if candidate.exists() or "." not in spec:
81
+ return str(candidate)
82
+ return spec # dotted module path
83
+
84
+
85
+ def _prune_runs(out_root: Path, keep: int) -> None:
86
+ if keep <= 0 or not out_root.is_dir():
87
+ return
88
+ runs = sorted([p for p in out_root.iterdir() if p.is_dir()], key=lambda p: p.name)
89
+ for old in runs[:-keep]:
90
+ shutil.rmtree(old, ignore_errors=True)
91
+
92
+
93
+ def cmd_init(args: argparse.Namespace) -> int:
94
+ root = Path.cwd()
95
+ cfg_path = root / CONFIG_FILENAME
96
+ if cfg_path.exists() and not args.force:
97
+ print(f"{CONFIG_FILENAME} already exists (use --force to overwrite)")
98
+ return 1
99
+ cfg_path.write_text(_CONFIG_TEMPLATE)
100
+ specs_dir = root / "walkthrough" / "specs"
101
+ specs_dir.mkdir(parents=True, exist_ok=True)
102
+ example = specs_dir / "example_tour.py"
103
+ if not example.exists() or args.force:
104
+ example.write_text(_EXAMPLE_SPEC)
105
+ state_dir = root / ".ccwalk"
106
+ state_dir.mkdir(exist_ok=True)
107
+ (state_dir / ".gitignore").write_text("*\n")
108
+ print(f"Wrote {CONFIG_FILENAME}, {example.relative_to(root)}, and .ccwalk/")
109
+ print("Next: run /ccwalk:setup in Claude Code to tailor the tour to this app.")
110
+ return 0
111
+
112
+
113
+ def cmd_run(args: argparse.Namespace) -> int:
114
+ cfg = load_config(args.config)
115
+ if args.base_url:
116
+ cfg.app.base_url = args.base_url.rstrip("/")
117
+ if cfg.custom_actions:
118
+ added = load_custom_actions(cfg.custom_actions)
119
+ if added:
120
+ print(f"Custom actions loaded: {', '.join(added)}")
121
+
122
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
123
+ out_root = Path(cfg.report.out_dir)
124
+ out_dir = Path(args.out_dir) if args.out_dir else out_root / timestamp
125
+
126
+ app_proc = None
127
+ if args.start_app:
128
+ if not cfg.app.start_command:
129
+ print("--start-app given but app.start_command is not configured", file=sys.stderr)
130
+ return 2
131
+ app_proc = AppProcess(
132
+ cfg.app.start_command,
133
+ cfg.app.base_url,
134
+ cfg.app.ready_check.path,
135
+ cfg.app.ready_check.timeout_s,
136
+ )
137
+ app_proc.__enter__()
138
+
139
+ try:
140
+ health = app_ready(cfg.app.base_url, cfg.app.ready_check.path)
141
+ if not health.get("reachable") and not args.force:
142
+ print(
143
+ f"App not reachable at {cfg.app.base_url}{cfg.app.ready_check.path} "
144
+ f"({health.get('error')}). Start it first, use --start-app, or --force "
145
+ "to run anyway.",
146
+ file=sys.stderr,
147
+ )
148
+ return 2
149
+ # Preflight passed -- only now claim a run directory, so a dead app
150
+ # doesn't litter reports/ with empty timestamp dirs.
151
+ out_dir.mkdir(parents=True, exist_ok=True)
152
+
153
+ run_meta = {
154
+ "timestamp": timestamp,
155
+ "git_head": git_head(),
156
+ "base_url": cfg.app.base_url,
157
+ "app_health": health,
158
+ "app_name": cfg.app.name,
159
+ "tool_version": __version__,
160
+ }
161
+
162
+ if args.capture_only:
163
+ run_meta["spec"] = f"--capture-only {args.capture_only}"
164
+ viewports = parse_viewports(args.viewports)
165
+ results = run_capture_only(
166
+ args.capture_only, viewports, cfg, out_dir, headed=args.headed
167
+ )
168
+ else:
169
+ spec_ref = _resolve_spec(cfg, args.spec)
170
+ run_meta["spec"] = spec_ref
171
+ from .actions import ACTIONS
172
+
173
+ steps = load_spec(spec_ref, known_actions=set(ACTIONS))
174
+ results = run_walkthrough(
175
+ steps, cfg, out_dir,
176
+ headed=args.headed,
177
+ record_video=cfg.capture.video and not args.no_video,
178
+ )
179
+ finally:
180
+ if app_proc:
181
+ app_proc.__exit__(None, None, None)
182
+
183
+ if args.mp4:
184
+ for w in transcode_videos_mp4(out_dir, results):
185
+ print(f"warning: {w}", file=sys.stderr)
186
+
187
+ embed = args.embed or cfg.report.embed
188
+ if embed:
189
+ total_media = sum(
190
+ (out_dir / p).stat().st_size
191
+ for r in results
192
+ for p in [*r.screenshots, *( [r.video_path] if r.video_path else [] )]
193
+ if (out_dir / p).exists()
194
+ )
195
+ if total_media > 20 * 1024 * 1024:
196
+ print(
197
+ f"warning: --embed with {total_media / 1e6:.0f}MB of media makes a very "
198
+ "large HTML file; files over 25MB stay linked",
199
+ file=sys.stderr,
200
+ )
201
+ report_path = write_report(run_meta, results, out_dir, embed=embed)
202
+ if not args.out_dir:
203
+ _prune_runs(out_root, cfg.report.keep_runs)
204
+
205
+ n_pass = sum(1 for r in results if r.status == "pass")
206
+ n_warn = sum(1 for r in results if r.status == "warn")
207
+ n_fail = sum(1 for r in results if r.status == "fail")
208
+ n_blocked = sum(1 for r in results if r.status == "blocked")
209
+ print(f"\nReport: {Path(report_path).resolve()}")
210
+ summary = f"Steps: {len(results)} PASS={n_pass} WARN={n_warn} FAIL={n_fail}"
211
+ if n_blocked:
212
+ summary += f" BLOCKED={n_blocked}"
213
+ print(summary)
214
+ for r in results:
215
+ line = f" [{r.status.upper():7s}] {r.step.name} ({r.duration_ms:.0f}ms)"
216
+ if r.error:
217
+ line += f" -- {r.error}"
218
+ print(line)
219
+ return 0 if n_fail == 0 else 1
220
+
221
+
222
+ def cmd_doctor(args: argparse.Namespace) -> int:
223
+ from .doctor import run_doctor
224
+
225
+ cfg = load_config(args.config)
226
+ if args.base_url:
227
+ cfg.app.base_url = args.base_url.rstrip("/")
228
+ return run_doctor(
229
+ cfg, args.url, args.find, headed=args.headed, no_auth=args.no_auth
230
+ )
231
+
232
+
233
+ def cmd_install_browsers(args: argparse.Namespace) -> int:
234
+ """Install Playwright's chromium via THIS interpreter -- works the
235
+ same for pip, uv tool, and pipx installs, where a bare `playwright`
236
+ command may not be on PATH."""
237
+ import subprocess
238
+
239
+ extra = args.args or ["chromium"]
240
+ proc = subprocess.run([sys.executable, "-m", "playwright", "install", *extra])
241
+ if proc.returncode == 0:
242
+ # playwright is silent when browsers are already present; say so
243
+ print(f"ccwalk: {' '.join(extra)} ready (installed or already present)")
244
+ else:
245
+ print("ccwalk: browser install FAILED - see output above", file=sys.stderr)
246
+ return proc.returncode
247
+
248
+
249
+ def cmd_serve(args: argparse.Namespace) -> int:
250
+ from . import serve as serve_mod
251
+
252
+ cfg = load_config(args.config)
253
+ root = cfg.source_path.parent if cfg.source_path else Path.cwd()
254
+ out_root = Path(cfg.report.out_dir)
255
+ if not out_root.is_absolute():
256
+ out_root = root / out_root
257
+ if args.action == "stop":
258
+ return serve_mod.stop(root)
259
+ if args.action == "status":
260
+ return serve_mod.status(root, out_root)
261
+ return serve_mod.start(
262
+ root, out_root, cfg.app.name,
263
+ port=args.port, bind=args.bind, daemon=args.daemon,
264
+ config_path=str(cfg.source_path) if cfg.source_path else None,
265
+ )
266
+
267
+
268
+ def cmd_report(args: argparse.Namespace) -> int:
269
+ import json
270
+
271
+ run_dir = Path(args.run_dir)
272
+ meta_path = run_dir / "run_meta.json"
273
+ if not meta_path.exists():
274
+ print(f"No run_meta.json in {run_dir}", file=sys.stderr)
275
+ return 2
276
+ run_meta = json.loads(meta_path.read_text())
277
+ results = results_from_run_meta(run_meta)
278
+ if args.mp4:
279
+ for w in transcode_videos_mp4(run_dir, results):
280
+ print(f"warning: {w}", file=sys.stderr)
281
+ path = write_report(
282
+ run_meta, results, run_dir, embed=args.embed,
283
+ in_place=getattr(args, "in_place", False),
284
+ )
285
+ print(f"Re-rendered: {path}" + (" (embedded media)" if args.embed else ""))
286
+ return 0
287
+
288
+
289
+ def build_arg_parser() -> argparse.ArgumentParser:
290
+ p = argparse.ArgumentParser(
291
+ prog="ccwalk",
292
+ description="cc-visual-walkthrough: records and captures spec-driven browser tours "
293
+ "into shareable HTML reports. Supported via the Claude Code plugin (/ccwalk:setup).",
294
+ )
295
+ p.add_argument("--version", action="version", version=f"ccwalk {__version__}")
296
+ sub = p.add_subparsers(dest="command", required=True)
297
+
298
+ p_init = sub.add_parser("init", help="Scaffold ccwalk.yaml + an example spec")
299
+ p_init.add_argument("--force", action="store_true")
300
+ p_init.set_defaults(func=cmd_init)
301
+
302
+ p_run = sub.add_parser("run", help="Run a walkthrough (or --capture-only) and write the report")
303
+ p_run.add_argument("--spec", default=None, help="Spec name in specs_dir, module path, or .py file")
304
+ p_run.add_argument("--config", default=None, help=f"Path to {CONFIG_FILENAME}")
305
+ p_run.add_argument("--base-url", default=None)
306
+ p_run.add_argument("--out-dir", default=None)
307
+ p_run.add_argument("--headed", action="store_true", help="Show the browser window")
308
+ p_run.add_argument("--no-video", action="store_true", help="Skip video recording")
309
+ p_run.add_argument("--embed", action="store_true", help="Base64-embed media into one HTML file")
310
+ p_run.add_argument("--mp4", action="store_true", help="Transcode videos to H.264 (needs ffmpeg)")
311
+ p_run.add_argument("--start-app", action="store_true", help="Launch app.start_command for the run")
312
+ p_run.add_argument("--force", action="store_true", help="Run even if the ready check fails")
313
+ p_run.add_argument("--capture-only", default=None, metavar="URL_OR_PATH",
314
+ help="Design mode: screenshot one page across --viewports")
315
+ p_run.add_argument("--viewports", default="390,768,1440")
316
+ p_run.set_defaults(func=cmd_run)
317
+
318
+ p_doc = sub.add_parser("doctor", help="Print stable-selector candidates for a page")
319
+ p_doc.add_argument("url", help="Path or URL to inspect")
320
+ p_doc.add_argument("--find", default=None, help="Text to locate on the page")
321
+ p_doc.add_argument("--config", default=None)
322
+ p_doc.add_argument("--base-url", default=None)
323
+ p_doc.add_argument("--headed", action="store_true")
324
+ p_doc.add_argument(
325
+ "--no-auth", dest="no_auth", action="store_true",
326
+ help="inspect without applying configured auth (the configured "
327
+ "login page is always exempt automatically)",
328
+ )
329
+ p_doc.set_defaults(func=cmd_doctor)
330
+
331
+ p_rep = sub.add_parser("report", help="Re-render report.html/.md from a run's run_meta.json")
332
+ p_rep.add_argument("run_dir")
333
+ p_rep.add_argument(
334
+ "--embed", action="store_true",
335
+ help="also write a self-contained report_embedded.html next to report.html",
336
+ )
337
+ p_rep.add_argument(
338
+ "--in-place", dest="in_place", action="store_true",
339
+ help="with --embed: overwrite report.html instead of writing a sibling",
340
+ )
341
+ p_rep.add_argument("--mp4", action="store_true")
342
+ p_rep.set_defaults(func=cmd_report)
343
+
344
+ p_srv = sub.add_parser(
345
+ "serve",
346
+ help="Serve the report directory with a runs index at / "
347
+ "(localhost, Cache-Control: no-store)",
348
+ description="Serve reports over HTTP with a generated runs index at /. "
349
+ "Binds 127.0.0.1 by default; exposing wider (--bind 0.0.0.0) is an "
350
+ "explicit choice - reports contain your app's screenshots. Safari "
351
+ "cannot play the .webm videos from this server (no Range support); "
352
+ "use --mp4 runs plus a range-capable server for Safari viewers.",
353
+ )
354
+ p_srv.add_argument(
355
+ "action", nargs="?", default="start", choices=["start", "stop", "status"],
356
+ help="start (default; restarts any running server), stop, or status",
357
+ )
358
+ p_srv.add_argument("--port", type=int, default=8378,
359
+ help="port to bind (default 8378; 0 = pick a free one)")
360
+ p_srv.add_argument("--bind", default="127.0.0.1")
361
+ p_srv.add_argument("--daemon", action="store_true",
362
+ help="run in the background (manage with serve stop/status)")
363
+ p_srv.add_argument("--config", default=None)
364
+ p_srv.set_defaults(func=cmd_serve)
365
+
366
+ p_ib = sub.add_parser(
367
+ "install-browsers",
368
+ help="Install Playwright's chromium for this interpreter (pip/uv tool/pipx safe)",
369
+ )
370
+ p_ib.add_argument("args", nargs="*", help="Extra args for `playwright install` (default: chromium)")
371
+ p_ib.set_defaults(func=cmd_install_browsers)
372
+ return p
373
+
374
+
375
+ def main(argv: list[str] | None = None) -> int:
376
+ args = build_arg_parser().parse_args(argv)
377
+ try:
378
+ return args.func(args)
379
+ except (ValueError, RuntimeError) as e:
380
+ # Config errors, spec errors, and browser-launch failures arrive
381
+ # here as clean one-liners (with their own remediation hints)
382
+ # instead of raw tracebacks.
383
+ print(f"ccwalk: {e}", file=sys.stderr)
384
+ return 2
385
+
386
+
387
+ if __name__ == "__main__":
388
+ sys.exit(main())