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.
@@ -0,0 +1,3 @@
1
+ """Keep the Claude desktop app's session list the same across two accounts on one machine."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m multicc_sync``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mccsync = multicc_sync.cli:main
3
+ mccsync-mcp = multicc_sync.mcp:main
@@ -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.