overleaf-for-agents 0.1.0__tar.gz

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,48 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ *.egg
11
+ .eggs/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # uv
19
+ .uv/
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ .tox/
26
+
27
+ # Type checking
28
+ .mypy_cache/
29
+ .pyright/
30
+ pyrightconfig.json
31
+
32
+ # IDEs
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+
38
+ # macOS
39
+ .DS_Store
40
+
41
+ # leaf runtime
42
+ .leaf/
43
+ *.olauth
44
+ ~/.olauth
45
+
46
+ # Claude Code
47
+ .claude/
48
+ .omc/
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: overleaf-for-agents
3
+ Version: 0.1.0
4
+ Summary: Bidirectional LaTeX sync for Overleaf — push, pull, and conflict resolution via git worktree
5
+ Project-URL: Repository, https://github.com/yqi96/overleaf-for-agents
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: beautifulsoup4>=4.12
9
+ Requires-Dist: click>=8.0
10
+ Requires-Dist: pychrome>=0.2
11
+ Requires-Dist: requests>=2.28
12
+ Description-Content-Type: text/markdown
13
+
14
+ # overleaf-for-agents
15
+
16
+ Bidirectional sync between local files and Overleaf.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install overleaf-for-agents
22
+ uv tool install overleaf-for-agents # recommended with uv
23
+ uv pip install -e /path/to/leaf # local dev
24
+ ```
25
+
26
+ ## For Claude Code agents
27
+
28
+ When the user asks to set up Overleaf sync, run:
29
+
30
+ ```bash
31
+ leaf claude
32
+ ```
33
+
34
+ Then read the output and follow every step.
35
+
36
+ ## License
37
+
38
+ MIT
@@ -0,0 +1,25 @@
1
+ # overleaf-for-agents
2
+
3
+ Bidirectional sync between local files and Overleaf.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install overleaf-for-agents
9
+ uv tool install overleaf-for-agents # recommended with uv
10
+ uv pip install -e /path/to/leaf # local dev
11
+ ```
12
+
13
+ ## For Claude Code agents
14
+
15
+ When the user asks to set up Overleaf sync, run:
16
+
17
+ ```bash
18
+ leaf claude
19
+ ```
20
+
21
+ Then read the output and follow every step.
22
+
23
+ ## License
24
+
25
+ MIT
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "overleaf-for-agents"
7
+ version = "0.1.0"
8
+ description = "Bidirectional LaTeX sync for Overleaf — push, pull, and conflict resolution via git worktree"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "requests>=2.28",
14
+ "beautifulsoup4>=4.12",
15
+ "click>=8.0",
16
+ "pychrome>=0.2", # CDP login backend (20 KB, uses existing Chrome)
17
+ ]
18
+
19
+ [project.scripts]
20
+ leaf = "leaf.cli:main"
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/yqi96/overleaf-for-agents"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/leaf"]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest>=9.1.1",
31
+ "responses>=0.26.2",
32
+ ]
@@ -0,0 +1,2 @@
1
+ """leaf: push local LaTeX files to Overleaf."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,311 @@
1
+ """
2
+ Authentication helpers for Overleaf.
3
+
4
+ Cookie storage format is compatible with overleaf-sync (ols) v1.x,
5
+ so `ols login` can be used as an alternative to the built-in login command.
6
+
7
+ Login backends (in priority order)
8
+ ------------------------------------
9
+ 1. CDP (default) — connects to your already-installed Chrome via Chrome
10
+ DevTools Protocol. Zero extra downloads. Requires only `pychrome`.
11
+ Falls back to Manual (2) automatically on any failure.
12
+
13
+ 2. Manual — opens the system browser, asks the user to copy the session
14
+ cookie from DevTools and paste it. Universal fallback; works on any
15
+ OS and any browser without any extra dependencies.
16
+
17
+ Note: the Claude Code browser MCP (mcp__browser__*) uses browser-pilot,
18
+ also a CDP-based tool — the same protocol as option 1 above.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pickle
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import requests
31
+ from bs4 import BeautifulSoup
32
+
33
+ COOKIE_PATH = Path.home() / ".olauth"
34
+ LOGIN_URL = "https://www.overleaf.com/login"
35
+ PROJECT_URL = "https://www.overleaf.com/project"
36
+ SESSION_COOKIE_NAME = "overleaf_session2"
37
+
38
+ # Default CDP debug port
39
+ CDP_PORT = 9222
40
+
41
+ # Known Chrome executable paths by platform
42
+ _CHROME_CANDIDATES = [
43
+ # macOS
44
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
45
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
46
+ # Linux
47
+ "google-chrome",
48
+ "google-chrome-stable",
49
+ "chromium-browser",
50
+ "chromium",
51
+ ]
52
+
53
+
54
+ # ── Cookie I/O ────────────────────────────────────────────────────────────────
55
+
56
+ def save_cookies(cookies: dict, csrf: str = "") -> None:
57
+ """Persist cookies to ~/.olauth (ols-compatible pickle format)."""
58
+ COOKIE_PATH.write_bytes(pickle.dumps({"cookie": cookies, "csrf": csrf}))
59
+
60
+
61
+ def load_cookies() -> dict:
62
+ """
63
+ Load cookies from ~/.olauth.
64
+
65
+ Raises FileNotFoundError if the file does not exist (user has not logged in).
66
+ """
67
+ if not COOKIE_PATH.exists():
68
+ raise FileNotFoundError(
69
+ f"{COOKIE_PATH} not found.\n"
70
+ "Run `leaf login` to authenticate first."
71
+ )
72
+ data = pickle.loads(COOKIE_PATH.read_bytes())
73
+ return data["cookie"]
74
+
75
+
76
+ # ── CSRF ──────────────────────────────────────────────────────────────────────
77
+
78
+ def fetch_csrf(session: requests.Session, project_id: str) -> str:
79
+ """
80
+ Fetch a fresh CSRF token from the project page.
81
+
82
+ Raises RuntimeError if the session has expired.
83
+ """
84
+ r = session.get(
85
+ f"https://www.overleaf.com/project/{project_id}",
86
+ headers={"Referer": "https://www.overleaf.com"},
87
+ )
88
+ r.raise_for_status()
89
+ meta = BeautifulSoup(r.content, "html.parser").find(
90
+ "meta", {"name": "ol-csrfToken"}
91
+ )
92
+ if not meta:
93
+ raise RuntimeError(
94
+ "CSRF token not found — session may have expired.\n"
95
+ "Run `leaf login` to re-authenticate."
96
+ )
97
+ return meta.get("content")
98
+
99
+
100
+ # ── Manual login (universal fallback — no browser automation needed) ──────────
101
+
102
+ def login_manual() -> None:
103
+ """
104
+ Guided manual cookie extraction — universal fallback.
105
+
106
+ Opens the Overleaf login page in the system browser and walks the user
107
+ through copying the session cookie from DevTools. Works on any browser,
108
+ any OS, even when Chrome/CDP/Playwright are unavailable.
109
+ """
110
+ import webbrowser
111
+
112
+ print()
113
+ print("── Manual login ────────────────────────────────────────────────")
114
+ print("Opening https://www.overleaf.com in your browser...")
115
+ webbrowser.open(LOGIN_URL)
116
+ print()
117
+ print("After you have logged in:")
118
+ print(" Chrome / Edge : F12 → Application → Cookies → https://www.overleaf.com")
119
+ print(" Firefox : F12 → Storage → Cookies → https://www.overleaf.com")
120
+ print(" Safari : Cmd+Option+I → Storage → Cookies")
121
+ print()
122
+ print(f"Find the cookie named {SESSION_COOKIE_NAME!r} and copy its Value.")
123
+ print()
124
+
125
+ try:
126
+ value = input("Paste cookie value here: ").strip()
127
+ except (EOFError, KeyboardInterrupt):
128
+ print("\nCancelled.", file=sys.stderr)
129
+ sys.exit(1)
130
+
131
+ if not value:
132
+ print("No value entered — aborted.", file=sys.stderr)
133
+ sys.exit(1)
134
+
135
+ save_cookies({SESSION_COOKIE_NAME: value})
136
+ print(f"\nCookie saved to {COOKIE_PATH}")
137
+ print("Verify with: leaf list")
138
+
139
+
140
+ # ── CDP login (primary — uses existing Chrome installation) ───────────────────
141
+
142
+ def _find_chrome() -> str | None:
143
+ """Return path to a Chrome/Chromium executable, or None."""
144
+ for candidate in _CHROME_CANDIDATES:
145
+ if candidate.startswith("/"):
146
+ if Path(candidate).exists():
147
+ return candidate
148
+ else:
149
+ if shutil.which(candidate):
150
+ return candidate
151
+ return None
152
+
153
+
154
+ def login_cdp(port: int = CDP_PORT, timeout: int = 300) -> None:
155
+ """
156
+ Launch Chrome with a CDP debug port, open Overleaf login, and extract
157
+ the session cookie once the user reaches the project dashboard.
158
+
159
+ Uses the user's existing Chrome installation — no extra downloads needed.
160
+ Only requires `pychrome` (pure Python, ~20 KB).
161
+
162
+ Parameters
163
+ ----------
164
+ port: CDP remote debugging port (default 9222).
165
+ timeout: Seconds to wait for the user to complete login (default 300).
166
+ """
167
+ import json as _json
168
+ import tempfile
169
+ import threading as _threading
170
+
171
+ try:
172
+ import pychrome
173
+ except ImportError:
174
+ print(
175
+ "pychrome is missing — falling back to manual cookie extraction.\n"
176
+ "(To fix: pip install --upgrade leaf)",
177
+ file=sys.stderr,
178
+ )
179
+ login_manual()
180
+ return
181
+
182
+ chrome_bin = _find_chrome()
183
+ if not chrome_bin:
184
+ print(
185
+ "Chrome not found. Falling back to manual cookie extraction.\n"
186
+ "(Alternatively: `leaf login --backend playwright`)",
187
+ file=sys.stderr,
188
+ )
189
+ login_manual()
190
+ return
191
+
192
+ # Use a temp profile dir so Chrome starts as an independent process even
193
+ # when another Chrome window is already open. Without this, macOS hands
194
+ # the URL to the existing process and the debug port never opens.
195
+ tmpdir = tempfile.mkdtemp(prefix="leaf-chrome-")
196
+
197
+ proc = subprocess.Popen(
198
+ [
199
+ chrome_bin,
200
+ f"--remote-debugging-port={port}",
201
+ f"--user-data-dir={tmpdir}",
202
+ "--no-first-run",
203
+ "--no-default-browser-check",
204
+ "--disable-extensions",
205
+ LOGIN_URL,
206
+ ],
207
+ stdout=subprocess.DEVNULL,
208
+ stderr=subprocess.DEVNULL,
209
+ )
210
+
211
+ print(f"Chrome opened (pid {proc.pid}) — please log in to Overleaf...")
212
+
213
+ # Suppress pychrome's _recv_loop JSONDecodeError when WebSocket closes.
214
+ _orig_excepthook = _threading.excepthook
215
+ def _quiet_excepthook(args):
216
+ if args.exc_type is _json.JSONDecodeError:
217
+ return
218
+ _orig_excepthook(args)
219
+ _threading.excepthook = _quiet_excepthook
220
+
221
+ # Wait for CDP REST endpoint to become available.
222
+ # Use requests directly — pychrome's Browser.list_tab() has a GenericAttr
223
+ # bug in 0.2.4 that causes TypeError on every call.
224
+ import requests as _req
225
+ for _ in range(30):
226
+ try:
227
+ _req.get(f"http://127.0.0.1:{port}/json", timeout=1).json()
228
+ break
229
+ except Exception:
230
+ time.sleep(0.5)
231
+ else:
232
+ proc.terminate()
233
+ print(
234
+ "Failed to connect to Chrome via CDP.\n"
235
+ "Falling back to manual cookie extraction.",
236
+ file=sys.stderr,
237
+ )
238
+ login_manual()
239
+ return
240
+
241
+ # Poll for the session cookie.
242
+ deadline = time.time() + timeout
243
+ cookies: dict[str, str] = {}
244
+
245
+ while time.time() < deadline:
246
+ try:
247
+ tabs_data = _req.get(
248
+ f"http://127.0.0.1:{port}/json", timeout=2
249
+ ).json()
250
+ for tab_json in tabs_data:
251
+ if tab_json.get("type") != "page":
252
+ continue
253
+ tab_url = tab_json.get("url", "")
254
+ # Only accept cookies once the user reaches the project
255
+ # dashboard — that URL confirms a successful, *current* login.
256
+ # Accepting cookies from /login (or any other overleaf.com page)
257
+ # is unsafe: Chrome Sync can restore a stale overleaf_session2
258
+ # from the user's Google account into the temp profile *before*
259
+ # the user has entered their credentials.
260
+ if not tab_url.startswith("https://www.overleaf.com/project"):
261
+ continue
262
+ try:
263
+ tab = pychrome.Tab(**tab_json)
264
+ tab.start()
265
+ result = tab.Network.getCookies(
266
+ urls=["https://www.overleaf.com"]
267
+ )
268
+ tab.stop()
269
+ found = {
270
+ c["name"]: c["value"]
271
+ for c in result.get("cookies", [])
272
+ if c["name"] in (SESSION_COOKIE_NAME, "GCLB")
273
+ }
274
+ if SESSION_COOKIE_NAME in found:
275
+ cookies = found
276
+ except Exception:
277
+ try:
278
+ tab.stop()
279
+ except Exception:
280
+ pass
281
+ except Exception:
282
+ pass
283
+
284
+ if SESSION_COOKIE_NAME in cookies:
285
+ break
286
+ time.sleep(1)
287
+
288
+ proc.terminate()
289
+
290
+ # Clean up the temporary profile directory
291
+ import shutil as _shutil
292
+ try:
293
+ _shutil.rmtree(tmpdir, ignore_errors=True)
294
+ except Exception:
295
+ pass
296
+
297
+ if SESSION_COOKIE_NAME not in cookies:
298
+ print(
299
+ f"\nCDP login not confirmed within {timeout}s.\n"
300
+ "Falling back to manual cookie extraction...",
301
+ file=sys.stderr,
302
+ )
303
+ login_manual()
304
+ return
305
+
306
+ save_cookies(cookies)
307
+ print(f"Logged in. Cookies saved to {COOKIE_PATH}")
308
+
309
+
310
+ # Default login backend
311
+ login_browser = login_cdp