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.
- claude_session_sync/__init__.py +5 -0
- claude_session_sync/__main__.py +4 -0
- claude_session_sync/cli.py +930 -0
- claude_session_sync_cli-0.1.0.dist-info/METADATA +265 -0
- claude_session_sync_cli-0.1.0.dist-info/RECORD +8 -0
- claude_session_sync_cli-0.1.0.dist-info/WHEEL +4 -0
- claude_session_sync_cli-0.1.0.dist-info/entry_points.txt +2 -0
- claude_session_sync_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](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,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.
|