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 +3 -0
- cync/auth.py +184 -0
- cync/client.py +319 -0
- cync/common.py +185 -0
- cync/config.py +108 -0
- cync/server.py +261 -0
- cync/storage.py +98 -0
- cync/supabase_store.py +199 -0
- cync_cli-0.3.0.dist-info/METADATA +176 -0
- cync_cli-0.3.0.dist-info/RECORD +13 -0
- cync_cli-0.3.0.dist-info/WHEEL +4 -0
- cync_cli-0.3.0.dist-info/entry_points.txt +3 -0
- cync_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
cync/__init__.py
ADDED
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
|