cync-cli 0.3.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.
cync/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """cync — sync Claude Code conversations across machines."""
2
+
3
+ __version__ = "0.3.0"
cync/auth.py ADDED
@@ -0,0 +1,184 @@
1
+ """GitHub (Supabase) auth for the cync CLI — browser loopback PKCE flow.
2
+
3
+ `login()` opens the browser to Supabase's GitHub OAuth, captures the redirect on
4
+ a localhost callback, and exchanges the code (PKCE) for a session. The session
5
+ (access + refresh tokens) is stored in ~/.config/cync/session.json; `refresh()`
6
+ renews the access token when it expires.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import http.server
13
+ import json
14
+ import os
15
+ import secrets
16
+ import tempfile
17
+ import time
18
+ import urllib.parse
19
+ import webbrowser
20
+ from dataclasses import asdict, dataclass
21
+ from pathlib import Path
22
+
23
+ import httpx
24
+
25
+ SESSION_PATH = Path.home() / ".config" / "cync" / "session.json"
26
+ CALLBACK_PORT = 8765
27
+ REDIRECT_URI = f"http://127.0.0.1:{CALLBACK_PORT}/callback"
28
+
29
+
30
+ @dataclass
31
+ class Session:
32
+ access_token: str
33
+ refresh_token: str
34
+ supabase_url: str
35
+ anon_key: str
36
+
37
+
38
+ def load_session() -> Session | None:
39
+ if SESSION_PATH.exists():
40
+ try:
41
+ return Session(**json.loads(SESSION_PATH.read_text()))
42
+ except Exception:
43
+ return None
44
+ return None
45
+
46
+
47
+ def save_session(s: Session) -> None:
48
+ d = SESSION_PATH.parent
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ try:
51
+ d.chmod(0o700)
52
+ except OSError:
53
+ pass
54
+ data = json.dumps(asdict(s), indent=2)
55
+ # atomic write, 0600 from creation so the refresh token is never world-readable
56
+ fd, tmp = tempfile.mkstemp(dir=str(d), prefix=".session.", suffix=".tmp")
57
+ try:
58
+ os.fchmod(fd, 0o600)
59
+ with os.fdopen(fd, "w") as f:
60
+ f.write(data)
61
+ os.replace(tmp, SESSION_PATH)
62
+ except BaseException:
63
+ try:
64
+ os.unlink(tmp)
65
+ except OSError:
66
+ pass
67
+ raise
68
+
69
+
70
+ def clear_session() -> None:
71
+ if SESSION_PATH.exists():
72
+ SESSION_PATH.unlink()
73
+
74
+
75
+ def discover(server_url: str) -> tuple[str, str]:
76
+ """Fetch the public Supabase URL + anon key from the cync server's /config."""
77
+ r = httpx.get(f"{server_url.rstrip('/')}/config", timeout=15)
78
+ r.raise_for_status()
79
+ d = r.json()
80
+ supabase_url = d["supabase_url"].rstrip("/")
81
+ if not supabase_url.startswith("https://"):
82
+ raise RuntimeError(f"refusing non-HTTPS Supabase URL from server: {supabase_url}")
83
+ return supabase_url, d["supabase_anon_key"]
84
+
85
+
86
+ def _pkce() -> tuple[str, str]:
87
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode()
88
+ digest = hashlib.sha256(verifier.encode()).digest()
89
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
90
+ return verifier, challenge
91
+
92
+
93
+ def login(server_url: str) -> Session:
94
+ supabase_url, anon = discover(server_url)
95
+ verifier, challenge = _pkce()
96
+ # Do NOT pass our own OAuth `state` to Supabase's /authorize: GoTrue manages
97
+ # the GitHub handshake state itself, and a caller-supplied state triggers
98
+ # `bad_oauth_state`. The CSRF protection on this loopback is PKCE — only a
99
+ # code bound to our code_challenge can be redeemed with our verifier.
100
+ authorize_url = (
101
+ f"{supabase_url}/auth/v1/authorize?provider=github"
102
+ f"&redirect_to={urllib.parse.quote(REDIRECT_URI, safe='')}"
103
+ f"&code_challenge={challenge}&code_challenge_method=s256"
104
+ )
105
+
106
+ result: dict = {}
107
+
108
+ class Handler(http.server.BaseHTTPRequestHandler):
109
+ def do_GET(self): # noqa: N802
110
+ parsed = urllib.parse.urlparse(self.path)
111
+ if parsed.path != "/callback":
112
+ self.send_response(404)
113
+ self.end_headers()
114
+ return
115
+ if result: # first callback wins; ignore later/duplicate hits
116
+ self.send_response(204)
117
+ self.end_headers()
118
+ return
119
+ params = urllib.parse.parse_qs(parsed.query)
120
+ result["code"] = params.get("code", [None])[0]
121
+ result["error"] = params.get("error_description", params.get("error", [None]))[0]
122
+ self.send_response(200)
123
+ self.send_header("Content-Type", "text/html")
124
+ self.end_headers()
125
+ self.wfile.write(
126
+ b"<html><body style='font-family:sans-serif'>"
127
+ b"<h2>cync: signed in. You can close this tab.</h2></body></html>"
128
+ )
129
+
130
+ def log_message(self, *args): # silence default logging
131
+ pass
132
+
133
+ httpd = http.server.HTTPServer(("127.0.0.1", CALLBACK_PORT), Handler)
134
+ httpd.timeout = 1 # return from handle_request each second so we can check the deadline
135
+ print(f"Opening browser to sign in with GitHub…\n if it doesn't open: {authorize_url}")
136
+ webbrowser.open(authorize_url)
137
+ deadline = time.monotonic() + 300
138
+ try:
139
+ while "code" not in result and "error" not in result:
140
+ if time.monotonic() > deadline:
141
+ break
142
+ httpd.handle_request()
143
+ finally:
144
+ httpd.server_close()
145
+
146
+ if not result.get("code"):
147
+ raise RuntimeError(f"login failed: {result.get('error') or 'no authorization code received'}")
148
+
149
+ r = httpx.post(
150
+ f"{supabase_url}/auth/v1/token", params={"grant_type": "pkce"},
151
+ headers={"apikey": anon, "Content-Type": "application/json"},
152
+ json={"auth_code": result["code"], "code_verifier": verifier}, timeout=30,
153
+ )
154
+ r.raise_for_status()
155
+ d = r.json()
156
+ s = Session(
157
+ access_token=d["access_token"], refresh_token=d["refresh_token"],
158
+ supabase_url=supabase_url, anon_key=anon,
159
+ )
160
+ save_session(s)
161
+ return s
162
+
163
+
164
+ def refresh(s: Session) -> Session:
165
+ r = httpx.post(
166
+ f"{s.supabase_url}/auth/v1/token", params={"grant_type": "refresh_token"},
167
+ headers={"apikey": s.anon_key, "Content-Type": "application/json"},
168
+ json={"refresh_token": s.refresh_token}, timeout=30,
169
+ )
170
+ r.raise_for_status()
171
+ d = r.json()
172
+ s.access_token = d["access_token"]
173
+ s.refresh_token = d["refresh_token"]
174
+ save_session(s)
175
+ return s
176
+
177
+
178
+ def current_user(s: Session) -> dict:
179
+ r = httpx.get(
180
+ f"{s.supabase_url}/auth/v1/user",
181
+ headers={"apikey": s.anon_key, "Authorization": f"Bearer {s.access_token}"},
182
+ timeout=15,
183
+ )
184
+ return r.json() if r.status_code == 200 else {}
cync/client.py ADDED
@@ -0,0 +1,319 @@
1
+ """cync client CLI — push/pull Claude Code conversations, authed via GitHub (Supabase).
2
+
3
+ Sign in once with `cync login`; the CLI stores a session and sends your Supabase
4
+ JWT to the server (auto-refreshing on expiry). Link a directory to a project with
5
+ `cync init` / `cync link`, then `cync push` / `cync pull`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from . import auth, common
20
+ from .config import ClientConfig, get_link, remove_link, set_link
21
+
22
+ app = typer.Typer(add_completion=False, help="Sync Claude Code conversations across machines.")
23
+ project_app = typer.Typer(help="Manage your projects.")
24
+ app.add_typer(project_app, name="project")
25
+ console = Console()
26
+
27
+
28
+ def _check(r: httpx.Response) -> None:
29
+ if r.is_success:
30
+ return
31
+ try:
32
+ detail = r.json().get("detail", "")
33
+ except Exception:
34
+ detail = r.text
35
+ console.print(f"[red]error {r.status_code}[/] {detail or r.reason_phrase}")
36
+ raise typer.Exit(1)
37
+
38
+
39
+ def _rewrite_cwd(data: bytes, origin: str, root: str) -> bytes:
40
+ """Rewrite only each line's top-level ``cwd`` field from origin -> root."""
41
+ out: list[bytes] = []
42
+ for line in data.split(b"\n"):
43
+ if not line.strip():
44
+ out.append(line)
45
+ continue
46
+ try:
47
+ obj = json.loads(line)
48
+ except Exception:
49
+ out.append(line)
50
+ continue
51
+ if isinstance(obj, dict) and obj.get("cwd") == origin:
52
+ obj["cwd"] = root
53
+ out.append(json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
54
+ else:
55
+ out.append(line)
56
+ return b"\n".join(out)
57
+
58
+
59
+ class Api:
60
+ """Authenticated client: sends the session JWT, refreshes once on 401."""
61
+
62
+ def __init__(self) -> None:
63
+ self.server = ClientConfig.load().server_url
64
+ self.session = auth.load_session()
65
+ if not self.session:
66
+ console.print("[red]not logged in[/] — run [bold]cync login[/]")
67
+ raise typer.Exit(1)
68
+
69
+ def request(self, method: str, path: str, **kw) -> httpx.Response:
70
+ url = self.server + path
71
+ headers = kw.pop("headers", {})
72
+ headers["Authorization"] = f"Bearer {self.session.access_token}"
73
+ r = httpx.request(method, url, headers=headers, timeout=120.0, **kw)
74
+ if r.status_code == 401:
75
+ try:
76
+ self.session = auth.refresh(self.session)
77
+ except Exception:
78
+ console.print("[red]session expired[/] — run [bold]cync login[/]")
79
+ raise typer.Exit(1)
80
+ headers["Authorization"] = f"Bearer {self.session.access_token}"
81
+ r = httpx.request(method, url, headers=headers, timeout=120.0, **kw)
82
+ return r
83
+
84
+ def get(self, p, **k):
85
+ return self.request("GET", p, **k)
86
+
87
+ def post(self, p, **k):
88
+ return self.request("POST", p, **k)
89
+
90
+ def put(self, p, **k):
91
+ return self.request("PUT", p, **k)
92
+
93
+ def delete(self, p, **k):
94
+ return self.request("DELETE", p, **k)
95
+
96
+
97
+ def _repo_root() -> str:
98
+ return common.git_root() or os.path.abspath(os.getcwd())
99
+
100
+
101
+ def _linked() -> tuple[str, str, Path]:
102
+ """(repo_root, project_id, local_project_folder) — requires a link."""
103
+ root = _repo_root()
104
+ pid = get_link(root)
105
+ if not pid:
106
+ raise typer.BadParameter(
107
+ "this directory isn't linked to a project — run `cync init <name>` "
108
+ "(new) or `cync link <name>` (existing)"
109
+ )
110
+ folder = common.claude_projects_dir() / common.encode_project_dir(root)
111
+ return root, pid, folder
112
+
113
+
114
+ def _find_by_slug(api: Api, slug: str) -> dict | None:
115
+ r = api.get("/projects")
116
+ _check(r)
117
+ for p in r.json():
118
+ if p.get("slug") == slug:
119
+ return p
120
+ return None
121
+
122
+
123
+ # ---- auth ----
124
+
125
+
126
+ @app.command()
127
+ def login() -> None:
128
+ """Sign in with GitHub (opens a browser)."""
129
+ server = ClientConfig.load().server_url
130
+ s = auth.login(server)
131
+ user = auth.current_user(s)
132
+ console.print(f"[green]logged in[/] as {user.get('email') or user.get('id')}")
133
+
134
+
135
+ @app.command()
136
+ def logout() -> None:
137
+ """Clear the stored session."""
138
+ auth.clear_session()
139
+ console.print("logged out")
140
+
141
+
142
+ @app.command()
143
+ def whoami() -> None:
144
+ """Show the signed-in user."""
145
+ api = Api()
146
+ user = auth.current_user(api.session)
147
+ console.print(user.get("email") or user.get("id") or "(unknown)")
148
+
149
+
150
+ # ---- project linking ----
151
+
152
+
153
+ @app.command()
154
+ def init(name: str = typer.Argument(..., help="project name to create + link here")) -> None:
155
+ """Create a project and link this directory to it."""
156
+ api = Api()
157
+ root = _repo_root()
158
+ slug = common.slugify_project(name)
159
+ r = api.post("/projects", json={"name": name})
160
+ if r.status_code == 409:
161
+ proj = _find_by_slug(api, slug)
162
+ if not proj:
163
+ _check(r)
164
+ console.print(f"[yellow]project '{slug}' already exists — linking to it[/]")
165
+ else:
166
+ _check(r)
167
+ proj = r.json()
168
+ console.print(f"[green]created[/] project '{proj['slug']}'")
169
+ set_link(root, proj["id"])
170
+ console.print(f"[green]linked[/] {root} -> {proj['slug']}\nnow run [bold]cync push[/]")
171
+
172
+
173
+ @app.command()
174
+ def link(name: str = typer.Argument(..., help="existing project to link this directory to")) -> None:
175
+ """Link this directory to an existing project (e.g. on a 2nd machine)."""
176
+ api = Api()
177
+ root = _repo_root()
178
+ slug = common.slugify_project(name)
179
+ proj = _find_by_slug(api, slug)
180
+ if not proj:
181
+ console.print(f"[red]no project '{slug}'[/] — create it with `cync init {name}`")
182
+ raise typer.Exit(1)
183
+ set_link(root, proj["id"])
184
+ console.print(f"[green]linked[/] {root} -> {proj['slug']}")
185
+
186
+
187
+ @app.command()
188
+ def unlink() -> None:
189
+ """Remove this directory's project link."""
190
+ root = _repo_root()
191
+ console.print("unlinked" if remove_link(root) else "[yellow]this directory wasn't linked[/]")
192
+
193
+
194
+ # ---- sync ----
195
+
196
+
197
+ @app.command()
198
+ def push(id: str = typer.Argument(None, help="conversation id (default: all here)")) -> None:
199
+ """Upload this project's conversation(s) to the server."""
200
+ api = Api()
201
+ root, pid, folder = _linked()
202
+ files = common.list_local_conversations(folder)
203
+ if id:
204
+ files = [f for f in files if f.stem == id]
205
+ if not files:
206
+ console.print("[yellow]no conversations found here[/]")
207
+ raise typer.Exit(code=0)
208
+ for f in files:
209
+ raw = f.read_bytes()
210
+ body = {
211
+ "project_id": pid,
212
+ "origin_path": root,
213
+ "title": common.extract_title(raw.decode("utf-8", "ignore")),
214
+ "mtime": f.stat().st_mtime,
215
+ "content_b64": base64.b64encode(raw).decode(),
216
+ }
217
+ r = api.put(f"/conversations/{f.stem}", json=body)
218
+ _check(r)
219
+ console.print(f"[green]pushed[/] {f.stem[:8]} {r.json().get('title', '')[:60]}")
220
+
221
+
222
+ @app.command()
223
+ def pull(id: str = typer.Argument(None, help="conversation id (default: all here)")) -> None:
224
+ """Download this project's conversation(s) into ~/.claude on this machine."""
225
+ api = Api()
226
+ root, pid, folder = _linked()
227
+ folder.mkdir(parents=True, exist_ok=True)
228
+ r = api.get("/conversations", params={"project_id": pid})
229
+ _check(r)
230
+ metas = r.json()
231
+ if id:
232
+ metas = [m for m in metas if m["id"] == id]
233
+ if not metas:
234
+ console.print("[yellow]nothing on the server for this project[/]")
235
+ raise typer.Exit(code=0)
236
+ for m in metas:
237
+ rr = api.get(f"/conversations/{m['id']}", params={"project_id": pid})
238
+ _check(rr)
239
+ d = rr.json()
240
+ data = base64.b64decode(d["content_b64"])
241
+ origin = d.get("origin_path") or ""
242
+ if origin and origin != root and origin not in ("/", os.sep) and len(origin) > 1:
243
+ data = _rewrite_cwd(data, origin, root)
244
+ dest = folder / f"{m['id']}.jsonl"
245
+ existed = dest.exists()
246
+ dest.write_bytes(data)
247
+ if m.get("mtime"):
248
+ os.utime(dest, (m["mtime"], m["mtime"]))
249
+ verb = "overwrote" if existed else "added"
250
+ console.print(f"[green]{verb}[/] {m['id'][:8]} {m.get('title', '')[:60]}")
251
+ console.print(f"\nresume with [bold]claude --resume[/] inside {root}")
252
+
253
+
254
+ @app.command(name="list")
255
+ def list_cmd() -> None:
256
+ """List this project's conversations stored on the server."""
257
+ api = Api()
258
+ _root, pid, _folder = _linked()
259
+ r = api.get("/conversations", params={"project_id": pid})
260
+ _check(r)
261
+ table = Table("id", "title", "size", "updated")
262
+ for m in r.json():
263
+ table.add_row(
264
+ m["id"][:8], m.get("title", "")[:60],
265
+ str(m.get("size", "")), (m.get("updated_at") or "")[:19],
266
+ )
267
+ console.print(table)
268
+
269
+
270
+ # ---- project management ----
271
+
272
+
273
+ @project_app.command("list")
274
+ def project_list() -> None:
275
+ """List your projects."""
276
+ api = Api()
277
+ r = api.get("/projects")
278
+ _check(r)
279
+ projects = r.json()
280
+ if not projects:
281
+ console.print("[yellow]no projects — create one with `cync init <name>`[/]")
282
+ return
283
+ table = Table("slug", "name", "chats", "created")
284
+ for p in projects:
285
+ table.add_row(
286
+ p.get("slug", ""), p.get("name", ""),
287
+ str(p.get("count", 0)), (p.get("created_at") or "")[:10],
288
+ )
289
+ console.print(table)
290
+
291
+
292
+ @project_app.command("rm")
293
+ def project_rm(
294
+ name: str = typer.Argument(..., help="project to delete"),
295
+ yes: bool = typer.Option(False, "--yes", "-y", help="skip confirmation"),
296
+ ) -> None:
297
+ """Delete a project AND all its conversations."""
298
+ api = Api()
299
+ slug = common.slugify_project(name)
300
+ proj = _find_by_slug(api, slug)
301
+ if not proj:
302
+ console.print(f"[yellow]no project '{slug}'[/]")
303
+ raise typer.Exit(0)
304
+ if not yes:
305
+ typer.confirm(f"delete project '{slug}' and ALL its conversations?", abort=True)
306
+ r = api.delete(f"/projects/{proj['id']}")
307
+ _check(r)
308
+ console.print(f"[green]deleted[/] '{slug}'")
309
+
310
+
311
+ def main() -> None:
312
+ from dotenv import load_dotenv
313
+
314
+ load_dotenv()
315
+ app()
316
+
317
+
318
+ if __name__ == "__main__":
319
+ main()
cync/common.py ADDED
@@ -0,0 +1,185 @@
1
+ """Shared helpers for cync.
2
+
3
+ The two tricky bits both live here:
4
+ * encode_project_dir() reproduces how Claude Code names the per-project
5
+ folder under ~/.claude/projects (the abs path with every non-alphanumeric
6
+ character replaced by '-').
7
+ * git_remote() gives a machine-independent project identity, so a
8
+ conversation pushed from /Users/me/x can be pulled into /home/me/x.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+
20
+ def now_iso() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ def claude_projects_dir() -> Path:
25
+ base = os.environ.get("CLAUDE_DATA_DIR")
26
+ root = Path(base) if base else Path.home() / ".claude"
27
+ return root / "projects"
28
+
29
+
30
+ def encode_project_dir(path: str) -> str:
31
+ """Reproduce Claude Code's project-folder naming.
32
+
33
+ Claude Code takes the absolute working directory and replaces every
34
+ non-alphanumeric character with '-'. e.g.
35
+ /Users/me/Desktop/stu -> -Users-me-Desktop-stu
36
+ /Users/me/Desktop/dev/site.com -> -Users-me-Desktop-dev-site-com
37
+ """
38
+ p = os.path.abspath(path)
39
+ return re.sub(r"[^A-Za-z0-9]", "-", p)
40
+
41
+
42
+ def _git(args: list[str], cwd: str | None = None) -> str | None:
43
+ try:
44
+ out = subprocess.run(
45
+ ["git", "-C", cwd or ".", *args],
46
+ capture_output=True,
47
+ text=True,
48
+ check=True,
49
+ )
50
+ return out.stdout.strip()
51
+ except Exception:
52
+ return None
53
+
54
+
55
+ def git_root(cwd: str | None = None) -> str | None:
56
+ return _git(["rev-parse", "--show-toplevel"], cwd)
57
+
58
+
59
+ def git_remote(cwd: str | None = None) -> str | None:
60
+ url = _git(["remote", "get-url", "origin"], cwd)
61
+ return normalize_remote(url) if url else None
62
+
63
+
64
+ def normalize_remote(url: str) -> str:
65
+ """Normalize a git remote to a stable host/owner/repo identity.
66
+
67
+ All of these collapse to ``github.com/org/repo`` so the same repo yields the
68
+ same identity regardless of how it was cloned (ports and trailing slashes
69
+ are stripped, the host is lowercased)::
70
+
71
+ git@github.com:org/repo.git
72
+ https://github.com/org/repo.git
73
+ https://github.com:443/org/repo/
74
+ ssh://git@github.com:2222/org/repo.git
75
+ """
76
+ u = url.strip().rstrip("/")
77
+ if u.endswith(".git"):
78
+ u = u[:-4]
79
+ had_scheme = False
80
+ for prefix in ("https://", "http://", "ssh://", "git://"):
81
+ if u.startswith(prefix):
82
+ u = u[len(prefix):]
83
+ had_scheme = True
84
+ break
85
+ # scp-style "git@host:org/repo" (no scheme) uses ':' as the path separator;
86
+ # a scheme'd URL like ssh://git@host:2222/... uses ':' for the PORT instead.
87
+ if not had_scheme and u.startswith("git@"):
88
+ u = u[len("git@"):].replace(":", "/", 1)
89
+ head, sep, tail = u.partition("/")
90
+ if "@" in head: # strip leftover user@host
91
+ head = head.split("@", 1)[1]
92
+ head = re.sub(r":\d+$", "", head) # strip port
93
+ return head.lower() + (sep + tail if sep else "")
94
+
95
+
96
+ def project_key(remote: str) -> str:
97
+ """An R2-key-safe prefix derived from the normalized git remote.
98
+
99
+ Collapses anything outside ``[A-Za-z0-9._-]`` to ``_`` and neutralizes
100
+ ``..`` / leading dots so the key can never be abused for path traversal.
101
+ """
102
+ key = re.sub(r"[^A-Za-z0-9._-]", "_", remote)
103
+ key = re.sub(r"\.\.+", "_", key) # never let '..' survive into a path/key
104
+ return key.lstrip(".-") or "_"
105
+
106
+
107
+ _SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9._-]+$")
108
+
109
+
110
+ def is_safe_segment(value: str) -> bool:
111
+ """True if ``value`` is safe to use as a single path/storage-key segment.
112
+
113
+ Rejects empty, traversal (``..``), separators, and leading dots — used to
114
+ validate untrusted ``{pk}`` / ``{cid}`` URL params before they touch the
115
+ filesystem (LocalStorage) or an object-store key.
116
+ """
117
+ return (
118
+ bool(value)
119
+ and ".." not in value
120
+ and "/" not in value
121
+ and "\\" not in value
122
+ and not value.startswith(".")
123
+ and bool(_SAFE_SEGMENT.match(value))
124
+ )
125
+
126
+
127
+ # Reserved storage prefix for project-registry records (not a conversation key).
128
+ PROJECTS_PREFIX = "__projects__"
129
+
130
+
131
+ def slugify_project(name: str) -> str:
132
+ """Turn a display name into a clean, URL/storage-key-safe project id.
133
+
134
+ "DroneForge" -> "droneforge", "my cool proj!" -> "my-cool-proj".
135
+ """
136
+ s = name.strip().lower()
137
+ s = re.sub(r"[^a-z0-9._-]+", "-", s)
138
+ s = re.sub(r"-{2,}", "-", s).strip("-._")
139
+ return s
140
+
141
+
142
+ def extract_title(jsonl_text: str) -> str:
143
+ """Best-effort human title: a summary line if present, else first user msg."""
144
+ first_user: str | None = None
145
+ for line in jsonl_text.splitlines():
146
+ line = line.strip()
147
+ if not line:
148
+ continue
149
+ try:
150
+ obj = json.loads(line)
151
+ except Exception:
152
+ continue
153
+ if obj.get("type") == "summary" and obj.get("summary"):
154
+ return " ".join(str(obj["summary"]).split())[:200]
155
+ if first_user is None and obj.get("type") == "user":
156
+ content = (obj.get("message") or {}).get("content")
157
+ if isinstance(content, list):
158
+ content = " ".join(
159
+ p.get("text", "") for p in content if isinstance(p, dict)
160
+ )
161
+ if isinstance(content, str):
162
+ text = " ".join(content.split()) # collapse newlines/whitespace
163
+ if text and not text.startswith("<"):
164
+ first_user = text[:200]
165
+ return first_user or "(untitled)"
166
+
167
+
168
+ def list_local_conversations(project_folder: Path) -> list[Path]:
169
+ """Real conversation .jsonl files in a project folder.
170
+
171
+ Skips agent-*.jsonl sidecars and empty files.
172
+ """
173
+ if not project_folder.is_dir():
174
+ return []
175
+ files: list[Path] = []
176
+ for f in sorted(project_folder.glob("*.jsonl")):
177
+ if f.name.startswith("agent-"):
178
+ continue
179
+ try:
180
+ if f.stat().st_size == 0:
181
+ continue
182
+ except OSError:
183
+ continue
184
+ files.append(f)
185
+ return files