cookietuner 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ # ABOUTME: Cookie extraction tool for macOS browsers
2
+ # ABOUTME: Entry point that re-exports the CLI main function
3
+
4
+ from .cli import main
5
+
6
+ __all__ = ["main"]
cookietuner/chrome.py ADDED
@@ -0,0 +1,223 @@
1
+ # ABOUTME: Chrome cookie extraction for macOS
2
+ # ABOUTME: Reads and decrypts cookies from Chrome's SQLite database
3
+
4
+ import shutil
5
+ import sqlite3
6
+ import subprocess
7
+ import tempfile
8
+ from datetime import datetime, timedelta, timezone
9
+ from hashlib import pbkdf2_hmac
10
+ from pathlib import Path
11
+
12
+ from cryptography.hazmat.backends import default_backend
13
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
14
+
15
+ from .models import BrowserProfile, Cookie
16
+
17
+ # Chrome uses microseconds since Jan 1, 1601 (Windows epoch)
18
+ CHROME_EPOCH = datetime(1601, 1, 1, tzinfo=timezone.utc)
19
+
20
+ SAME_SITE_MAP: dict[int, str | None] = {
21
+ -1: None,
22
+ 0: "none",
23
+ 1: "lax",
24
+ 2: "strict",
25
+ }
26
+
27
+ CHROME_BASE_PATH = Path.home() / "Library/Application Support/Google/Chrome"
28
+
29
+
30
+ def list_profiles() -> list[BrowserProfile]:
31
+ """Lists all available Chrome profiles."""
32
+ profiles = []
33
+
34
+ if not CHROME_BASE_PATH.exists():
35
+ return profiles
36
+
37
+ for item in CHROME_BASE_PATH.iterdir():
38
+ if item.is_dir():
39
+ cookies_file = item / "Cookies"
40
+ if cookies_file.exists():
41
+ # "Default" is the main profile, others are "Profile 1", "Profile 2", etc.
42
+ profile_name = item.name
43
+ if profile_name == "Default":
44
+ profile_name = "Default"
45
+ profiles.append(
46
+ BrowserProfile(
47
+ browser="chrome",
48
+ profile_name=profile_name,
49
+ path=str(item),
50
+ )
51
+ )
52
+
53
+ return profiles
54
+
55
+
56
+ def _get_cookie_path(profile: str = "Default") -> Path:
57
+ """Returns the path to Chrome's cookie database for a given profile."""
58
+ return CHROME_BASE_PATH / profile / "Cookies"
59
+
60
+
61
+ def _get_encryption_key() -> bytes:
62
+ """Retrieves Chrome's encryption key from macOS Keychain."""
63
+ result = subprocess.run(
64
+ [
65
+ "security",
66
+ "find-generic-password",
67
+ "-s",
68
+ "Chrome Safe Storage",
69
+ "-w",
70
+ ],
71
+ capture_output=True,
72
+ text=True,
73
+ check=True,
74
+ )
75
+ password = result.stdout.strip()
76
+
77
+ # Derive the key using PBKDF2
78
+ # Chrome uses "saltysalt" as salt and 1003 iterations on macOS
79
+ key = pbkdf2_hmac(
80
+ "sha1",
81
+ password.encode("utf-8"),
82
+ b"saltysalt",
83
+ 1003,
84
+ dklen=16,
85
+ )
86
+ return key
87
+
88
+
89
+ def _decrypt_value(encrypted_value: bytes, key: bytes, strip_hash: bool) -> str:
90
+ """Decrypts a Chrome cookie value."""
91
+ if not encrypted_value:
92
+ return ""
93
+
94
+ # Chrome prepends 'v10' to encrypted values on macOS
95
+ if encrypted_value[:3] == b"v10":
96
+ encrypted_value = encrypted_value[3:]
97
+
98
+ # Chrome uses AES-128-CBC with a 16-byte IV of spaces
99
+ iv = b" " * 16
100
+ cipher = Cipher(
101
+ algorithms.AES(key),
102
+ modes.CBC(iv),
103
+ backend=default_backend(),
104
+ )
105
+ decryptor = cipher.decryptor()
106
+ decrypted = decryptor.update(encrypted_value) + decryptor.finalize()
107
+
108
+ # Remove PKCS7 padding
109
+ padding_len = decrypted[-1]
110
+ decrypted = decrypted[:-padding_len]
111
+
112
+ # Chrome 130+ (DB version >= 24) prepends SHA256 hash of domain (32 bytes)
113
+ if strip_hash:
114
+ decrypted = decrypted[32:]
115
+
116
+ return decrypted.decode("utf-8")
117
+
118
+ # Unencrypted value (older Chrome versions)
119
+ return encrypted_value.decode("utf-8")
120
+
121
+
122
+ def _get_db_version(cursor: sqlite3.Cursor) -> int:
123
+ """Gets the cookie database version from the meta table."""
124
+ cursor.execute("SELECT value FROM meta WHERE key='version'")
125
+ row = cursor.fetchone()
126
+ return int(row[0]) if row else 0
127
+
128
+
129
+ def _chrome_time_to_datetime(chrome_time: int) -> datetime | None:
130
+ """Converts Chrome's timestamp (microseconds since 1601) to datetime."""
131
+ if chrome_time == 0:
132
+ return None
133
+ try:
134
+ seconds = chrome_time / 1_000_000
135
+ return CHROME_EPOCH + timedelta(seconds=seconds)
136
+ except (ValueError, OverflowError):
137
+ return None
138
+
139
+
140
+ def get_cookies(
141
+ domain: str | None = None,
142
+ profile: str = "Default",
143
+ ) -> list[Cookie]:
144
+ """
145
+ Reads cookies from Chrome's cookie database.
146
+
147
+ Args:
148
+ domain: If specified, only return cookies matching this domain.
149
+ Matches if the domain contains this string.
150
+ profile: Chrome profile to read from (default: "Default").
151
+
152
+ Returns:
153
+ List of Cookie objects.
154
+ """
155
+ cookie_path = _get_cookie_path(profile)
156
+
157
+ if not cookie_path.exists():
158
+ return []
159
+
160
+ key = _get_encryption_key()
161
+
162
+ # Copy the database to avoid locking issues while Chrome is running
163
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
164
+ tmp_path = Path(tmp.name)
165
+
166
+ try:
167
+ shutil.copy2(cookie_path, tmp_path)
168
+
169
+ conn = sqlite3.connect(tmp_path)
170
+ cursor = conn.cursor()
171
+
172
+ # Check if we need to strip the hash prefix (Chrome 130+)
173
+ db_version = _get_db_version(cursor)
174
+ strip_hash = db_version >= 24
175
+
176
+ query = """
177
+ SELECT host_key, name, encrypted_value, path,
178
+ expires_utc, is_secure, is_httponly, samesite
179
+ FROM cookies
180
+ """
181
+ params: tuple[str, ...] = ()
182
+
183
+ if domain:
184
+ query += " WHERE host_key LIKE ?"
185
+ params = (f"%{domain}%",)
186
+
187
+ cursor.execute(query, params)
188
+ rows = cursor.fetchall()
189
+ conn.close()
190
+
191
+ cookies = []
192
+ for (
193
+ host_key,
194
+ name,
195
+ encrypted_value,
196
+ path,
197
+ expires_utc,
198
+ is_secure,
199
+ is_httponly,
200
+ samesite,
201
+ ) in rows:
202
+ try:
203
+ value = _decrypt_value(encrypted_value, key, strip_hash)
204
+ cookies.append(
205
+ Cookie(
206
+ domain=host_key,
207
+ name=name,
208
+ value=value,
209
+ path=path,
210
+ expires=_chrome_time_to_datetime(expires_utc),
211
+ is_secure=bool(is_secure),
212
+ is_httponly=bool(is_httponly),
213
+ same_site=SAME_SITE_MAP.get(samesite, "unspecified"),
214
+ )
215
+ )
216
+ except Exception:
217
+ # Skip cookies that fail to decrypt
218
+ pass
219
+
220
+ return cookies
221
+
222
+ finally:
223
+ tmp_path.unlink(missing_ok=True)
cookietuner/cli.py ADDED
@@ -0,0 +1,163 @@
1
+ # ABOUTME: Command-line interface using Typer
2
+ # ABOUTME: Provides commands to list and extract browser cookies
3
+
4
+ import json
5
+ import sys
6
+ from enum import Enum
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from . import chrome, safari
13
+
14
+ app = typer.Typer(help="Extract cookies from your browsers", no_args_is_help=True)
15
+ console = Console()
16
+
17
+
18
+ def _check_macos() -> None:
19
+ """Ensure we're running on macOS."""
20
+ if sys.platform != "darwin":
21
+ console.print("[red]Error: cookietuner only supports macOS[/red]")
22
+ raise typer.Exit(1)
23
+
24
+
25
+ @app.callback()
26
+ def main_callback() -> None:
27
+ """Check platform before running any command."""
28
+ _check_macos()
29
+
30
+
31
+ class Browser(str, Enum):
32
+ chrome = "chrome"
33
+ safari = "safari"
34
+
35
+
36
+ class OutputFormat(str, Enum):
37
+ table = "table"
38
+ short = "short"
39
+ line = "line"
40
+ json = "json"
41
+
42
+
43
+ @app.command()
44
+ def cookies(
45
+ browser: Browser = typer.Option(
46
+ ..., "--browser", "-b", help="Browser to extract from"
47
+ ),
48
+ domain: str | None = typer.Option(
49
+ None, "--domain", "-d", help="Filter by domain (partial match)"
50
+ ),
51
+ profile: str = typer.Option(
52
+ "Default", "--profile", "-p", help="Browser profile name"
53
+ ),
54
+ output: OutputFormat = typer.Option(
55
+ OutputFormat.table, "--output", "-o", help="Output format"
56
+ ),
57
+ ) -> None:
58
+ """List cookies from a browser."""
59
+ if browser == Browser.chrome:
60
+ cookie_list = chrome.get_cookies(domain=domain, profile=profile)
61
+ elif browser == Browser.safari:
62
+ cookie_list = safari.get_cookies(domain=domain)
63
+
64
+ if not cookie_list:
65
+ if output == OutputFormat.json:
66
+ print("[]")
67
+ elif output == OutputFormat.line:
68
+ pass # No output for line format when empty
69
+ else:
70
+ console.print("[yellow]No cookies found[/yellow]")
71
+ raise typer.Exit(0)
72
+
73
+ if output == OutputFormat.json:
74
+ data = [c.model_dump(mode="json", exclude_none=True) for c in cookie_list]
75
+ print(json.dumps(data, indent=2))
76
+ return
77
+
78
+ if output == OutputFormat.line:
79
+ for cookie in cookie_list:
80
+ print(f"{cookie.domain} {cookie.name} {cookie.value}")
81
+ return
82
+
83
+ if output == OutputFormat.short:
84
+ table = Table(title=f"Cookies ({len(cookie_list)} found)")
85
+ table.add_column("Domain", style="cyan")
86
+ table.add_column("Name", style="green")
87
+ table.add_column("Value", style="white", max_width=50, overflow="ellipsis")
88
+
89
+ for cookie in cookie_list:
90
+ value_display = (
91
+ cookie.value[:47] + "..." if len(cookie.value) > 50 else cookie.value
92
+ )
93
+ table.add_row(cookie.domain, cookie.name, value_display)
94
+
95
+ console.print(table)
96
+ return
97
+
98
+ table = Table(title=f"Cookies ({len(cookie_list)} found)")
99
+ table.add_column("Domain", style="cyan")
100
+ table.add_column("Name", style="green")
101
+ table.add_column("Value", style="white", max_width=40, overflow="ellipsis")
102
+ table.add_column("Expires", style="dim")
103
+ table.add_column("Flags", style="yellow")
104
+
105
+ for cookie in cookie_list:
106
+ value_display = (
107
+ cookie.value[:37] + "..." if len(cookie.value) > 40 else cookie.value
108
+ )
109
+ expires_display = (
110
+ cookie.expires.strftime("%Y-%m-%d") if cookie.expires else "session"
111
+ )
112
+
113
+ flags = []
114
+ if cookie.is_secure:
115
+ flags.append("Secure")
116
+ if cookie.is_httponly:
117
+ flags.append("HttpOnly")
118
+ if cookie.same_site:
119
+ flags.append(f"SameSite={cookie.same_site}")
120
+ flags_display = ", ".join(flags) if flags else "-"
121
+
122
+ table.add_row(
123
+ cookie.domain, cookie.name, value_display, expires_display, flags_display
124
+ )
125
+
126
+ console.print(table)
127
+
128
+
129
+ @app.command()
130
+ def profiles(
131
+ browser: Browser | None = typer.Option(
132
+ None, "--browser", "-b", help="Filter by browser"
133
+ ),
134
+ ) -> None:
135
+ """List available browser profiles."""
136
+ profile_list = []
137
+
138
+ if browser is None or browser == Browser.chrome:
139
+ profile_list.extend(chrome.list_profiles())
140
+
141
+ if browser is None or browser == Browser.safari:
142
+ profile_list.extend(safari.list_profiles())
143
+
144
+ if not profile_list:
145
+ console.print("[yellow]No profiles found[/yellow]")
146
+ raise typer.Exit(0)
147
+
148
+ title = (
149
+ "Browser Profiles" if browser is None else f"{browser.value.title()} Profiles"
150
+ )
151
+ table = Table(title=title)
152
+ table.add_column("Browser", style="cyan")
153
+ table.add_column("Profile Name", style="green")
154
+ table.add_column("Path", style="dim")
155
+
156
+ for p in profile_list:
157
+ table.add_row(p.browser.title(), p.profile_name, p.path)
158
+
159
+ console.print(table)
160
+
161
+
162
+ def main() -> None:
163
+ app()
cookietuner/models.py ADDED
@@ -0,0 +1,44 @@
1
+ # ABOUTME: Pydantic models for cookie data structures
2
+ # ABOUTME: Defines Cookie and browser profile configuration
3
+
4
+ from datetime import datetime, timezone
5
+
6
+ from pydantic import BaseModel, computed_field
7
+
8
+
9
+ class Cookie(BaseModel):
10
+ """Represents a browser cookie with all attributes."""
11
+
12
+ domain: str
13
+ name: str
14
+ value: str
15
+ path: str
16
+ expires: datetime | None = None
17
+ is_secure: bool = False
18
+ is_httponly: bool = False
19
+ same_site: str | None = None
20
+
21
+ @computed_field
22
+ @property
23
+ def is_expired(self) -> bool:
24
+ """Returns True if the cookie has expired."""
25
+ if self.expires is None:
26
+ return False # Session cookies don't expire
27
+ now = datetime.now(timezone.utc)
28
+ expires = (
29
+ self.expires
30
+ if self.expires.tzinfo
31
+ else self.expires.replace(tzinfo=timezone.utc)
32
+ )
33
+ return expires < now
34
+
35
+ def __str__(self) -> str:
36
+ return f"{self.name}={self.value}"
37
+
38
+
39
+ class BrowserProfile(BaseModel):
40
+ """Represents a browser profile location."""
41
+
42
+ browser: str
43
+ profile_name: str
44
+ path: str
cookietuner/safari.py ADDED
@@ -0,0 +1,197 @@
1
+ # ABOUTME: Safari cookie extraction for macOS
2
+ # ABOUTME: Parses the .binarycookies format used by Safari
3
+
4
+ import struct
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+
8
+ from .models import BrowserProfile, Cookie
9
+
10
+ # Sandboxed Safari (modern macOS) stores cookies here
11
+ SAFARI_COOKIES_PATH_SANDBOXED = (
12
+ Path.home()
13
+ / "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"
14
+ )
15
+ # Legacy location for older macOS versions
16
+ SAFARI_COOKIES_PATH_LEGACY = Path.home() / "Library/Cookies/Cookies.binarycookies"
17
+
18
+ # Safari uses Mac absolute time (seconds since Jan 1, 2001)
19
+ MAC_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
20
+
21
+ # SameSite is encoded in bits 3-5 of the flags field
22
+ # https://gist.github.com/creachadair/ba843bd92c2cfc78dc5e1a53b44775a3
23
+ SAME_SITE_MAP: dict[int, str | None] = {
24
+ 0: None, # unspecified
25
+ 4: "none", # 0b100
26
+ 5: "lax", # 0b101
27
+ 7: "strict", # 0b111
28
+ }
29
+
30
+
31
+ def _get_cookies_path() -> Path | None:
32
+ """Returns the path to Safari's cookies file, or None if not found."""
33
+ for path in [SAFARI_COOKIES_PATH_SANDBOXED, SAFARI_COOKIES_PATH_LEGACY]:
34
+ if path.exists():
35
+ return path
36
+ return None
37
+
38
+
39
+ def list_profiles() -> list[BrowserProfile]:
40
+ """Safari only has one profile."""
41
+ cookies_path = _get_cookies_path()
42
+ if cookies_path is None:
43
+ return []
44
+ return [
45
+ BrowserProfile(
46
+ browser="safari",
47
+ profile_name="Default",
48
+ path=str(cookies_path),
49
+ )
50
+ ]
51
+
52
+
53
+ def _mac_time_to_datetime(mac_time: float) -> datetime | None:
54
+ """Converts Mac absolute time to datetime."""
55
+ if mac_time == 0:
56
+ return None
57
+ try:
58
+ return MAC_EPOCH + timedelta(seconds=mac_time)
59
+ except (ValueError, OverflowError):
60
+ return None
61
+
62
+
63
+ def _read_cstring(data: bytes, offset: int) -> str:
64
+ """Reads a null-terminated C string from data."""
65
+ end = data.index(b"\x00", offset)
66
+ return data[offset:end].decode("utf-8", errors="replace")
67
+
68
+
69
+ def _parse_cookie(data: bytes) -> Cookie | None:
70
+ """Parses a single cookie from binary data."""
71
+ try:
72
+ # Cookie structure:
73
+ # 4 bytes: cookie size
74
+ # 4 bytes: unknown
75
+ # 4 bytes: flags
76
+ # 4 bytes: unknown
77
+ # 4 bytes: domain offset
78
+ # 4 bytes: name offset
79
+ # 4 bytes: path offset
80
+ # 4 bytes: value offset
81
+ # 8 bytes: end of cookie
82
+ # 8 bytes: expiration date (double)
83
+ # 8 bytes: creation date (double)
84
+
85
+ if len(data) < 48:
86
+ return None
87
+
88
+ flags = struct.unpack("<I", data[8:12])[0]
89
+ domain_offset = struct.unpack("<I", data[16:20])[0]
90
+ name_offset = struct.unpack("<I", data[20:24])[0]
91
+ path_offset = struct.unpack("<I", data[24:28])[0]
92
+ value_offset = struct.unpack("<I", data[28:32])[0]
93
+ expiration = struct.unpack("<d", data[40:48])[0]
94
+
95
+ domain = _read_cstring(data, domain_offset)
96
+ name = _read_cstring(data, name_offset)
97
+ path = _read_cstring(data, path_offset)
98
+ value = _read_cstring(data, value_offset)
99
+
100
+ is_secure = bool(flags & 0x1)
101
+ is_httponly = bool(flags & 0x4)
102
+ same_site_bits = (flags >> 3) & 0x7
103
+ same_site = SAME_SITE_MAP.get(same_site_bits)
104
+
105
+ return Cookie(
106
+ domain=domain,
107
+ name=name,
108
+ value=value,
109
+ path=path,
110
+ expires=_mac_time_to_datetime(expiration),
111
+ is_secure=is_secure,
112
+ is_httponly=is_httponly,
113
+ same_site=same_site,
114
+ )
115
+ except Exception:
116
+ return None
117
+
118
+
119
+ def get_cookies(
120
+ domain: str | None = None,
121
+ profile: str = "Default", # Safari only has one profile, ignored
122
+ ) -> list[Cookie]:
123
+ """
124
+ Reads cookies from Safari's binarycookies file.
125
+
126
+ Args:
127
+ domain: If specified, only return cookies matching this domain.
128
+ profile: Ignored for Safari (only one profile).
129
+
130
+ Returns:
131
+ List of Cookie objects.
132
+ """
133
+ cookies_path = _get_cookies_path()
134
+ if cookies_path is None:
135
+ return []
136
+
137
+ with open(cookies_path, "rb") as f:
138
+ data = f.read()
139
+
140
+ cookies: list[Cookie] = []
141
+
142
+ # File format:
143
+ # 4 bytes: magic "cook"
144
+ # 4 bytes: number of pages (big endian)
145
+ # 4 bytes * num_pages: page sizes (big endian)
146
+ # pages follow...
147
+
148
+ if len(data) < 8 or data[:4] != b"cook":
149
+ return cookies
150
+
151
+ num_pages = struct.unpack(">I", data[4:8])[0]
152
+ page_sizes = []
153
+
154
+ offset = 8
155
+ for _ in range(num_pages):
156
+ page_sizes.append(struct.unpack(">I", data[offset : offset + 4])[0])
157
+ offset += 4
158
+
159
+ # Parse each page
160
+ for page_size in page_sizes:
161
+ page_data = data[offset : offset + page_size]
162
+ offset += page_size
163
+
164
+ if len(page_data) < 8:
165
+ continue
166
+
167
+ # Page header:
168
+ # 4 bytes: page header (0x00000100)
169
+ # 4 bytes: number of cookies in page
170
+ # 4 bytes * num_cookies: cookie offsets
171
+
172
+ num_cookies = struct.unpack("<I", page_data[4:8])[0]
173
+ cookie_offsets = []
174
+
175
+ page_offset = 8
176
+ for _ in range(num_cookies):
177
+ cookie_offsets.append(
178
+ struct.unpack("<I", page_data[page_offset : page_offset + 4])[0]
179
+ )
180
+ page_offset += 4
181
+
182
+ # Parse each cookie
183
+ for i, cookie_offset in enumerate(cookie_offsets):
184
+ # Determine cookie size
185
+ if i + 1 < len(cookie_offsets):
186
+ cookie_size = cookie_offsets[i + 1] - cookie_offset
187
+ else:
188
+ cookie_size = page_size - cookie_offset
189
+
190
+ cookie_data = page_data[cookie_offset : cookie_offset + cookie_size]
191
+ cookie = _parse_cookie(cookie_data)
192
+
193
+ if cookie:
194
+ if domain is None or domain.lower() in cookie.domain.lower():
195
+ cookies.append(cookie)
196
+
197
+ return cookies
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: cookietuner
3
+ Version: 0.1.1
4
+ Summary: Extract and display cookies from macOS browsers (Chrome, Safari)
5
+ Keywords: cookies,browser,chrome,safari,macos,cli
6
+ Author: David Poblador i Garcia
7
+ Author-email: David Poblador i Garcia <david@poblador.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Environment :: MacOS X
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Utilities
19
+ Requires-Dist: cryptography>=46.0.3
20
+ Requires-Dist: pydantic>=2.12.5
21
+ Requires-Dist: typer>=0.21.1
22
+ Requires-Python: >=3.14
23
+ Project-URL: Homepage, https://github.com/alltuner/cookietuner
24
+ Project-URL: Documentation, https://cookietuner.alltuner.com
25
+ Project-URL: Repository, https://github.com/alltuner/cookietuner
26
+ Project-URL: Issues, https://github.com/alltuner/cookietuner/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # cookietuner
30
+
31
+ A command-line tool to extract and display cookies from macOS browsers.
32
+
33
+ ## Installation
34
+
35
+ The easiest way to run cookietuner is with [uv](https://docs.astral.sh/uv/):
36
+
37
+ ```bash
38
+ uvx cookietuner
39
+ ```
40
+
41
+ Or install it permanently:
42
+
43
+ ```bash
44
+ uv tool install cookietuner
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ### List cookies
50
+
51
+ ```bash
52
+ # Chrome cookies (browser is required)
53
+ cookietuner cookies -b chrome
54
+
55
+ # Safari cookies
56
+ cookietuner cookies -b safari
57
+
58
+ # Filter by domain
59
+ cookietuner cookies -b chrome -d google.com
60
+
61
+ # Use a specific Chrome profile
62
+ cookietuner cookies -b chrome -p "Profile 1"
63
+ ```
64
+
65
+ ### Output formats
66
+
67
+ ```bash
68
+ # Full table with all details (default)
69
+ cookietuner cookies -b chrome -o table
70
+
71
+ # Short table with just domain, name, value
72
+ cookietuner cookies -b chrome -o short
73
+
74
+ # Space-separated line format (for scripting)
75
+ cookietuner cookies -b chrome -o line
76
+
77
+ # JSON output
78
+ cookietuner cookies -b chrome -o json
79
+ ```
80
+
81
+ ### List browser profiles
82
+
83
+ ```bash
84
+ # List all profiles
85
+ cookietuner profiles
86
+
87
+ # Filter by browser
88
+ cookietuner profiles -b chrome
89
+ ```
90
+
91
+ ## Features
92
+
93
+ - **Chrome support**: Decrypts cookies using macOS Keychain, supports Chrome 130+ format
94
+ - **Safari support**: Parses the binary cookies format with SameSite detection
95
+ - **Multiple output formats**: table, short, and JSON
96
+ - **Domain filtering**: Filter cookies by partial domain match
97
+ - **Profile selection**: Choose which browser profile to read from
98
+ - **Cookie metadata**: Shows expiration, Secure, HttpOnly, and SameSite flags
99
+
100
+ ## Requirements
101
+
102
+ - macOS (the tool only works on macOS)
103
+ - Python 3.14+
104
+ - Chrome and/or Safari browser
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ # Clone the repository
110
+ git clone https://github.com/alltuner/cookietuner.git
111
+ cd cookietuner
112
+
113
+ # Install dependencies
114
+ uv sync
115
+
116
+ # Run tests
117
+ uv run pytest
118
+
119
+ # Run the CLI
120
+ uv run cookietuner
121
+ ```
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,9 @@
1
+ cookietuner/__init__.py,sha256=Sm_ODTlONVtM14EyabFqFe9NjfsD6NfBZcsuOYEECto,157
2
+ cookietuner/chrome.py,sha256=VI2qd3AjleOfmHsX1iaT1SLv-v1XQAi-nFDLFTQ52eM,6492
3
+ cookietuner/cli.py,sha256=bWnYd4ARLPzX_bVPXCMlX43vCrs-Ge44SgMjSmN9fG4,4797
4
+ cookietuner/models.py,sha256=FsECJijz6FbbrrrtZIMbuxLsXFYl-39CUsnhE5cWeFY,1120
5
+ cookietuner/safari.py,sha256=d0hrqR0sLwHsLTVwVq1IR-OBm5BNjkXRL9LnX2oN_Vg,5911
6
+ cookietuner-0.1.1.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
7
+ cookietuner-0.1.1.dist-info/entry_points.txt,sha256=nPWOt_SGVme76enxEoNacmUk_Q9Mz-IPHn0EMD4pK7o,50
8
+ cookietuner-0.1.1.dist-info/METADATA,sha256=Vn3_EaPmjD_JU88NEWVOBj_Y6Wxn5Xqda-MTIq-xgsY,2938
9
+ cookietuner-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.27
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cookietuner = cookietuner:main
3
+