claude-session-sync-cli 0.1.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.
@@ -0,0 +1,5 @@
1
+ """Sync local Claude Code CLI sessions into the Claude desktop app sidebar."""
2
+
3
+ from .cli import __version__, main
4
+
5
+ __all__ = ["__version__", "main"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,930 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claude-session-sync
4
+ 把本地 Claude Code CLI 会话同步到 Claude 桌面端 Code 标签页的侧边栏(支持多账号)。
5
+
6
+ 原理:
7
+ CLI 会话记录在 ~/.claude/projects/<项目>/<会话ID>.jsonl(两端共用)。
8
+ 桌面端侧边栏只读它自己的索引:
9
+ claude-code-sessions/<账号ID>/<组织ID>/local_<uuid>.json
10
+ 索引里用 cliSessionId 指向 .jsonl。本脚本为缺失的会话补写索引文件,
11
+ 同步时不修改任何 .jsonl 原始记录(只有 --purge-archived 会把已归档会话的 .jsonl 移到备份目录)。
12
+
13
+ 规则:
14
+ * 遍历找到的每个 <账号>/<组织> 目录,分别补齐该账号缺少的会话。
15
+ * 在任何账号里被归档或被桌面端删除(deleted_<id> 标记)的会话,所有账号都不再补
16
+ (可用 --per-account-archive 改为只看本账号的归档状态)。
17
+ * 已存在于该账号索引里的会话(无论是否归档)不会重复写入。
18
+ * 新索引文件以该账号下已有的一个桌面端会话为模板,只替换会话相关字段;
19
+ 账号下没有会话时借用其他账号的会话作为模板。
20
+
21
+ 注意:桌面端索引格式没有官方文档,桌面端更新后可能失效。
22
+ * 默认只预览(dry-run),加 --apply 才写入。
23
+ * 写入前请彻底退出桌面端,写完重新打开。
24
+ * 每次写入都会记日志,可用 --undo 撤销最近一次。
25
+
26
+ 用法:见 claude-session-sync --help
27
+ """
28
+
29
+ import argparse
30
+ import copy
31
+ import datetime as dt
32
+ import glob
33
+ import json
34
+ import os
35
+ import platform
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import time
40
+ import uuid
41
+ from pathlib import Path, PurePosixPath, PureWindowsPath
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ HOME = Path.home()
46
+ JOURNAL_DIR = HOME / ".claude-desktop-sync"
47
+ TITLE_MAX = 60
48
+ RUNNING_GRACE_SEC = 120 # 最近 2 分钟内还在写入的会话视为正在运行,跳过
49
+
50
+
51
+ # ---------------------------------------------------------------- 输出语言
52
+
53
+ LANG_ENV = "CLAUDE_SESSION_SYNC_LANG"
54
+ _LANG_ZH = None # None 表示还没检测;测试里可以直接设成 True/False
55
+
56
+
57
+ def _detect_zh():
58
+ """CLAUDE_SESSION_SYNC_LANG > LC_ALL > LC_MESSAGES > LANG > Windows 界面语言,默认英文。"""
59
+ forced = os.environ.get(LANG_ENV)
60
+ if forced:
61
+ return forced.lower().startswith("zh")
62
+ for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
63
+ val = os.environ.get(key)
64
+ if val:
65
+ return val.lower().startswith("zh")
66
+ if platform.system() == "Windows":
67
+ try:
68
+ import ctypes
69
+
70
+ langid = ctypes.windll.kernel32.GetUserDefaultUILanguage()
71
+ return langid & 0x3FF == 0x04 # LANG_CHINESE
72
+ except Exception: # noqa: BLE001
73
+ pass
74
+ return False
75
+
76
+
77
+ def t(en, zh):
78
+ """按当前语言返回英文或中文文案。"""
79
+ global _LANG_ZH
80
+ if _LANG_ZH is None:
81
+ _LANG_ZH = _detect_zh()
82
+ return zh if _LANG_ZH else en
83
+
84
+
85
+ # ---------------------------------------------------------------- 路径发现
86
+
87
+
88
+ def cli_projects_dir(override=None):
89
+ if override:
90
+ return Path(override).expanduser()
91
+ cfg = os.environ.get("CLAUDE_CONFIG_DIR")
92
+ base = Path(cfg).expanduser() if cfg else HOME / ".claude"
93
+ return base / "projects"
94
+
95
+
96
+ def candidate_desktop_roots():
97
+ """返回可能存在的 claude-code-sessions 目录。"""
98
+ sysname = platform.system()
99
+ c = []
100
+ if sysname == "Darwin":
101
+ c.append(HOME / "Library/Application Support/Claude/claude-code-sessions")
102
+ c.append(HOME / "Library/Application Support/Claude-3p/claude-code-sessions")
103
+ elif sysname == "Windows":
104
+ appdata = Path(os.environ.get("APPDATA", HOME / "AppData/Roaming"))
105
+ local = Path(os.environ.get("LOCALAPPDATA", HOME / "AppData/Local"))
106
+ c.append(appdata / "Claude/claude-code-sessions")
107
+ c.append(appdata / "Claude-3p/claude-code-sessions")
108
+ c.append(local / "Claude-3p/claude-code-sessions")
109
+ # 微软商店 (MSIX) 安装版
110
+ for p in glob.glob(str(local / "Packages/Claude_*/LocalCache/Roaming/Claude/claude-code-sessions")):
111
+ c.append(Path(p))
112
+ else:
113
+ xdg = Path(os.environ.get("XDG_CONFIG_HOME", HOME / ".config"))
114
+ c.append(xdg / "Claude/claude-code-sessions")
115
+ return [p for p in c if p.is_dir()]
116
+
117
+
118
+ def account_dirs(roots):
119
+ """返回 [(root, account, org, dirpath)]。"""
120
+ out = []
121
+ for root in roots:
122
+ for acct in sorted(root.iterdir()):
123
+ if not acct.is_dir():
124
+ continue
125
+ for org in sorted(acct.iterdir()):
126
+ if org.is_dir():
127
+ out.append((root, acct.name, org.name, org))
128
+ return out
129
+
130
+
131
+ # ---------------------------------------------------------------- 桌面端索引
132
+
133
+
134
+ def load_index(dirpath):
135
+ entries = []
136
+ for f in sorted(dirpath.glob("local_*.json")):
137
+ try:
138
+ with open(f, encoding="utf-8") as fh:
139
+ entries.append((f, json.load(fh)))
140
+ except Exception as e: # noqa: BLE001
141
+ print(t(f" ! Cannot read {f.name}: {e}", f" ! 无法读取 {f.name}: {e}"), file=sys.stderr)
142
+ return entries
143
+
144
+
145
+ def load_markers(dirpath):
146
+ """读取桌面端的删除标记与归档索引。
147
+ 返回 (deleted_cli_ids, archived_local_ids)。
148
+ * deleted_<cliSessionId>:桌面端删除会话后留下的标记文件
149
+ * archived-sessions.idx:{"v":1,"archived":["local_..."]}
150
+ """
151
+ deleted = {f.name[len("deleted_") :] for f in dirpath.glob("deleted_*")}
152
+ archived_local = set()
153
+ idx = dirpath / "archived-sessions.idx"
154
+ if idx.is_file():
155
+ try:
156
+ data = json.loads(idx.read_text(encoding="utf-8"))
157
+ archived_local = set(data.get("archived") or [])
158
+ except Exception as e: # noqa: BLE001
159
+ print(t(f" ! Cannot read {idx}: {e}", f" ! 无法读取 {idx}: {e}"), file=sys.stderr)
160
+ return deleted, archived_local
161
+
162
+
163
+ def is_archived(entry):
164
+ """兼容几种可能的归档字段写法。"""
165
+ for k in ("isArchived", "archived"):
166
+ if entry.get(k) is True:
167
+ return True
168
+ for k in ("archivedAt", "archived_at"):
169
+ if entry.get(k):
170
+ return True
171
+ status = entry.get("status")
172
+ if isinstance(status, str) and status.lower() == "archived":
173
+ return True
174
+ return False
175
+
176
+
177
+ def pick_template(entries):
178
+ """优先选非归档、非 worktree(cwd == originCwd)、最近活动的条目。"""
179
+
180
+ def score(e):
181
+ d = e[1]
182
+ return (
183
+ not is_archived(d),
184
+ d.get("cwd") == d.get("originCwd"),
185
+ d.get("lastActivityAt") or 0,
186
+ )
187
+
188
+ usable = [e for e in entries if isinstance(e[1], dict) and "cliSessionId" in e[1]]
189
+ return max(usable, key=score)[1] if usable else None
190
+
191
+
192
+ # ---------------------------------------------------------------- CLI 会话解析
193
+
194
+
195
+ def iso_to_ms(s):
196
+ try:
197
+ return int(dt.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000)
198
+ except Exception: # noqa: BLE001
199
+ return None
200
+
201
+
202
+ def text_of(content):
203
+ if isinstance(content, str):
204
+ return content
205
+ if isinstance(content, list):
206
+ for part in content:
207
+ if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
208
+ return part["text"]
209
+ return ""
210
+
211
+
212
+ def is_abs_any(p):
213
+ """不管当前是什么系统,POSIX 或 Windows 风格的绝对路径都算。
214
+ (Python 3.13 起 Windows 上 os.path.isabs("/x") 返回 False,而记录可能来自别的系统。)"""
215
+ return isinstance(p, str) and (PurePosixPath(p).is_absolute() or PureWindowsPath(p).is_absolute())
216
+
217
+
218
+ def use_safe_stdio():
219
+ """会话标题可能含任意字符;输出被重定向到非 UTF-8 编码时替换掉无法编码的字符,而不是报错。"""
220
+ for stream in (sys.stdout, sys.stderr):
221
+ try:
222
+ stream.reconfigure(errors="replace")
223
+ except (AttributeError, ValueError):
224
+ pass
225
+
226
+
227
+ def parse_session(path):
228
+ """读取 .jsonl,返回会话信息;没有用户消息或没有 cwd 的返回 None。"""
229
+ cwd = None
230
+ first_ts = last_ts = None
231
+ custom_title = summary = first_user = None
232
+ has_user = False
233
+ try:
234
+ with open(path, encoding="utf-8", errors="replace") as fh:
235
+ for line in fh:
236
+ line = line.strip()
237
+ if not line:
238
+ continue
239
+ try:
240
+ o = json.loads(line)
241
+ except Exception: # noqa: BLE001
242
+ continue
243
+ if not isinstance(o, dict):
244
+ continue
245
+ if not cwd and is_abs_any(o.get("cwd")):
246
+ cwd = o["cwd"]
247
+ ts = o.get("timestamp")
248
+ if isinstance(ts, str):
249
+ ms = iso_to_ms(ts)
250
+ if ms:
251
+ first_ts = first_ts or ms
252
+ last_ts = ms
253
+ kind = o.get("type")
254
+ if kind == "custom-title" and o.get("customTitle"):
255
+ custom_title = o["customTitle"]
256
+ elif kind == "summary" and o.get("summary"):
257
+ summary = o["summary"]
258
+ elif kind == "user" and not o.get("isSidechain") and not o.get("isMeta"):
259
+ msg = o.get("message") or {}
260
+ txt = text_of(msg.get("content")).strip()
261
+ if txt and not txt.startswith("<"): # 跳过命令/系统注入内容
262
+ has_user = True
263
+ first_user = first_user or txt
264
+ except OSError as e:
265
+ print(t(f" ! Cannot read {path}: {e}", f" ! 无法读取 {path}: {e}"), file=sys.stderr)
266
+ return None
267
+ if not has_user or not cwd:
268
+ return None
269
+ mtime_ms = int(path.stat().st_mtime * 1000)
270
+ title = (custom_title or summary or first_user or path.stem).replace("\n", " ").strip()
271
+ if len(title) > TITLE_MAX:
272
+ title = title[: TITLE_MAX - 1] + "…"
273
+ return {
274
+ "id": path.stem,
275
+ "path": path,
276
+ "cwd": cwd,
277
+ "title": title,
278
+ "createdAt": first_ts or mtime_ms,
279
+ "lastActivityAt": max(last_ts or 0, mtime_ms),
280
+ "mtime": path.stat().st_mtime,
281
+ }
282
+
283
+
284
+ def scan_cli_sessions(projects):
285
+ sessions = []
286
+ skipped = 0
287
+ for proj in sorted(projects.iterdir()):
288
+ if not proj.is_dir():
289
+ continue
290
+ # 只取项目目录顶层的 .jsonl(子目录里是子代理记录等)
291
+ for f in sorted(proj.glob("*.jsonl")):
292
+ s = parse_session(f)
293
+ if s:
294
+ sessions.append(s)
295
+ else:
296
+ skipped += 1
297
+ return sessions, skipped
298
+
299
+
300
+ # ---------------------------------------------------------------- 写入
301
+
302
+
303
+ def build_entry(template, s):
304
+ e = copy.deepcopy(template)
305
+ old_cwd = template.get("cwd")
306
+ new_id = f"local_{uuid.uuid4()}"
307
+ # 模板里其他字段若等于旧 cwd,一并替换(避免指向别的项目)
308
+ for k, v in list(e.items()):
309
+ if isinstance(v, str) and old_cwd and v == old_cwd:
310
+ e[k] = s["cwd"]
311
+ e.update(
312
+ {
313
+ "sessionId": new_id,
314
+ "cliSessionId": s["id"],
315
+ "cwd": s["cwd"],
316
+ "originCwd": s["cwd"],
317
+ "createdAt": s["createdAt"],
318
+ "lastActivityAt": s["lastActivityAt"],
319
+ }
320
+ )
321
+ for k in ("title", "name"):
322
+ if k in e:
323
+ e[k] = s["title"]
324
+ if "title" not in e and "name" not in e:
325
+ e["title"] = s["title"]
326
+ # 清除归档相关状态
327
+ for k in ("isArchived", "archived"):
328
+ if k in e:
329
+ e[k] = False
330
+ for k in ("archivedAt", "archived_at"):
331
+ if k in e:
332
+ e[k] = None
333
+ if isinstance(e.get("status"), str) and e["status"].lower() == "archived":
334
+ e["status"] = "idle"
335
+ return new_id, e
336
+
337
+
338
+ # Linux 上没有官方桌面端,社区打包版的进程名不统一,这里只按常见名字精确匹配。
339
+ # 不用 `pgrep -f claude`,否则会把正在运行的 CLI(claude)误判为桌面端。
340
+ LINUX_PROCESS_NAMES = ("Claude", "claude-desktop")
341
+
342
+
343
+ # Windows 上桌面端和原生安装的 CLI 都叫 claude.exe,只能按可执行文件路径区分。
344
+ # 以下规则来自常见安装位置,尚未在所有安装方式上验证。
345
+ WINDOWS_DESKTOP_HINTS = ("\\anthropicclaude\\", "\\windowsapps\\claude_", "\\programs\\claude\\", "\\claude-3p\\")
346
+ WINDOWS_CLI_HINTS = ("\\.local\\bin\\", "\\npm\\", "\\node_modules\\", "\\.claude\\", "\\scoop\\", "\\winget\\")
347
+
348
+
349
+ def windows_claude_exe_paths():
350
+ """返回所有 claude.exe 进程的路径;拿不到路径(如权限不足)的进程记为空字符串。"""
351
+ r = subprocess.run(
352
+ [
353
+ "powershell",
354
+ "-NoProfile",
355
+ "-NonInteractive",
356
+ "-Command",
357
+ "Get-Process -Name claude -ErrorAction SilentlyContinue | ForEach-Object { '' + $_.Path }",
358
+ ],
359
+ capture_output=True,
360
+ text=True,
361
+ timeout=15,
362
+ )
363
+ return [ln.strip() for ln in r.stdout.splitlines()]
364
+
365
+
366
+ def windows_desktop_running(paths):
367
+ """根据 claude.exe 的路径判断桌面端是否在运行。
368
+ 明确是桌面端 → True;明确是 CLI → 忽略;分辨不出 → 打印提醒但不阻止(返回 False)。"""
369
+ unknown = []
370
+ for p in paths:
371
+ low = p.lower()
372
+ if any(h in low for h in WINDOWS_DESKTOP_HINTS):
373
+ return True
374
+ if low and any(h in low for h in WINDOWS_CLI_HINTS):
375
+ continue
376
+ unknown.append(p or "?")
377
+ if unknown:
378
+ print(
379
+ t(
380
+ " ! Found claude.exe process(es) that may or may not be the desktop app: "
381
+ f"{', '.join(unknown)}. Make sure the desktop app is fully quit.",
382
+ f" ! 发现无法判断是否为桌面端的 claude.exe 进程:{', '.join(unknown)}。请自行确认桌面端已彻底退出。",
383
+ ),
384
+ file=sys.stderr,
385
+ )
386
+ return False
387
+
388
+
389
+ def desktop_running():
390
+ sysname = platform.system()
391
+ try:
392
+ if sysname == "Darwin":
393
+ r = subprocess.run(["pgrep", "-x", "Claude"], capture_output=True)
394
+ return r.returncode == 0
395
+ if sysname == "Windows":
396
+ return windows_desktop_running(windows_claude_exe_paths())
397
+ for name in LINUX_PROCESS_NAMES:
398
+ if subprocess.run(["pgrep", "-x", name], capture_output=True).returncode == 0:
399
+ return True
400
+ except Exception: # noqa: BLE001
401
+ pass
402
+ return False
403
+
404
+
405
+ def ensure_desktop_stopped(force):
406
+ """桌面端运行时直接退出,不做任何写入。"""
407
+ if desktop_running() and not force:
408
+ sys.exit(
409
+ t(
410
+ "The Claude desktop app is running. Nothing was written. "
411
+ "Quit it completely (from the menu bar / system tray) and try again, or pass --force.",
412
+ "检测到桌面端正在运行,这次没有写入。请彻底退出(托盘/菜单栏里退出)后重试,或加 --force。",
413
+ )
414
+ )
415
+
416
+
417
+ def new_stamp():
418
+ """日志/备份目录的时间戳,精确到微秒,避免同一秒内运行两次互相覆盖。"""
419
+ return dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
420
+
421
+
422
+ def write_journal(created, moved=None, stamp=None):
423
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
424
+ p = JOURNAL_DIR / f"journal-{stamp or new_stamp()}.json"
425
+ with open(p, "w", encoding="utf-8") as fh:
426
+ json.dump(
427
+ {"created": [str(c) for c in created], "moved": [[str(a), str(b)] for a, b in (moved or [])]},
428
+ fh,
429
+ ensure_ascii=False,
430
+ indent=2,
431
+ )
432
+ return p
433
+
434
+
435
+ def undo(force=False):
436
+ journals = sorted(JOURNAL_DIR.glob("journal-*.json")) if JOURNAL_DIR.exists() else []
437
+ if not journals:
438
+ print(t("Nothing to undo.", "没有可撤销的记录。"))
439
+ return
440
+ ensure_desktop_stopped(force)
441
+ j = journals[-1]
442
+ data = json.loads(j.read_text(encoding="utf-8"))
443
+ n = 0
444
+ for f in data.get("created", []):
445
+ fp = Path(f)
446
+ if fp.exists():
447
+ fp.unlink()
448
+ n += 1
449
+ m = 0
450
+ for orig, backup in reversed(data.get("moved", [])):
451
+ orig, backup = Path(orig), Path(backup)
452
+ if backup.exists():
453
+ orig.parent.mkdir(parents=True, exist_ok=True)
454
+ if orig.is_file():
455
+ orig.unlink() # 被改写过的文件(如 archived-sessions.idx)先删掉再还原
456
+ shutil.move(str(backup), str(orig))
457
+ m += 1
458
+ j.rename(j.with_suffix(".undone"))
459
+ print(
460
+ t(
461
+ f"Undid {j.name}: removed {n} created file(s), restored {m} file(s)/folder(s). Restart the desktop app.",
462
+ f"已撤销 {j.name}:删除了 {n} 个新建文件,还原了 {m} 个文件/目录。请重启桌面端。",
463
+ )
464
+ )
465
+
466
+
467
+ # ---------------------------------------------------------------- 清除已归档
468
+
469
+
470
+ def is_recently_written(paths, now=None):
471
+ """任一 .jsonl 在最近 RUNNING_GRACE_SEC 秒内被修改过,就认为会话可能正在运行。"""
472
+ now = now if now is not None else time.time()
473
+ for p in paths:
474
+ try:
475
+ if p.is_file() and now - p.stat().st_mtime < RUNNING_GRACE_SEC:
476
+ return True
477
+ except OSError:
478
+ return True # 读不到状态时保守处理,宁可不删
479
+ return False
480
+
481
+
482
+ def purge_archived(indexes, archived_ids, projects, apply, trash, excluded=(), project=None):
483
+ """彻底删除已归档会话,模仿桌面端"删除"的效果:
484
+ * 删除所有账号里指向该会话的 local_*.json 索引
485
+ * 从 archived-sessions.idx 中移除对应条目
486
+ * 在原账号目录写入 deleted_<cliSessionId> 标记(防止被再次同步)
487
+ * 删除 CLI 的 <会话ID>.jsonl 及同名子目录(子代理记录等)
488
+ 所有被删/被改的文件都先移动到 trash 目录,可用 --undo 还原。
489
+ excluded 里的会话、工作目录不包含 project 的会话、最近仍在写入的会话都会跳过。
490
+ 返回 (created, moved)。
491
+ """
492
+ created, moved = [], []
493
+ now_ms = str(int(time.time() * 1000))
494
+
495
+ idx_backed = set()
496
+
497
+ def backup_path(path):
498
+ dst = trash / path.relative_to(path.anchor)
499
+ dst.parent.mkdir(parents=True, exist_ok=True)
500
+ return dst
501
+
502
+ def backup(path):
503
+ dst = backup_path(path)
504
+ shutil.move(str(path), str(dst))
505
+ moved.append((path, dst))
506
+ return dst
507
+
508
+ plan, running, filtered = [], [], 0
509
+ for cid in sorted(i for i in archived_ids if i):
510
+ if cid in excluded:
511
+ filtered += 1
512
+ continue
513
+ idx_hits = []
514
+ for d, (_acct, _org, entries, _, _) in indexes.items():
515
+ for f, e in entries:
516
+ if isinstance(e, dict) and e.get("cliSessionId") == cid and f.exists():
517
+ idx_hits.append((d, f, e))
518
+ if project and not any(project in str(e.get("cwd") or "") for _, _, e in idx_hits):
519
+ filtered += 1
520
+ continue
521
+ cli_hits = list(projects.glob(f"*/{cid}.jsonl")) + [p for p in projects.glob(f"*/{cid}") if p.is_dir()]
522
+ if is_recently_written(cli_hits):
523
+ running.append(cid)
524
+ continue
525
+ plan.append((cid, idx_hits, cli_hits))
526
+
527
+ print(t(f"\n=== Purge archived sessions: {len(plan)} ===", f"\n=== 清除已归档会话:{len(plan)} 个 ==="))
528
+ for cid, idx_hits, cli_hits in plan:
529
+ title = next((e.get("title") for _, _, e in idx_hits if e.get("title")), cid)
530
+ print(
531
+ t(
532
+ f" - {title} [{cid}] {len(idx_hits)} index file(s), {len(cli_hits)} CLI file(s)",
533
+ f" - {title} [{cid}] 索引 {len(idx_hits)} 个,CLI 文件 {len(cli_hits)} 个",
534
+ )
535
+ )
536
+ if not apply:
537
+ continue
538
+ local_ids_by_dir = {}
539
+ for d, f, e in idx_hits:
540
+ local_ids_by_dir.setdefault(d, set()).update({f.stem, e.get("sessionId")})
541
+ backup(f)
542
+ for d, local_ids in local_ids_by_dir.items():
543
+ marker = d / f"deleted_{cid}"
544
+ if not marker.exists():
545
+ marker.write_text(now_ms, encoding="utf-8")
546
+ created.append(marker)
547
+ idx = d / "archived-sessions.idx"
548
+ if idx.is_file():
549
+ try:
550
+ data = json.loads(idx.read_text(encoding="utf-8"))
551
+ except Exception: # noqa: BLE001
552
+ continue
553
+ arch = data.get("archived") or []
554
+ kept = [x for x in arch if x not in local_ids]
555
+ if len(kept) != len(arch):
556
+ if idx not in idx_backed: # 每个 idx 只备份一次原始版本,undo 时覆盖回去
557
+ shutil.copy2(idx, backup_path(idx))
558
+ moved.append((idx, backup_path(idx)))
559
+ idx_backed.add(idx)
560
+ data["archived"] = kept
561
+ idx.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
562
+ for p in cli_hits:
563
+ backup(p)
564
+ if running:
565
+ print(
566
+ t(
567
+ f" (skipped {len(running)} archived session(s) still being written)",
568
+ f" (跳过 {len(running)} 个最近仍在写入的归档会话)",
569
+ )
570
+ )
571
+ if filtered:
572
+ print(
573
+ t(
574
+ f" (skipped {filtered} archived session(s) because of --exclude/--project)",
575
+ f" (按 --exclude/--project 跳过 {filtered} 个归档会话)",
576
+ )
577
+ )
578
+ return created, moved
579
+
580
+
581
+ # ---------------------------------------------------------------- 主流程
582
+
583
+
584
+ def main(argv=None):
585
+ use_safe_stdio()
586
+ ap = argparse.ArgumentParser(
587
+ prog="claude-session-sync",
588
+ formatter_class=argparse.RawDescriptionHelpFormatter,
589
+ description=t(
590
+ "Sync local Claude Code CLI sessions into the Claude desktop app's Code sidebar (multi-account).\n"
591
+ "Archived sessions and sessions deleted in the desktop app are skipped. "
592
+ "Previews by default; pass --apply to write.",
593
+ "把本地 Claude Code CLI 会话同步到 Claude 桌面端 Code 标签页的侧边栏(支持多账号)。\n"
594
+ "自动排除已归档、桌面端已删除的会话。默认只预览,加 --apply 才写入。",
595
+ ),
596
+ epilog=t(
597
+ """\
598
+ Typical workflow:
599
+ %(prog)s preview what each account would get
600
+ %(prog)s --apply write (quit the desktop app completely first, e.g. ⌘Q)
601
+ %(prog)s --undo undo the most recent write/purge
602
+
603
+ Examples:
604
+ %(prog)s --account <account-id> --project myapp --apply
605
+ sync only the myapp project into one account
606
+ %(prog)s --exclude-file ~/no-sync.txt --apply
607
+ leave out the sessions listed in a file
608
+ %(prog)s --purge-archived preview which archived sessions would be deleted
609
+ %(prog)s --purge-archived --apply sync, then permanently delete archived sessions (undoable)
610
+
611
+ Notes:
612
+ * Quit the desktop app completely before writing, and reopen it afterwards.
613
+ * Journals and backups live in ~/.claude-desktop-sync/; --undo steps back one run at a time.
614
+ * The desktop index format is undocumented and may break with desktop updates.
615
+ * Output language follows LANG; set CLAUDE_SESSION_SYNC_LANG=en or zh to override.
616
+ """,
617
+ """\
618
+ 常用流程:
619
+ %(prog)s 预览每个账号会补哪些会话
620
+ %(prog)s --apply 写入(先 ⌘Q 彻底退出桌面端)
621
+ %(prog)s --undo 撤销最近一次写入/删除
622
+
623
+ 示例:
624
+ %(prog)s --account <账号ID> --project myapp --apply
625
+ 只把 myapp 项目同步到指定账号
626
+ %(prog)s --exclude-file ~/no-sync.txt --apply
627
+ 排除列表中的会话
628
+ %(prog)s --purge-archived 预览会被彻底删除的已归档会话
629
+ %(prog)s --purge-archived --apply 同步后彻底删除已归档会话(可 --undo)
630
+
631
+ 注意:
632
+ * 写入前请彻底退出桌面端(菜单栏 ⌘Q),写完重新打开。
633
+ * 日志与备份在 ~/.claude-desktop-sync/,--undo 按时间倒序逐次撤销。
634
+ * 桌面端索引格式无官方文档,桌面端更新后可能失效。
635
+ * 输出语言跟随 LANG,可用 CLAUDE_SESSION_SYNC_LANG=en 或 zh 强制指定。
636
+ """,
637
+ ),
638
+ )
639
+ ap.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
640
+ ap.add_argument(
641
+ "--apply", action="store_true", help=t("actually write (default: preview only)", "实际写入(默认只预览)")
642
+ )
643
+ ap.add_argument("--undo", action="store_true", help=t("undo the most recent write", "撤销最近一次写入"))
644
+ ap.add_argument(
645
+ "--show-template",
646
+ action="store_true",
647
+ help=t("print each account's template fields and exit", "显示各账号模板字段后退出"),
648
+ )
649
+ ap.add_argument(
650
+ "--account",
651
+ action="append",
652
+ help=t("only sync this account ID (repeatable)", "只同步指定账号ID(可多次使用)"),
653
+ )
654
+ ap.add_argument(
655
+ "--project",
656
+ help=t(
657
+ "only handle sessions whose working directory contains this string (sync and purge)",
658
+ "只处理工作目录包含该字符串的会话(同步和清除都生效)",
659
+ ),
660
+ )
661
+ ap.add_argument(
662
+ "--exclude",
663
+ action="append",
664
+ default=[],
665
+ help=t("session ID to skip in sync and purge (repeatable)", "排除的会话ID,同步和清除都跳过(可多次使用)"),
666
+ )
667
+ ap.add_argument(
668
+ "--exclude-file",
669
+ help=t("file with session IDs to exclude, one per line", "排除列表文件,每行一个会话ID"),
670
+ )
671
+ ap.add_argument(
672
+ "--per-account-archive",
673
+ action="store_true",
674
+ help=t(
675
+ "only exclude sessions archived in the same account (default: archived anywhere = excluded everywhere)",
676
+ "只按本账号的归档状态排除(默认:任一账号归档即全部排除)",
677
+ ),
678
+ )
679
+ ap.add_argument(
680
+ "--projects-dir",
681
+ help=t("CLI sessions directory (default: ~/.claude/projects)", "CLI 会话目录(默认 ~/.claude/projects)"),
682
+ )
683
+ ap.add_argument(
684
+ "--desktop-root",
685
+ action="append",
686
+ help=t("path to a claude-code-sessions directory (repeatable)", "手动指定 claude-code-sessions 目录"),
687
+ )
688
+ ap.add_argument(
689
+ "--force",
690
+ action="store_true",
691
+ help=t(
692
+ "run --apply / --undo even while the desktop app is running (not recommended)",
693
+ "桌面端运行中也强制执行 --apply / --undo(不推荐)",
694
+ ),
695
+ )
696
+ ap.add_argument(
697
+ "--purge-archived",
698
+ action="store_true",
699
+ help=t(
700
+ "after syncing, permanently delete archived sessions (index + CLI .jsonl, backed up, undoable)",
701
+ "同步后彻底删除已归档会话(索引 + CLI 的 .jsonl,备份到日志目录,可 --undo)",
702
+ ),
703
+ )
704
+ args = ap.parse_args(argv)
705
+
706
+ if args.undo:
707
+ undo(args.force)
708
+ return
709
+
710
+ projects = cli_projects_dir(args.projects_dir)
711
+ if not projects.is_dir():
712
+ sys.exit(t(f"CLI sessions directory not found: {projects}", f"找不到 CLI 会话目录:{projects}"))
713
+
714
+ roots = [Path(r).expanduser() for r in args.desktop_root] if args.desktop_root else candidate_desktop_roots()
715
+ if not roots:
716
+ sys.exit(
717
+ t(
718
+ "Cannot find the desktop app's claude-code-sessions directory. "
719
+ "Create a Code session in the desktop app first, or pass --desktop-root.",
720
+ "找不到桌面端的 claude-code-sessions 目录。请先在桌面端建一个 Code 会话,或用 --desktop-root 指定路径。",
721
+ )
722
+ )
723
+
724
+ accts = account_dirs(roots)
725
+ if args.account:
726
+ accts = [a for a in accts if a[1] in args.account]
727
+ if not accts:
728
+ sys.exit(t("No account directories found.", "没有找到任何账号目录。"))
729
+
730
+ excluded = set(args.exclude)
731
+ if args.exclude_file:
732
+ excluded |= {
733
+ ln.strip() for ln in Path(args.exclude_file).read_text(encoding="utf-8").splitlines() if ln.strip()
734
+ }
735
+
736
+ # 读取各账号索引
737
+ indexes = {}
738
+ archived_global = set()
739
+ deleted_global = set()
740
+ archived_only_global = set()
741
+ for _root, acct, org, d in accts:
742
+ entries = load_index(d)
743
+ deleted, archived_local = load_markers(d)
744
+ known = {e.get("cliSessionId") for _, e in entries if isinstance(e, dict)}
745
+ archived = {
746
+ e.get("cliSessionId")
747
+ for f, e in entries
748
+ if isinstance(e, dict)
749
+ and (is_archived(e) or f.stem in archived_local or e.get("sessionId") in archived_local)
750
+ }
751
+ archived_only_global |= archived
752
+ archived |= deleted # 桌面端删除过的会话同样不再补
753
+ archived_global |= archived
754
+ deleted_global |= deleted
755
+ indexes[d] = (acct, org, entries, known, archived)
756
+
757
+ # 账号目录为空时借用的后备模板(优先同一 root 下的)
758
+ fallback = {}
759
+ for root, _acct, _org, d in accts:
760
+ if root not in fallback:
761
+ tpl = pick_template(indexes[d][2])
762
+ if tpl:
763
+ fallback[root] = tpl
764
+ any_fallback = next(iter(fallback.values()), None)
765
+ if any_fallback is None: # --account 过滤后可能没有,再从全部账号里找
766
+ for _, _, _, d in account_dirs(roots):
767
+ any_fallback = pick_template(load_index(d))
768
+ if any_fallback:
769
+ break
770
+ root_of = {d: root for root, _, _, d in accts}
771
+
772
+ if args.show_template:
773
+ for _d, (acct, org, entries, _, _) in indexes.items():
774
+ tpl = pick_template(entries)
775
+ print(t(f"\n[{acct}/{org}] {len(entries)} index file(s)", f"\n[{acct}/{org}] {len(entries)} 个索引文件"))
776
+ if tpl:
777
+ print(
778
+ json.dumps(
779
+ {
780
+ k: (v if not isinstance(v, (dict, list)) else f"<{type(v).__name__}>")
781
+ for k, v in tpl.items()
782
+ },
783
+ ensure_ascii=False,
784
+ indent=2,
785
+ )
786
+ )
787
+ else:
788
+ print(t(" (no usable template)", " (无可用模板)"))
789
+ return
790
+
791
+ sessions, skipped = scan_cli_sessions(projects)
792
+ if args.project:
793
+ sessions = [s for s in sessions if args.project in s["cwd"]]
794
+ now = time.time()
795
+ running = {s["id"] for s in sessions if now - s["mtime"] < RUNNING_GRACE_SEC}
796
+
797
+ print(
798
+ t(
799
+ f"CLI sessions: {len(sessions)} usable (skipped {skipped} empty or without a working directory)",
800
+ f"CLI 会话:{len(sessions)} 个有效(跳过 {skipped} 个空会话/无 cwd 的记录)",
801
+ )
802
+ )
803
+ print(
804
+ t(
805
+ f"Desktop account directories: {len(indexes)}; archived/deleted in any account: "
806
+ f"{len(archived_global)} (deletion markers: {len(deleted_global)})\n",
807
+ f"桌面端账号目录:{len(indexes)} 个;任一账号中已归档/已删除:"
808
+ f"{len(archived_global)} 个(其中删除标记 {len(deleted_global)} 个)\n",
809
+ )
810
+ )
811
+
812
+ if args.apply:
813
+ ensure_desktop_stopped(args.force)
814
+
815
+ created = []
816
+ for d, (acct, org, entries, known, archived) in indexes.items():
817
+ template = pick_template(entries)
818
+ arch = archived if args.per_account_archive else archived_global
819
+ todo = [
820
+ s
821
+ for s in sessions
822
+ if s["id"] not in known and s["id"] not in arch and s["id"] not in excluded and s["id"] not in running
823
+ ]
824
+ print(
825
+ t(
826
+ f"[{acct[:8]}…/{org[:8]}…] existing {len(known)}, to add {len(todo)}",
827
+ f"[{acct[:8]}…/{org[:8]}…] 已有 {len(known)},待补 {len(todo)}",
828
+ )
829
+ )
830
+ if not template:
831
+ template = fallback.get(root_of[d]) or any_fallback
832
+ if template and todo:
833
+ print(
834
+ t(
835
+ " · No sessions in this account yet; borrowing a template from another account",
836
+ " · 该账号下暂无会话,借用其他账号的会话作为模板",
837
+ )
838
+ )
839
+ if not template:
840
+ if todo:
841
+ print(
842
+ t(
843
+ " ! No session to use as a template; skipped. Create a Code session in the desktop app first.",
844
+ " ! 找不到任何可用作模板的会话,跳过。请先在桌面端建一个 Code 会话。",
845
+ )
846
+ )
847
+ continue
848
+ for s in sorted(todo, key=lambda x: -x["lastActivityAt"]):
849
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(s["lastActivityAt"] / 1000))
850
+ print(f" + {when} {s['title']} ({s['cwd']})")
851
+ if args.apply:
852
+ new_id, entry = build_entry(template, s)
853
+ out = d / f"{new_id}.json"
854
+ with open(out, "w", encoding="utf-8") as fh:
855
+ json.dump(entry, fh, ensure_ascii=False, indent=2)
856
+ created.append(out)
857
+
858
+ if running:
859
+ print(
860
+ t(
861
+ f"\nSkipped {len(running)} session(s) written in the last 2 minutes (probably still running).",
862
+ f"\n跳过 {len(running)} 个最近 2 分钟内仍在写入的会话(可能正在运行)。",
863
+ )
864
+ )
865
+
866
+ moved = []
867
+ stamp = new_stamp()
868
+ if args.purge_archived:
869
+ # 清除时始终覆盖全部账号,避免 --account 过滤后其他账号残留指向已删 .jsonl 的索引;
870
+ # --exclude 和 --project 仍然生效,正在写入的会话按 .jsonl 修改时间单独判断
871
+ all_idx, all_archived = {}, set()
872
+ for _root, acct, org, d in account_dirs(roots):
873
+ entries = load_index(d)
874
+ _, archived_local = load_markers(d)
875
+ all_archived |= {
876
+ e.get("cliSessionId")
877
+ for f, e in entries
878
+ if isinstance(e, dict)
879
+ and (is_archived(e) or f.stem in archived_local or e.get("sessionId") in archived_local)
880
+ }
881
+ all_idx[d] = (acct, org, entries, None, None)
882
+ c2, moved = purge_archived(
883
+ all_idx,
884
+ all_archived,
885
+ projects,
886
+ args.apply,
887
+ JOURNAL_DIR / f"trash-{stamp}",
888
+ excluded=excluded,
889
+ project=args.project,
890
+ )
891
+ created += c2
892
+
893
+ if args.apply:
894
+ if created or moved:
895
+ j = write_journal(created, moved, stamp)
896
+ trash = JOURNAL_DIR / f"trash-{stamp}"
897
+ if moved:
898
+ print(
899
+ t(
900
+ f"\nCreated {len(created)} file(s), moved {len(moved)} file(s)/folder(s) to {trash}. "
901
+ f"Journal: {j}",
902
+ f"\n已写入/新建 {len(created)} 个文件,移除 {len(moved)} 个文件/目录(备份在 {trash})。日志:{j}",
903
+ )
904
+ )
905
+ else:
906
+ print(
907
+ t(
908
+ f"\nCreated {len(created)} file(s). Journal: {j}",
909
+ f"\n已写入/新建 {len(created)} 个文件。日志:{j}",
910
+ )
911
+ )
912
+ print(
913
+ t(
914
+ "Reopen the desktop app to check. If anything looks wrong, run --undo.",
915
+ "请重新打开桌面端查看;如有问题运行 --undo 撤销。",
916
+ )
917
+ )
918
+ else:
919
+ print(t("\nNothing to write.", "\n没有需要写入的会话。"))
920
+ else:
921
+ print(
922
+ t(
923
+ "\n(Preview only; nothing was written. Run again with --apply to write.)",
924
+ "\n(预览模式,未写入任何文件。确认无误后加 --apply 执行。)",
925
+ )
926
+ )
927
+
928
+
929
+ if __name__ == "__main__":
930
+ main()
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.5
2
+ Name: claude-session-sync-cli
3
+ Version: 0.1.0
4
+ Summary: Sync local Claude Code CLI sessions into the Claude desktop app sidebar (unofficial).
5
+ Project-URL: Homepage, https://github.com/xushenjie/claude-session-sync
6
+ Project-URL: Issues, https://github.com/xushenjie/claude-session-sync/issues
7
+ Project-URL: Changelog, https://github.com/xushenjie/claude-session-sync/blob/main/CHANGELOG.md
8
+ Author: xushenjie
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude,claude-code,cli,sessions,sync
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Utilities
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/markdown
29
+
30
+ # claude-session-sync
31
+
32
+ **English** | [简体中文](README.zh-CN.md)
33
+
34
+ [![CI](https://github.com/xushenjie/claude-session-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/xushenjie/claude-session-sync/actions/workflows/ci.yml)
35
+
36
+ Make your local Claude Code **CLI sessions** show up in the sidebar of the **Claude desktop app's Code tab**, across multiple accounts and organizations. Sessions you archived or deleted in the desktop app are left out automatically. It can also permanently purge archived sessions.
37
+
38
+ > ⚠️ **Unofficial tool, not affiliated with Anthropic.** The desktop app's index format is undocumented and may change with any update. The tool previews by default, refuses to write while the desktop app is running, and every write or deletion can be undone.
39
+
40
+ ---
41
+
42
+ ## Why
43
+
44
+ Local CLI and desktop sessions share the same transcripts, but **each side keeps its own session list**:
45
+
46
+ ```
47
+ Desktop sidebar index (one per account/org)
48
+ claude-code-sessions/<account-id>/<org-id>/
49
+ ├─ local_<uuid>.json session index; cliSessionId points to a transcript below
50
+ ├─ archived-sessions.idx archived sessions (local_ IDs)
51
+ └─ deleted_<session-id> marker left when a session is deleted in the desktop app
52
+ │
53
+ ▼
54
+ Transcripts (shared by CLI and desktop, account-independent)
55
+ ~/.claude/projects/<project-dir>/<session-id>.jsonl
56
+ ▲
57
+ └─ `claude --resume` scans these directly
58
+ ```
59
+
60
+ As a result:
61
+
62
+ - the CLI sees sessions created in the desktop app, but the desktop app doesn't see sessions created in the CLI;
63
+ - after switching account or organization, the desktop app reads a different index directory, so the list changes;
64
+ - the official import paths (`/desktop`, `claude --desktop --resume <id>`, desktop Help → Import CLI sessions) only import into the current account, and sometimes miss sessions.
65
+
66
+ This tool adds the missing index files for **every account/org** so CLI sessions appear in the sidebar. Syncing **never modifies any `.jsonl` transcript**; only `--purge-archived` removes transcripts of archived sessions, and it backs them up first.
67
+
68
+ ---
69
+
70
+ ## Install
71
+
72
+ Requires Python 3.9+ (the `python3` that ships with macOS works). Standard library only, no dependencies.
73
+
74
+ ```bash
75
+ uv tool install claude-session-sync-cli
76
+ ```
77
+
78
+ Or with pipx:
79
+
80
+ ```bash
81
+ pipx install claude-session-sync-cli
82
+ ```
83
+
84
+ The PyPI package is named `claude-session-sync-cli`; the command it installs is `claude-session-sync`. To install the latest unreleased code instead, use `uv tool install git+https://github.com/xushenjie/claude-session-sync`.
85
+
86
+ Or skip installing and run the single file directly — replace `claude-session-sync` below with `python3 cli.py`:
87
+
88
+ ```bash
89
+ curl -LO https://raw.githubusercontent.com/xushenjie/claude-session-sync/main/src/claude_session_sync/cli.py
90
+ ```
91
+
92
+ On Windows, use `py` instead of `python3`.
93
+
94
+ You also need **at least one Code session created in the desktop app** (any account), which is used as a template. Accounts/orgs that have no sessions yet borrow a template from another account.
95
+
96
+ Desktop data directories (found automatically):
97
+
98
+ | OS | Path |
99
+ |---|---|
100
+ | macOS | `~/Library/Application Support/Claude/claude-code-sessions` |
101
+ | macOS (third-party deployment) | `~/Library/Application Support/Claude-3p/claude-code-sessions` |
102
+ | Windows | `%APPDATA%\Claude\claude-code-sessions` |
103
+ | Windows (third-party deployment) | `%APPDATA%\Claude-3p\…` or `%LOCALAPPDATA%\Claude-3p\…` |
104
+ | Windows (Microsoft Store) | `%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\claude-code-sessions` |
105
+ | Linux | `~/.config/Claude/claude-code-sessions` |
106
+
107
+ If yours is elsewhere, pass it with `--desktop-root`.
108
+
109
+ ---
110
+
111
+ ## Quick start
112
+
113
+ ```bash
114
+ # Full help
115
+ claude-session-sync --help
116
+
117
+ # 1. Preview: list which sessions each account would get, write nothing
118
+ claude-session-sync
119
+
120
+ # 2. Fully quit the desktop app (⌘Q on macOS; closing the window or "restart" is not enough), then write
121
+ claude-session-sync --apply
122
+
123
+ # 3. Reopen the desktop app. If anything looks wrong, undo the last run
124
+ claude-session-sync --undo
125
+ ```
126
+
127
+ > If it says the desktop app is running, the app is still alive in the menu bar or system tray and **nothing was written**. Quit it fully and run again.
128
+
129
+ Output is in English, or in Chinese when your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`, or the Windows display language) is Chinese. Set `CLAUDE_SESSION_SYNC_LANG=en` or `zh` to override.
130
+
131
+ ---
132
+
133
+ ## Sync rules
134
+
135
+ | Case | What happens |
136
+ |---|---|
137
+ | The session is already in an account's list (archived or not) | Not added to that account again |
138
+ | The session is archived in **any** account (`isArchived` field or `archived-sessions.idx`) | Not added to any account. Use `--per-account-archive` to only consider each account's own archive state |
139
+ | The session was deleted in the desktop app in any account (`deleted_<session-id>` marker) | Not added to any account |
140
+ | The session is listed via `--exclude` or `--exclude-file` | Not added |
141
+ | The transcript was written within the last 2 minutes (probably still running) | Skipped |
142
+ | Empty sessions (no user message) or transcripts without a working directory | Skipped |
143
+ | `.jsonl` files in subfolders of a project directory (subagents etc.) | Not scanned |
144
+
145
+ Each new index file is **cloned from an existing desktop session in the same account**: only the session ID, `cliSessionId`, working directory, title, and timestamps are replaced, so the rest matches your current desktop version as closely as possible. Accounts without sessions borrow a template from another account, preferring the same desktop data directory.
146
+
147
+ Session titles come from, in order: custom title → summary → first user message, truncated to 60 characters.
148
+
149
+ Running it again is safe; already-synced sessions are skipped.
150
+
151
+ ---
152
+
153
+ ## Permanently purge archived sessions (`--purge-archived`)
154
+
155
+ Syncs as usual, then permanently deletes **archived sessions across all accounts**, the same way deleting them in the desktop app would:
156
+
157
+ 1. removes every `local_*.json` index pointing to the session, in all accounts;
158
+ 2. removes it from `archived-sessions.idx`;
159
+ 3. writes a `deleted_<session-id>` marker in the original account, so future syncs won't bring it back;
160
+ 4. removes the CLI's `<session-id>.jsonl` and the folder of the same name (subagent transcripts etc.).
161
+
162
+ ```bash
163
+ claude-session-sync --purge-archived # preview what would be deleted
164
+ claude-session-sync --purge-archived --apply # do it
165
+ ```
166
+
167
+ - Nothing is erased outright: files are moved to `~/.claude-desktop-sync/trash-<timestamp>/`, and `--undo` restores everything;
168
+ - purging always covers **all accounts**, even with `--account`, so no account is left with an index pointing to a removed transcript;
169
+ - `--exclude`, `--exclude-file`, and `--project` apply to purging too — excluded sessions are never deleted;
170
+ - sessions whose `.jsonl` was modified in the last 2 minutes are skipped;
171
+ - backups are not cleaned up automatically; once you're happy, delete `~/.claude-desktop-sync/trash-*` yourself.
172
+
173
+ ---
174
+
175
+ ## Options
176
+
177
+ | Option | Description |
178
+ |---|---|
179
+ | `-h`, `--help` | Show help, examples, and caveats |
180
+ | `-V`, `--version` | Show the version |
181
+ | (no options) | Preview mode |
182
+ | `--apply` | Actually write |
183
+ | `--undo` | Undo the most recent write or deletion |
184
+ | `--show-template` | Print each account's template fields and exit (for troubleshooting) |
185
+ | `--account <account-id>` | Only sync this account; repeatable |
186
+ | `--project <string>` | Only handle sessions whose working directory contains this string (sync and purge) |
187
+ | `--exclude <session-id>` | Exclude a session from sync and purge; repeatable |
188
+ | `--exclude-file <file>` | Read excluded session IDs from a file, one per line |
189
+ | `--per-account-archive` | Only exclude sessions archived in the same account |
190
+ | `--purge-archived` | Permanently delete archived sessions after syncing (see above) |
191
+ | `--projects-dir <path>` | CLI sessions directory; defaults to `~/.claude/projects` and honors `CLAUDE_CONFIG_DIR` |
192
+ | `--desktop-root <path>` | Path to a `claude-code-sessions` directory; repeatable |
193
+ | `--force` | Run `--apply` or `--undo` even while the desktop app is running (not recommended; the app may overwrite the changes) |
194
+
195
+ Examples:
196
+
197
+ ```bash
198
+ # Sync only the myapp project into one account
199
+ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
200
+
201
+ # Leave out sessions you don't want
202
+ claude-session-sync --exclude-file ~/no-sync.txt --apply
203
+ ```
204
+
205
+ A session ID is the file name of a `.jsonl` under `~/.claude/projects/<project>/`. An account ID is a top-level directory name under `claude-code-sessions/`; the preview shows the first 8 characters.
206
+
207
+ ---
208
+
209
+ ## Undo and journal
210
+
211
+ Every `--apply` records a journal at `~/.claude-desktop-sync/journal-<timestamp>.json` (microsecond precision, so back-to-back runs never overwrite each other):
212
+
213
+ - `created`: files created by this run (index files, `deleted_` markers);
214
+ - `moved`: files moved to the backup folder, plus backups of `archived-sessions.idx` taken before it was rewritten.
215
+
216
+ `--undo` deletes the created files, moves backups back into place, and renames the journal to `.undone`. Run `--undo` repeatedly to step back through runs, newest first.
217
+
218
+ Undo only touches files the tool itself changed, never the desktop app's existing sessions. Like `--apply`, `--undo` requires the desktop app to be fully quit.
219
+
220
+ ---
221
+
222
+ ## Known limitations
223
+
224
+ - **The index format is inferred from existing files.** Archived state is recognized from `isArchived`, `archived`, `archivedAt`, `status: "archived"`, and `archived-sessions.idx`; deletions from `deleted_<session-id>` markers. If a desktop update changes the format, `is_archived()` or `load_markers()` need updating.
225
+ - **Templates copy most fields from the original session as-is** (e.g. `envScopeId`, `postTurnSummary`). If a session misbehaves after borrowing a template from another account, run `--undo` first.
226
+ - **Windows and Linux support is experimental.** Development and real-data testing happen on macOS; on Windows and Linux only the automated tests have run, so the desktop data paths and index format there are unverified. Reports (with redacted `--show-template` output) are very welcome.
227
+ - **On Windows, the desktop app and a natively installed CLI are both `claude.exe`**, so the tool tells them apart by install path. If it can't tell, it prints a warning instead of refusing — make sure the desktop app is quit yourself.
228
+ - **Running-app detection on Linux is best effort.** There is no official Linux desktop app; the tool only looks for processes named `Claude` or `claude-desktop`. Make sure the app is closed yourself.
229
+ - **Cloud sessions are not handled.** They live on claude.ai, not in local files.
230
+ - **Don't type into the same session from the CLI and the desktop app at the same time**, or the transcript may get interleaved. Switching back and forth is fine.
231
+ - Claude Code keeps transcripts for 30 days by default. To keep them longer, set this in `~/.claude/settings.json`:
232
+ ```json
233
+ { "cleanupPeriodDays": 3650 }
234
+ ```
235
+
236
+ ---
237
+
238
+ ## FAQ
239
+
240
+ **Sessions still don't show up in the desktop app?**
241
+ 1. Check whether the output says the desktop app is running — if so, nothing was written;
242
+ 2. make sure the app was fully quit before writing, and reopen it afterwards;
243
+ 3. make sure the account/org you're signed into is in the output's account list, and its "to add" count isn't 0;
244
+ 4. check whether the session landed in a different project group (e.g. a subdirectory or worktree).
245
+
246
+ **A deleted session came back?**
247
+ The tool honors the desktop app's `deleted_` markers, so this shouldn't happen. If you deleted the `.jsonl` directly or some other way, exclude it with `--exclude`.
248
+
249
+ **The desktop app errors when opening a session?**
250
+ Run `--undo` first, then [open an issue](https://github.com/xushenjie/claude-session-sync/issues/new/choose) with the output of `--show-template` (**redact paths and titles first**).
251
+
252
+ **Want to import a single session the official way?**
253
+ ```bash
254
+ claude --desktop --resume <session-id>
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Contributing
260
+
261
+ Issues and pull requests are welcome, in English or Chinese. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, tests, commit conventions, and releasing. Please report anything that could lose or leak data privately, as described in [SECURITY.md](SECURITY.md).
262
+
263
+ ## License
264
+
265
+ [MIT](LICENSE). Claude is a trademark of Anthropic. This project is not affiliated with Anthropic in any way.
@@ -0,0 +1,8 @@
1
+ claude_session_sync/__init__.py,sha256=isVxxYaSSulytAuMu1Ngs5Pn7HsjQGgfzYbwcCuHNaY,150
2
+ claude_session_sync/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ claude_session_sync/cli.py,sha256=Jqk9ebVfwIqS2i_aZvaajpRqr6dGUT6uendaNuseLC0,36581
4
+ claude_session_sync_cli-0.1.0.dist-info/METADATA,sha256=V0FD6rI0BFahT3N32A0sNHtUrxctfqMl0LGvWGiPuDY,13774
5
+ claude_session_sync_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ claude_session_sync_cli-0.1.0.dist-info/entry_points.txt,sha256=uRISCzJtnCBJO9qM9xus_kyXmRJ0m5Lsew9Cm8buw4o,69
7
+ claude_session_sync_cli-0.1.0.dist-info/licenses/LICENSE,sha256=0H5SKhMoUoP14QR8YSxdVWL_f22OL_Uo9McfYZVLav4,1066
8
+ claude_session_sync_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claude-session-sync = claude_session_sync.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xushenjie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.