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.
- overleaf_for_agents-0.1.0/.gitignore +48 -0
- overleaf_for_agents-0.1.0/PKG-INFO +38 -0
- overleaf_for_agents-0.1.0/README.md +25 -0
- overleaf_for_agents-0.1.0/pyproject.toml +32 -0
- overleaf_for_agents-0.1.0/src/leaf/__init__.py +2 -0
- overleaf_for_agents-0.1.0/src/leaf/auth.py +311 -0
- overleaf_for_agents-0.1.0/src/leaf/cli.py +557 -0
- overleaf_for_agents-0.1.0/src/leaf/client.py +581 -0
- overleaf_for_agents-0.1.0/src/leaf/create.py +40 -0
- overleaf_for_agents-0.1.0/src/leaf/git.py +128 -0
- overleaf_for_agents-0.1.0/tests/__init__.py +0 -0
- overleaf_for_agents-0.1.0/tests/test_cli.py +265 -0
- overleaf_for_agents-0.1.0/tests/test_client.py +345 -0
- overleaf_for_agents-0.1.0/tests/test_git.py +153 -0
- overleaf_for_agents-0.1.0/uv.lock +346 -0
|
@@ -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,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
|