archivebox_persona.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
  1. #!/usr/bin/env python3
  2. """
  3. archivebox persona <action> [args...] [--filters]
  4. Manage Persona records (browser profiles for archiving).
  5. Actions:
  6. create - Create Personas
  7. list - List Personas as JSONL (with optional filters)
  8. update - Update Personas from stdin JSONL
  9. delete - Delete Personas from stdin JSONL
  10. Examples:
  11. # Create a new persona
  12. archivebox persona create work
  13. archivebox persona create --import=chrome personal
  14. archivebox persona create --import=edge work
  15. # List all personas
  16. archivebox persona list
  17. # Delete a persona
  18. archivebox persona list --name=old | archivebox persona delete --yes
  19. """
  20. __package__ = 'archivebox.cli'
  21. __command__ = 'archivebox persona'
  22. import os
  23. import sys
  24. import shutil
  25. import platform
  26. import subprocess
  27. import tempfile
  28. from pathlib import Path
  29. from typing import Optional, Iterable
  30. from collections import OrderedDict
  31. import rich_click as click
  32. from rich import print as rprint
  33. from archivebox.cli.cli_utils import apply_filters
  34. # =============================================================================
  35. # Browser Profile Locations
  36. # =============================================================================
  37. def get_chrome_user_data_dir() -> Optional[Path]:
  38. """Get the default Chrome user data directory for the current platform."""
  39. system = platform.system()
  40. home = Path.home()
  41. if system == 'Darwin': # macOS
  42. candidates = [
  43. home / 'Library' / 'Application Support' / 'Google' / 'Chrome',
  44. home / 'Library' / 'Application Support' / 'Chromium',
  45. ]
  46. elif system == 'Linux':
  47. candidates = [
  48. home / '.config' / 'google-chrome',
  49. home / '.config' / 'chromium',
  50. home / '.config' / 'chrome',
  51. home / 'snap' / 'chromium' / 'common' / 'chromium',
  52. ]
  53. elif system == 'Windows':
  54. local_app_data = Path(os.environ.get('LOCALAPPDATA', home / 'AppData' / 'Local'))
  55. candidates = [
  56. local_app_data / 'Google' / 'Chrome' / 'User Data',
  57. local_app_data / 'Chromium' / 'User Data',
  58. ]
  59. else:
  60. candidates = []
  61. for candidate in candidates:
  62. if candidate.exists() and (candidate / 'Default').exists():
  63. return candidate
  64. return None
  65. def get_brave_user_data_dir() -> Optional[Path]:
  66. """Get the default Brave user data directory for the current platform."""
  67. system = platform.system()
  68. home = Path.home()
  69. if system == 'Darwin':
  70. candidates = [
  71. home / 'Library' / 'Application Support' / 'BraveSoftware' / 'Brave-Browser',
  72. ]
  73. elif system == 'Linux':
  74. candidates = [
  75. home / '.config' / 'BraveSoftware' / 'Brave-Browser',
  76. ]
  77. elif system == 'Windows':
  78. local_app_data = Path(os.environ.get('LOCALAPPDATA', home / 'AppData' / 'Local'))
  79. candidates = [
  80. local_app_data / 'BraveSoftware' / 'Brave-Browser' / 'User Data',
  81. ]
  82. else:
  83. candidates = []
  84. for candidate in candidates:
  85. if candidate.exists() and (candidate / 'Default').exists():
  86. return candidate
  87. return None
  88. def get_edge_user_data_dir() -> Optional[Path]:
  89. """Get the default Edge user data directory for the current platform."""
  90. system = platform.system()
  91. home = Path.home()
  92. if system == 'Darwin':
  93. candidates = [
  94. home / 'Library' / 'Application Support' / 'Microsoft Edge',
  95. ]
  96. elif system == 'Linux':
  97. candidates = [
  98. home / '.config' / 'microsoft-edge',
  99. home / '.config' / 'microsoft-edge-beta',
  100. home / '.config' / 'microsoft-edge-dev',
  101. ]
  102. elif system == 'Windows':
  103. local_app_data = Path(os.environ.get('LOCALAPPDATA', home / 'AppData' / 'Local'))
  104. candidates = [
  105. local_app_data / 'Microsoft' / 'Edge' / 'User Data',
  106. ]
  107. else:
  108. candidates = []
  109. for candidate in candidates:
  110. if candidate.exists() and (candidate / 'Default').exists():
  111. return candidate
  112. return None
  113. BROWSER_PROFILE_FINDERS = {
  114. 'chrome': get_chrome_user_data_dir,
  115. 'chromium': get_chrome_user_data_dir, # Same locations
  116. 'brave': get_brave_user_data_dir,
  117. 'edge': get_edge_user_data_dir,
  118. }
  119. CHROMIUM_BROWSERS = {'chrome', 'chromium', 'brave', 'edge'}
  120. # =============================================================================
  121. # Cookie Extraction via CDP
  122. # =============================================================================
  123. NETSCAPE_COOKIE_HEADER = [
  124. '# Netscape HTTP Cookie File',
  125. '# https://curl.se/docs/http-cookies.html',
  126. '# This file was generated by ArchiveBox persona cookie extraction',
  127. '#',
  128. '# Format: domain\\tincludeSubdomains\\tpath\\tsecure\\texpiry\\tname\\tvalue',
  129. '',
  130. ]
  131. def _parse_netscape_cookies(path: Path) -> "OrderedDict[tuple[str, str, str], tuple[str, str, str, str, str, str, str]]":
  132. cookies = OrderedDict()
  133. if not path.exists():
  134. return cookies
  135. for line in path.read_text().splitlines():
  136. if not line or line.startswith('#'):
  137. continue
  138. parts = line.split('\t')
  139. if len(parts) < 7:
  140. continue
  141. domain, include_subdomains, cookie_path, secure, expiry, name, value = parts[:7]
  142. key = (domain, cookie_path, name)
  143. cookies[key] = (domain, include_subdomains, cookie_path, secure, expiry, name, value)
  144. return cookies
  145. def _write_netscape_cookies(path: Path, cookies: "OrderedDict[tuple[str, str, str], tuple[str, str, str, str, str, str, str]]") -> None:
  146. lines = list(NETSCAPE_COOKIE_HEADER)
  147. for cookie in cookies.values():
  148. lines.append('\t'.join(cookie))
  149. path.write_text('\n'.join(lines) + '\n')
  150. def _merge_netscape_cookies(existing_file: Path, new_file: Path) -> None:
  151. existing = _parse_netscape_cookies(existing_file)
  152. new = _parse_netscape_cookies(new_file)
  153. for key, cookie in new.items():
  154. existing[key] = cookie
  155. _write_netscape_cookies(existing_file, existing)
  156. def extract_cookies_via_cdp(user_data_dir: Path, output_file: Path) -> bool:
  157. """
  158. Launch Chrome with the given user data dir and extract cookies via CDP.
  159. Returns True if successful, False otherwise.
  160. """
  161. from archivebox.config.common import STORAGE_CONFIG
  162. # Find the cookie extraction script
  163. chrome_plugin_dir = Path(__file__).parent.parent / 'plugins' / 'chrome'
  164. extract_script = chrome_plugin_dir / 'extract_cookies.js'
  165. if not extract_script.exists():
  166. rprint(f'[yellow]Cookie extraction script not found at {extract_script}[/yellow]', file=sys.stderr)
  167. return False
  168. # Get node modules dir
  169. node_modules_dir = STORAGE_CONFIG.LIB_DIR / 'npm' / 'node_modules'
  170. # Set up environment
  171. env = os.environ.copy()
  172. env['NODE_MODULES_DIR'] = str(node_modules_dir)
  173. env['CHROME_USER_DATA_DIR'] = str(user_data_dir)
  174. env['CHROME_HEADLESS'] = 'true'
  175. output_path = output_file
  176. temp_output = None
  177. temp_dir = None
  178. if output_file.exists():
  179. temp_dir = Path(tempfile.mkdtemp(prefix='ab_cookies_'))
  180. temp_output = temp_dir / 'cookies.txt'
  181. output_path = temp_output
  182. env['COOKIES_OUTPUT_FILE'] = str(output_path)
  183. try:
  184. result = subprocess.run(
  185. ['node', str(extract_script)],
  186. env=env,
  187. capture_output=True,
  188. text=True,
  189. timeout=60,
  190. )
  191. if result.returncode == 0:
  192. if temp_output and temp_output.exists():
  193. _merge_netscape_cookies(output_file, temp_output)
  194. return True
  195. else:
  196. rprint(f'[yellow]Cookie extraction failed: {result.stderr}[/yellow]', file=sys.stderr)
  197. return False
  198. except subprocess.TimeoutExpired:
  199. rprint('[yellow]Cookie extraction timed out[/yellow]', file=sys.stderr)
  200. return False
  201. except FileNotFoundError:
  202. rprint('[yellow]Node.js not found. Cannot extract cookies.[/yellow]', file=sys.stderr)
  203. return False
  204. except Exception as e:
  205. rprint(f'[yellow]Cookie extraction error: {e}[/yellow]', file=sys.stderr)
  206. return False
  207. finally:
  208. if temp_dir and temp_dir.exists():
  209. shutil.rmtree(temp_dir, ignore_errors=True)
  210. # =============================================================================
  211. # Validation Helpers
  212. # =============================================================================
  213. def validate_persona_name(name: str) -> tuple[bool, str]:
  214. """
  215. Validate persona name to prevent path traversal attacks.
  216. Returns:
  217. (is_valid, error_message): tuple indicating if name is valid
  218. """
  219. if not name or not name.strip():
  220. return False, "Persona name cannot be empty"
  221. # Check for path separators
  222. if '/' in name or '\\' in name:
  223. return False, "Persona name cannot contain path separators (/ or \\)"
  224. # Check for parent directory references
  225. if '..' in name:
  226. return False, "Persona name cannot contain parent directory references (..)"
  227. # Check for hidden files/directories
  228. if name.startswith('.'):
  229. return False, "Persona name cannot start with a dot (.)"
  230. # Ensure name doesn't contain null bytes or other dangerous chars
  231. if '\x00' in name or '\n' in name or '\r' in name:
  232. return False, "Persona name contains invalid characters"
  233. return True, ""
  234. def ensure_path_within_personas_dir(persona_path: Path) -> bool:
  235. """
  236. Verify that a persona path is within PERSONAS_DIR.
  237. This is a safety check to prevent path traversal attacks where
  238. a malicious persona name could cause operations on paths outside
  239. the expected PERSONAS_DIR.
  240. Returns:
  241. True if path is safe, False otherwise
  242. """
  243. from archivebox.config.constants import CONSTANTS
  244. try:
  245. # Resolve both paths to absolute paths
  246. personas_dir = CONSTANTS.PERSONAS_DIR.resolve()
  247. resolved_path = persona_path.resolve()
  248. # Check if resolved_path is a child of personas_dir
  249. return resolved_path.is_relative_to(personas_dir)
  250. except (ValueError, RuntimeError):
  251. return False
  252. # =============================================================================
  253. # CREATE
  254. # =============================================================================
  255. def create_personas(
  256. names: Iterable[str],
  257. import_from: Optional[str] = None,
  258. ) -> int:
  259. """
  260. Create Personas from names.
  261. If --import is specified, copy the browser profile to the persona directory
  262. and extract cookies.
  263. Exit codes:
  264. 0: Success
  265. 1: Failure
  266. """
  267. from archivebox.misc.jsonl import write_record
  268. from archivebox.personas.models import Persona
  269. from archivebox.config.constants import CONSTANTS
  270. is_tty = sys.stdout.isatty()
  271. name_list = list(names) if names else []
  272. if not name_list:
  273. rprint('[yellow]No persona names provided. Pass names as arguments.[/yellow]', file=sys.stderr)
  274. return 1
  275. # Validate import source if specified
  276. source_profile_dir = None
  277. if import_from:
  278. import_from = import_from.lower()
  279. if import_from not in BROWSER_PROFILE_FINDERS:
  280. rprint(f'[red]Unknown browser: {import_from}[/red]', file=sys.stderr)
  281. rprint(f'[dim]Supported browsers: {", ".join(BROWSER_PROFILE_FINDERS.keys())}[/dim]', file=sys.stderr)
  282. return 1
  283. source_profile_dir = BROWSER_PROFILE_FINDERS[import_from]()
  284. if not source_profile_dir:
  285. rprint(f'[red]Could not find {import_from} profile directory[/red]', file=sys.stderr)
  286. return 1
  287. rprint(f'[dim]Found {import_from} profile: {source_profile_dir}[/dim]', file=sys.stderr)
  288. created_count = 0
  289. for name in name_list:
  290. name = name.strip()
  291. if not name:
  292. continue
  293. # Validate persona name to prevent path traversal
  294. is_valid, error_msg = validate_persona_name(name)
  295. if not is_valid:
  296. rprint(f'[red]Invalid persona name "{name}": {error_msg}[/red]', file=sys.stderr)
  297. continue
  298. persona, created = Persona.objects.get_or_create(name=name)
  299. if created:
  300. persona.ensure_dirs()
  301. created_count += 1
  302. rprint(f'[green]Created persona: {name}[/green]', file=sys.stderr)
  303. else:
  304. rprint(f'[dim]Persona already exists: {name}[/dim]', file=sys.stderr)
  305. # Import browser profile if requested
  306. if import_from and source_profile_dir:
  307. cookies_file = Path(persona.path) / 'cookies.txt'
  308. if import_from in CHROMIUM_BROWSERS:
  309. persona_chrome_dir = Path(persona.CHROME_USER_DATA_DIR)
  310. # Copy the browser profile
  311. rprint(f'[dim]Copying browser profile to {persona_chrome_dir}...[/dim]', file=sys.stderr)
  312. try:
  313. # Remove existing chrome_user_data if it exists
  314. if persona_chrome_dir.exists():
  315. shutil.rmtree(persona_chrome_dir)
  316. # Copy the profile directory
  317. # We copy the entire user data dir, not just Default profile
  318. shutil.copytree(
  319. source_profile_dir,
  320. persona_chrome_dir,
  321. symlinks=True,
  322. ignore=shutil.ignore_patterns(
  323. 'Cache', 'Code Cache', 'GPUCache', 'ShaderCache',
  324. 'Service Worker', 'GCM Store', '*.log', 'Crashpad',
  325. 'BrowserMetrics', 'BrowserMetrics-spare.pma',
  326. 'SingletonLock', 'SingletonSocket', 'SingletonCookie',
  327. ),
  328. )
  329. rprint(f'[green]Copied browser profile to persona[/green]', file=sys.stderr)
  330. # Extract cookies via CDP
  331. rprint(f'[dim]Extracting cookies via CDP...[/dim]', file=sys.stderr)
  332. if extract_cookies_via_cdp(persona_chrome_dir, cookies_file):
  333. rprint(f'[green]Extracted cookies to {cookies_file}[/green]', file=sys.stderr)
  334. else:
  335. rprint(f'[yellow]Could not extract cookies automatically.[/yellow]', file=sys.stderr)
  336. rprint(f'[dim]You can manually export cookies using a browser extension.[/dim]', file=sys.stderr)
  337. except Exception as e:
  338. rprint(f'[red]Failed to copy browser profile: {e}[/red]', file=sys.stderr)
  339. return 1
  340. if not is_tty:
  341. write_record({
  342. 'id': str(persona.id) if hasattr(persona, 'id') else None,
  343. 'name': persona.name,
  344. 'path': str(persona.path),
  345. 'CHROME_USER_DATA_DIR': persona.CHROME_USER_DATA_DIR,
  346. 'COOKIES_FILE': persona.COOKIES_FILE,
  347. })
  348. rprint(f'[green]Created {created_count} new persona(s)[/green]', file=sys.stderr)
  349. return 0
  350. # =============================================================================
  351. # LIST
  352. # =============================================================================
  353. def list_personas(
  354. name: Optional[str] = None,
  355. name__icontains: Optional[str] = None,
  356. limit: Optional[int] = None,
  357. ) -> int:
  358. """
  359. List Personas as JSONL with optional filters.
  360. Exit codes:
  361. 0: Success (even if no results)
  362. """
  363. from archivebox.misc.jsonl import write_record
  364. from archivebox.personas.models import Persona
  365. is_tty = sys.stdout.isatty()
  366. queryset = Persona.objects.all().order_by('name')
  367. # Apply filters
  368. filter_kwargs = {
  369. 'name': name,
  370. 'name__icontains': name__icontains,
  371. }
  372. queryset = apply_filters(queryset, filter_kwargs, limit=limit)
  373. count = 0
  374. for persona in queryset:
  375. cookies_status = '[green]✓[/green]' if persona.COOKIES_FILE else '[dim]✗[/dim]'
  376. chrome_status = '[green]✓[/green]' if Path(persona.CHROME_USER_DATA_DIR).exists() else '[dim]✗[/dim]'
  377. if is_tty:
  378. rprint(f'[cyan]{persona.name:20}[/cyan] cookies:{cookies_status} chrome:{chrome_status} [dim]{persona.path}[/dim]')
  379. else:
  380. write_record({
  381. 'id': str(persona.id) if hasattr(persona, 'id') else None,
  382. 'name': persona.name,
  383. 'path': str(persona.path),
  384. 'CHROME_USER_DATA_DIR': persona.CHROME_USER_DATA_DIR,
  385. 'COOKIES_FILE': persona.COOKIES_FILE,
  386. })
  387. count += 1
  388. rprint(f'[dim]Listed {count} persona(s)[/dim]', file=sys.stderr)
  389. return 0
  390. # =============================================================================
  391. # UPDATE
  392. # =============================================================================
  393. def update_personas(name: Optional[str] = None) -> int:
  394. """
  395. Update Personas from stdin JSONL.
  396. Reads Persona records from stdin and applies updates.
  397. Uses PATCH semantics - only specified fields are updated.
  398. Exit codes:
  399. 0: Success
  400. 1: No input or error
  401. """
  402. from archivebox.misc.jsonl import read_stdin, write_record
  403. from archivebox.personas.models import Persona
  404. is_tty = sys.stdout.isatty()
  405. records = list(read_stdin())
  406. if not records:
  407. rprint('[yellow]No records provided via stdin[/yellow]', file=sys.stderr)
  408. return 1
  409. updated_count = 0
  410. for record in records:
  411. persona_id = record.get('id')
  412. old_name = record.get('name')
  413. if not persona_id and not old_name:
  414. continue
  415. try:
  416. if persona_id:
  417. persona = Persona.objects.get(id=persona_id)
  418. else:
  419. persona = Persona.objects.get(name=old_name)
  420. # Apply updates from CLI flags
  421. if name:
  422. # Validate new name to prevent path traversal
  423. is_valid, error_msg = validate_persona_name(name)
  424. if not is_valid:
  425. rprint(f'[red]Invalid new persona name "{name}": {error_msg}[/red]', file=sys.stderr)
  426. continue
  427. # Rename the persona directory too
  428. old_path = persona.path
  429. persona.name = name
  430. new_path = persona.path
  431. if old_path.exists() and old_path != new_path:
  432. shutil.move(str(old_path), str(new_path))
  433. persona.save()
  434. updated_count += 1
  435. if not is_tty:
  436. write_record({
  437. 'id': str(persona.id) if hasattr(persona, 'id') else None,
  438. 'name': persona.name,
  439. 'path': str(persona.path),
  440. })
  441. except Persona.DoesNotExist:
  442. rprint(f'[yellow]Persona not found: {persona_id or old_name}[/yellow]', file=sys.stderr)
  443. continue
  444. rprint(f'[green]Updated {updated_count} persona(s)[/green]', file=sys.stderr)
  445. return 0
  446. # =============================================================================
  447. # DELETE
  448. # =============================================================================
  449. def delete_personas(yes: bool = False, dry_run: bool = False) -> int:
  450. """
  451. Delete Personas from stdin JSONL.
  452. Requires --yes flag to confirm deletion.
  453. Exit codes:
  454. 0: Success
  455. 1: No input or missing --yes flag
  456. """
  457. from archivebox.misc.jsonl import read_stdin
  458. from archivebox.personas.models import Persona
  459. records = list(read_stdin())
  460. if not records:
  461. rprint('[yellow]No records provided via stdin[/yellow]', file=sys.stderr)
  462. return 1
  463. # Collect persona IDs or names
  464. persona_ids = []
  465. persona_names = []
  466. for r in records:
  467. if r.get('id'):
  468. persona_ids.append(r['id'])
  469. elif r.get('name'):
  470. persona_names.append(r['name'])
  471. if not persona_ids and not persona_names:
  472. rprint('[yellow]No valid persona IDs or names in input[/yellow]', file=sys.stderr)
  473. return 1
  474. from django.db.models import Q
  475. query = Q()
  476. if persona_ids:
  477. query |= Q(id__in=persona_ids)
  478. if persona_names:
  479. query |= Q(name__in=persona_names)
  480. personas = Persona.objects.filter(query)
  481. count = personas.count()
  482. if count == 0:
  483. rprint('[yellow]No matching personas found[/yellow]', file=sys.stderr)
  484. return 0
  485. if dry_run:
  486. rprint(f'[yellow]Would delete {count} persona(s) (dry run)[/yellow]', file=sys.stderr)
  487. for persona in personas:
  488. rprint(f' {persona.name} ({persona.path})', file=sys.stderr)
  489. return 0
  490. if not yes:
  491. rprint('[red]Use --yes to confirm deletion[/red]', file=sys.stderr)
  492. return 1
  493. # Delete persona directories and database records
  494. deleted_count = 0
  495. for persona in personas:
  496. persona_path = persona.path
  497. # Safety check: ensure path is within PERSONAS_DIR before deletion
  498. if not ensure_path_within_personas_dir(persona_path):
  499. rprint(f'[red]Security error: persona path "{persona_path}" is outside PERSONAS_DIR. Skipping deletion.[/red]', file=sys.stderr)
  500. continue
  501. if persona_path.exists():
  502. shutil.rmtree(persona_path)
  503. persona.delete()
  504. deleted_count += 1
  505. rprint(f'[green]Deleted {deleted_count} persona(s)[/green]', file=sys.stderr)
  506. return 0
  507. # =============================================================================
  508. # CLI Commands
  509. # =============================================================================
  510. @click.group()
  511. def main():
  512. """Manage Persona records (browser profiles)."""
  513. pass
  514. @main.command('create')
  515. @click.argument('names', nargs=-1)
  516. @click.option('--import', 'import_from', help='Import profile from browser (chrome, chromium, brave, edge)')
  517. def create_cmd(names: tuple, import_from: Optional[str]):
  518. """Create Personas, optionally importing from a browser profile."""
  519. sys.exit(create_personas(names, import_from=import_from))
  520. @main.command('list')
  521. @click.option('--name', help='Filter by exact name')
  522. @click.option('--name__icontains', help='Filter by name contains')
  523. @click.option('--limit', '-n', type=int, help='Limit number of results')
  524. def list_cmd(name: Optional[str], name__icontains: Optional[str], limit: Optional[int]):
  525. """List Personas as JSONL."""
  526. sys.exit(list_personas(name=name, name__icontains=name__icontains, limit=limit))
  527. @main.command('update')
  528. @click.option('--name', '-n', help='Set new name')
  529. def update_cmd(name: Optional[str]):
  530. """Update Personas from stdin JSONL."""
  531. sys.exit(update_personas(name=name))
  532. @main.command('delete')
  533. @click.option('--yes', '-y', is_flag=True, help='Confirm deletion')
  534. @click.option('--dry-run', is_flag=True, help='Show what would be deleted')
  535. def delete_cmd(yes: bool, dry_run: bool):
  536. """Delete Personas from stdin JSONL."""
  537. sys.exit(delete_personas(yes=yes, dry_run=dry_run))
  538. if __name__ == '__main__':
  539. main()