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.
Files changed (53) hide show
  1. {cync_cli-0.3.0 → cync_cli-0.5.0}/.env.example +2 -0
  2. {cync_cli-0.3.0 → cync_cli-0.5.0}/Dockerfile +2 -1
  3. {cync_cli-0.3.0 → cync_cli-0.5.0}/PKG-INFO +23 -1
  4. {cync_cli-0.3.0 → cync_cli-0.5.0}/README.md +19 -0
  5. {cync_cli-0.3.0 → cync_cli-0.5.0}/pyproject.toml +5 -1
  6. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/__init__.py +1 -1
  7. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/client.py +163 -1
  8. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/config.py +2 -0
  9. cync_cli-0.5.0/src/cync/mcp_server.py +132 -0
  10. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/server.py +5 -1
  11. {cync_cli-0.3.0 → cync_cli-0.5.0}/.dockerignore +0 -0
  12. {cync_cli-0.3.0 → cync_cli-0.5.0}/.github/workflows/publish.yml +0 -0
  13. {cync_cli-0.3.0 → cync_cli-0.5.0}/.gitignore +0 -0
  14. {cync_cli-0.3.0 → cync_cli-0.5.0}/LICENSE +0 -0
  15. {cync_cli-0.3.0 → cync_cli-0.5.0}/PRIVACY.md +0 -0
  16. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.env.example +0 -0
  17. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.gitignore +0 -0
  18. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.npmrc +0 -0
  19. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/.vscode/extensions.json +0 -0
  20. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/README.md +0 -0
  21. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/package.json +0 -0
  22. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/pnpm-lock.yaml +0 -0
  23. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/pnpm-workspace.yaml +0 -0
  24. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/app.d.ts +0 -0
  25. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/app.html +0 -0
  26. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/hooks.server.ts +0 -0
  27. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/assets/favicon.svg +0 -0
  28. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/index.ts +0 -0
  29. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/parse.ts +0 -0
  30. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/server/cync.ts +0 -0
  31. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/lib/types.ts +0 -0
  32. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+layout.server.ts +0 -0
  33. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+layout.svelte +0 -0
  34. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+layout.ts +0 -0
  35. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+page.server.ts +0 -0
  36. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/+page.svelte +0 -0
  37. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/auth/callback/+server.ts +0 -0
  38. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/auth/signout/+server.ts +0 -0
  39. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/login/+page.svelte +0 -0
  40. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/+page.server.ts +0 -0
  41. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/+page.svelte +0 -0
  42. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/c/[id]/+page.server.ts +0 -0
  43. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/p/[slug]/c/[id]/+page.svelte +0 -0
  44. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/src/routes/privacy/+page.svelte +0 -0
  45. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/static/robots.txt +0 -0
  46. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/tsconfig.json +0 -0
  47. {cync_cli-0.3.0 → cync_cli-0.5.0}/client/vite.config.ts +0 -0
  48. {cync_cli-0.3.0 → cync_cli-0.5.0}/scripts/migrate_legacy.py +0 -0
  49. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/auth.py +0 -0
  50. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/common.py +0 -0
  51. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/storage.py +0 -0
  52. {cync_cli-0.3.0 → cync_cli-0.5.0}/src/cync/supabase_store.py +0 -0
  53. {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
- COPY pyproject.toml ./
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.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.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
  ]
@@ -1,3 +1,3 @@
1
1
  """cync — sync Claude Code conversations across machines."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
@@ -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 {"supabase_url": cfg.supabase_url, "supabase_anon_key": cfg.supabase_anon_key}
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