cync-cli 0.3.0__tar.gz → 0.5.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.
- {cync_cli-0.3.0 → cync_cli-0.5.0}/.env.example +2 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/Dockerfile +2 -1
- {cync_cli-0.3.0 → cync_cli-0.5.0}/PKG-INFO +23 -1
- {cync_cli-0.3.0 → cync_cli-0.5.0}/README.md +19 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/pyproject.toml +5 -1
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/__init__.py +1 -1
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/client.py +163 -1
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/config.py +2 -0
- cync_cli-0.5.0/src/cync/mcp_server.py +132 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/server.py +5 -1
- {cync_cli-0.3.0 → cync_cli-0.5.0}/.dockerignore +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/.github/workflows/publish.yml +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/.gitignore +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/LICENSE +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/PRIVACY.md +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.env.example +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.gitignore +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.npmrc +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.vscode/extensions.json +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/README.md +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/package.json +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/pnpm-lock.yaml +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/pnpm-workspace.yaml +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/app.d.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/app.html +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/hooks.server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/assets/favicon.svg +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/index.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/parse.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/server/cync.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/types.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+layout.server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+layout.svelte +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+layout.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+page.server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+page.svelte +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/auth/callback/+server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/auth/signout/+server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/login/+page.svelte +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/+page.server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/+page.svelte +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/c/[id]/+page.server.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/c/[id]/+page.svelte +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/privacy/+page.svelte +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/static/robots.txt +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/tsconfig.json +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/client/vite.config.ts +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/scripts/migrate_legacy.py +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/auth.py +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/common.py +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/storage.py +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/supabase_store.py +0 -0
- {cync_cli-0.3.0 → cync_cli-0.5.0}/supabase/schema.sql +0 -0
|
@@ -22,6 +22,8 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
|
|
22
22
|
CYNC_HOST=127.0.0.1
|
|
23
23
|
CYNC_PORT=8787
|
|
24
24
|
# CYNC_MAX_BODY_BYTES=67108864 # 64 MB request-body cap
|
|
25
|
+
# Public web viewer URL, advertised via /config so `cync open` can find it:
|
|
26
|
+
# CYNC_WEB_URL=https://your-cync-web.vercel.app
|
|
25
27
|
|
|
26
28
|
# ---- hosted-service policy (optional) ----
|
|
27
29
|
# Access: restrict to specific GitHub emails/usernames (comma-separated). Empty = open.
|
|
@@ -4,7 +4,8 @@ FROM python:3.12-slim
|
|
|
4
4
|
WORKDIR /app
|
|
5
5
|
|
|
6
6
|
# Install the package + server extras (fastapi, uvicorn, boto3, pydantic).
|
|
7
|
-
|
|
7
|
+
# README.md + LICENSE are needed because pyproject references them (readme/license).
|
|
8
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
8
9
|
COPY src ./src
|
|
9
10
|
RUN pip install --no-cache-dir ".[server]"
|
|
10
11
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Sync Claude Code conversations across machines — GitHub-authed CLI + server (Postgres metadata, R2 blobs).
|
|
5
5
|
Project-URL: Homepage, https://github.com/03hgryan/cync
|
|
6
6
|
Project-URL: Repository, https://github.com/03hgryan/cync
|
|
@@ -41,10 +41,13 @@ Requires-Dist: typer<1,>=0.12
|
|
|
41
41
|
Provides-Extra: dev
|
|
42
42
|
Requires-Dist: boto3<2,>=1.34; extra == 'dev'
|
|
43
43
|
Requires-Dist: fastapi<1,>=0.110; extra == 'dev'
|
|
44
|
+
Requires-Dist: mcp>=1.0; extra == 'dev'
|
|
44
45
|
Requires-Dist: pydantic<3,>=2; extra == 'dev'
|
|
45
46
|
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
46
47
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
47
48
|
Requires-Dist: uvicorn[standard]<1,>=0.29; extra == 'dev'
|
|
49
|
+
Provides-Extra: mcp
|
|
50
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
48
51
|
Provides-Extra: server
|
|
49
52
|
Requires-Dist: boto3<2,>=1.34; extra == 'server'
|
|
50
53
|
Requires-Dist: fastapi<1,>=0.110; extra == 'server'
|
|
@@ -142,10 +145,29 @@ The SvelteKit server holds the token (Supabase SSR cookies); browse at `/p/<proj
|
|
|
142
145
|
| `cync init <name>` | create a project + link this directory |
|
|
143
146
|
| `cync link <name>` | link this directory to an existing project |
|
|
144
147
|
| `cync unlink` | remove this directory's link |
|
|
148
|
+
| `cync status` | show login, linked project, and local-vs-server sync state |
|
|
145
149
|
| `cync push [id]` | upload this project's conversation(s) |
|
|
146
150
|
| `cync pull [id]` | download into `~/.claude` (overwrite/add) |
|
|
147
151
|
| `cync list` | list this project's conversations |
|
|
152
|
+
| `cync rm <id>` | delete one conversation from the server (local copy kept) |
|
|
153
|
+
| `cync open` | open the web viewer for this project |
|
|
154
|
+
| `cync install-hooks` | auto-sync via Claude Code hooks (pull on start, push on end) |
|
|
148
155
|
| `cync project list` / `rm <name>` | manage your projects |
|
|
156
|
+
| `cync --version` | print the version |
|
|
157
|
+
|
|
158
|
+
## MCP server (optional)
|
|
159
|
+
Let Claude (or any MCP host) read your synced conversations *as context* — pull a
|
|
160
|
+
past chat into the model mid-conversation, not just sync files.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pipx install 'cync-cli[mcp]' # the mcp extra
|
|
164
|
+
cync login # the server uses your cync session
|
|
165
|
+
```
|
|
166
|
+
Add it to your MCP host (Claude Code `.mcp.json`, Claude Desktop config, etc.):
|
|
167
|
+
```json
|
|
168
|
+
{ "mcpServers": { "cync": { "command": "cync", "args": ["mcp"] } } }
|
|
169
|
+
```
|
|
170
|
+
Tools: `list_projects`, `list_conversations(project)`, `get_conversation(project, id)`.
|
|
149
171
|
|
|
150
172
|
## Migrating from v0.2
|
|
151
173
|
If you have v0.2 (static-token) data still in R2, after `cync login`:
|
|
@@ -88,10 +88,29 @@ The SvelteKit server holds the token (Supabase SSR cookies); browse at `/p/<proj
|
|
|
88
88
|
| `cync init <name>` | create a project + link this directory |
|
|
89
89
|
| `cync link <name>` | link this directory to an existing project |
|
|
90
90
|
| `cync unlink` | remove this directory's link |
|
|
91
|
+
| `cync status` | show login, linked project, and local-vs-server sync state |
|
|
91
92
|
| `cync push [id]` | upload this project's conversation(s) |
|
|
92
93
|
| `cync pull [id]` | download into `~/.claude` (overwrite/add) |
|
|
93
94
|
| `cync list` | list this project's conversations |
|
|
95
|
+
| `cync rm <id>` | delete one conversation from the server (local copy kept) |
|
|
96
|
+
| `cync open` | open the web viewer for this project |
|
|
97
|
+
| `cync install-hooks` | auto-sync via Claude Code hooks (pull on start, push on end) |
|
|
94
98
|
| `cync project list` / `rm <name>` | manage your projects |
|
|
99
|
+
| `cync --version` | print the version |
|
|
100
|
+
|
|
101
|
+
## MCP server (optional)
|
|
102
|
+
Let Claude (or any MCP host) read your synced conversations *as context* — pull a
|
|
103
|
+
past chat into the model mid-conversation, not just sync files.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pipx install 'cync-cli[mcp]' # the mcp extra
|
|
107
|
+
cync login # the server uses your cync session
|
|
108
|
+
```
|
|
109
|
+
Add it to your MCP host (Claude Code `.mcp.json`, Claude Desktop config, etc.):
|
|
110
|
+
```json
|
|
111
|
+
{ "mcpServers": { "cync": { "command": "cync", "args": ["mcp"] } } }
|
|
112
|
+
```
|
|
113
|
+
Tools: `list_projects`, `list_conversations(project)`, `get_conversation(project, id)`.
|
|
95
114
|
|
|
96
115
|
## Migrating from v0.2
|
|
97
116
|
If you have v0.2 (static-token) data still in R2, after `cync login`:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cync-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Sync Claude Code conversations across machines — GitHub-authed CLI + server (Postgres metadata, R2 blobs)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -34,8 +34,12 @@ server = [
|
|
|
34
34
|
"boto3>=1.34,<2",
|
|
35
35
|
"pydantic>=2,<3",
|
|
36
36
|
]
|
|
37
|
+
mcp = [
|
|
38
|
+
"mcp>=1.0",
|
|
39
|
+
]
|
|
37
40
|
dev = [
|
|
38
41
|
"cync-cli[server]",
|
|
42
|
+
"cync-cli[mcp]",
|
|
39
43
|
"pytest>=8,<9",
|
|
40
44
|
"ruff>=0.5",
|
|
41
45
|
]
|
|
@@ -16,7 +16,7 @@ import typer
|
|
|
16
16
|
from rich.console import Console
|
|
17
17
|
from rich.table import Table
|
|
18
18
|
|
|
19
|
-
from . import auth, common
|
|
19
|
+
from . import __version__, auth, common
|
|
20
20
|
from .config import ClientConfig, get_link, remove_link, set_link
|
|
21
21
|
|
|
22
22
|
app = typer.Typer(add_completion=False, help="Sync Claude Code conversations across machines.")
|
|
@@ -25,6 +25,22 @@ app.add_typer(project_app, name="project")
|
|
|
25
25
|
console = Console()
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _version_callback(value: bool) -> None:
|
|
29
|
+
if value:
|
|
30
|
+
console.print(f"cync {__version__}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def _main(
|
|
36
|
+
version: bool = typer.Option(
|
|
37
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
38
|
+
help="Show the cync version and exit.",
|
|
39
|
+
),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Sync Claude Code conversations across machines."""
|
|
42
|
+
|
|
43
|
+
|
|
28
44
|
def _check(r: httpx.Response) -> None:
|
|
29
45
|
if r.is_success:
|
|
30
46
|
return
|
|
@@ -147,6 +163,41 @@ def whoami() -> None:
|
|
|
147
163
|
console.print(user.get("email") or user.get("id") or "(unknown)")
|
|
148
164
|
|
|
149
165
|
|
|
166
|
+
@app.command()
|
|
167
|
+
def status() -> None:
|
|
168
|
+
"""Show login, the linked project, and local-vs-server sync state."""
|
|
169
|
+
root = _repo_root()
|
|
170
|
+
console.print(f"dir: {root}")
|
|
171
|
+
session = auth.load_session()
|
|
172
|
+
pid = get_link(root)
|
|
173
|
+
if not session:
|
|
174
|
+
console.print("login: [yellow]not logged in[/] (run `cync login`)")
|
|
175
|
+
if not pid:
|
|
176
|
+
console.print("project: [yellow]not linked[/] (run `cync init <name>` or `cync link <name>`)")
|
|
177
|
+
if not session or not pid:
|
|
178
|
+
return
|
|
179
|
+
api = Api()
|
|
180
|
+
user = auth.current_user(session)
|
|
181
|
+
console.print(f"login: {user.get('email') or user.get('id')}")
|
|
182
|
+
r = api.get("/projects")
|
|
183
|
+
_check(r)
|
|
184
|
+
proj = next((p for p in r.json() if p["id"] == pid), None)
|
|
185
|
+
console.print(f"project: {proj['slug'] if proj else '[red]link points to a missing project[/]'}")
|
|
186
|
+
folder = common.claude_projects_dir() / common.encode_project_dir(root)
|
|
187
|
+
local = {f.stem for f in common.list_local_conversations(folder)}
|
|
188
|
+
rr = api.get("/conversations", params={"project_id": pid})
|
|
189
|
+
_check(rr)
|
|
190
|
+
server = {m["id"] for m in rr.json()}
|
|
191
|
+
console.print(
|
|
192
|
+
f"sync: {len(local & server)} synced · "
|
|
193
|
+
f"{len(local - server)} local-only · {len(server - local)} server-only"
|
|
194
|
+
)
|
|
195
|
+
if local - server:
|
|
196
|
+
console.print(f" [yellow]push these:[/] {', '.join(sorted(s[:8] for s in (local - server)))}")
|
|
197
|
+
if server - local:
|
|
198
|
+
console.print(f" [cyan]pull these:[/] {', '.join(sorted(s[:8] for s in (server - local)))}")
|
|
199
|
+
|
|
200
|
+
|
|
150
201
|
# ---- project linking ----
|
|
151
202
|
|
|
152
203
|
|
|
@@ -267,6 +318,117 @@ def list_cmd() -> None:
|
|
|
267
318
|
console.print(table)
|
|
268
319
|
|
|
269
320
|
|
|
321
|
+
@app.command()
|
|
322
|
+
def rm(
|
|
323
|
+
id: str = typer.Argument(..., help="conversation id or prefix to delete from the server"),
|
|
324
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="skip confirmation"),
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Delete a single conversation from the server (your local copy is untouched)."""
|
|
327
|
+
api = Api()
|
|
328
|
+
_root, pid, _folder = _linked()
|
|
329
|
+
r = api.get("/conversations", params={"project_id": pid})
|
|
330
|
+
_check(r)
|
|
331
|
+
matches = [m["id"] for m in r.json() if m["id"].startswith(id)]
|
|
332
|
+
if not matches:
|
|
333
|
+
console.print(f"[yellow]no conversation matching '{id}'[/]")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
if len(matches) > 1:
|
|
336
|
+
console.print(f"[red]'{id}' is ambiguous[/] ({len(matches)} matches) — use more characters")
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
full = matches[0]
|
|
339
|
+
if not yes:
|
|
340
|
+
typer.confirm(f"delete conversation {full[:8]} from the server?", abort=True)
|
|
341
|
+
rr = api.delete(f"/conversations/{full}", params={"project_id": pid})
|
|
342
|
+
_check(rr)
|
|
343
|
+
console.print(f"[green]deleted[/] {full[:8]} from the server (local copy kept)")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command(name="open")
|
|
347
|
+
def open_cmd() -> None:
|
|
348
|
+
"""Open the web viewer for this project in your browser."""
|
|
349
|
+
import webbrowser
|
|
350
|
+
|
|
351
|
+
server = ClientConfig.load().server_url
|
|
352
|
+
web = os.environ.get("CYNC_WEB_URL") or ""
|
|
353
|
+
if not web:
|
|
354
|
+
try:
|
|
355
|
+
web = httpx.get(f"{server}/config", timeout=15).json().get("web_url") or ""
|
|
356
|
+
except Exception:
|
|
357
|
+
web = ""
|
|
358
|
+
web = web.rstrip("/")
|
|
359
|
+
if not web:
|
|
360
|
+
console.print("[yellow]web viewer URL unknown[/] — set CYNC_WEB_URL or configure it on the server")
|
|
361
|
+
raise typer.Exit(1)
|
|
362
|
+
url = web
|
|
363
|
+
pid = get_link(_repo_root())
|
|
364
|
+
if pid and auth.load_session():
|
|
365
|
+
r = Api().get("/projects")
|
|
366
|
+
if r.is_success:
|
|
367
|
+
proj = next((p for p in r.json() if p["id"] == pid), None)
|
|
368
|
+
if proj:
|
|
369
|
+
url = f"{web}/p/{proj['slug']}"
|
|
370
|
+
console.print(f"opening {url}")
|
|
371
|
+
webbrowser.open(url)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@app.command(name="install-hooks")
|
|
375
|
+
def install_hooks(
|
|
376
|
+
uninstall: bool = typer.Option(False, "--uninstall", help="remove cync's Claude Code hooks"),
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Auto-sync via Claude Code hooks: pull on session start, push on session end."""
|
|
379
|
+
settings = common.claude_projects_dir().parent / "settings.json" # ~/.claude/settings.json
|
|
380
|
+
settings.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
data: dict = {}
|
|
382
|
+
if settings.exists():
|
|
383
|
+
try:
|
|
384
|
+
data = json.loads(settings.read_text())
|
|
385
|
+
except Exception:
|
|
386
|
+
data = {}
|
|
387
|
+
hooks = data.setdefault("hooks", {})
|
|
388
|
+
|
|
389
|
+
def _strip(event: str) -> None:
|
|
390
|
+
kept = [
|
|
391
|
+
e for e in hooks.get(event, [])
|
|
392
|
+
if not any("cync" in h.get("command", "") for h in e.get("hooks", []))
|
|
393
|
+
]
|
|
394
|
+
if kept:
|
|
395
|
+
hooks[event] = kept
|
|
396
|
+
else:
|
|
397
|
+
hooks.pop(event, None)
|
|
398
|
+
|
|
399
|
+
_strip("SessionStart")
|
|
400
|
+
_strip("SessionEnd")
|
|
401
|
+
if uninstall:
|
|
402
|
+
if not hooks:
|
|
403
|
+
data.pop("hooks", None)
|
|
404
|
+
settings.write_text(json.dumps(data, indent=2))
|
|
405
|
+
console.print("removed cync hooks")
|
|
406
|
+
return
|
|
407
|
+
hooks.setdefault("SessionStart", []).append(
|
|
408
|
+
{"hooks": [{"type": "command", "command": "cync pull >/dev/null 2>&1 || true"}]}
|
|
409
|
+
)
|
|
410
|
+
hooks.setdefault("SessionEnd", []).append(
|
|
411
|
+
{"hooks": [{"type": "command", "command": "cync push >/dev/null 2>&1 || true"}]}
|
|
412
|
+
)
|
|
413
|
+
settings.write_text(json.dumps(data, indent=2))
|
|
414
|
+
console.print(f"[green]installed[/] Claude Code hooks in {settings}")
|
|
415
|
+
console.print(" · pull on session start, push on session end (only for linked dirs)")
|
|
416
|
+
console.print(" · last-writer-wins; run [bold]cync install-hooks --uninstall[/] to remove")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@app.command(name="mcp")
|
|
420
|
+
def mcp_cmd() -> None:
|
|
421
|
+
"""Run the cync MCP server (stdio) so MCP hosts can read your conversations."""
|
|
422
|
+
import sys
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
from .mcp_server import run
|
|
426
|
+
except ImportError:
|
|
427
|
+
print("MCP support not installed — run: pipx install 'cync-cli[mcp]'", file=sys.stderr)
|
|
428
|
+
raise typer.Exit(1)
|
|
429
|
+
run()
|
|
430
|
+
|
|
431
|
+
|
|
270
432
|
# ---- project management ----
|
|
271
433
|
|
|
272
434
|
|
|
@@ -18,6 +18,7 @@ class ServerConfig:
|
|
|
18
18
|
token: str
|
|
19
19
|
supabase_url: str
|
|
20
20
|
supabase_anon_key: str
|
|
21
|
+
web_url: str
|
|
21
22
|
|
|
22
23
|
@classmethod
|
|
23
24
|
def from_env(cls) -> "ServerConfig":
|
|
@@ -33,6 +34,7 @@ class ServerConfig:
|
|
|
33
34
|
token=os.environ.get("CYNC_TOKEN", ""),
|
|
34
35
|
supabase_url=os.environ.get("SUPABASE_URL", "").rstrip("/"),
|
|
35
36
|
supabase_anon_key=os.environ.get("SUPABASE_ANON_KEY", ""),
|
|
37
|
+
web_url=os.environ.get("CYNC_WEB_URL", "").rstrip("/"),
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""cync MCP server — expose your synced conversations to MCP hosts.
|
|
2
|
+
|
|
3
|
+
Run via `cync mcp` (stdio). Authenticates with your cync session (from
|
|
4
|
+
`cync login`) and calls the cync API, so an MCP host (Claude Desktop, Claude
|
|
5
|
+
Code, etc.) can read your conversations across machines as retrievable context.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from . import auth, common
|
|
16
|
+
from .config import ClientConfig
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("cync")
|
|
19
|
+
|
|
20
|
+
_MAX_CHARS = 30000 # cap transcript size returned to the model
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _request(method: str, path: str, **kw) -> httpx.Response:
|
|
24
|
+
session = auth.load_session()
|
|
25
|
+
if not session:
|
|
26
|
+
raise RuntimeError("not signed in — run `cync login` in a terminal first")
|
|
27
|
+
server = ClientConfig.load().server_url
|
|
28
|
+
headers = {"Authorization": f"Bearer {session.access_token}"}
|
|
29
|
+
r = httpx.request(method, server + path, headers=headers, timeout=60.0, **kw)
|
|
30
|
+
if r.status_code == 401:
|
|
31
|
+
session = auth.refresh(session)
|
|
32
|
+
headers["Authorization"] = f"Bearer {session.access_token}"
|
|
33
|
+
r = httpx.request(method, server + path, headers=headers, timeout=60.0, **kw)
|
|
34
|
+
r.raise_for_status()
|
|
35
|
+
return r
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_project(project: str) -> dict:
|
|
39
|
+
slug = common.slugify_project(project)
|
|
40
|
+
for p in _request("GET", "/projects").json():
|
|
41
|
+
if p.get("slug") == slug:
|
|
42
|
+
return p
|
|
43
|
+
raise ValueError(f"no project '{project}' — call list_projects() to see what's available")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _render(jsonl_text: str) -> str:
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
for line in jsonl_text.splitlines():
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
o = json.loads(line)
|
|
54
|
+
except Exception:
|
|
55
|
+
continue
|
|
56
|
+
typ = o.get("type")
|
|
57
|
+
if typ == "summary" and o.get("summary"):
|
|
58
|
+
out.append(f"[summary] {o['summary']}")
|
|
59
|
+
continue
|
|
60
|
+
if typ not in ("user", "assistant"):
|
|
61
|
+
continue
|
|
62
|
+
content = (o.get("message") or {}).get("content")
|
|
63
|
+
text = ""
|
|
64
|
+
if isinstance(content, str):
|
|
65
|
+
text = content
|
|
66
|
+
elif isinstance(content, list):
|
|
67
|
+
parts = []
|
|
68
|
+
for p in content:
|
|
69
|
+
if not isinstance(p, dict):
|
|
70
|
+
continue
|
|
71
|
+
if p.get("type") == "text":
|
|
72
|
+
parts.append(p.get("text", ""))
|
|
73
|
+
elif p.get("type") == "tool_use":
|
|
74
|
+
parts.append(f"[tool: {p.get('name', '')}]")
|
|
75
|
+
elif p.get("type") == "tool_result":
|
|
76
|
+
parts.append("[tool result]")
|
|
77
|
+
text = "\n".join(x for x in parts if x)
|
|
78
|
+
if text.strip() and not text.startswith("<"):
|
|
79
|
+
out.append(f"{typ}: {text}")
|
|
80
|
+
return "\n\n".join(out)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def list_projects() -> list[dict]:
|
|
85
|
+
"""List your cync projects (name, slug, and conversation count)."""
|
|
86
|
+
return [
|
|
87
|
+
{"slug": p["slug"], "name": p.get("name", ""), "conversations": p.get("count", 0)}
|
|
88
|
+
for p in _request("GET", "/projects").json()
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.tool()
|
|
93
|
+
def list_conversations(project: str) -> list[dict]:
|
|
94
|
+
"""List conversations in a project (by slug): id, title, and updated time."""
|
|
95
|
+
proj = _resolve_project(project)
|
|
96
|
+
convs = _request("GET", f"/projects/{proj['id']}/conversations").json()
|
|
97
|
+
return [
|
|
98
|
+
{"id": c["id"], "title": c.get("title", ""), "updated": c.get("updated_at", "")}
|
|
99
|
+
for c in convs
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def get_conversation(project: str, conversation_id: str) -> str:
|
|
105
|
+
"""Fetch a past conversation's transcript as readable text, to use as context.
|
|
106
|
+
|
|
107
|
+
`project` is a slug; `conversation_id` is a full id or an unambiguous prefix.
|
|
108
|
+
"""
|
|
109
|
+
proj = _resolve_project(project)
|
|
110
|
+
convs = _request("GET", f"/projects/{proj['id']}/conversations").json()
|
|
111
|
+
matches = [c["id"] for c in convs if c["id"].startswith(conversation_id)]
|
|
112
|
+
if not matches:
|
|
113
|
+
raise ValueError(f"no conversation matching '{conversation_id}'")
|
|
114
|
+
if len(matches) > 1:
|
|
115
|
+
raise ValueError(f"'{conversation_id}' is ambiguous ({len(matches)} matches)")
|
|
116
|
+
d = _request("GET", f"/projects/{proj['id']}/conversations/{matches[0]}").json()
|
|
117
|
+
text = _render(base64.b64decode(d["content_b64"]).decode("utf-8", "ignore"))
|
|
118
|
+
if len(text) > _MAX_CHARS:
|
|
119
|
+
text = "…(earlier messages truncated)…\n\n" + text[-_MAX_CHARS:]
|
|
120
|
+
return text or "(no renderable content)"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def run() -> None:
|
|
124
|
+
"""Run the stdio MCP server (used by `cync mcp`).
|
|
125
|
+
|
|
126
|
+
Silence per-request httpx logs — in stdio mode stdout must be a clean
|
|
127
|
+
JSON-RPC stream (the mcp SDK sends its own logs to stderr).
|
|
128
|
+
"""
|
|
129
|
+
import logging
|
|
130
|
+
|
|
131
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
132
|
+
mcp.run()
|
|
@@ -132,7 +132,11 @@ def health() -> dict:
|
|
|
132
132
|
@app.get("/config")
|
|
133
133
|
def public_config() -> dict:
|
|
134
134
|
"""Public Supabase params for the CLI/web login flow (the anon key is public)."""
|
|
135
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
"supabase_url": cfg.supabase_url,
|
|
137
|
+
"supabase_anon_key": cfg.supabase_anon_key,
|
|
138
|
+
"web_url": cfg.web_url,
|
|
139
|
+
}
|
|
136
140
|
|
|
137
141
|
|
|
138
142
|
# ---- projects ----
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|