waidrop-cli 1.0.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.
waidrop_cli/cli.py ADDED
@@ -0,0 +1,733 @@
1
+ """waidrop command line interface (encrypted Dropbox archive)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import json
8
+ import os
9
+ import sys
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .archive import ArchiveCodec, resolve_archive
15
+ from .auth import (
16
+ Credentials,
17
+ delete_credentials,
18
+ load_credentials,
19
+ missing_scopes,
20
+ save_credentials,
21
+ )
22
+ from .auth_flow import run_loopback_flow, run_paste_flow
23
+ from .chats import ChatEntry, load_directory, select_chats
24
+ from .config import (
25
+ ACCOUNT_RE,
26
+ DEFAULT_ACCOUNT,
27
+ AuthConfig,
28
+ DropboxConfig,
29
+ Window,
30
+ load_config,
31
+ parse_context_before,
32
+ resolve_auth,
33
+ resolve_window,
34
+ )
35
+ from .context import (
36
+ attach_media_exports,
37
+ build_chunks,
38
+ group_records,
39
+ is_media_record,
40
+ render_chunks_ndjson,
41
+ render_chunks_text,
42
+ )
43
+ from .crypto import is_valid_key
44
+ from .dropbox import DropboxClient
45
+ from .env_file import default_env_path, load_env_file, read_env_file_unchecked, write_env_file
46
+ from .errors import CliError, NoMatchesError, RemoteError, UsageError
47
+ from .fetch import collect, plan_tasks
48
+ from .media import export_media, write_manifest
49
+ from .output import render, safe_cell, write_lines
50
+
51
+ CHAT_HEADERS = ["NAME", "PHONE", "TYPE", "HASH", "CHAT ID"]
52
+
53
+
54
+ def _add_window_arguments(parser: argparse.ArgumentParser) -> None:
55
+ parser.add_argument(
56
+ "--from",
57
+ dest="from_text",
58
+ metavar="BOUND",
59
+ help="start bound: YYYY-MM-DD[THH[:MM[:SS]]] (required unless --last)",
60
+ )
61
+ parser.add_argument(
62
+ "--to",
63
+ dest="to_text",
64
+ metavar="BOUND",
65
+ help="end bound (inclusive by precision; default: now)",
66
+ )
67
+ parser.add_argument(
68
+ "--last",
69
+ dest="last_text",
70
+ metavar="DURATION",
71
+ help="relative window, e.g. 90m, 36h, 7d, 2w (excludes --from/--to)",
72
+ )
73
+
74
+
75
+ def build_parser() -> argparse.ArgumentParser:
76
+ parser = argparse.ArgumentParser(
77
+ prog="waidrop",
78
+ description="Query an encrypted WhatsApp archive stored in a Dropbox app folder.",
79
+ )
80
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
81
+ subparsers = parser.add_subparsers(dest="command", required=True)
82
+
83
+ common = argparse.ArgumentParser(add_help=False)
84
+ common.add_argument(
85
+ "--dropbox-token",
86
+ help="Dropbox access token (or WAIDROP_DROPBOX_TOKEN; env is safer, argv is visible to ps)",
87
+ )
88
+ common.add_argument(
89
+ "--dropbox-refresh-token",
90
+ help="Dropbox refresh token (or WAIDROP_DROPBOX_REFRESH_TOKEN; env is safer than argv)",
91
+ )
92
+ common.add_argument("--dropbox-app-key", help="Dropbox app key (or WAIDROP_DROPBOX_APP_KEY)")
93
+ common.add_argument(
94
+ "--dropbox-app-secret",
95
+ help="Dropbox app secret (or WAIDROP_DROPBOX_APP_SECRET; env is safer than argv)",
96
+ )
97
+ common.add_argument(
98
+ "--credentials-file", help="Dropbox credentials file (or WAIDROP_CREDENTIALS)"
99
+ )
100
+ common.add_argument(
101
+ "--key",
102
+ help="archive encryption key for encrypted archives, base64 32 bytes (or WAIDROP_KEY)",
103
+ )
104
+ common.add_argument(
105
+ "--account", help="account alias folder (or WAIDROP_ACCOUNT; default: primary)"
106
+ )
107
+ common.add_argument("--folder", help="subfolder inside the app folder (or WAIDROP_FOLDER)")
108
+ common.add_argument("--tz", help="IANA timezone for date bounds (default: system local)")
109
+ common.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds")
110
+ common.add_argument(
111
+ "--max-parts", type=int, default=1000, help="abort if more part objects would be fetched"
112
+ )
113
+ common.add_argument("--quiet", action="store_true", help="suppress progress lines on stderr")
114
+
115
+ selectors = argparse.ArgumentParser(add_help=False)
116
+ selectors.add_argument(
117
+ "--name",
118
+ action="append",
119
+ default=[],
120
+ metavar="GLOB",
121
+ help="chat name glob/substring (repeatable, case-insensitive)",
122
+ )
123
+ selectors.add_argument(
124
+ "--number",
125
+ action="append",
126
+ default=[],
127
+ metavar="PHONE",
128
+ help="phone number, any format (repeatable)",
129
+ )
130
+ selectors.add_argument(
131
+ "--chat", action="append", default=[], metavar="HASH", help="chat hash folder (repeatable)"
132
+ )
133
+ selectors.add_argument(
134
+ "--jid",
135
+ action="append",
136
+ default=[],
137
+ metavar="JID",
138
+ help="raw chat jid, e.g. 49171234567@c.us (repeatable)",
139
+ )
140
+
141
+ subparsers.add_parser(
142
+ "chats",
143
+ parents=[common, selectors],
144
+ help="resolve chats to hashes without fetching messages",
145
+ )
146
+
147
+ query = subparsers.add_parser("query", parents=[common, selectors], help="fetch messages")
148
+ _add_window_arguments(query)
149
+ query.add_argument("--format", choices=["ndjson", "text", "csv"], default="ndjson")
150
+ query.add_argument("--out", metavar="FILE", help="write output to a file instead of stdout")
151
+ query.add_argument("--media-out", metavar="DIR", help="export full-resolution media into DIR")
152
+ query.add_argument(
153
+ "--media-manifest",
154
+ metavar="FILE",
155
+ help="manifest path (default: DIR/manifest.ndjson)",
156
+ )
157
+ query.add_argument(
158
+ "--media-max-bytes",
159
+ type=int,
160
+ default=25 * 1024 * 1024,
161
+ help="skip media larger than this many bytes (default: 25 MiB)",
162
+ )
163
+ query.add_argument(
164
+ "--media-fail-fast",
165
+ action="store_true",
166
+ help="abort on the first media export error",
167
+ )
168
+
169
+ context = subparsers.add_parser(
170
+ "context", parents=[common, selectors], help="fetch per-chat conversation chunks"
171
+ )
172
+ _add_window_arguments(context)
173
+ context.add_argument(
174
+ "--context-before",
175
+ dest="context_before",
176
+ default="0",
177
+ metavar="DURATION",
178
+ help="include this much before the analysis window as context (default: 0)",
179
+ )
180
+ context.add_argument(
181
+ "--max-chars",
182
+ type=int,
183
+ default=0,
184
+ metavar="N",
185
+ help="split each chat into parts of at most N rendered message characters (default: 0 = no split)",
186
+ )
187
+ context.add_argument("--format", choices=["ndjson", "text"], default="ndjson")
188
+ context.add_argument("--out", metavar="FILE", help="write output to a file instead of stdout")
189
+ context.add_argument(
190
+ "--media-out",
191
+ metavar="DIR",
192
+ help="export images/stickers/GIFs referenced by the chunks into DIR",
193
+ )
194
+ context.add_argument(
195
+ "--media-manifest",
196
+ metavar="FILE",
197
+ help="manifest path (default: DIR/manifest.ndjson)",
198
+ )
199
+ context.add_argument(
200
+ "--media-max-bytes",
201
+ type=int,
202
+ default=25 * 1024 * 1024,
203
+ help="skip media larger than this many bytes (default: 25 MiB)",
204
+ )
205
+ context.add_argument(
206
+ "--media-fail-fast",
207
+ action="store_true",
208
+ help="abort on the first media export error",
209
+ )
210
+
211
+ auth = subparsers.add_parser(
212
+ "auth", help="connect to Dropbox (OAuth PKCE) or manage credentials"
213
+ )
214
+ auth.add_argument("--status", action="store_true", help="show account, scopes and token health")
215
+ auth.add_argument(
216
+ "--revoke",
217
+ action="store_true",
218
+ help="revoke the Dropbox token and delete local credentials",
219
+ )
220
+ auth.add_argument(
221
+ "--no-browser", action="store_true", help="do not open a browser; print the URL only"
222
+ )
223
+ auth.add_argument(
224
+ "--loopback",
225
+ action="store_true",
226
+ help="use a localhost callback instead of pasting the code",
227
+ )
228
+ auth.add_argument(
229
+ "--port", type=int, default=53682, help="loopback callback port (default: 53682)"
230
+ )
231
+ auth.add_argument(
232
+ "--code", metavar="CODE", help="authorization code to exchange (non-interactive)"
233
+ )
234
+ auth.add_argument(
235
+ "--json", action="store_true", dest="as_json", help="machine-readable output for --status"
236
+ )
237
+ auth.add_argument("--dropbox-app-key", help="Dropbox app key (or WAIDROP_DROPBOX_APP_KEY)")
238
+ auth.add_argument(
239
+ "--dropbox-app-secret",
240
+ help="Dropbox app secret (or WAIDROP_DROPBOX_APP_SECRET); not needed for the built-in app",
241
+ )
242
+ auth.add_argument(
243
+ "--credentials-file", help="Dropbox credentials file (or WAIDROP_CREDENTIALS)"
244
+ )
245
+ auth.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds")
246
+
247
+ config = subparsers.add_parser(
248
+ "config", help="store the archive key and account in the CLI env file"
249
+ )
250
+ config.add_argument(
251
+ "--key",
252
+ help="archive encryption key, base64 32 bytes (or WAIDROP_KEY; prompted if omitted)",
253
+ )
254
+ config.add_argument(
255
+ "--account",
256
+ help="account alias (or WAIDROP_ACCOUNT; prompted if unset; default: existing value or primary)",
257
+ )
258
+ config.add_argument(
259
+ "--env-file",
260
+ help="env file path (default: next to the credentials file)",
261
+ )
262
+
263
+ return parser
264
+
265
+
266
+ def _open_archive(args):
267
+ cfg = load_config(args)
268
+ client = DropboxClient(cfg)
269
+ codec = resolve_archive(client, cfg)
270
+ return cfg, client, codec
271
+
272
+
273
+ def _print_chats(entries: list[ChatEntry]) -> None:
274
+ rows = [
275
+ [
276
+ safe_cell(entry.chat_name),
277
+ safe_cell(entry.chat_phone or "-"),
278
+ "group" if entry.is_group else "dm",
279
+ safe_cell(entry.hash),
280
+ safe_cell(entry.chat_id),
281
+ ]
282
+ for entry in sorted(entries, key=lambda item: item.chat_name.casefold())
283
+ ]
284
+ widths = [len(header) for header in CHAT_HEADERS]
285
+ for row in rows:
286
+ for index, cell in enumerate(row):
287
+ widths[index] = max(widths[index], len(cell))
288
+
289
+ print(
290
+ " ".join(header.ljust(widths[index]) for index, header in enumerate(CHAT_HEADERS)).rstrip()
291
+ )
292
+ for row in rows:
293
+ print(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)).rstrip())
294
+
295
+
296
+ def _has_selectors(args) -> bool:
297
+ return bool(args.name or args.number or args.chat or args.jid)
298
+
299
+
300
+ def _selected_chats(args, entries: list[ChatEntry], codec: ArchiveCodec) -> list[ChatEntry]:
301
+ if _has_selectors(args):
302
+ selected = select_chats(entries, args.name, args.number, args.chat, args.jid, codec)
303
+ if not selected:
304
+ raise NoMatchesError("no chats matched the selectors; try `waidrop chats --name '*'`")
305
+ return selected
306
+ if not entries:
307
+ raise NoMatchesError("chat directory is empty (has anything been uploaded yet?)")
308
+ return entries
309
+
310
+
311
+ def _run_chats(args) -> int:
312
+ cfg, client, codec = _open_archive(args)
313
+ entries = load_directory(client, cfg, codec)
314
+ selected = select_chats(entries, args.name, args.number, args.chat, args.jid, codec)
315
+
316
+ if not entries:
317
+ raise NoMatchesError("chat directory is empty (has anything been uploaded yet?)")
318
+ if _has_selectors(args) and not selected:
319
+ raise NoMatchesError("no chats matched the selectors; try --name '*' to list everything")
320
+
321
+ _print_chats(selected if selected else entries)
322
+ return 0
323
+
324
+
325
+ def _run_query(args) -> int:
326
+ cfg, client, codec = _open_archive(args)
327
+ window = resolve_window(args.from_text, args.to_text, args.last_text, cfg.tz)
328
+ entries = load_directory(client, cfg, codec)
329
+ selected = _selected_chats(args, entries, codec)
330
+
331
+ if not args.quiet:
332
+ print(f"waidrop: window {window.label}", file=sys.stderr)
333
+ if _has_selectors(args):
334
+ print(f"waidrop: {len(selected)} chat(s) matched", file=sys.stderr)
335
+ else:
336
+ print(f"waidrop: all chats ({len(selected)})", file=sys.stderr)
337
+
338
+ tasks = plan_tasks(client, cfg, codec, selected, window)
339
+ if not args.quiet:
340
+ print(f"waidrop: {len(tasks)} part object(s) in range", file=sys.stderr)
341
+
342
+ records = collect(client, cfg, codec, tasks, window)
343
+ if not records:
344
+ raise NoMatchesError("no archived messages found for the matched chats in this window")
345
+
346
+ lines = render(records, args.format, cfg.tz)
347
+ write_lines(lines, args.out)
348
+
349
+ if args.media_out:
350
+ if args.media_max_bytes < 1:
351
+ raise UsageError("--media-max-bytes must be positive")
352
+ entries = export_media(
353
+ client,
354
+ cfg,
355
+ codec,
356
+ records,
357
+ args.media_out,
358
+ max_bytes=args.media_max_bytes,
359
+ fail_fast=args.media_fail_fast,
360
+ )
361
+ manifest = args.media_manifest or str(Path(args.media_out) / "manifest.ndjson")
362
+ write_manifest(manifest, entries)
363
+ exported = sum(1 for entry in entries if entry["status"] == "exported")
364
+ failed = sum(1 for entry in entries if entry["status"] == "failed")
365
+ skipped = sum(1 for entry in entries if entry["status"] == "skipped")
366
+ if not args.quiet:
367
+ print(
368
+ f"waidrop: media {exported} exported, {skipped} skipped, {failed} failed "
369
+ f"(manifest: {manifest})",
370
+ file=sys.stderr,
371
+ )
372
+
373
+ if not args.quiet:
374
+ print(f"waidrop: {len(records)} record(s) written", file=sys.stderr)
375
+ return 0
376
+
377
+
378
+ def _run_context(args) -> int:
379
+ cfg, client, codec = _open_archive(args)
380
+ analysis = resolve_window(args.from_text, args.to_text, args.last_text, cfg.tz)
381
+ context_before = parse_context_before(args.context_before)
382
+ context_ms = int(context_before.total_seconds() * 1000)
383
+ if args.max_chars < 0:
384
+ raise UsageError("--max-chars must be zero or positive")
385
+ if args.media_out and args.media_max_bytes < 1:
386
+ raise UsageError("--media-max-bytes must be positive")
387
+
388
+ if context_ms > 0:
389
+ fetch_start = analysis.start - context_before
390
+ fetch_window = Window(
391
+ start_ms=int(fetch_start.timestamp() * 1000),
392
+ end_ms=analysis.end_ms,
393
+ start=fetch_start,
394
+ end=analysis.end,
395
+ label=f"context before {args.context_before} + {analysis.label}",
396
+ )
397
+ else:
398
+ fetch_window = analysis
399
+
400
+ entries = load_directory(client, cfg, codec)
401
+ selected = _selected_chats(args, entries, codec)
402
+
403
+ if not args.quiet:
404
+ print(f"waidrop: analysis window {analysis.label}", file=sys.stderr)
405
+ if context_ms > 0:
406
+ print(f"waidrop: fetching context before {args.context_before}", file=sys.stderr)
407
+ if _has_selectors(args):
408
+ print(f"waidrop: {len(selected)} chat(s) matched", file=sys.stderr)
409
+ else:
410
+ print(f"waidrop: all chats ({len(selected)})", file=sys.stderr)
411
+
412
+ tasks = plan_tasks(client, cfg, codec, selected, fetch_window)
413
+ if not args.quiet:
414
+ print(f"waidrop: {len(tasks)} part object(s) in range", file=sys.stderr)
415
+
416
+ records = collect(client, cfg, codec, tasks, fetch_window)
417
+ threads = group_records(records, selected, analysis)
418
+ if not threads:
419
+ raise NoMatchesError(
420
+ "no archived messages found in the analysis window (context-only records ignored)"
421
+ )
422
+
423
+ if args.media_out:
424
+ media_dir = Path(args.media_out).expanduser().resolve()
425
+ media_records = [
426
+ message for thread in threads for message in thread.messages if is_media_record(message)
427
+ ]
428
+ media_entries = export_media(
429
+ client,
430
+ cfg,
431
+ codec,
432
+ media_records,
433
+ media_dir,
434
+ max_bytes=args.media_max_bytes,
435
+ fail_fast=args.media_fail_fast,
436
+ )
437
+ attach_media_exports(threads, media_entries)
438
+ manifest = args.media_manifest or str(media_dir / "manifest.ndjson")
439
+ write_manifest(manifest, media_entries)
440
+ if not args.quiet:
441
+ exported = sum(1 for entry in media_entries if entry["status"] == "exported")
442
+ skipped = sum(1 for entry in media_entries if entry["status"] == "skipped")
443
+ failed = sum(1 for entry in media_entries if entry["status"] == "failed")
444
+ print(
445
+ f"waidrop: media {exported} exported, {skipped} skipped, {failed} failed "
446
+ f"(manifest: {manifest})",
447
+ file=sys.stderr,
448
+ )
449
+
450
+ chunks = build_chunks(threads, analysis, context_ms, args.max_chars, cfg.tz)
451
+ lines = (
452
+ render_chunks_ndjson(chunks)
453
+ if args.format == "ndjson"
454
+ else render_chunks_text(chunks, cfg.tz)
455
+ )
456
+ write_lines(lines, args.out)
457
+
458
+ if not args.quiet:
459
+ in_window = sum(
460
+ 1 for thread in threads for message in thread.messages if message["inWindow"]
461
+ )
462
+ context = sum(
463
+ 1 for thread in threads for message in thread.messages if not message["inWindow"]
464
+ )
465
+ print(
466
+ f"waidrop: {in_window} message(s) in window, {context} context message(s)",
467
+ file=sys.stderr,
468
+ )
469
+ print(f"waidrop: {len(chunks)} chunk(s) written", file=sys.stderr)
470
+ return 0
471
+
472
+
473
+ def _auth_config_for_client(auth: AuthConfig) -> DropboxConfig:
474
+ """Build a client config for account/status/revoke (no archive key needed)."""
475
+ return DropboxConfig(
476
+ token=auth.token,
477
+ refresh_token=auth.refresh_token,
478
+ app_key=auth.app_key,
479
+ app_secret=auth.app_secret,
480
+ archive_key="",
481
+ account="primary",
482
+ folder="",
483
+ tz=datetime.now().astimezone().tzinfo,
484
+ timeout=auth.timeout,
485
+ max_parts=1000,
486
+ api_base=auth.api_base,
487
+ content_base=auth.content_base,
488
+ token_url=auth.token_url,
489
+ revoke_url=auth.revoke_url,
490
+ credentials_path=auth.credentials_path,
491
+ from_file=auth.from_file,
492
+ token_expires_at=auth.token_expires_at,
493
+ )
494
+
495
+
496
+ def _run_auth(args) -> int:
497
+ if args.revoke:
498
+ return _auth_revoke(args)
499
+ if args.status:
500
+ return _auth_status(args)
501
+ return _auth_login(args)
502
+
503
+
504
+ def _auth_login(args) -> int:
505
+ auth = resolve_auth(args)
506
+ if not auth.app_key:
507
+ raise UsageError(
508
+ "no Dropbox app key configured; set WAIDROP_DROPBOX_APP_KEY or --dropbox-app-key"
509
+ )
510
+ if args.dropbox_app_secret:
511
+ print(
512
+ "waidrop: warning: --dropbox-app-secret is not needed for the built-in app and is never stored",
513
+ file=sys.stderr,
514
+ )
515
+
516
+ open_browser = not args.no_browser
517
+ if args.loopback:
518
+ parsed = run_loopback_flow(
519
+ app_key=auth.app_key,
520
+ token_url=auth.token_url,
521
+ port=args.port,
522
+ client_secret=auth.app_secret or None,
523
+ open_browser=open_browser,
524
+ timeout=max(auth.timeout * 5, 120.0),
525
+ print_fn=lambda message: print(message, file=sys.stderr),
526
+ )
527
+ else:
528
+ parsed = run_paste_flow(
529
+ app_key=auth.app_key,
530
+ token_url=auth.token_url,
531
+ client_secret=auth.app_secret or None,
532
+ open_browser=open_browser,
533
+ code=args.code,
534
+ print_fn=lambda message: print(message, file=sys.stderr),
535
+ timeout=auth.timeout,
536
+ )
537
+
538
+ missing = missing_scopes(parsed["scope"])
539
+ if missing:
540
+ raise UsageError(
541
+ f"Dropbox authorized but missing required scopes: {', '.join(missing)}. "
542
+ "Enable them in the App Console and run `waidrop auth` again."
543
+ )
544
+ if not parsed.get("refresh_token"):
545
+ raise RemoteError(
546
+ "Dropbox did not return a refresh token; the app must allow offline access (token_access_type=offline)"
547
+ )
548
+
549
+ creds = Credentials(
550
+ app_key=auth.app_key,
551
+ refresh_token=parsed["refresh_token"],
552
+ access_token=parsed["access_token"],
553
+ expires_at=parsed["expires_at"],
554
+ scope=parsed["scope"],
555
+ account_id=parsed.get("account_id"),
556
+ obtained_at=datetime.now().astimezone().isoformat(),
557
+ )
558
+
559
+ try:
560
+ client = DropboxClient(_auth_config_for_client(auth))
561
+ account = client.get_current_account()
562
+ name = account.get("name") or {}
563
+ creds.account_name = name.get("display_name") or name.get("given_name")
564
+ creds.email = account.get("email")
565
+ except RemoteError:
566
+ pass
567
+
568
+ save_credentials(auth.credentials_path, creds)
569
+ who = creds.account_name or creds.email or creds.account_id or "Dropbox"
570
+ print(f"waidrop: connected as {who}")
571
+ print(f"waidrop: credentials stored at {auth.credentials_path}")
572
+ return 0
573
+
574
+
575
+ def _auth_status(args) -> int:
576
+ auth = resolve_auth(args)
577
+ if not (auth.token or (auth.refresh_token and auth.app_key)):
578
+ raise UsageError("Dropbox is not connected. Run `waidrop auth`.")
579
+
580
+ client = DropboxClient(_auth_config_for_client(auth))
581
+ account = client.get_current_account()
582
+ name = account.get("name") or {}
583
+ account_name = name.get("display_name") or name.get("given_name")
584
+
585
+ creds = load_credentials(auth.credentials_path) if auth.from_file else None
586
+ scopes = (creds.scope if creds else None) or []
587
+ missing = missing_scopes(scopes) if scopes else []
588
+
589
+ if args.as_json:
590
+ payload = {
591
+ "ok": not missing,
592
+ "appKey": auth.app_key,
593
+ "account": {
594
+ "name": account_name,
595
+ "email": account.get("email"),
596
+ "accountId": account.get("account_id"),
597
+ },
598
+ "scopes": scopes,
599
+ "missingScopes": missing,
600
+ "credentialsPath": auth.credentials_path if creds else None,
601
+ }
602
+ print(json.dumps(payload, indent=2))
603
+ else:
604
+ who = account_name or account.get("email") or account.get("account_id") or "Dropbox"
605
+ print(f"Dropbox: connected as {who}")
606
+ if scopes:
607
+ print(f"Scopes: {' '.join(scopes)}")
608
+ if missing:
609
+ print(f"Missing required scopes: {' '.join(missing)}", file=sys.stderr)
610
+ if creds:
611
+ print(f"Credentials: {auth.credentials_path}")
612
+ return 0 if not missing else 2
613
+
614
+
615
+ def _auth_revoke(args) -> int:
616
+ auth = resolve_auth(args)
617
+ attempted = bool(auth.token or (auth.refresh_token and auth.app_key))
618
+ revoked = False
619
+ revoke_error: RemoteError | None = None
620
+ if attempted:
621
+ try:
622
+ client = DropboxClient(_auth_config_for_client(auth))
623
+ revoked = client.revoke()
624
+ except RemoteError as exc:
625
+ revoke_error = exc
626
+ print(f"waidrop: {exc}", file=sys.stderr)
627
+
628
+ if revoke_error is not None:
629
+ print(
630
+ "waidrop: could not revoke Dropbox access; local credentials were kept",
631
+ file=sys.stderr,
632
+ )
633
+ return revoke_error.exit_code
634
+
635
+ deleted = delete_credentials(auth.credentials_path)
636
+ if revoked:
637
+ print("waidrop: revoked Dropbox access and removed local credentials")
638
+ elif deleted:
639
+ print(
640
+ "waidrop: token already invalid; removed local credentials "
641
+ "(revoke in Dropbox settings if needed)",
642
+ file=sys.stderr,
643
+ )
644
+ else:
645
+ print("waidrop: no Dropbox credentials to revoke", file=sys.stderr)
646
+ return 0
647
+
648
+
649
+ def _run_config(args) -> int:
650
+ env_path = Path(args.env_file).expanduser() if args.env_file else default_env_path()
651
+ existing = read_env_file_unchecked(env_path)
652
+
653
+ key = str(args.key or os.environ.get("WAIDROP_KEY") or "").strip()
654
+ if not key:
655
+ existing_key = existing.get("WAIDROP_KEY", "").strip()
656
+ if existing_key and not is_valid_key(existing_key):
657
+ existing_key = ""
658
+ if not sys.stdin.isatty():
659
+ if not existing_key:
660
+ raise UsageError(
661
+ "archive key is required; pass --key, set WAIDROP_KEY, or run interactively"
662
+ )
663
+ key = existing_key
664
+ else:
665
+ prompt = "Archive key (base64, 32 bytes)"
666
+ if existing_key:
667
+ prompt += " [keep existing]"
668
+ prompt += ": "
669
+ try:
670
+ key = getpass.getpass(prompt).strip()
671
+ except (EOFError, KeyboardInterrupt) as exc:
672
+ raise UsageError("archive key prompt aborted") from exc
673
+ if not key:
674
+ if not existing_key:
675
+ raise UsageError("archive key is required")
676
+ key = existing_key
677
+ if not is_valid_key(key):
678
+ raise UsageError("encryption key must be 32 bytes encoded as base64")
679
+
680
+ account = (
681
+ str(args.account or "").strip() or str(os.environ.get("WAIDROP_ACCOUNT") or "").strip()
682
+ )
683
+ if not account:
684
+ default_account = existing.get("WAIDROP_ACCOUNT", "").strip() or DEFAULT_ACCOUNT
685
+ if sys.stdin.isatty():
686
+ try:
687
+ account = input(f"Account alias [{default_account}]: ").strip() or default_account
688
+ except (EOFError, KeyboardInterrupt) as exc:
689
+ raise UsageError("account alias prompt aborted") from exc
690
+ else:
691
+ account = default_account
692
+ if not ACCOUNT_RE.match(account):
693
+ raise UsageError("account alias may only contain letters, digits and . _ -")
694
+
695
+ try:
696
+ write_env_file(env_path, key=key, account=account)
697
+ except OSError as exc:
698
+ raise UsageError(f"could not write {env_path}: {exc}") from exc
699
+
700
+ mode = "" if os.name == "nt" else " (0600)"
701
+ print(f"waidrop: wrote {env_path}{mode}")
702
+ print(f"waidrop: account {account}")
703
+ return 0
704
+
705
+
706
+ def _attach_env_file(args) -> None:
707
+ """Attach CLI env file values (flags and the shell environment still win)."""
708
+ args.env_file_values = load_env_file(default_env_path(args))
709
+
710
+
711
+ def main(argv: list[str] | None = None) -> int:
712
+ parser = build_parser()
713
+ args = parser.parse_args(argv)
714
+
715
+ try:
716
+ if args.command in {"chats", "query", "context"}:
717
+ _attach_env_file(args)
718
+ if args.command == "auth":
719
+ return _run_auth(args)
720
+ if args.command == "config":
721
+ return _run_config(args)
722
+ if args.command == "chats":
723
+ return _run_chats(args)
724
+ if args.command == "context":
725
+ return _run_context(args)
726
+ return _run_query(args)
727
+ except CliError as exc:
728
+ print(f"waidrop: {exc}", file=sys.stderr)
729
+ return exc.exit_code
730
+
731
+
732
+ if __name__ == "__main__":
733
+ raise SystemExit(main())