multicc-sync 1.0.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.
- multicc_sync/__init__.py +3 -0
- multicc_sync/__main__.py +6 -0
- multicc_sync/adopt.py +127 -0
- multicc_sync/cli.py +247 -0
- multicc_sync/index.py +131 -0
- multicc_sync/layout.py +109 -0
- multicc_sync/mcp.py +155 -0
- multicc_sync/merge.py +200 -0
- multicc_sync/state.py +77 -0
- multicc_sync-1.0.0.dist-info/METADATA +77 -0
- multicc_sync-1.0.0.dist-info/RECORD +14 -0
- multicc_sync-1.0.0.dist-info/WHEEL +4 -0
- multicc_sync-1.0.0.dist-info/entry_points.txt +3 -0
- multicc_sync-1.0.0.dist-info/licenses/LICENSE +21 -0
multicc_sync/__init__.py
ADDED
multicc_sync/__main__.py
ADDED
multicc_sync/adopt.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Give transcripts that only the terminal ``claude`` produced an index entry, so the app lists them.
|
|
2
|
+
|
|
3
|
+
A transcript with no entry in any account is usually one of three things: an earlier
|
|
4
|
+
segment of a session the app already lists (recorded in ``priorCliSessionIds``), a session
|
|
5
|
+
the user deleted (a tombstone exists), or a session started from the command line, which
|
|
6
|
+
the desktop app never sees. Only the last kind is adopted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .index import ENTRY_PREFIX, Index
|
|
16
|
+
|
|
17
|
+
TITLE_CHARS = 60
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Orphan:
|
|
22
|
+
cli_id: str
|
|
23
|
+
path: Path
|
|
24
|
+
cwd: str
|
|
25
|
+
title: str
|
|
26
|
+
first_ms: int
|
|
27
|
+
last_ms: int
|
|
28
|
+
size: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def known_ids(indexes: list[Index]) -> set[str]:
|
|
32
|
+
"""Every transcript id some entry already covers, current or prior."""
|
|
33
|
+
ids: set[str] = set()
|
|
34
|
+
for index in indexes:
|
|
35
|
+
for entry in index.sessions.values():
|
|
36
|
+
if entry.get("cliSessionId"):
|
|
37
|
+
ids.add(entry["cliSessionId"])
|
|
38
|
+
ids.update(entry.get("priorCliSessionIds") or [])
|
|
39
|
+
ids.update(index.tombstones)
|
|
40
|
+
return ids
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scan(path: Path) -> tuple[str, str, int, int] | None:
|
|
44
|
+
"""cwd, first user message, first and last timestamp of a transcript."""
|
|
45
|
+
cwd = ""
|
|
46
|
+
title = ""
|
|
47
|
+
first = last = 0
|
|
48
|
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
|
49
|
+
for line in fh:
|
|
50
|
+
try:
|
|
51
|
+
record = json.loads(line)
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
stamp = _ms(record.get("timestamp"))
|
|
55
|
+
if stamp:
|
|
56
|
+
first = first or stamp
|
|
57
|
+
last = max(last, stamp)
|
|
58
|
+
if not cwd and record.get("cwd"):
|
|
59
|
+
cwd = record["cwd"]
|
|
60
|
+
if not title and record.get("type") == "user":
|
|
61
|
+
text = _text(record.get("message"))
|
|
62
|
+
if text and not text.startswith("<"):
|
|
63
|
+
title = text.replace("\n", " ")[:TITLE_CHARS].strip()
|
|
64
|
+
if not cwd:
|
|
65
|
+
return None
|
|
66
|
+
return cwd, title, first, last
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _text(message) -> str:
|
|
70
|
+
if not isinstance(message, dict):
|
|
71
|
+
return ""
|
|
72
|
+
content = message.get("content")
|
|
73
|
+
if isinstance(content, str):
|
|
74
|
+
return content.strip()
|
|
75
|
+
if isinstance(content, list):
|
|
76
|
+
return " ".join(part.get("text", "") for part in content if isinstance(part, dict)).strip()
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ms(stamp) -> int:
|
|
81
|
+
if not isinstance(stamp, str):
|
|
82
|
+
return 0
|
|
83
|
+
from datetime import datetime, timezone
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return int(datetime.fromisoformat(stamp.replace("Z", "+00:00")).astimezone(timezone.utc).timestamp() * 1000)
|
|
87
|
+
except ValueError:
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find_orphans(projects: Path, indexes: list[Index], min_bytes: int = 1024) -> list[Orphan]:
|
|
92
|
+
"""Transcripts no account lists, large enough to be worth showing."""
|
|
93
|
+
known = known_ids(indexes)
|
|
94
|
+
found: list[Orphan] = []
|
|
95
|
+
if not projects.is_dir():
|
|
96
|
+
return found
|
|
97
|
+
for project in sorted(projects.iterdir()):
|
|
98
|
+
if not project.is_dir():
|
|
99
|
+
continue
|
|
100
|
+
for path in sorted(project.glob("*.jsonl")):
|
|
101
|
+
cli_id = path.stem
|
|
102
|
+
if cli_id in known or path.stat().st_size < min_bytes:
|
|
103
|
+
continue
|
|
104
|
+
scanned = _scan(path)
|
|
105
|
+
if scanned is None:
|
|
106
|
+
continue
|
|
107
|
+
cwd, title, first, last = scanned
|
|
108
|
+
found.append(Orphan(cli_id, path, cwd, title or cli_id[:8], first, last or int(path.stat().st_mtime * 1000), path.stat().st_size))
|
|
109
|
+
found.sort(key=lambda o: o.last_ms, reverse=True)
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def entry_for(orphan: Orphan) -> dict:
|
|
114
|
+
"""A minimal index entry in the shape the app writes for its own sessions."""
|
|
115
|
+
return {
|
|
116
|
+
"sessionId": f"{ENTRY_PREFIX}{orphan.cli_id}",
|
|
117
|
+
"cliSessionId": orphan.cli_id,
|
|
118
|
+
"cwd": orphan.cwd,
|
|
119
|
+
"originCwd": orphan.cwd,
|
|
120
|
+
"createdAt": orphan.first_ms or orphan.last_ms,
|
|
121
|
+
"lastActivityAt": orphan.last_ms,
|
|
122
|
+
"lastFocusedAt": orphan.last_ms,
|
|
123
|
+
"isArchived": False,
|
|
124
|
+
"title": orphan.title,
|
|
125
|
+
"titleSource": "user",
|
|
126
|
+
"previousTitles": [],
|
|
127
|
+
}
|
multicc_sync/cli.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Command line entry point. ``mccsync`` alone runs a sync; the other commands are explicit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .adopt import entry_for, find_orphans
|
|
12
|
+
from .index import Index, discover, pick, transcript_path
|
|
13
|
+
from .layout import TESTED_WITH, app_running, check_layout, index_root, projects_root, signed_in_account
|
|
14
|
+
from .merge import ADD, DELETE, SKIP, UPDATE, Plan, apply, plan, snapshot_after
|
|
15
|
+
from .state import backup, list_backups, load_snapshot, restore, save_snapshot
|
|
16
|
+
|
|
17
|
+
COMMANDS = ("sync", "list", "find", "adopt", "restore", "mcp")
|
|
18
|
+
APP_OPEN = (
|
|
19
|
+
"the Claude desktop app is running; quit it first (it keeps the session list in memory "
|
|
20
|
+
"and rewrites the directory on exit), or pass --force"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
"""The argparse parser; ``sync`` is inserted when no command is given."""
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="mccsync",
|
|
28
|
+
description="Keep the Claude desktop app's session list the same across two accounts on this machine.",
|
|
29
|
+
epilog=f"Tested with {TESTED_WITH}. Transcripts are never touched; only the app's per-account index is.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"mccsync {__version__}")
|
|
32
|
+
sub = parser.add_subparsers(dest="command")
|
|
33
|
+
|
|
34
|
+
p_sync = sub.add_parser("sync", help="make both accounts list the same sessions (the default)")
|
|
35
|
+
p_sync.add_argument("-n", "--dry-run", action="store_true", help="show the plan, write nothing")
|
|
36
|
+
p_sync.add_argument("--accounts", metavar="A,B", help="uuid prefixes of the two accounts (needed when more than two hold sessions)")
|
|
37
|
+
p_sync.add_argument("--keep-deleted", action="store_true", help="do not carry deletions across; only block re-adding")
|
|
38
|
+
p_sync.add_argument("--allow-missing", action="store_true", help="copy entries whose transcript is not on this machine")
|
|
39
|
+
p_sync.add_argument("--force", action="store_true", help="write even while the desktop app is running")
|
|
40
|
+
p_sync.set_defaults(func=cmd_sync)
|
|
41
|
+
|
|
42
|
+
p_list = sub.add_parser("list", help="show every account and its sessions")
|
|
43
|
+
p_list.set_defaults(func=cmd_list)
|
|
44
|
+
|
|
45
|
+
p_find = sub.add_parser("find", help="search session titles and working directories across all accounts")
|
|
46
|
+
p_find.add_argument("query")
|
|
47
|
+
p_find.set_defaults(func=cmd_find)
|
|
48
|
+
|
|
49
|
+
p_adopt = sub.add_parser("adopt", help="list terminal-only transcripts in the app (experimental)")
|
|
50
|
+
p_adopt.add_argument("-n", "--dry-run", action="store_true")
|
|
51
|
+
p_adopt.add_argument("--account", help="uuid prefix of the account to add them to (default: the signed-in one)")
|
|
52
|
+
p_adopt.add_argument("--min-kb", type=int, default=100, help="ignore transcripts smaller than this; one-shot claude -p runs are tiny (default 100)")
|
|
53
|
+
p_adopt.add_argument("--force", action="store_true")
|
|
54
|
+
p_adopt.set_defaults(func=cmd_adopt)
|
|
55
|
+
|
|
56
|
+
p_restore = sub.add_parser("restore", help="put back a backup (the latest when none is named)")
|
|
57
|
+
p_restore.add_argument("backup", nargs="?")
|
|
58
|
+
p_restore.add_argument("--force", action="store_true")
|
|
59
|
+
p_restore.set_defaults(func=cmd_restore)
|
|
60
|
+
|
|
61
|
+
p_mcp = sub.add_parser("mcp", help="run the MCP server over stdio")
|
|
62
|
+
p_mcp.set_defaults(func=cmd_mcp)
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
68
|
+
if not argv or argv[0] not in COMMANDS and argv[0] not in ("-h", "--help", "--version"):
|
|
69
|
+
argv.insert(0, "sync")
|
|
70
|
+
args = build_parser().parse_args(argv)
|
|
71
|
+
try:
|
|
72
|
+
return args.func(args) or 0
|
|
73
|
+
except (LookupError, FileNotFoundError, RuntimeError) as exc:
|
|
74
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_indexes() -> list[Index]:
|
|
79
|
+
"""All account indexes, after checking the directory still looks as expected."""
|
|
80
|
+
root = index_root()
|
|
81
|
+
problems = check_layout(root)
|
|
82
|
+
if problems:
|
|
83
|
+
raise RuntimeError("\n ".join([f"{root} does not look like a session index this tool understands:"] + problems))
|
|
84
|
+
return discover(root)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def choose_pair(indexes: list[Index], spec: str | None) -> tuple[Index, Index]:
|
|
88
|
+
"""The two accounts to sync: named by prefix, or the only two that hold sessions."""
|
|
89
|
+
if spec:
|
|
90
|
+
parts = [p.strip() for p in spec.split(",") if p.strip()]
|
|
91
|
+
if len(parts) != 2:
|
|
92
|
+
raise LookupError("--accounts takes exactly two uuid prefixes, comma separated")
|
|
93
|
+
a, b = pick(indexes, parts[0]), pick(indexes, parts[1])
|
|
94
|
+
if a is b:
|
|
95
|
+
raise LookupError("both prefixes name the same account")
|
|
96
|
+
return a, b
|
|
97
|
+
used = indexes if len(indexes) == 2 else [i for i in indexes if not i.empty]
|
|
98
|
+
if len(used) != 2:
|
|
99
|
+
names = ", ".join(i.label for i in used) or "none"
|
|
100
|
+
raise LookupError(f"{len(used)} accounts hold sessions ({names}); say which two with --accounts A,B")
|
|
101
|
+
return used[0], used[1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fmt_time(ms: int | None) -> str:
|
|
105
|
+
return datetime.fromtimestamp(ms / 1000).strftime("%m-%d %H:%M") if ms else "-" * 11
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def describe(plan_: Plan, dry_run: bool) -> str:
|
|
109
|
+
"""Human readable plan."""
|
|
110
|
+
lines: list[str] = []
|
|
111
|
+
for target in (plan_.a, plan_.b):
|
|
112
|
+
other = plan_.b if target is plan_.a else plan_.a
|
|
113
|
+
steps = plan_.for_target(target)
|
|
114
|
+
verb = "would change" if dry_run else "changing"
|
|
115
|
+
lines.append(f"{verb} {target.label} (from {other.label}):")
|
|
116
|
+
for step in steps:
|
|
117
|
+
flag = " !" if step.conflict else ""
|
|
118
|
+
lines.append(f" {step.action:<6} {fmt_time(step.data.get('lastActivityAt') or step.target.sessions.get(step.app_id, {}).get('lastActivityAt'))} {step.title[:32]:<32} {step.note}{flag}")
|
|
119
|
+
if not steps:
|
|
120
|
+
lines.append(" nothing")
|
|
121
|
+
counts = plan_.counts()
|
|
122
|
+
lines.append(
|
|
123
|
+
f"{counts[ADD]} to add, {counts[UPDATE]} to update, {counts[DELETE]} to delete, "
|
|
124
|
+
f"{plan_.unchanged} already in step, {counts[SKIP]} skipped"
|
|
125
|
+
+ (f", {counts['conflict']} conflicts resolved by recency (!)" if counts["conflict"] else "")
|
|
126
|
+
)
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def make_plan(a: Index, b: Index, keep_deleted: bool, allow_missing: bool) -> Plan:
|
|
131
|
+
projects = projects_root()
|
|
132
|
+
return plan(a, b, load_snapshot(a, b), keep_deleted, allow_missing, lambda cli: transcript_path(cli, projects) is not None)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_sync(args) -> int:
|
|
136
|
+
indexes = load_indexes()
|
|
137
|
+
a, b = choose_pair(indexes, args.accounts)
|
|
138
|
+
plan_ = make_plan(a, b, args.keep_deleted, args.allow_missing)
|
|
139
|
+
print(describe(plan_, args.dry_run))
|
|
140
|
+
if args.dry_run:
|
|
141
|
+
print("dry run, nothing written")
|
|
142
|
+
return 0
|
|
143
|
+
if not plan_.changes():
|
|
144
|
+
save_snapshot(a, b, snapshot_after(a, b))
|
|
145
|
+
print("nothing to do")
|
|
146
|
+
return 0
|
|
147
|
+
if app_running() and not args.force:
|
|
148
|
+
raise RuntimeError(APP_OPEN)
|
|
149
|
+
dest = backup()
|
|
150
|
+
written = apply(plan_)
|
|
151
|
+
saved = save_snapshot(a, b, snapshot_after(a, b))
|
|
152
|
+
print(f"wrote {written} entries; backup at {dest}; state at {saved}")
|
|
153
|
+
print("start the Claude app to see the result")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_list(args) -> int:
|
|
158
|
+
current = signed_in_account()
|
|
159
|
+
projects = projects_root()
|
|
160
|
+
for index in load_indexes():
|
|
161
|
+
mark = " (signed in)" if index.account == current else ""
|
|
162
|
+
print(f"account {index.account} org {index.org}{mark}")
|
|
163
|
+
rows = sorted(index.sessions.values(), key=lambda e: e.get("lastActivityAt") or 0, reverse=True)
|
|
164
|
+
for entry in rows:
|
|
165
|
+
path = transcript_path(entry.get("cliSessionId"), projects)
|
|
166
|
+
size = f"{path.stat().st_size / 1048576:6.1f}MB" if path else " missing"
|
|
167
|
+
archived = " archived" if entry.get("isArchived") else ""
|
|
168
|
+
print(f" {fmt_time(entry.get('lastActivityAt'))} {(entry.get('title') or '(untitled)')[:32]:<32} {(entry.get('cliSessionId') or '')[:8]} {size}{archived}")
|
|
169
|
+
print(f" {len(index.sessions)} sessions, {len(index.tombstones)} deleted")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def search(indexes: list[Index], query: str) -> list[tuple[Index, str, dict]]:
|
|
174
|
+
"""Entries whose title, cwd or ids contain the query, most recent first."""
|
|
175
|
+
needle = query.lower()
|
|
176
|
+
hits = []
|
|
177
|
+
for index in indexes:
|
|
178
|
+
for app_id, entry in index.sessions.items():
|
|
179
|
+
haystack = " ".join(str(entry.get(k) or "") for k in ("title", "cwd", "cliSessionId", "sessionId")).lower()
|
|
180
|
+
haystack += " " + " ".join(entry.get("previousTitles") or []).lower()
|
|
181
|
+
if needle in haystack:
|
|
182
|
+
hits.append((index, app_id, entry))
|
|
183
|
+
hits.sort(key=lambda h: h[2].get("lastActivityAt") or 0, reverse=True)
|
|
184
|
+
return hits
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_find(args) -> int:
|
|
188
|
+
projects = projects_root()
|
|
189
|
+
hits = search(load_indexes(), args.query)
|
|
190
|
+
for index, app_id, entry in hits:
|
|
191
|
+
path = transcript_path(entry.get("cliSessionId"), projects)
|
|
192
|
+
print(f"{fmt_time(entry.get('lastActivityAt'))} {(entry.get('title') or '(untitled)')[:32]:<32} account {index.label}")
|
|
193
|
+
print(f" transcript {path or 'missing'}")
|
|
194
|
+
if not hits:
|
|
195
|
+
print("no session matches")
|
|
196
|
+
return 1
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_adopt(args) -> int:
|
|
201
|
+
indexes = load_indexes()
|
|
202
|
+
target = pick(indexes, args.account) if args.account else None
|
|
203
|
+
if target is None:
|
|
204
|
+
current = signed_in_account()
|
|
205
|
+
candidates = [i for i in indexes if i.account == current] if current else []
|
|
206
|
+
if len(candidates) != 1:
|
|
207
|
+
raise LookupError("cannot tell which account is signed in; pass --account")
|
|
208
|
+
target = candidates[0]
|
|
209
|
+
orphans = find_orphans(projects_root(), indexes, args.min_kb * 1024)
|
|
210
|
+
for orphan in orphans:
|
|
211
|
+
print(f" {fmt_time(orphan.last_ms)} {orphan.title[:32]:<32} {orphan.cli_id[:8]} {orphan.size / 1048576:6.1f}MB {orphan.cwd}")
|
|
212
|
+
print(f"{len(orphans)} terminal-only transcripts" + (f" to add to {target.label}" if orphans else ""))
|
|
213
|
+
if args.dry_run or not orphans:
|
|
214
|
+
return 0
|
|
215
|
+
if app_running() and not args.force:
|
|
216
|
+
raise RuntimeError(APP_OPEN)
|
|
217
|
+
dest = backup()
|
|
218
|
+
for orphan in orphans:
|
|
219
|
+
target.write(f"local_{orphan.cli_id}", entry_for(orphan))
|
|
220
|
+
print(f"added {len(orphans)} entries; backup at {dest}; start the Claude app to see them")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_restore(args) -> int:
|
|
225
|
+
if args.backup:
|
|
226
|
+
source = Path(args.backup)
|
|
227
|
+
else:
|
|
228
|
+
backups = list_backups()
|
|
229
|
+
if not backups:
|
|
230
|
+
raise FileNotFoundError("no backups yet")
|
|
231
|
+
source = backups[-1]
|
|
232
|
+
if app_running() and not args.force:
|
|
233
|
+
raise RuntimeError(APP_OPEN)
|
|
234
|
+
root = restore(source)
|
|
235
|
+
print(f"restored {source} -> {root}")
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_mcp(args) -> int:
|
|
240
|
+
from .mcp import main as mcp_main
|
|
241
|
+
|
|
242
|
+
mcp_main()
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
raise SystemExit(main())
|
multicc_sync/index.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""One account's session index directory, read into memory and written back entry by entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .layout import UUID
|
|
12
|
+
|
|
13
|
+
ENTRY_PREFIX = "local_"
|
|
14
|
+
TOMBSTONE_PREFIX = "deleted_"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now_ms() -> int:
|
|
18
|
+
"""Milliseconds since the epoch, the unit the app uses."""
|
|
19
|
+
return int(time.time() * 1000)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Index:
|
|
24
|
+
"""The entries and tombstones of ``<root>/<account>/<org>``."""
|
|
25
|
+
|
|
26
|
+
path: Path
|
|
27
|
+
sessions: dict[str, dict] = field(default_factory=dict)
|
|
28
|
+
tombstones: dict[str, int] = field(default_factory=dict)
|
|
29
|
+
problems: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, path: Path) -> "Index":
|
|
33
|
+
"""Read every ``local_*.json`` and ``deleted_*`` file in the directory."""
|
|
34
|
+
index = cls(path=path)
|
|
35
|
+
for child in sorted(path.iterdir()):
|
|
36
|
+
name = child.name
|
|
37
|
+
if name.startswith(ENTRY_PREFIX) and name.endswith(".json"):
|
|
38
|
+
try:
|
|
39
|
+
with open(child, encoding="utf-8") as fh:
|
|
40
|
+
index.sessions[name[: -len(".json")]] = json.load(fh)
|
|
41
|
+
except (OSError, ValueError) as exc:
|
|
42
|
+
index.problems.append(f"{child}: {exc}")
|
|
43
|
+
elif name.startswith(TOMBSTONE_PREFIX):
|
|
44
|
+
index.tombstones[name[len(TOMBSTONE_PREFIX):]] = _read_stamp(child)
|
|
45
|
+
return index
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def account(self) -> str:
|
|
49
|
+
return self.path.parent.name
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def org(self) -> str:
|
|
53
|
+
return self.path.name
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def label(self) -> str:
|
|
57
|
+
return self.account[:8]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def empty(self) -> bool:
|
|
61
|
+
return not self.sessions and not self.tombstones
|
|
62
|
+
|
|
63
|
+
def tombstone_for(self, app_id: str) -> int | None:
|
|
64
|
+
"""Deletion time of an entry the user removed here, if any."""
|
|
65
|
+
return self.tombstones.get(app_id[len(ENTRY_PREFIX):])
|
|
66
|
+
|
|
67
|
+
def write(self, app_id: str, data: dict) -> None:
|
|
68
|
+
"""Create or replace an entry, clearing any tombstone that would contradict it."""
|
|
69
|
+
with open(self.path / f"{app_id}.json", "w", encoding="utf-8") as fh:
|
|
70
|
+
json.dump(data, fh, ensure_ascii=False)
|
|
71
|
+
self.sessions[app_id] = data
|
|
72
|
+
bare = app_id[len(ENTRY_PREFIX):]
|
|
73
|
+
if bare in self.tombstones:
|
|
74
|
+
grave = self.path / f"{TOMBSTONE_PREFIX}{bare}"
|
|
75
|
+
if grave.exists():
|
|
76
|
+
os.remove(grave)
|
|
77
|
+
del self.tombstones[bare]
|
|
78
|
+
|
|
79
|
+
def delete(self, app_id: str, stamp: int | None = None) -> None:
|
|
80
|
+
"""Remove an entry and leave the tombstone the app itself would leave."""
|
|
81
|
+
target = self.path / f"{app_id}.json"
|
|
82
|
+
if target.exists():
|
|
83
|
+
os.remove(target)
|
|
84
|
+
self.sessions.pop(app_id, None)
|
|
85
|
+
bare = app_id[len(ENTRY_PREFIX):]
|
|
86
|
+
stamp = stamp or now_ms()
|
|
87
|
+
with open(self.path / f"{TOMBSTONE_PREFIX}{bare}", "w", encoding="utf-8") as fh:
|
|
88
|
+
fh.write(str(stamp))
|
|
89
|
+
self.tombstones[bare] = stamp
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_stamp(path: Path) -> int:
|
|
93
|
+
try:
|
|
94
|
+
return int(path.read_text().strip() or 0)
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def discover(root: Path) -> list[Index]:
|
|
100
|
+
"""Every ``<account>/<org>`` directory under the root, loaded."""
|
|
101
|
+
found: list[Index] = []
|
|
102
|
+
if not root.is_dir():
|
|
103
|
+
return found
|
|
104
|
+
for account in sorted(root.iterdir()):
|
|
105
|
+
if not (account.is_dir() and UUID.match(account.name)):
|
|
106
|
+
continue
|
|
107
|
+
for org in sorted(account.iterdir()):
|
|
108
|
+
if org.is_dir() and UUID.match(org.name):
|
|
109
|
+
found.append(Index.load(org))
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pick(indexes: list[Index], needle: str) -> Index:
|
|
114
|
+
"""The index whose account or org uuid starts with ``needle``; must be unique."""
|
|
115
|
+
hits = [i for i in indexes if i.account.startswith(needle) or i.org.startswith(needle)]
|
|
116
|
+
if not hits:
|
|
117
|
+
raise LookupError(f"no account matches {needle!r}")
|
|
118
|
+
if len(hits) > 1:
|
|
119
|
+
raise LookupError(f"{needle!r} matches {len(hits)} accounts, use a longer prefix")
|
|
120
|
+
return hits[0]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def transcript_path(cli_session_id: str | None, projects: Path) -> Path | None:
|
|
124
|
+
"""Locate a transcript by id without guessing the project directory's name."""
|
|
125
|
+
if not cli_session_id or not projects.is_dir():
|
|
126
|
+
return None
|
|
127
|
+
for project in projects.iterdir():
|
|
128
|
+
candidate = project / f"{cli_session_id}.jsonl"
|
|
129
|
+
if candidate.is_file():
|
|
130
|
+
return candidate
|
|
131
|
+
return None
|
multicc_sync/layout.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Where the desktop app keeps things, and a check that the layout still looks the way this tool expects.
|
|
2
|
+
|
|
3
|
+
The app stores conversation transcripts under ``~/.claude/projects`` and, separately, a
|
|
4
|
+
per-account session index::
|
|
5
|
+
|
|
6
|
+
<app support>/claude-code-sessions/<accountUuid>/<orgUuid>/local_<appSessionId>.json
|
|
7
|
+
<app support>/claude-code-sessions/<accountUuid>/<orgUuid>/deleted_<appSessionId>
|
|
8
|
+
|
|
9
|
+
Transcripts are shared by every account; only the index decides what the sidebar shows.
|
|
10
|
+
None of this is a documented interface, so every path here can be overridden by an
|
|
11
|
+
environment variable and ``check_layout`` refuses to proceed when the directory does not
|
|
12
|
+
look right.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
TESTED_WITH = "Claude desktop 1.49585.0 (runtime 2.1.260) on macOS"
|
|
25
|
+
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
26
|
+
ENTRY_KEYS = ("sessionId", "cliSessionId", "cwd")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def app_support() -> Path:
|
|
30
|
+
"""The desktop app's data directory for this platform."""
|
|
31
|
+
if sys.platform == "darwin":
|
|
32
|
+
return Path.home() / "Library" / "Application Support" / "Claude"
|
|
33
|
+
if sys.platform == "win32":
|
|
34
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude"
|
|
35
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Claude"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def index_root() -> Path:
|
|
39
|
+
"""Directory holding one subdirectory per account (``MCCSYNC_ROOT`` overrides)."""
|
|
40
|
+
return Path(os.environ.get("MCCSYNC_ROOT") or app_support() / "claude-code-sessions")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def projects_root() -> Path:
|
|
44
|
+
"""Where the CLI writes transcripts (``MCCSYNC_PROJECTS`` overrides)."""
|
|
45
|
+
return Path(os.environ.get("MCCSYNC_PROJECTS") or Path.home() / ".claude" / "projects")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tool_home() -> Path:
|
|
49
|
+
"""This tool's own directory for state and backups (``MCCSYNC_HOME`` overrides)."""
|
|
50
|
+
return Path(os.environ.get("MCCSYNC_HOME") or Path.home() / ".multicc-sync")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def signed_in_account() -> str | None:
|
|
54
|
+
"""The account the app last used, from ``config.json``.
|
|
55
|
+
|
|
56
|
+
Only ``lastKnownAccountUuid`` is read; the same file holds OAuth tokens, which this
|
|
57
|
+
tool never touches or copies.
|
|
58
|
+
"""
|
|
59
|
+
path = Path(os.environ.get("MCCSYNC_CONFIG") or app_support() / "config.json")
|
|
60
|
+
try:
|
|
61
|
+
with open(path, encoding="utf-8") as fh:
|
|
62
|
+
value = json.load(fh).get("lastKnownAccountUuid")
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
return None
|
|
65
|
+
return value if isinstance(value, str) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_layout(root: Path) -> list[str]:
|
|
69
|
+
"""Reasons the directory does not look like a session index; empty when it does."""
|
|
70
|
+
if not root.is_dir():
|
|
71
|
+
return [f"{root} does not exist; is the Claude desktop app installed and signed in?"]
|
|
72
|
+
accounts = [p for p in root.iterdir() if p.is_dir() and UUID.match(p.name)]
|
|
73
|
+
if not accounts:
|
|
74
|
+
return [f"{root} has no account directories named by uuid"]
|
|
75
|
+
problems: list[str] = []
|
|
76
|
+
entries = 0
|
|
77
|
+
for account in accounts:
|
|
78
|
+
for org in account.iterdir():
|
|
79
|
+
if not org.is_dir():
|
|
80
|
+
continue
|
|
81
|
+
if not UUID.match(org.name):
|
|
82
|
+
problems.append(f"unexpected directory {org}")
|
|
83
|
+
continue
|
|
84
|
+
for path in org.glob("local_*.json"):
|
|
85
|
+
entries += 1
|
|
86
|
+
try:
|
|
87
|
+
with open(path, encoding="utf-8") as fh:
|
|
88
|
+
data = json.load(fh)
|
|
89
|
+
except (OSError, ValueError) as exc:
|
|
90
|
+
problems.append(f"unreadable entry {path}: {exc}")
|
|
91
|
+
continue
|
|
92
|
+
missing = [key for key in ENTRY_KEYS if key not in data]
|
|
93
|
+
if missing:
|
|
94
|
+
problems.append(f"{path.name} lacks {', '.join(missing)}")
|
|
95
|
+
if entries == 0 and not problems:
|
|
96
|
+
problems.append(f"{root} holds no session entries at all")
|
|
97
|
+
return problems
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def app_running() -> bool:
|
|
101
|
+
"""Whether the desktop app is open; it caches the index in memory and rewrites it on exit."""
|
|
102
|
+
try:
|
|
103
|
+
if sys.platform == "win32":
|
|
104
|
+
out = subprocess.run(["tasklist"], capture_output=True, text=True, check=True).stdout
|
|
105
|
+
return "claude.exe" in out.lower()
|
|
106
|
+
out = subprocess.run(["ps", "-Ao", "comm="], capture_output=True, text=True, check=True).stdout
|
|
107
|
+
except (OSError, subprocess.CalledProcessError):
|
|
108
|
+
return False
|
|
109
|
+
return any(line.strip().endswith("/Claude.app/Contents/MacOS/Claude") for line in out.splitlines())
|
multicc_sync/mcp.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""MCP server so an agent can find a session in any account and preview or run a sync.
|
|
2
|
+
|
|
3
|
+
Every tool answers with a ``summary`` line first. Applying a sync needs the desktop app closed,
|
|
4
|
+
which is rarely true while an agent inside that app is asking, so ``sync_sessions`` previews by
|
|
5
|
+
default and says plainly when it cannot write.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .adopt import find_orphans
|
|
11
|
+
from .cli import choose_pair, describe, load_indexes, make_plan, search
|
|
12
|
+
from .index import transcript_path
|
|
13
|
+
from .layout import TESTED_WITH, app_running, projects_root, signed_in_account
|
|
14
|
+
from .merge import apply, snapshot_after
|
|
15
|
+
from .state import backup, save_snapshot
|
|
16
|
+
|
|
17
|
+
INSTRUCTIONS = (
|
|
18
|
+
"Session list sync for the Claude desktop app across two accounts on this machine. "
|
|
19
|
+
"Call find_sessions to locate a past session by title or directory in any account, "
|
|
20
|
+
"list_accounts to see what each account lists, sync_sessions to preview or apply a sync. "
|
|
21
|
+
"Writing requires the desktop app to be closed; when it is open, report the plan and tell "
|
|
22
|
+
"the user to quit the app and run `mccsync` in a terminal. Never touch transcripts. "
|
|
23
|
+
f"Tested with {TESTED_WITH}."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def list_accounts() -> dict:
|
|
28
|
+
"""Every account index on this machine with its session count, which one is signed in, and whether the app is running."""
|
|
29
|
+
try:
|
|
30
|
+
indexes = load_indexes()
|
|
31
|
+
except RuntimeError as exc:
|
|
32
|
+
return {"summary": str(exc), "accounts": []}
|
|
33
|
+
current = signed_in_account()
|
|
34
|
+
accounts = [
|
|
35
|
+
{
|
|
36
|
+
"account": i.account,
|
|
37
|
+
"org": i.org,
|
|
38
|
+
"signed_in": i.account == current,
|
|
39
|
+
"sessions": len(i.sessions),
|
|
40
|
+
"deleted": len(i.tombstones),
|
|
41
|
+
"archived": sum(1 for e in i.sessions.values() if e.get("isArchived")),
|
|
42
|
+
}
|
|
43
|
+
for i in indexes
|
|
44
|
+
]
|
|
45
|
+
running = app_running()
|
|
46
|
+
return {
|
|
47
|
+
"summary": f"{len(accounts)} account indexes, app {'running' if running else 'closed'}",
|
|
48
|
+
"app_running": running,
|
|
49
|
+
"accounts": accounts,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def find_sessions(query: str, limit: int = 10) -> dict:
|
|
54
|
+
"""Find sessions whose title, previous titles, working directory or id contain ``query``, in any account.
|
|
55
|
+
|
|
56
|
+
Returns, most recent first, the account, title, cwd, cli session id and the transcript path on disk,
|
|
57
|
+
so the caller can open the transcript directly even when the session belongs to another account.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
indexes = load_indexes()
|
|
61
|
+
except RuntimeError as exc:
|
|
62
|
+
return {"summary": str(exc), "sessions": []}
|
|
63
|
+
projects = projects_root()
|
|
64
|
+
hits = search(indexes, query)[:limit]
|
|
65
|
+
sessions = []
|
|
66
|
+
for index, app_id, entry in hits:
|
|
67
|
+
path = transcript_path(entry.get("cliSessionId"), projects)
|
|
68
|
+
sessions.append(
|
|
69
|
+
{
|
|
70
|
+
"title": entry.get("title"),
|
|
71
|
+
"account": index.account,
|
|
72
|
+
"cwd": entry.get("cwd"),
|
|
73
|
+
"cli_session_id": entry.get("cliSessionId"),
|
|
74
|
+
"prior_cli_session_ids": entry.get("priorCliSessionIds") or [],
|
|
75
|
+
"last_activity_ms": entry.get("lastActivityAt"),
|
|
76
|
+
"archived": bool(entry.get("isArchived")),
|
|
77
|
+
"transcript": str(path) if path else None,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
return {"summary": f"{len(sessions)} sessions match {query!r}", "sessions": sessions}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def sync_sessions(apply_changes: bool = False, keep_deleted: bool = False, accounts: str | None = None) -> dict:
|
|
84
|
+
"""Preview (default) or apply a sync between the two accounts that hold sessions.
|
|
85
|
+
|
|
86
|
+
``accounts`` names the pair as two uuid prefixes separated by a comma when more than two accounts
|
|
87
|
+
exist. Applying is refused while the desktop app runs; the plan is returned either way.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
indexes = load_indexes()
|
|
91
|
+
a, b = choose_pair(indexes, accounts)
|
|
92
|
+
except (RuntimeError, LookupError) as exc:
|
|
93
|
+
return {"summary": str(exc), "applied": False}
|
|
94
|
+
plan_ = make_plan(a, b, keep_deleted, False)
|
|
95
|
+
counts = plan_.counts()
|
|
96
|
+
text = describe(plan_, not apply_changes)
|
|
97
|
+
result = {"applied": False, "plan": text, "counts": counts, "pair": [a.account, b.account]}
|
|
98
|
+
if not apply_changes:
|
|
99
|
+
result["summary"] = f"preview: {counts['add']} add, {counts['update']} update, {counts['delete']} delete"
|
|
100
|
+
return result
|
|
101
|
+
if not plan_.changes():
|
|
102
|
+
save_snapshot(a, b, snapshot_after(a, b))
|
|
103
|
+
result["summary"] = "nothing to do"
|
|
104
|
+
return result
|
|
105
|
+
if app_running():
|
|
106
|
+
result["summary"] = "not applied: the desktop app is running; ask the user to quit it and run mccsync"
|
|
107
|
+
return result
|
|
108
|
+
dest = backup()
|
|
109
|
+
written = apply(plan_)
|
|
110
|
+
save_snapshot(a, b, snapshot_after(a, b))
|
|
111
|
+
result.update(applied=True, written=written, backup=str(dest))
|
|
112
|
+
result["summary"] = f"applied: {written} entries written, backup at {dest}"
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def terminal_only_sessions(min_kb: int = 1) -> dict:
|
|
117
|
+
"""Transcripts that only the terminal `claude` created and no account lists; `mccsync adopt` can add them."""
|
|
118
|
+
try:
|
|
119
|
+
indexes = load_indexes()
|
|
120
|
+
except RuntimeError as exc:
|
|
121
|
+
return {"summary": str(exc), "sessions": []}
|
|
122
|
+
orphans = find_orphans(projects_root(), indexes, min_kb * 1024)
|
|
123
|
+
return {
|
|
124
|
+
"summary": f"{len(orphans)} terminal-only transcripts",
|
|
125
|
+
"sessions": [
|
|
126
|
+
{"title": o.title, "cwd": o.cwd, "cli_session_id": o.cli_id, "transcript": str(o.path), "bytes": o.size, "last_activity_ms": o.last_ms}
|
|
127
|
+
for o in orphans
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_server():
|
|
133
|
+
"""The MCP server with the tools registered; works with mcp 1.x (FastMCP) and 2.x (MCPServer)."""
|
|
134
|
+
try:
|
|
135
|
+
try:
|
|
136
|
+
from mcp.server.mcpserver import MCPServer as Server
|
|
137
|
+
except ImportError:
|
|
138
|
+
from mcp.server.fastmcp import FastMCP as Server
|
|
139
|
+
except ImportError as exc:
|
|
140
|
+
raise SystemExit("the MCP server needs the 'mcp' package: pip install 'multicc-sync[mcp]'") from exc
|
|
141
|
+
server = Server("multicc-sync", instructions=INSTRUCTIONS)
|
|
142
|
+
server.tool()(list_accounts)
|
|
143
|
+
server.tool()(find_sessions)
|
|
144
|
+
server.tool()(sync_sessions)
|
|
145
|
+
server.tool()(terminal_only_sessions)
|
|
146
|
+
return server
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main() -> None:
|
|
150
|
+
"""Run the server over stdio."""
|
|
151
|
+
build_server().run()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
multicc_sync/merge.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Three-way merge of two session indexes against the snapshot left by the previous sync.
|
|
2
|
+
|
|
3
|
+
Without a snapshot the first sync takes the union, and where both sides hold an entry the
|
|
4
|
+
more recently active copy wins. With a snapshot, a side that still matches it did not change,
|
|
5
|
+
so whatever the other side did (renamed, archived, deleted) is carried across. When both
|
|
6
|
+
sides changed the same thing, the more recently active side wins and the step is reported
|
|
7
|
+
as a conflict rather than silently chosen.
|
|
8
|
+
|
|
9
|
+
Deletion in the app removes the entry and leaves a timestamped tombstone; the transcript
|
|
10
|
+
stays on disk. Propagating a deletion therefore only changes what the sidebar lists.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from .index import Index, now_ms
|
|
19
|
+
|
|
20
|
+
MUTABLE = (
|
|
21
|
+
"title",
|
|
22
|
+
"titleSource",
|
|
23
|
+
"previousTitles",
|
|
24
|
+
"isArchived",
|
|
25
|
+
"lastActivityAt",
|
|
26
|
+
"lastFocusedAt",
|
|
27
|
+
"completedTurns",
|
|
28
|
+
"cliSessionId",
|
|
29
|
+
"priorCliSessionIds",
|
|
30
|
+
)
|
|
31
|
+
ACCOUNT_SCOPED = ("remoteMcpServersConfig", "enabledMcpTools", "bridgeSessionIds")
|
|
32
|
+
|
|
33
|
+
ADD, UPDATE, DELETE, SKIP = "add", "update", "delete", "skip"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Step:
|
|
38
|
+
"""One change the sync would make to ``target``."""
|
|
39
|
+
|
|
40
|
+
action: str
|
|
41
|
+
target: Index
|
|
42
|
+
app_id: str
|
|
43
|
+
title: str
|
|
44
|
+
note: str = ""
|
|
45
|
+
data: dict = field(default_factory=dict)
|
|
46
|
+
stamp: int | None = None
|
|
47
|
+
conflict: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Plan:
|
|
52
|
+
"""Everything a sync between two indexes would do."""
|
|
53
|
+
|
|
54
|
+
a: Index
|
|
55
|
+
b: Index
|
|
56
|
+
steps: list[Step] = field(default_factory=list)
|
|
57
|
+
unchanged: int = 0
|
|
58
|
+
|
|
59
|
+
def changes(self) -> list[Step]:
|
|
60
|
+
return [s for s in self.steps if s.action != SKIP]
|
|
61
|
+
|
|
62
|
+
def for_target(self, index: Index) -> list[Step]:
|
|
63
|
+
return [s for s in self.steps if s.target is index]
|
|
64
|
+
|
|
65
|
+
def counts(self) -> dict[str, int]:
|
|
66
|
+
out = {ADD: 0, UPDATE: 0, DELETE: 0, SKIP: 0, "conflict": 0}
|
|
67
|
+
for step in self.steps:
|
|
68
|
+
out[step.action] += 1
|
|
69
|
+
out["conflict"] += step.conflict
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fingerprint(entry: dict) -> dict:
|
|
74
|
+
"""The fields the sync tracks, as stored in the snapshot."""
|
|
75
|
+
return {key: entry[key] for key in MUTABLE if key in entry}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _title(entry: dict | None) -> str:
|
|
79
|
+
return (entry or {}).get("title") or "(untitled)"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _activity(entry: dict | None) -> int:
|
|
83
|
+
return int((entry or {}).get("lastActivityAt") or 0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve(ea: dict, eb: dict, snap: dict | None) -> tuple[dict, bool]:
|
|
87
|
+
"""Merged mutable fields and whether any field was a genuine two-sided conflict."""
|
|
88
|
+
fa, fb = fingerprint(ea), fingerprint(eb)
|
|
89
|
+
if fa == fb:
|
|
90
|
+
return fa, False
|
|
91
|
+
newer = fa if _activity(ea) >= _activity(eb) else fb
|
|
92
|
+
if snap is None:
|
|
93
|
+
return dict(newer), False
|
|
94
|
+
merged: dict = {}
|
|
95
|
+
conflict = False
|
|
96
|
+
for key in MUTABLE:
|
|
97
|
+
va, vb, vs = fa.get(key), fb.get(key), snap.get(key)
|
|
98
|
+
if va == vb:
|
|
99
|
+
value = va
|
|
100
|
+
elif va == vs:
|
|
101
|
+
value = vb
|
|
102
|
+
elif vb == vs:
|
|
103
|
+
value = va
|
|
104
|
+
else:
|
|
105
|
+
value = newer.get(key)
|
|
106
|
+
conflict = True
|
|
107
|
+
if value is not None:
|
|
108
|
+
merged[key] = value
|
|
109
|
+
return merged, conflict
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def plan(
|
|
113
|
+
a: Index,
|
|
114
|
+
b: Index,
|
|
115
|
+
snapshot: dict[str, dict],
|
|
116
|
+
keep_deleted: bool = False,
|
|
117
|
+
allow_missing: bool = False,
|
|
118
|
+
has_transcript: Callable[[str | None], bool] = lambda cli: True,
|
|
119
|
+
) -> Plan:
|
|
120
|
+
"""Compute the steps that make ``a`` and ``b`` agree."""
|
|
121
|
+
result = Plan(a=a, b=b)
|
|
122
|
+
ids = sorted(set(a.sessions) | set(b.sessions) | set(snapshot))
|
|
123
|
+
for app_id in ids:
|
|
124
|
+
ea, eb, snap = a.sessions.get(app_id), b.sessions.get(app_id), snapshot.get(app_id)
|
|
125
|
+
if ea is not None and eb is not None:
|
|
126
|
+
_both(result, app_id, ea, eb, snap)
|
|
127
|
+
elif ea is not None:
|
|
128
|
+
_one_sided(result, app_id, ea, a, b, snap, keep_deleted, allow_missing, has_transcript)
|
|
129
|
+
elif eb is not None:
|
|
130
|
+
_one_sided(result, app_id, eb, b, a, snap, keep_deleted, allow_missing, has_transcript)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _both(result: Plan, app_id: str, ea: dict, eb: dict, snap: dict | None) -> None:
|
|
135
|
+
merged, conflict = _resolve(ea, eb, snap)
|
|
136
|
+
touched = False
|
|
137
|
+
for entry, index in ((ea, result.a), (eb, result.b)):
|
|
138
|
+
if fingerprint(entry) != merged:
|
|
139
|
+
note = "both sides changed, newer kept" if conflict else "carried across"
|
|
140
|
+
result.steps.append(Step(UPDATE, index, app_id, merged.get("title") or _title(entry), note, merged, conflict=conflict))
|
|
141
|
+
touched = True
|
|
142
|
+
if not touched:
|
|
143
|
+
result.unchanged += 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _one_sided(
|
|
147
|
+
result: Plan,
|
|
148
|
+
app_id: str,
|
|
149
|
+
entry: dict,
|
|
150
|
+
have: Index,
|
|
151
|
+
lack: Index,
|
|
152
|
+
snap: dict | None,
|
|
153
|
+
keep_deleted: bool,
|
|
154
|
+
allow_missing: bool,
|
|
155
|
+
has_transcript: Callable[[str | None], bool],
|
|
156
|
+
) -> None:
|
|
157
|
+
title = _title(entry)
|
|
158
|
+
stamp = lack.tombstone_for(app_id)
|
|
159
|
+
if stamp is not None:
|
|
160
|
+
removed_there = stamp >= _activity(entry)
|
|
161
|
+
else:
|
|
162
|
+
removed_there = snap is not None
|
|
163
|
+
if removed_there:
|
|
164
|
+
if keep_deleted:
|
|
165
|
+
result.steps.append(Step(SKIP, have, app_id, title, f"deleted on {lack.label}, kept"))
|
|
166
|
+
else:
|
|
167
|
+
result.steps.append(Step(DELETE, have, app_id, title, f"deleted on {lack.label}", stamp=stamp or now_ms()))
|
|
168
|
+
return
|
|
169
|
+
if not has_transcript(entry.get("cliSessionId")) and not allow_missing:
|
|
170
|
+
result.steps.append(Step(SKIP, lack, app_id, title, "no transcript on this machine"))
|
|
171
|
+
return
|
|
172
|
+
data = {k: v for k, v in entry.items() if k not in ACCOUNT_SCOPED}
|
|
173
|
+
result.steps.append(Step(ADD, lack, app_id, title, f"new on {have.label}", data))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def apply(plan_: Plan) -> int:
|
|
177
|
+
"""Write every change in the plan; returns how many entries were touched."""
|
|
178
|
+
written = 0
|
|
179
|
+
for step in plan_.steps:
|
|
180
|
+
if step.action == ADD:
|
|
181
|
+
step.target.write(step.app_id, step.data)
|
|
182
|
+
elif step.action == UPDATE:
|
|
183
|
+
current = dict(step.target.sessions[step.app_id])
|
|
184
|
+
for key in MUTABLE:
|
|
185
|
+
if key in step.data:
|
|
186
|
+
current[key] = step.data[key]
|
|
187
|
+
else:
|
|
188
|
+
current.pop(key, None)
|
|
189
|
+
step.target.write(step.app_id, current)
|
|
190
|
+
elif step.action == DELETE:
|
|
191
|
+
step.target.delete(step.app_id, step.stamp)
|
|
192
|
+
else:
|
|
193
|
+
continue
|
|
194
|
+
written += 1
|
|
195
|
+
return written
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def snapshot_after(a: Index, b: Index) -> dict[str, dict]:
|
|
199
|
+
"""The state to remember for next time: every entry both sides now hold."""
|
|
200
|
+
return {app_id: fingerprint(a.sessions[app_id]) for app_id in a.sessions if app_id in b.sessions}
|
multicc_sync/state.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The snapshot left by the last sync of a pair of accounts, and backups of the whole index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .index import Index
|
|
11
|
+
from .layout import index_root, tool_home
|
|
12
|
+
|
|
13
|
+
STATE_VERSION = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pair_key(a: Index, b: Index) -> str:
|
|
17
|
+
return "-".join(sorted((a.account[:8], b.account[:8])))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def state_path(a: Index, b: Index) -> Path:
|
|
21
|
+
return tool_home() / f"state-{pair_key(a, b)}.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_snapshot(a: Index, b: Index) -> dict[str, dict]:
|
|
25
|
+
"""Entries as they were after the previous sync of this pair; empty on the first run."""
|
|
26
|
+
path = state_path(a, b)
|
|
27
|
+
try:
|
|
28
|
+
with open(path, encoding="utf-8") as fh:
|
|
29
|
+
data = json.load(fh)
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return {}
|
|
32
|
+
if data.get("version") != STATE_VERSION:
|
|
33
|
+
return {}
|
|
34
|
+
return data.get("sessions", {})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_snapshot(a: Index, b: Index, sessions: dict[str, dict]) -> Path:
|
|
38
|
+
path = state_path(a, b)
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
41
|
+
json.dump({"version": STATE_VERSION, "synced_at": int(time.time() * 1000), "sessions": sessions}, fh)
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def backups_dir() -> Path:
|
|
46
|
+
return tool_home() / "backups"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def backup(root: Path | None = None) -> Path:
|
|
50
|
+
"""Copy the whole index tree aside before anything is written."""
|
|
51
|
+
root = root or index_root()
|
|
52
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
53
|
+
dest = backups_dir() / stamp
|
|
54
|
+
for n in range(1, 1000):
|
|
55
|
+
if not dest.exists():
|
|
56
|
+
break
|
|
57
|
+
dest = backups_dir() / f"{stamp}-{n}"
|
|
58
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
shutil.copytree(root, dest)
|
|
60
|
+
return dest
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_backups() -> list[Path]:
|
|
64
|
+
if not backups_dir().is_dir():
|
|
65
|
+
return []
|
|
66
|
+
return sorted(p for p in backups_dir().iterdir() if p.is_dir())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def restore(source: Path, root: Path | None = None) -> Path:
|
|
70
|
+
"""Replace the index tree with a backup."""
|
|
71
|
+
root = root or index_root()
|
|
72
|
+
if not source.is_dir():
|
|
73
|
+
raise FileNotFoundError(source)
|
|
74
|
+
if root.exists():
|
|
75
|
+
shutil.rmtree(root)
|
|
76
|
+
shutil.copytree(source, root)
|
|
77
|
+
return root
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: multicc-sync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Keep the Claude desktop app's session list the same across two accounts on one machine
|
|
5
|
+
Project-URL: Homepage, https://github.com/kangkangzi2025/multicc-sync
|
|
6
|
+
Project-URL: Repository, https://github.com/kangkangzi2025/multicc-sync
|
|
7
|
+
Project-URL: Issues, https://github.com/kangkangzi2025/multicc-sync/issues
|
|
8
|
+
Author: Fukang Wen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: accounts,claude,claude-code,desktop,sessions,sync
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: mcp
|
|
21
|
+
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# multicc-sync
|
|
25
|
+
|
|
26
|
+
Two Claude accounts on one Mac, one session list. Sign out of the Claude desktop app
|
|
27
|
+
and into another account and every Claude Code session disappears from the sidebar.
|
|
28
|
+
Nothing was deleted: transcripts live in `~/.claude/projects` and are shared, but the
|
|
29
|
+
app keeps a separate list of sessions per account. `mccsync` makes both accounts list
|
|
30
|
+
the same sessions, and keeps them in step afterwards: renames, archives and deletions
|
|
31
|
+
made in one account carry over to the other.
|
|
32
|
+
|
|
33
|
+
It never touches transcripts, never reads tokens, and makes no network calls. Every
|
|
34
|
+
write is preceded by a backup.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install multicc-sync
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Python 3.11 or newer. For the MCP server as well, install `multicc-sync[mcp]`.
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
Quit the Claude app, then:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
mccsync
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That is the whole update. The first run takes the union of both accounts; later runs
|
|
53
|
+
carry across whatever changed on either side since the previous run, and say so
|
|
54
|
+
entry by entry.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
mccsync -n # show the plan, write nothing
|
|
58
|
+
mccsync list # every account and what it lists
|
|
59
|
+
mccsync find "case study" # locate a session in any account, with its transcript path
|
|
60
|
+
mccsync adopt # list terminal-only sessions in the app (experimental)
|
|
61
|
+
mccsync restore # put back the last backup
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`mccsync --help` lists the rest, including `--keep-deleted` and `--accounts A,B` for
|
|
65
|
+
machines with more than two accounts.
|
|
66
|
+
|
|
67
|
+
For agents, `mccsync-mcp` is a stdio MCP server with four tools: `find_sessions`,
|
|
68
|
+
`list_accounts`, `sync_sessions` and `terminal_only_sessions`. Register it in Claude
|
|
69
|
+
Code with `claude mcp add multicc-sync -- mccsync-mcp`. The repository carries a
|
|
70
|
+
matching skill in `skills/session-sync`.
|
|
71
|
+
|
|
72
|
+
The app's session index is not a documented interface. This tool was written against
|
|
73
|
+
Claude desktop 1.49585.0 on macOS and checks the directory layout before every run;
|
|
74
|
+
if the app changes it, `mccsync` stops rather than guesses. Windows and Linux paths
|
|
75
|
+
are present but untested.
|
|
76
|
+
|
|
77
|
+
MIT license.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
multicc_sync/__init__.py,sha256=u_eZFFbLN4KK09GgsDAZo8gCcPNO6lRMFnsy4P4uzmY,117
|
|
2
|
+
multicc_sync/__main__.py,sha256=gpkBTmx4wywhhrDVI7qD6nJ_3sNSnvaNk0VMShXQViM,120
|
|
3
|
+
multicc_sync/adopt.py,sha256=i12r9ayqJI6aNbfOBOM3SmcKaAoLKoiFH83OpbNayrk,4177
|
|
4
|
+
multicc_sync/cli.py,sha256=KXsMKXMeht_XSeBSGdZurAf3gMVCfFxbF6dFmQIzDBQ,10850
|
|
5
|
+
multicc_sync/index.py,sha256=kFYNUmDjqV48aBYt_B0_pSP0dDz-Nu446xhx_mpQ0kY,4557
|
|
6
|
+
multicc_sync/layout.py,sha256=9bZSJsNARv5pDvHMoVPe5-tdk7mSphna_TN6nJ20ljA,4542
|
|
7
|
+
multicc_sync/mcp.py,sha256=wuP-ZNj21_4x4pCex8YM-HeU4QN8hgovnjwDrtswWyc,6358
|
|
8
|
+
multicc_sync/merge.py,sha256=7G6Y0cojy09i3cY4c2d3ObVcE3ghLrHO2RUx0xlrTso,6683
|
|
9
|
+
multicc_sync/state.py,sha256=FFMelAhOUP6oysyQYyNkZSEwv0sMByUAX7SV28fM4zc,2222
|
|
10
|
+
multicc_sync-1.0.0.dist-info/METADATA,sha256=7Urzn8pPcOc_cDaxQFztW8LfDbkyFgLLduJ6mKK9gI8,2956
|
|
11
|
+
multicc_sync-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
multicc_sync-1.0.0.dist-info/entry_points.txt,sha256=BZ1M1J9sWRzZXnIcNEPJaWXsQEqAQuKjgidHA_v1_J0,86
|
|
13
|
+
multicc_sync-1.0.0.dist-info/licenses/LICENSE,sha256=lgXSdW18iC8F_rrHev08Aaf1RHBsElY1CJOCgyUWWQ0,1067
|
|
14
|
+
multicc_sync-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fukang Wen
|
|
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.
|