web2cli 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.
Files changed (44) hide show
  1. web2cli/__init__.py +3 -0
  2. web2cli/__main__.py +5 -0
  3. web2cli/adapter/__init__.py +0 -0
  4. web2cli/adapter/lint.py +667 -0
  5. web2cli/adapter/loader.py +157 -0
  6. web2cli/adapter/validator.py +127 -0
  7. web2cli/adapters/discord.com/web2cli.yaml +476 -0
  8. web2cli/adapters/mail.google.com/parsers/inbox.py +200 -0
  9. web2cli/adapters/mail.google.com/web2cli.yaml +52 -0
  10. web2cli/adapters/news.ycombinator.com/web2cli.yaml +356 -0
  11. web2cli/adapters/reddit.com/web2cli.yaml +233 -0
  12. web2cli/adapters/slack.com/web2cli.yaml +445 -0
  13. web2cli/adapters/stackoverflow.com/web2cli.yaml +257 -0
  14. web2cli/adapters/x.com/providers/x_graphql.py +299 -0
  15. web2cli/adapters/x.com/web2cli.yaml +449 -0
  16. web2cli/auth/__init__.py +0 -0
  17. web2cli/auth/browser_login.py +820 -0
  18. web2cli/auth/manager.py +166 -0
  19. web2cli/auth/store.py +68 -0
  20. web2cli/cli.py +1286 -0
  21. web2cli/executor/__init__.py +0 -0
  22. web2cli/executor/http.py +113 -0
  23. web2cli/output/__init__.py +0 -0
  24. web2cli/output/formatter.py +116 -0
  25. web2cli/parser/__init__.py +0 -0
  26. web2cli/parser/custom.py +21 -0
  27. web2cli/parser/html_parser.py +111 -0
  28. web2cli/parser/transforms.py +127 -0
  29. web2cli/pipe.py +10 -0
  30. web2cli/providers/__init__.py +6 -0
  31. web2cli/providers/base.py +22 -0
  32. web2cli/providers/registry.py +86 -0
  33. web2cli/runtime/__init__.py +1 -0
  34. web2cli/runtime/cache.py +42 -0
  35. web2cli/runtime/engine.py +743 -0
  36. web2cli/runtime/parser.py +398 -0
  37. web2cli/runtime/template.py +52 -0
  38. web2cli/types.py +71 -0
  39. web2cli-0.2.0.dist-info/METADATA +467 -0
  40. web2cli-0.2.0.dist-info/RECORD +44 -0
  41. web2cli-0.2.0.dist-info/WHEEL +5 -0
  42. web2cli-0.2.0.dist-info/entry_points.txt +2 -0
  43. web2cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  44. web2cli-0.2.0.dist-info/top_level.txt +1 -0
web2cli/cli.py ADDED
@@ -0,0 +1,1286 @@
1
+ """CLI entry point for web2cli."""
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from typer.core import TyperGroup
10
+
11
+ from web2cli import __version__
12
+ from web2cli.adapter.lint import lint_adapter
13
+ from web2cli.adapter.loader import AdapterNotFound, load_adapter, list_adapters
14
+ from web2cli.auth.manager import (
15
+ check_session,
16
+ create_session,
17
+ get_session,
18
+ parse_cookie_file,
19
+ parse_cookie_string,
20
+ remove_session,
21
+ )
22
+ from web2cli.auth.browser_login import (
23
+ AutoCdpSession,
24
+ BrowserLoginCancelled,
25
+ BrowserLoginError,
26
+ TokenCaptureRule,
27
+ capture_auth_with_browser,
28
+ find_local_chrome_executable,
29
+ probe_cdp_endpoint,
30
+ start_auto_cdp_chrome,
31
+ stop_auto_cdp_chrome,
32
+ )
33
+ from web2cli.auth.store import SESSIONS_DIR
34
+ from web2cli.executor.http import HttpError
35
+ from web2cli.output.formatter import format_output
36
+ from web2cli.pipe import read_stdin
37
+ from web2cli.types import AdapterSpec, CommandArg, CommandSpec
38
+ from web2cli.runtime.engine import execute_command
39
+
40
+ err = Console(stderr=True)
41
+
42
+
43
+ class CommandArgsError(Exception):
44
+ """Raised for user-facing command argument validation errors."""
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Custom TyperGroup: routes unknown subcommands to "run" handler
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class DynamicGroup(TyperGroup):
53
+ def parse_args(self, ctx, args):
54
+ if args and not args[0].startswith("-") and args[0] not in self.commands:
55
+ args = ["run"] + args
56
+ return super().parse_args(ctx, args)
57
+
58
+
59
+ app = typer.Typer(
60
+ name="web2cli",
61
+ help="Every website is a command.\n\nUsage: web2cli <domain> <command> [--args] [--format] [--fields] [--raw] [--trace] [--verbose] [--no-color]",
62
+ no_args_is_help=True,
63
+ add_completion=False,
64
+ cls=DynamicGroup,
65
+ )
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Dynamic argument parsing
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def parse_dynamic_args(
74
+ raw_args: list[str], arg_specs: dict[str, CommandArg]
75
+ ) -> tuple[dict, dict]:
76
+ """Parse CLI args against command arg definitions.
77
+
78
+ Returns (command_args, extra_global_flags).
79
+ """
80
+ command_args: dict = {}
81
+ global_flags: dict = {}
82
+ i = 0
83
+
84
+ while i < len(raw_args):
85
+ token = raw_args[i]
86
+ if not token.startswith("--"):
87
+ i += 1
88
+ continue
89
+
90
+ key = token[2:].replace("-", "_")
91
+
92
+ # Find matching arg spec — exact, then unambiguous prefix
93
+ spec = arg_specs.get(key)
94
+ if spec is None:
95
+ matches = [n for n in arg_specs if n.startswith(key)]
96
+ if len(matches) == 1:
97
+ key = matches[0]
98
+ spec = arg_specs[key]
99
+
100
+ # Unknown arg → store as extra global flag
101
+ if spec is None:
102
+ if i + 1 < len(raw_args) and not raw_args[i + 1].startswith("--"):
103
+ global_flags[token[2:].replace("-", "_")] = raw_args[i + 1]
104
+ i += 2
105
+ else:
106
+ global_flags[token[2:].replace("-", "_")] = True
107
+ i += 1
108
+ continue
109
+
110
+ # Flag type — no value
111
+ if spec.type == "flag":
112
+ command_args[key] = True
113
+ i += 1
114
+ continue
115
+
116
+ # string[] — collect repeated values
117
+ if spec.type == "string[]":
118
+ command_args.setdefault(key, [])
119
+ i += 1
120
+ if i < len(raw_args) and not raw_args[i].startswith("--"):
121
+ command_args[key].append(raw_args[i])
122
+ i += 1
123
+ continue
124
+
125
+ # All other types: consume next token as value
126
+ i += 1
127
+ if i >= len(raw_args):
128
+ raise CommandArgsError(f"--{key} expects a value")
129
+ raw_value = raw_args[i]
130
+ i += 1
131
+
132
+ if spec.type == "int":
133
+ try:
134
+ command_args[key] = int(raw_value)
135
+ except ValueError:
136
+ raise CommandArgsError(
137
+ f"--{key} expects an integer, got '{raw_value}'"
138
+ )
139
+ elif spec.type == "float":
140
+ try:
141
+ command_args[key] = float(raw_value)
142
+ except ValueError:
143
+ raise CommandArgsError(
144
+ f"--{key} expects a number, got '{raw_value}'"
145
+ )
146
+ elif spec.type == "bool":
147
+ command_args[key] = raw_value.lower() in ("true", "1", "yes")
148
+ else:
149
+ command_args[key] = raw_value
150
+
151
+ # Apply defaults
152
+ for name, spec in arg_specs.items():
153
+ if name not in command_args and spec.default is not None:
154
+ command_args[name] = spec.default
155
+
156
+ return command_args, global_flags
157
+
158
+
159
+ def validate_command_args(command_args: dict, arg_specs: dict[str, CommandArg]) -> None:
160
+ """Validate parsed args. Raises CommandArgsError on invalid input."""
161
+ # Required
162
+ missing = [
163
+ name for name, spec in arg_specs.items()
164
+ if spec.required and name not in command_args
165
+ ]
166
+ if missing:
167
+ raise CommandArgsError(
168
+ f"Missing required arguments: {', '.join('--' + m for m in missing)}"
169
+ )
170
+
171
+ # Enum
172
+ for name, spec in arg_specs.items():
173
+ if spec.enum and name in command_args and command_args[name] not in spec.enum:
174
+ raise CommandArgsError(
175
+ f"--{name} must be one of: {', '.join(spec.enum)}"
176
+ )
177
+
178
+ # Min/max clamping
179
+ for name, spec in arg_specs.items():
180
+ if name in command_args and isinstance(command_args[name], (int, float)):
181
+ if spec.min is not None and command_args[name] < spec.min:
182
+ command_args[name] = spec.min
183
+ if spec.max is not None and command_args[name] > spec.max:
184
+ command_args[name] = spec.max
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Help helpers
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ GLOBAL_FLAGS_HELP = """\
193
+ [bold]Global flags:[/bold]
194
+ --format, -f Output format: table, json, csv, plain, md
195
+ --fields Comma-separated list of fields to display
196
+ --no-truncate Disable all parser-level truncate rules
197
+ --sort Override output sort field (if command doesn't use --sort)
198
+ --sort-by Override output sort field (always safe)
199
+ --raw Show raw HTTP response body
200
+ --trace Show pipeline step trace (debug)
201
+ --verbose Show request URL, params, and timing
202
+ --no-color Disable colors and use ASCII table borders
203
+ --no-header Omit header row (csv only)"""
204
+
205
+
206
+ def print_adapter_info(adapter: AdapterSpec) -> None:
207
+ aliases = ", ".join(adapter.meta.aliases)
208
+ err.print(f"\n[bold]{adapter.meta.name}[/bold] — {adapter.meta.description}")
209
+ if aliases:
210
+ err.print(f" aliases: {aliases}")
211
+ err.print(f"\n[bold]Commands:[/bold]")
212
+ for name, cmd in adapter.commands.items():
213
+ err.print(f" {name:15} {cmd.description}")
214
+ err.print()
215
+ err.print(GLOBAL_FLAGS_HELP)
216
+ err.print()
217
+
218
+
219
+ def _unique_extend(dest: list[str], values: list[str]) -> None:
220
+ for value in values:
221
+ if value and value not in dest:
222
+ dest.append(value)
223
+
224
+
225
+ def _cookie_keys_from_auth_spec(auth_spec: dict | None) -> list[str]:
226
+ if not isinstance(auth_spec, dict):
227
+ return []
228
+ methods = auth_spec.get("methods", [])
229
+ if not isinstance(methods, list):
230
+ return []
231
+
232
+ out: list[str] = []
233
+ for method in methods:
234
+ if not isinstance(method, dict):
235
+ continue
236
+ method_type = str(method.get("type", "cookies")).lower()
237
+ if method_type != "cookies":
238
+ continue
239
+ keys = method.get("keys", [])
240
+ if not isinstance(keys, list):
241
+ continue
242
+ for key in keys:
243
+ if isinstance(key, str) and key and key not in out:
244
+ out.append(key)
245
+ return out
246
+
247
+
248
+ def _token_capture_rules_from_auth_spec(auth_spec: dict | None) -> list[TokenCaptureRule]:
249
+ if not isinstance(auth_spec, dict):
250
+ return []
251
+ methods = auth_spec.get("methods", [])
252
+ if not isinstance(methods, list):
253
+ return []
254
+
255
+ out: list[TokenCaptureRule] = []
256
+ for method in methods:
257
+ if not isinstance(method, dict):
258
+ continue
259
+ method_type = str(method.get("type", "")).lower()
260
+ if method_type != "token":
261
+ continue
262
+
263
+ capture = method.get("capture")
264
+ if not isinstance(capture, dict):
265
+ continue
266
+
267
+ source = str(capture.get("from", "")).strip().lower()
268
+ key = str(capture.get("key", "")).strip()
269
+ if not source or not key:
270
+ continue
271
+
272
+ match = capture.get("match")
273
+ match_dict = match if isinstance(match, dict) else {}
274
+ host = match_dict.get("host")
275
+ path_regex = match_dict.get("path_regex")
276
+ method_name = match_dict.get("method")
277
+ strip_prefix = capture.get("strip_prefix")
278
+
279
+ out.append(
280
+ TokenCaptureRule(
281
+ source=source,
282
+ key=key,
283
+ host=str(host).strip() if isinstance(host, str) and host.strip() else None,
284
+ path_regex=(
285
+ str(path_regex).strip()
286
+ if isinstance(path_regex, str) and path_regex.strip()
287
+ else None
288
+ ),
289
+ method=(
290
+ str(method_name).strip().upper()
291
+ if isinstance(method_name, str) and method_name.strip()
292
+ else None
293
+ ),
294
+ strip_prefix=(
295
+ str(strip_prefix)
296
+ if isinstance(strip_prefix, str) and strip_prefix
297
+ else None
298
+ ),
299
+ )
300
+ )
301
+ return out
302
+
303
+
304
+ def _token_capture_rule_text(rule: TokenCaptureRule) -> str:
305
+ parts = [f"{rule.source}:{rule.key}"]
306
+ if rule.method:
307
+ parts.append(rule.method.upper())
308
+ if rule.host:
309
+ parts.append(rule.host)
310
+ if rule.path_regex:
311
+ parts.append(f"path~{rule.path_regex}")
312
+ if rule.strip_prefix:
313
+ parts.append(f"strip_prefix={rule.strip_prefix!r}")
314
+ return " ".join(parts)
315
+
316
+
317
+ def _print_login_auth_guide(
318
+ adapter: AdapterSpec,
319
+ *,
320
+ resolved_domain: str,
321
+ login_target: str,
322
+ ) -> None:
323
+ auth = adapter.auth if isinstance(adapter.auth, dict) else {}
324
+ methods = auth.get("methods", [])
325
+ if not isinstance(methods, list):
326
+ methods = []
327
+
328
+ cookie_keys: list[str] = []
329
+ cookie_env_vars: list[str] = []
330
+ token_env_vars: list[str] = []
331
+ for method in methods:
332
+ if not isinstance(method, dict):
333
+ continue
334
+ mtype = str(method.get("type", "cookies")).lower()
335
+ env_var = method.get("env_var")
336
+ if mtype == "cookies":
337
+ keys = method.get("keys", [])
338
+ if isinstance(keys, list):
339
+ for key in keys:
340
+ if isinstance(key, str) and key and key not in cookie_keys:
341
+ cookie_keys.append(key)
342
+ if isinstance(env_var, str) and env_var and env_var not in cookie_env_vars:
343
+ cookie_env_vars.append(env_var)
344
+ elif mtype == "token":
345
+ if isinstance(env_var, str) and env_var and env_var not in token_env_vars:
346
+ token_env_vars.append(env_var)
347
+
348
+ token_capture_rules = _token_capture_rules_from_auth_spec(adapter.auth)
349
+ browser_supported = bool(cookie_keys or token_capture_rules)
350
+
351
+ err.print()
352
+ err.print(f"[bold]Auth guide for {resolved_domain}[/bold]")
353
+
354
+ if browser_supported:
355
+ err.print(
356
+ f" [green]Auto capture (recommended):[/green] "
357
+ f"`web2cli login {login_target} --browser`"
358
+ )
359
+ else:
360
+ err.print(" [yellow]Auto capture:[/yellow] not configured in this adapter")
361
+
362
+ if cookie_keys:
363
+ cookie_example = "; ".join(f"{k}=<value>" for k in cookie_keys)
364
+ cookie_json_example = ", ".join(f'"{k}": "<value>"' for k in cookie_keys)
365
+ err.print(f" Cookies required: {', '.join(cookie_keys)}")
366
+ err.print(
367
+ f" Manual: `web2cli login {login_target} --cookies \"{cookie_example}\"`"
368
+ )
369
+ err.print(
370
+ f" File: `web2cli login {login_target} --cookie-file /path/cookies.json` "
371
+ f"(JSON: {{{cookie_json_example}}})"
372
+ )
373
+
374
+ if token_env_vars or token_capture_rules:
375
+ err.print(f" Token: `web2cli login {login_target} --token \"<token>\"`")
376
+
377
+ if cookie_env_vars:
378
+ cookie_env_example = "; ".join(f"{k}=<value>" for k in cookie_keys) or "k=v"
379
+ for env_var in cookie_env_vars:
380
+ err.print(f" Env: `export {env_var}=\"{cookie_env_example}\"`")
381
+
382
+ if token_env_vars:
383
+ for env_var in token_env_vars:
384
+ err.print(f" Env: `export {env_var}=\"<token>\"`")
385
+
386
+ if token_capture_rules:
387
+ err.print(" Token capture rules from adapter:")
388
+ for idx, rule in enumerate(token_capture_rules, start=1):
389
+ err.print(f" {idx}. {_token_capture_rule_text(rule)}")
390
+ err.print(
391
+ " Manual token extraction: open DevTools -> Network, find a request matching "
392
+ "the rule, copy the token value."
393
+ )
394
+
395
+ err.print(f" Check: `web2cli login {login_target} --status`")
396
+
397
+
398
+ def _field_names_from_parse_spec(parse_spec: dict[str, Any]) -> list[str]:
399
+ fields = parse_spec.get("fields")
400
+ if not isinstance(fields, list):
401
+ return []
402
+ out: list[str] = []
403
+ for field in fields:
404
+ if isinstance(field, dict) and isinstance(field.get("name"), str):
405
+ _unique_extend(out, [field["name"]])
406
+ return out
407
+
408
+
409
+ def _resource_output_fields(
410
+ adapter: AdapterSpec,
411
+ resource_name: str,
412
+ ) -> tuple[list[str], bool]:
413
+ resource_spec = adapter.resources.get(resource_name)
414
+ if not isinstance(resource_spec, dict):
415
+ return [], False
416
+
417
+ parse_spec = resource_spec.get("response") or resource_spec.get("parse")
418
+ if not isinstance(parse_spec, dict):
419
+ return [], False
420
+
421
+ names = _field_names_from_parse_spec(parse_spec)
422
+ if names:
423
+ return names, True
424
+ return [], False
425
+
426
+
427
+ def _collect_pipeline_steps(cmd: CommandSpec) -> list[tuple[str, str, dict[str, Any]]]:
428
+ steps: list[tuple[str, str, dict[str, Any]]] = []
429
+ for idx, raw_step in enumerate(cmd.pipeline or []):
430
+ if not isinstance(raw_step, dict):
431
+ continue
432
+ step_type = None
433
+ for key in ("resolve", "request", "fanout", "parse", "transform"):
434
+ if key in raw_step:
435
+ step_type = key
436
+ break
437
+ if step_type is None:
438
+ continue
439
+
440
+ step_spec = raw_step.get(step_type) or {}
441
+ if not isinstance(step_spec, dict):
442
+ step_spec = {}
443
+ step_name = str(step_spec.get("name") or raw_step.get("name") or f"{step_type}_{idx}")
444
+ steps.append((step_name, step_type, step_spec))
445
+ return steps
446
+
447
+
448
+ def _infer_command_fields(
449
+ adapter: AdapterSpec,
450
+ cmd: CommandSpec,
451
+ ) -> tuple[list[str], list[str], bool]:
452
+ raw_default_fields = cmd.output.get("default_fields")
453
+ default_fields = (
454
+ [str(x) for x in raw_default_fields if str(x)]
455
+ if isinstance(raw_default_fields, list)
456
+ else []
457
+ )
458
+
459
+ steps = _collect_pipeline_steps(cmd)
460
+ if not steps:
461
+ return list(default_fields), default_fields, False
462
+
463
+ step_index = {name: i for i, (name, _, _) in enumerate(steps)}
464
+ cache: dict[str, tuple[list[str], bool]] = {}
465
+
466
+ def _prev_step_name(index: int) -> str | None:
467
+ return steps[index - 1][0] if index > 0 else None
468
+
469
+ def _infer(step_name: str, visiting: set[str]) -> tuple[list[str], bool]:
470
+ if step_name in cache:
471
+ return cache[step_name]
472
+ if step_name in visiting:
473
+ return [], False
474
+ index = step_index.get(step_name)
475
+ if index is None:
476
+ return [], False
477
+
478
+ visiting.add(step_name)
479
+ _, step_type, step_spec = steps[index]
480
+ fields: list[str] = []
481
+ complete = True
482
+
483
+ if step_type == "parse":
484
+ if step_spec.get("parser") == "custom":
485
+ complete = False
486
+ else:
487
+ parse_fields = _field_names_from_parse_spec(step_spec)
488
+ if parse_fields:
489
+ _unique_extend(fields, parse_fields)
490
+ else:
491
+ complete = False
492
+ from_step = step_spec.get("from") or _prev_step_name(index)
493
+ if isinstance(from_step, str):
494
+ source_fields, source_complete = _infer(from_step, visiting)
495
+ _unique_extend(fields, source_fields)
496
+ complete = complete and source_complete
497
+
498
+ elif step_type == "resolve":
499
+ resource_name = step_spec.get("resource")
500
+ if isinstance(resource_name, str):
501
+ resource_fields, resource_complete = _resource_output_fields(adapter, resource_name)
502
+ _unique_extend(fields, resource_fields)
503
+ complete = resource_complete
504
+ else:
505
+ complete = False
506
+
507
+ elif step_type == "transform":
508
+ from_step = step_spec.get("from") or _prev_step_name(index)
509
+ if isinstance(from_step, str):
510
+ source_fields, source_complete = _infer(from_step, visiting)
511
+ _unique_extend(fields, source_fields)
512
+ complete = source_complete
513
+ else:
514
+ complete = False
515
+
516
+ ops = step_spec.get("ops", [])
517
+ if isinstance(ops, list):
518
+ for op in ops:
519
+ if not (isinstance(op, dict) and "concat" in op):
520
+ continue
521
+ cfg = op.get("concat") or {}
522
+ extra_steps = cfg.get("steps", [])
523
+ if isinstance(extra_steps, str):
524
+ extra_steps = [extra_steps]
525
+ if not isinstance(extra_steps, list):
526
+ complete = False
527
+ continue
528
+ for extra_step in extra_steps:
529
+ if not isinstance(extra_step, str):
530
+ complete = False
531
+ continue
532
+ extra_fields, extra_complete = _infer(extra_step, visiting)
533
+ _unique_extend(fields, extra_fields)
534
+ complete = complete and extra_complete
535
+
536
+ else:
537
+ # request/fanout output shape is dynamic unless parsed later.
538
+ complete = False
539
+
540
+ visiting.remove(step_name)
541
+ cache[step_name] = (fields, complete)
542
+ return cache[step_name]
543
+
544
+ output_from = cmd.output.get("from_step")
545
+ if not isinstance(output_from, str) or output_from not in step_index:
546
+ output_from = steps[-1][0]
547
+
548
+ inferred_fields, fields_complete = _infer(output_from, set())
549
+ available_fields = list(inferred_fields)
550
+ _unique_extend(available_fields, default_fields)
551
+ return available_fields, default_fields, fields_complete
552
+
553
+
554
+ def print_command_help(adapter: AdapterSpec, cmd: CommandSpec) -> None:
555
+ err.print(
556
+ f"\n[bold]web2cli {adapter.meta.name} {cmd.name}[/bold]"
557
+ f" — {cmd.description}\n"
558
+ )
559
+ if not cmd.args:
560
+ err.print(" (no arguments)")
561
+ else:
562
+ err.print("[bold]Arguments:[/bold]")
563
+ for name, arg in cmd.args.items():
564
+ req = "[red]required[/red]" if arg.required else f"default: {arg.default}"
565
+ desc = arg.description or ""
566
+ enum_str = f" [{', '.join(arg.enum)}]" if arg.enum else ""
567
+ pipe_str = " [dim]pipeable[/dim]" if "stdin" in arg.source else ""
568
+ err.print(f" --{name:15} {arg.type:10} {desc}{enum_str} ({req}){pipe_str}")
569
+
570
+ available_fields, default_fields, fields_complete = _infer_command_fields(adapter, cmd)
571
+ if available_fields:
572
+ default_set = set(default_fields)
573
+ err.print("\n[bold]Fields:[/bold]")
574
+ for field_name in available_fields:
575
+ suffix = " [green](default)[/green]" if field_name in default_set else ""
576
+ err.print(f" {field_name}{suffix}")
577
+ if not fields_complete:
578
+ err.print(" [dim](inferred list may be incomplete for dynamic outputs)[/dim]")
579
+
580
+ err.print()
581
+ err.print(GLOBAL_FLAGS_HELP)
582
+ err.print()
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # Main command: web2cli <domain> <command> [--args]
587
+ # ---------------------------------------------------------------------------
588
+
589
+
590
+ @app.command(
591
+ "run",
592
+ hidden=True,
593
+ context_settings={
594
+ "allow_extra_args": True,
595
+ "allow_interspersed_args": True,
596
+ "ignore_unknown_options": True,
597
+ "help_option_names": [],
598
+ },
599
+ )
600
+ def run_command(
601
+ ctx: typer.Context,
602
+ domain: str = typer.Argument(
603
+ ...,
604
+ help="Domain or adapter alias",
605
+ ),
606
+ command: str = typer.Argument(
607
+ None,
608
+ help="Command to execute",
609
+ ),
610
+ output_format: str = typer.Option(
611
+ None, "--format", "-f", help="Output format (table|json|csv|plain)"
612
+ ),
613
+ fields: str = typer.Option(None, "--fields", help="Comma-separated fields"),
614
+ no_truncate: bool = typer.Option(
615
+ False,
616
+ "--no-truncate",
617
+ help="Disable parser-level truncation rules",
618
+ ),
619
+ raw: bool = typer.Option(False, "--raw", help="Show raw HTTP response"),
620
+ trace: bool = typer.Option(False, "--trace", help="Show pipeline step trace"),
621
+ verbose: bool = typer.Option(False, "--verbose", help="Show request details"),
622
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
623
+ no_header: bool = typer.Option(False, "--no-header", help="Omit header row (csv)"),
624
+ ) -> None:
625
+ """Execute an adapter command."""
626
+ # Load adapter
627
+ try:
628
+ adapter = load_adapter(domain)
629
+ except AdapterNotFound as e:
630
+ err.print(f"[red]{e}[/red]")
631
+ raise typer.Exit(1)
632
+
633
+ # Help handling
634
+ help_requested = "--help" in ctx.args or command == "--help"
635
+ if command == "--help":
636
+ command = None
637
+
638
+ # No command → show adapter info
639
+ if command is None or (help_requested and command not in adapter.commands):
640
+ print_adapter_info(adapter)
641
+ raise typer.Exit(0)
642
+
643
+ # Command help
644
+ if help_requested and command in adapter.commands:
645
+ print_command_help(adapter, adapter.commands[command])
646
+ raise typer.Exit(0)
647
+
648
+ # Resolve command
649
+ if command not in adapter.commands:
650
+ err.print(f"[red]Unknown command '{command}' for {adapter.meta.domain}[/red]")
651
+ err.print(f"Available: {', '.join(adapter.commands.keys())}")
652
+ raise typer.Exit(1)
653
+
654
+ cmd_spec = adapter.commands[command]
655
+
656
+ # Parse + validate command args
657
+ try:
658
+ command_args, extra_globals = parse_dynamic_args(ctx.args, cmd_spec.args)
659
+ except CommandArgsError as e:
660
+ err.print(f"[red]{e}[/red]")
661
+ err.print()
662
+ print_command_help(adapter, cmd_spec)
663
+ raise typer.Exit(1)
664
+
665
+ # Merge extra globals
666
+ for k, v in extra_globals.items():
667
+ if k == "limit":
668
+ try:
669
+ extra_globals[k] = int(v)
670
+ except (ValueError, TypeError):
671
+ pass
672
+
673
+ # --- Stdin injection ---
674
+ stdin_value = read_stdin()
675
+ if stdin_value:
676
+ for arg_name, arg_spec in cmd_spec.args.items():
677
+ if "stdin" in arg_spec.source and arg_name not in command_args:
678
+ command_args[arg_name] = stdin_value
679
+ break
680
+
681
+ # Re-validate after stdin injection (required/enum/min/max rules)
682
+ try:
683
+ validate_command_args(command_args, cmd_spec.args)
684
+ except CommandArgsError as e:
685
+ err.print(f"[red]{e}[/red]")
686
+ err.print()
687
+ print_command_help(adapter, cmd_spec)
688
+ raise typer.Exit(1)
689
+
690
+ # --- Load session (if adapter supports auth) ---
691
+ session = get_session(adapter.meta.domain, adapter.auth)
692
+
693
+ try:
694
+ run_result = execute_command(
695
+ adapter=adapter,
696
+ cmd=cmd_spec,
697
+ args=command_args,
698
+ session=session,
699
+ verbose=verbose,
700
+ trace=trace,
701
+ no_truncate=no_truncate,
702
+ )
703
+ except HttpError as e:
704
+ err.print(f"[red]{e}[/red]")
705
+ raise typer.Exit(1)
706
+ except Exception as e:
707
+ err.print(f"[red]{e}[/red]")
708
+ raise typer.Exit(1)
709
+
710
+ if trace:
711
+ for line in run_result.trace_lines:
712
+ err.print(f"[dim]{line}[/dim]")
713
+
714
+ if raw:
715
+ print(run_result.last_response_body or "")
716
+ raise typer.Exit(0)
717
+
718
+ records = run_result.records
719
+
720
+ if not records:
721
+ err.print("[yellow]No results.[/yellow]")
722
+ raise typer.Exit(0)
723
+
724
+ # --- Sort ---
725
+ output_spec = cmd_spec.output
726
+ global_sort = extra_globals.get("sort_by")
727
+ if global_sort is None:
728
+ global_sort = extra_globals.get("sort")
729
+
730
+ if isinstance(global_sort, bool):
731
+ err.print("[red]--sort/--sort-by expects a field name[/red]")
732
+ raise typer.Exit(1)
733
+
734
+ sort_by = global_sort or output_spec.get("sort_by")
735
+ sort_order = output_spec.get("sort_order", "desc")
736
+ should_sort = bool(sort_by)
737
+
738
+ if should_sort and records:
739
+ records.sort(
740
+ key=lambda r: r.get(sort_by, 0) or 0,
741
+ reverse=(sort_order == "desc"),
742
+ )
743
+
744
+ # --- Limit ---
745
+ limit = extra_globals.get("limit")
746
+ if limit is None:
747
+ # Use command arg limit for post-processing cap too
748
+ limit = command_args.get("limit")
749
+ if limit:
750
+ try:
751
+ records = records[:int(limit)]
752
+ except (ValueError, TypeError):
753
+ pass
754
+
755
+ # --- Format and output ---
756
+ fmt = output_format or output_spec.get("default_format", "table")
757
+ show_fields = (
758
+ fields.split(",") if fields
759
+ else output_spec.get("default_fields")
760
+ )
761
+
762
+ result = format_output(records, fmt, show_fields, no_color, no_header=no_header)
763
+ if result:
764
+ print(result)
765
+
766
+
767
+ # ---------------------------------------------------------------------------
768
+ # Subcommand: web2cli adapters list|info
769
+ # ---------------------------------------------------------------------------
770
+
771
+
772
+ adapters_app = typer.Typer(
773
+ name="adapters", help="Manage adapters",
774
+ invoke_without_command=True,
775
+ )
776
+ app.add_typer(adapters_app)
777
+
778
+
779
+ @adapters_app.callback()
780
+ def adapters_callback(ctx: typer.Context) -> None:
781
+ if ctx.invoked_subcommand is None:
782
+ typer.echo(ctx.get_help())
783
+ raise typer.Exit(0)
784
+
785
+
786
+ @adapters_app.command("list")
787
+ def adapters_list() -> None:
788
+ """List all available adapters."""
789
+ for adapter in list_adapters():
790
+ aliases = ", ".join(adapter.meta.aliases)
791
+ alias_str = f" ({aliases})" if aliases else ""
792
+ cmds = ", ".join(adapter.commands.keys())
793
+ err.print(
794
+ f" [bold]{adapter.meta.domain}[/bold]{alias_str}"
795
+ f" — {len(adapter.commands)} commands: {cmds}"
796
+ )
797
+
798
+
799
+ @adapters_app.command("info")
800
+ def adapters_info(
801
+ domain: str = typer.Argument(
802
+ ...,
803
+ help="Domain or alias",
804
+ ),
805
+ ) -> None:
806
+ """Show details for an adapter."""
807
+ try:
808
+ adapter = load_adapter(domain)
809
+ except AdapterNotFound as e:
810
+ err.print(f"[red]{e}[/red]")
811
+ raise typer.Exit(1)
812
+ print_adapter_info(adapter)
813
+
814
+
815
+ @adapters_app.command("validate")
816
+ def adapters_validate() -> None:
817
+ """Validate all available adapters."""
818
+ had_errors = False
819
+ adapters = list_adapters()
820
+ for adapter in adapters:
821
+ domain = adapter.meta.domain
822
+ try:
823
+ load_adapter(domain)
824
+ err.print(f"[green]ok[/green] {domain}")
825
+ except Exception as e:
826
+ had_errors = True
827
+ err.print(f"[red]error[/red] {domain}: {e}")
828
+
829
+ if had_errors:
830
+ raise typer.Exit(1)
831
+
832
+
833
+ @adapters_app.command("lint")
834
+ def adapters_lint(
835
+ domain: str | None = typer.Argument(
836
+ None,
837
+ help="Optional domain or alias",
838
+ ),
839
+ ) -> None:
840
+ """Run semantic lint checks for adapter specs."""
841
+ had_errors = False
842
+ adapters: list[AdapterSpec] = []
843
+
844
+ if domain:
845
+ try:
846
+ adapters = [load_adapter(domain)]
847
+ except Exception as e:
848
+ err.print(f"[red]error[/red] {domain}: {e}")
849
+ raise typer.Exit(1)
850
+ else:
851
+ for listed in list_adapters():
852
+ try:
853
+ adapters.append(load_adapter(listed.meta.domain))
854
+ except Exception as e:
855
+ had_errors = True
856
+ err.print(f"[red]error[/red] {listed.meta.domain}: {e}")
857
+
858
+ for adapter in adapters:
859
+ issues = lint_adapter(adapter)
860
+ errors = [i for i in issues if i.level == "error"]
861
+ warnings = [i for i in issues if i.level == "warning"]
862
+
863
+ if errors:
864
+ had_errors = True
865
+ err.print(
866
+ f"[red]error[/red] {adapter.meta.domain}: "
867
+ f"{len(errors)} error(s), {len(warnings)} warning(s)"
868
+ )
869
+ elif warnings:
870
+ err.print(
871
+ f"[yellow]warn[/yellow] {adapter.meta.domain}: "
872
+ f"{len(warnings)} warning(s)"
873
+ )
874
+ else:
875
+ err.print(f"[green]ok[/green] {adapter.meta.domain}")
876
+
877
+ for issue in issues:
878
+ level = "E" if issue.level == "error" else "W"
879
+ err.print(f" {level} {issue.path}: {issue.message}")
880
+
881
+ if had_errors:
882
+ raise typer.Exit(1)
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # Subcommand: web2cli doctor
887
+ # ---------------------------------------------------------------------------
888
+
889
+
890
+ doctor_app = typer.Typer(
891
+ name="doctor",
892
+ help="Run environment diagnostics",
893
+ invoke_without_command=True,
894
+ )
895
+ app.add_typer(doctor_app)
896
+
897
+
898
+ @doctor_app.callback()
899
+ def doctor_callback(ctx: typer.Context) -> None:
900
+ if ctx.invoked_subcommand is None:
901
+ typer.echo(ctx.get_help())
902
+ raise typer.Exit(0)
903
+
904
+
905
+ def _print_doctor_status(status: str, name: str, detail: str) -> None:
906
+ if status == "ok":
907
+ err.print(f" [green]ok[/green] {name}: {detail}")
908
+ elif status == "warn":
909
+ err.print(f" [yellow]warn[/yellow] {name}: {detail}")
910
+ else:
911
+ err.print(f" [red]fail[/red] {name}: {detail}")
912
+
913
+
914
+ def _doctor_error_summary(exc: Exception, max_len: int = 220) -> str:
915
+ text = str(exc).strip()
916
+ if not text:
917
+ return exc.__class__.__name__
918
+ line = text.splitlines()[0].strip()
919
+ if len(line) > max_len:
920
+ return line[: max_len - 3] + "..."
921
+ return line
922
+
923
+
924
+ @doctor_app.command("browser")
925
+ def doctor_browser(
926
+ deep: bool = typer.Option(
927
+ False,
928
+ "--deep",
929
+ help="Run launch smoke tests (starts/stops browser processes)",
930
+ ),
931
+ cdp_url: str = typer.Option(
932
+ "http://127.0.0.1:9222",
933
+ "--cdp-url",
934
+ help="CDP endpoint to probe (info only)",
935
+ ),
936
+ ) -> None:
937
+ """Diagnose browser stack used by `web2cli login --browser`."""
938
+ failures = 0
939
+ warnings = 0
940
+
941
+ err.print("\n[bold]Browser Doctor[/bold]\n")
942
+
943
+ chrome_path = find_local_chrome_executable()
944
+ if chrome_path:
945
+ _print_doctor_status("ok", "chrome", chrome_path)
946
+ else:
947
+ _print_doctor_status("warn", "chrome", "not found in standard locations/PATH")
948
+ warnings += 1
949
+
950
+ cdp_running = probe_cdp_endpoint(cdp_url, timeout_seconds=1.0)
951
+ if cdp_running:
952
+ _print_doctor_status("ok", "cdp-endpoint", f"reachable at {cdp_url}")
953
+ else:
954
+ _print_doctor_status("warn", "cdp-endpoint", f"not reachable at {cdp_url}")
955
+ warnings += 1
956
+
957
+ playwright_async_api = None
958
+ try:
959
+ from playwright import async_api as playwright_async_api # type: ignore[assignment]
960
+
961
+ _print_doctor_status("ok", "playwright-python", "importable")
962
+ except Exception as e:
963
+ _print_doctor_status("fail", "playwright-python", str(e))
964
+ failures += 1
965
+
966
+ if deep:
967
+ err.print("\n[bold]Deep checks[/bold]")
968
+
969
+ auto_session: AutoCdpSession | None = None
970
+ try:
971
+ auto_session = start_auto_cdp_chrome(
972
+ status_cb=None,
973
+ debug_cb=None,
974
+ headless=True,
975
+ )
976
+ _print_doctor_status("ok", "cdp-auto-launch", auto_session.cdp_url)
977
+ if probe_cdp_endpoint(auto_session.cdp_url, timeout_seconds=1.5):
978
+ _print_doctor_status("ok", "cdp-auto-probe", "endpoint responded")
979
+ else:
980
+ _print_doctor_status("fail", "cdp-auto-probe", "endpoint did not respond")
981
+ failures += 1
982
+ except Exception as e:
983
+ _print_doctor_status("fail", "cdp-auto-launch", _doctor_error_summary(e))
984
+ failures += 1
985
+ finally:
986
+ if auto_session is not None:
987
+ stop_auto_cdp_chrome(auto_session)
988
+
989
+ if playwright_async_api is not None:
990
+ async def _probe_playwright_launch() -> None:
991
+ async with playwright_async_api.async_playwright() as p:
992
+ browser = await p.chromium.launch(
993
+ headless=True,
994
+ ignore_default_args=["--enable-automation", "--no-sandbox"],
995
+ )
996
+ await browser.close()
997
+
998
+ try:
999
+ asyncio.run(_probe_playwright_launch())
1000
+ _print_doctor_status("ok", "playwright-launch", "chromium launched headless")
1001
+ except Exception as e:
1002
+ _print_doctor_status("fail", "playwright-launch", _doctor_error_summary(e))
1003
+ failures += 1
1004
+
1005
+ err.print()
1006
+ if failures:
1007
+ err.print(
1008
+ f"[red]browser doctor failed: {failures} failure(s), {warnings} warning(s)[/red]"
1009
+ )
1010
+ raise typer.Exit(1)
1011
+
1012
+ if warnings:
1013
+ err.print(
1014
+ f"[yellow]browser doctor passed with warnings ({warnings})[/yellow]"
1015
+ )
1016
+ else:
1017
+ err.print("[green]browser doctor passed[/green]")
1018
+
1019
+
1020
+ # ---------------------------------------------------------------------------
1021
+ # Subcommand: web2cli login / logout
1022
+ # ---------------------------------------------------------------------------
1023
+
1024
+
1025
+ @app.command("login")
1026
+ def login_command(
1027
+ ctx: typer.Context,
1028
+ domain: str | None = typer.Argument(
1029
+ None,
1030
+ help="Domain or alias to authenticate",
1031
+ ),
1032
+ cookies: str = typer.Option(None, "--cookies", help='Cookies string "k=v; k2=v2"'),
1033
+ cookie_file: str = typer.Option(None, "--cookie-file", help="Path to cookies JSON"),
1034
+ token: str = typer.Option(None, "--token", help="Auth token"),
1035
+ browser: bool = typer.Option(
1036
+ False,
1037
+ "--browser",
1038
+ help="Open browser and capture required auth values automatically",
1039
+ ),
1040
+ browser_debug: bool = typer.Option(
1041
+ False,
1042
+ "--browser-debug",
1043
+ help="Show browser auth-capture debug (cookies/token/tabs)",
1044
+ ),
1045
+ browser_cdp_url: str = typer.Option(
1046
+ None,
1047
+ "--browser-cdp-url",
1048
+ help="Attach to existing Chrome via CDP (e.g. http://127.0.0.1:9222)",
1049
+ hidden=True,
1050
+ ),
1051
+ browser_cdp_auto: bool = typer.Option(
1052
+ False,
1053
+ "--browser-cdp-auto",
1054
+ help="Start local Chrome automatically and attach via CDP",
1055
+ hidden=True,
1056
+ ),
1057
+ browser_cdp_port: int = typer.Option(
1058
+ None,
1059
+ "--browser-cdp-port",
1060
+ help="CDP port for --browser-cdp-auto (default: random free port)",
1061
+ hidden=True,
1062
+ ),
1063
+ browser_chrome_path: str = typer.Option(
1064
+ None,
1065
+ "--browser-chrome-path",
1066
+ help="Chrome executable path for --browser-cdp-auto",
1067
+ hidden=True,
1068
+ ),
1069
+ status: bool = typer.Option(False, "--status", help="Check login status"),
1070
+ ) -> None:
1071
+ """Save authentication session for a domain.
1072
+
1073
+ Examples:
1074
+ web2cli login hn --browser
1075
+ web2cli login slack --browser --browser-debug
1076
+ web2cli login reddit --cookies "reddit_session=..."
1077
+ web2cli login discord --token "..."
1078
+ web2cli login x --status
1079
+ web2cli login hn # show adapter-specific auth guide
1080
+ """
1081
+ if not domain:
1082
+ err.print(ctx.get_help())
1083
+ raise typer.Exit(0)
1084
+
1085
+ # Resolve alias → adapter domain
1086
+ adapter: AdapterSpec | None = None
1087
+ try:
1088
+ adapter = load_adapter(domain)
1089
+ resolved_domain = adapter.meta.domain
1090
+ except AdapterNotFound:
1091
+ resolved_domain = domain
1092
+
1093
+ # --status: show session info and exit
1094
+ if status:
1095
+ info = check_session(resolved_domain)
1096
+ if not info.get("exists"):
1097
+ err.print(f"[yellow]No session for {resolved_domain}[/yellow]")
1098
+ raise typer.Exit(1)
1099
+ err.print(f"[green]Logged in to {resolved_domain}[/green]")
1100
+ err.print(f" type: {info.get('auth_type', '?')}")
1101
+ if info.get("cookie_keys"):
1102
+ err.print(f" cookies: {', '.join(info['cookie_keys'])}")
1103
+ if info.get("has_token"):
1104
+ err.print(" token: present")
1105
+ if info.get("created_at"):
1106
+ err.print(f" created: {info['created_at']}")
1107
+ raise typer.Exit(0)
1108
+
1109
+ if browser and (cookies or cookie_file or token):
1110
+ err.print(
1111
+ "[red]--browser cannot be combined with --cookies, --cookie-file, or --token[/red]"
1112
+ )
1113
+ raise typer.Exit(1)
1114
+ if browser_cdp_url and not browser:
1115
+ err.print("[red]--browser-cdp-url requires --browser[/red]")
1116
+ raise typer.Exit(1)
1117
+ if browser_cdp_auto and not browser:
1118
+ err.print("[red]--browser-cdp-auto requires --browser[/red]")
1119
+ raise typer.Exit(1)
1120
+ if browser_cdp_url and browser_cdp_auto:
1121
+ err.print("[red]Use only one: --browser-cdp-url or --browser-cdp-auto[/red]")
1122
+ raise typer.Exit(1)
1123
+ if browser_cdp_port is not None and not browser:
1124
+ err.print("[red]--browser-cdp-port requires --browser[/red]")
1125
+ raise typer.Exit(1)
1126
+ if browser_chrome_path and not browser:
1127
+ err.print("[red]--browser-chrome-path requires --browser[/red]")
1128
+ raise typer.Exit(1)
1129
+ if browser_cdp_url and browser_cdp_port is not None:
1130
+ err.print("[red]--browser-cdp-port cannot be used with --browser-cdp-url[/red]")
1131
+ raise typer.Exit(1)
1132
+ if browser_cdp_url and browser_chrome_path:
1133
+ err.print("[red]--browser-chrome-path cannot be used with --browser-cdp-url[/red]")
1134
+ raise typer.Exit(1)
1135
+ if browser_debug and not browser:
1136
+ err.print("[red]--browser-debug requires --browser[/red]")
1137
+ raise typer.Exit(1)
1138
+
1139
+ if browser:
1140
+ if adapter is None:
1141
+ err.print(
1142
+ f"[red]No adapter found for '{domain}'. --browser requires a known adapter "
1143
+ "with browser auth capture config.[/red]"
1144
+ )
1145
+ raise typer.Exit(1)
1146
+
1147
+ required_cookie_keys = _cookie_keys_from_auth_spec(adapter.auth)
1148
+ token_capture_rules = _token_capture_rules_from_auth_spec(adapter.auth)
1149
+ if not required_cookie_keys and not token_capture_rules:
1150
+ err.print(
1151
+ f"[red]{resolved_domain} does not declare browser-capturable auth in auth.methods "
1152
+ "(cookie keys and/or token capture rules)[/red]"
1153
+ )
1154
+ raise typer.Exit(1)
1155
+
1156
+ err.print(f"\n[bold]Logging into {resolved_domain}[/bold]\n")
1157
+ err.print(" Opening browser...")
1158
+ err.print(f" → Log in to {resolved_domain} if needed")
1159
+ err.print(" → I'll capture required auth values automatically when ready")
1160
+ if browser_cdp_url:
1161
+ err.print(f" → Using existing browser via CDP: {browser_cdp_url}")
1162
+ err.print(" → Press Ctrl+C to cancel\n")
1163
+ err.print(" ⏳ Waiting for login... (detected: not logged in)")
1164
+ if browser_debug:
1165
+ err.print(" [dim]Browser debug enabled[/dim]")
1166
+
1167
+ try:
1168
+ parsed_cookies, captured_token = capture_auth_with_browser(
1169
+ domain=resolved_domain,
1170
+ required_cookies=required_cookie_keys,
1171
+ token_rules=token_capture_rules,
1172
+ status_cb=lambda msg: err.print(f" {msg}"),
1173
+ debug_cb=(
1174
+ (lambda msg: err.print(f" [dim]browser-debug:[/dim] {escape(msg)}"))
1175
+ if browser_debug
1176
+ else None
1177
+ ),
1178
+ cdp_url=browser_cdp_url,
1179
+ cdp_auto=browser_cdp_auto,
1180
+ cdp_port=browser_cdp_port,
1181
+ chrome_path=browser_chrome_path,
1182
+ )
1183
+ except BrowserLoginCancelled:
1184
+ err.print("[yellow]Login cancelled.[/yellow]")
1185
+ raise typer.Exit(1)
1186
+ except BrowserLoginError as e:
1187
+ err.print(f"[red]{e}[/red]")
1188
+ raise typer.Exit(1)
1189
+
1190
+ err.print(" ✓ Login detected! Capturing session...")
1191
+ create_session(
1192
+ resolved_domain,
1193
+ cookies=parsed_cookies or None,
1194
+ token=captured_token,
1195
+ )
1196
+ session_path = SESSIONS_DIR / f"{resolved_domain}.json.enc"
1197
+ auth_parts: list[str] = []
1198
+ if parsed_cookies:
1199
+ auth_parts.append("cookies")
1200
+ if captured_token:
1201
+ auth_parts.append("token")
1202
+ auth_label = "+".join(auth_parts) if auth_parts else "auth"
1203
+ err.print(f" ✓ {auth_label.capitalize()} encrypted and saved to {session_path}")
1204
+ err.print()
1205
+ err.print(" Try it now:")
1206
+ run_target = adapter.meta.aliases[0] if adapter.meta.aliases else adapter.meta.domain
1207
+ if "search" in adapter.commands:
1208
+ sample_cmd = "search"
1209
+ elif adapter.commands:
1210
+ sample_cmd = next(iter(adapter.commands))
1211
+ else:
1212
+ sample_cmd = "--help"
1213
+ err.print(f" web2cli {run_target} {sample_cmd}")
1214
+ raise typer.Exit(0)
1215
+
1216
+ # Parse cookies from string or file
1217
+ parsed_cookies = None
1218
+ if cookies:
1219
+ parsed_cookies = parse_cookie_string(cookies)
1220
+ elif cookie_file:
1221
+ try:
1222
+ parsed_cookies = parse_cookie_file(cookie_file)
1223
+ except (OSError, ValueError) as e:
1224
+ err.print(f"[red]Failed to read cookie file: {e}[/red]")
1225
+ raise typer.Exit(1)
1226
+
1227
+ if not parsed_cookies and not token:
1228
+ err.print("[red]Provide one of: --cookies, --cookie-file, --token, or --browser[/red]")
1229
+ if adapter is not None:
1230
+ login_target = adapter.meta.aliases[0] if adapter.meta.aliases else domain
1231
+ _print_login_auth_guide(
1232
+ adapter,
1233
+ resolved_domain=resolved_domain,
1234
+ login_target=login_target,
1235
+ )
1236
+ raise typer.Exit(1)
1237
+
1238
+ # Warn about missing keys if adapter has an auth spec
1239
+ if adapter and adapter.auth and parsed_cookies:
1240
+ for method in adapter.auth.get("methods", []):
1241
+ expected = method.get("keys", [])
1242
+ missing = [k for k in expected if k not in parsed_cookies]
1243
+ if missing:
1244
+ err.print(
1245
+ f"[yellow]Warning: adapter expects cookie keys "
1246
+ f"{missing} but they were not provided[/yellow]"
1247
+ )
1248
+
1249
+ # Save session
1250
+ session = create_session(resolved_domain, cookies=parsed_cookies, token=token)
1251
+ err.print(f"[green]Session saved for {resolved_domain} ({session.auth_type})[/green]")
1252
+
1253
+
1254
+ @app.command("logout")
1255
+ def logout_command(
1256
+ domain: str = typer.Argument(
1257
+ ...,
1258
+ help="Domain or alias to log out",
1259
+ ),
1260
+ ) -> None:
1261
+ """Remove stored session for a domain."""
1262
+ try:
1263
+ adapter = load_adapter(domain)
1264
+ resolved_domain = adapter.meta.domain
1265
+ except AdapterNotFound:
1266
+ resolved_domain = domain
1267
+
1268
+ if remove_session(resolved_domain):
1269
+ err.print(f"[green]Session removed for {resolved_domain}[/green]")
1270
+ else:
1271
+ err.print(f"[yellow]No session found for {resolved_domain}[/yellow]")
1272
+
1273
+
1274
+ # ---------------------------------------------------------------------------
1275
+ # Version callback
1276
+ # ---------------------------------------------------------------------------
1277
+
1278
+
1279
+ @app.callback(invoke_without_command=True)
1280
+ def main(
1281
+ ctx: typer.Context,
1282
+ version: bool = typer.Option(False, "--version", "-v", help="Show version"),
1283
+ ) -> None:
1284
+ if version:
1285
+ typer.echo(f"web2cli {__version__}")
1286
+ raise typer.Exit()