claude-desktop-merge 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sebryu
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.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # claude-desktop-merge
2
+
3
+ Merge and sync **Claude desktop app** sessions across accounts — macOS, zero dependencies, dry-run by default.
4
+
5
+ > When you log into a different Claude account, the desktop app shows an empty session list. Your conversations aren't gone — the app just scopes its session index per account. This tool reconciles that: it merges every account's session list into one canonical location and (optionally) symlinks the others to it so they stay in sync.
6
+
7
+ Not affiliated with Anthropic. It only reorganizes local files the desktop app already wrote; it never uploads anything and never touches your transcripts.
8
+
9
+ ## Demo
10
+
11
+ <p align="center">
12
+ <img src="docs/showcase.svg" alt="claude-desktop-merge dry run: discover every account, print a plan, change nothing" width="680">
13
+ </p>
14
+
15
+ A **dry run** discovers every account, prints exactly what it would copy, and changes nothing until you add `--apply`. There's a full walkthrough page at [`docs/index.html`](docs/index.html) — open it locally, or serve the `docs/` folder with GitHub Pages.
16
+
17
+ <details>
18
+ <summary>Same run, as plain text</summary>
19
+
20
+ ```text
21
+ $ npx claude-desktop-merge
22
+ claude-desktop-merge (macOS)
23
+ BASE: ~/Library/Application Support/Claude
24
+ Active account: be60c637
25
+
26
+ Discovered locations:
27
+ 1) be60c637/04127aae (102 sessions) [ACTIVE]
28
+ · Refactor auth middleware
29
+ · Wire up billing webhooks
30
+ 2) d8755814/f9c0ba6b (47 sessions)
31
+ · Fix flaky e2e test
32
+ · Draft launch checklist
33
+
34
+ Choose CANONICAL location (number): 1
35
+ Choose SOURCE location(s) — 'a' for all others: a
36
+ 1) MERGE only 2) MERGE then LINK — choose [1/2]: 1
37
+
38
+ PLAN
39
+ Canonical: be60c637/04127aae
40
+ Source d8755814/f9c0ba6b
41
+ [claude-code-sessions] copy 47 files, 0 dirs; 0 conflicts
42
+ [agent-mode] copy 41 files, 6 dirs; 2 conflicts (kept canonical)
43
+
44
+ Totals: 88 file(s) + 6 dir(s) to copy, 2 kept, 3 space(s) added
45
+
46
+ DRY RUN — no filesystem changes were made.
47
+ Re-run with --apply to execute the plan above.
48
+ ```
49
+
50
+ <sub>Account/org IDs are shortened here for readability; the tool prints the full UUIDs.</sub>
51
+
52
+ </details>
53
+
54
+ ## Install (macOS)
55
+
56
+ Pick whichever you like — all three give you a `claude-desktop-merge` command and **none of them require you to install anything first**.
57
+
58
+ **1. One-line installer** — the simplest. Downloads the tool and puts it on your PATH; if you don't already have a runtime it installs [Bun](https://bun.sh) for you.
59
+
60
+ ```sh
61
+ curl -fsSL https://raw.githubusercontent.com/sebryu/claude-desktop-merge/main/install.sh | bash
62
+ ```
63
+
64
+ **2. npm** — if you already have Node:
65
+
66
+ ```sh
67
+ npx claude-desktop-merge # run once, nothing installed
68
+ # or install the command globally:
69
+ npm install -g claude-desktop-merge
70
+ ```
71
+
72
+ **3. From source** — if you want to read or hack on it:
73
+
74
+ ```sh
75
+ git clone https://github.com/sebryu/claude-desktop-merge.git
76
+ cd claude-desktop-merge
77
+ bun claude-desktop-merge.ts # or: node claude-desktop-merge.ts (Node >= 23.6)
78
+ ```
79
+
80
+ Then run it — it's a **dry run** that changes nothing:
81
+
82
+ ```sh
83
+ claude-desktop-merge # discover accounts, print a plan
84
+ claude-desktop-merge --revert # preview undoing a previous --link
85
+ ```
86
+
87
+ That's the whole tool — **one command**; everything else is a flag, and **nothing changes until you add `--apply`.** See [Usage](#usage) for the flag table.
88
+
89
+ > **Uninstall.** Installer: `rm ~/.local/bin/claude-desktop-merge && rm -rf ~/.local/share/claude-desktop-merge`. npm: `npm uninstall -g claude-desktop-merge`.
90
+
91
+ ---
92
+
93
+ ## The storage model (why this is safe)
94
+
95
+ The macOS desktop app keeps two different things in two different places:
96
+
97
+ | | Location | Account-scoped? |
98
+ |---|---|---|
99
+ | **Transcripts** (the actual conversation) | `~/.claude/projects/<encoded-cwd>/<id>.jsonl` | **No** — global, shared with the CLI |
100
+ | **Session index** (pointer files: title, model, cwd, archive state) | `~/Library/Application Support/Claude/{claude-code-sessions,local-agent-mode-sessions}/<accountUuid>/<orgUuid>/local_<id>.json` | **Yes** — one folder per account/org |
101
+
102
+ Because the transcripts are global, switching accounts never loses a conversation — the desktop UI just stops *listing* it, since it reads a different `<account>/<org>` folder. This tool only reorganizes the small pointer files. **It never reads, writes, moves, or deletes anything under `~/.claude` or any `.jsonl`.**
103
+
104
+ ## How it works — canonical merge, then link
105
+
106
+ 1. **MERGE** (non-destructive) — pick one **canonical** `<account>/<org>`. Copy the pointer files that exist only in other accounts *into* canonical. Union `spaces.json` by id. Archive flags ride along inside each pointer file.
107
+ 2. **LINK** (opt-in, `--link`) — back up each other account's folder to `*.bak-<timestamp>` and replace it with a symlink to canonical, so all accounts share one live set going forward.
108
+
109
+ The one rule that keeps it consistent: **copy _into_ canonical; symlink every other account _at_ canonical.** Never copy into a folder you then link away.
110
+
111
+ Pinned/grouped sessions live in a single global LevelDB keyed by session id, so they follow the merged sessions automatically — the tool reads it read-only to report which pins carry over, and never edits it.
112
+
113
+ ## Usage
114
+
115
+ ```
116
+ claude-desktop-merge [flags]
117
+ ```
118
+
119
+ Everything is a **dry run** until you pass `--apply`.
120
+
121
+ ```sh
122
+ # 1. See what's there (interactive picker + plan, no changes)
123
+ claude-desktop-merge
124
+
125
+ # 2. Merge one account's sessions into another
126
+ claude-desktop-merge --canonical=<acct>/<org> --sources=<acct>/<org> --apply
127
+
128
+ # 3. Keep them in sync: quit the desktop app first, then link
129
+ claude-desktop-merge --canonical=<acct>/<org> --sources=<acct>/<org> --link --apply
130
+
131
+ # 4. Changed your mind? Undo the linking (quit the desktop app first)
132
+ claude-desktop-merge --revert # dry-run: preview what gets restored
133
+ claude-desktop-merge --revert --apply # restore the pre-symlink backups
134
+ ```
135
+
136
+ | Flag | Meaning |
137
+ |---|---|
138
+ | `--canonical=<a>/<o>` | Target location that receives the merged sessions |
139
+ | `--sources=<a>/<o>[,...]` | Source locations to merge from |
140
+ | `--all-others` | Use every non-canonical location as a source |
141
+ | `--link` | Also do Step B: back up each source folder and symlink it to canonical |
142
+ | `--revert` | Undo a previous `--link`: restore each `*.bak-<ts>` backup over its symlink |
143
+ | `--apply` | Execute. Without it, the tool is a dry run |
144
+ | `--yes`, `-y` | Skip the confirmation prompt on `--apply` |
145
+ | `--log-file=<path>` | Write the run log here (default: `logs/desktop-merge-<ts>.log`) |
146
+ | `--no-log` | Disable file logging for this run |
147
+ | `--help`, `-h` | Show help |
148
+
149
+ ## Reverting a link
150
+
151
+ `--link` is fully reversible. It leaves a `*.bak-<timestamp>` backup next to every folder it turns into a symlink, and `--revert` walks those backups to put things back:
152
+
153
+ ```sh
154
+ claude-desktop-merge --revert # dry-run: show which symlinks would be restored
155
+ claude-desktop-merge --revert --apply # drop each symlink, move the newest backup back
156
+ ```
157
+
158
+ - It only ever removes a **symlink**. If a real directory has reappeared at the original path (e.g. the desktop app recreated it), that's reported as a conflict and left untouched — nothing is overwritten.
159
+ - When several backups exist for the same folder, the **newest** is restored and older ones are left in place.
160
+ - Quit the desktop app first, same as with `--link`.
161
+
162
+ The merged copies inside canonical are *not* touched by revert — reverting only undoes the symlinking (Step B), returning each account to its own independent folder.
163
+
164
+ ## Logging
165
+
166
+ Every run is mirrored to a log file (ANSI colors stripped) so you have a record of exactly what was planned and applied:
167
+
168
+ ```
169
+ logs/desktop-merge-<timestamp>.log # default, next to the script
170
+ ```
171
+
172
+ - `--log-file=<path>` writes the log somewhere else.
173
+ - `--no-log` turns file logging off for that run.
174
+
175
+ The `logs/` folder is git-ignored.
176
+
177
+ ## Safety
178
+
179
+ - **Dry-run by default** — nothing changes without `--apply`.
180
+ - **Never overwrites** canonical files; conflicts are reported and canonical is kept.
181
+ - **Backs up** every folder before replacing it with a symlink (`*.bak-<timestamp>`), and `--revert` restores those backups.
182
+ - **Read-only** on LevelDB; **never touches** `~/.claude` transcripts.
183
+ - **Every run is logged** to a file for an audit trail.
184
+ - Quit the desktop app before `--link` or `--revert` so it doesn't recreate the folders mid-operation.
185
+
186
+ ## License
187
+
188
+ MIT
@@ -0,0 +1,961 @@
1
+ #!/usr/bin/env node
2
+
3
+ // claude-desktop-merge.ts
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import readline from "node:readline/promises";
8
+ import { stdin, stdout } from "node:process";
9
+ var useColor = process.env.NO_COLOR == null && stdout.isTTY === true;
10
+ var paint = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
11
+ var c = {
12
+ bold: paint("1"),
13
+ dim: paint("2"),
14
+ red: paint("31"),
15
+ green: paint("32"),
16
+ yellow: paint("33"),
17
+ blue: paint("34"),
18
+ cyan: paint("36")
19
+ };
20
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+ var logFd = null;
22
+ var logFilePath = null;
23
+ function scriptDir() {
24
+ const meta = import.meta;
25
+ if (typeof meta.dir === "string")
26
+ return meta.dir;
27
+ return path.dirname(new URL(meta.url).pathname);
28
+ }
29
+ function writeLogRaw(line) {
30
+ if (logFd == null)
31
+ return;
32
+ try {
33
+ fs.writeSync(logFd, line);
34
+ } catch {}
35
+ }
36
+ function initLogging(explicitPath, argv) {
37
+ try {
38
+ const target = explicitPath ? path.resolve(explicitPath) : path.join(scriptDir(), "logs", `desktop-merge-${timestamp()}.log`);
39
+ fs.mkdirSync(path.dirname(target), { recursive: true });
40
+ logFd = fs.openSync(target, "a");
41
+ logFilePath = target;
42
+ const patch = (orig) => function(chunk, ...rest) {
43
+ try {
44
+ if (logFd != null) {
45
+ const s = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
46
+ fs.writeSync(logFd, s.replace(ANSI_RE, ""));
47
+ }
48
+ } catch {}
49
+ return orig(chunk, ...rest);
50
+ };
51
+ process.stdout.write = patch(process.stdout.write.bind(process.stdout));
52
+ process.stderr.write = patch(process.stderr.write.bind(process.stderr));
53
+ writeLogRaw(`
54
+ ===== claude-desktop-merge @ ${new Date().toISOString()} =====
55
+ `);
56
+ writeLogRaw(`argv: ${argv.length ? argv.join(" ") : "(none)"}
57
+
58
+ `);
59
+ } catch (err) {
60
+ process.stderr.write(c.yellow(`warning: file logging disabled (${err.message})
61
+ `));
62
+ logFd = null;
63
+ logFilePath = null;
64
+ }
65
+ }
66
+ function closeLogging() {
67
+ if (logFd == null)
68
+ return;
69
+ writeLogRaw(`
70
+ ===== end @ ${new Date().toISOString()} =====
71
+ `);
72
+ try {
73
+ fs.closeSync(logFd);
74
+ } catch {}
75
+ logFd = null;
76
+ }
77
+ var TREES = ["claude-code-sessions", "local-agent-mode-sessions"];
78
+ var SESSION_FILE_RE = /^local_.*\.json$/;
79
+ function isDir(p) {
80
+ try {
81
+ return fs.statSync(p).isDirectory();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+ function exists(p) {
87
+ try {
88
+ fs.lstatSync(p);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ function isSymlink(p) {
95
+ try {
96
+ return fs.lstatSync(p).isSymbolicLink();
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+ function listDirs(dir) {
102
+ try {
103
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() || d.isSymbolicLink() && isDir(path.join(dir, d.name))).map((d) => d.name).sort();
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+ function readJson(file) {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(file, "utf8"));
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+ function fail(msg) {
116
+ process.stderr.write(c.red(`error: ${msg}`) + `
117
+ `);
118
+ process.exit(1);
119
+ }
120
+ function parseArgs(argv) {
121
+ const a = { apply: false, link: false, revert: false, yes: false, help: false, allOthers: false, noLog: false };
122
+ for (const raw of argv) {
123
+ if (raw === "--apply")
124
+ a.apply = true;
125
+ else if (raw === "--link")
126
+ a.link = true;
127
+ else if (raw === "--revert")
128
+ a.revert = true;
129
+ else if (raw === "--yes" || raw === "-y")
130
+ a.yes = true;
131
+ else if (raw === "--help" || raw === "-h")
132
+ a.help = true;
133
+ else if (raw === "--all-others")
134
+ a.allOthers = true;
135
+ else if (raw === "--no-log")
136
+ a.noLog = true;
137
+ else if (raw.startsWith("--log-file="))
138
+ a.logFile = raw.slice("--log-file=".length).trim();
139
+ else if (raw.startsWith("--canonical="))
140
+ a.canonical = raw.slice("--canonical=".length).trim();
141
+ else if (raw.startsWith("--sources=")) {
142
+ a.sources = raw.slice("--sources=".length).split(",").map((s) => s.trim()).filter(Boolean);
143
+ } else
144
+ fail(`unknown argument: ${raw} (use --help)`);
145
+ }
146
+ return a;
147
+ }
148
+ function printHelp() {
149
+ const b = c.bold;
150
+ process.stdout.write(`${b("claude-desktop-merge")} — merge Claude desktop session metadata across accounts (macOS only)
151
+
152
+ ${b("USAGE")}
153
+ claude-desktop-merge [flags]
154
+
155
+ ${b("FLAGS")}
156
+ --canonical=<acct>/<org> Target location that receives the merged sessions.
157
+ --sources=<a>/<o>[,...] One or more source locations to merge FROM.
158
+ --all-others Use every non-canonical location as a source.
159
+ --link Also do Step B: back up each source org dir and symlink it to canonical.
160
+ --revert Undo a previous --link: restore each *.bak-<ts> backup over its symlink.
161
+ --apply Execute changes. Without this flag the tool is a DRY RUN (nothing changes).
162
+ --yes, -y Skip the confirmation prompt on --apply.
163
+ --log-file=<path> Write the run log here (default: <script-dir>/logs/desktop-merge-<ts>.log).
164
+ --no-log Disable file logging for this run.
165
+ --help, -h Show this help.
166
+
167
+ ${b("CONCEPT")}
168
+ A "location" is an <account>/<org> pair under the two session trees. MERGE (non-destructive) copies
169
+ pointer files/dirs from sources INTO canonical across both trees and unions spaces.json by id
170
+ (canonical wins). LINK (destructive, opt-in) replaces each source org dir with a symlink to canonical.
171
+ REVERT undoes LINK by restoring the *.bak-<ts> backups it left behind. Every run is logged to a file.
172
+
173
+ ${b("EXAMPLES")}
174
+ claude-desktop-merge # interactive, dry-run
175
+ claude-desktop-merge --canonical=A/O --sources=B/O # dry-run plan
176
+ claude-desktop-merge --canonical=A/O --all-others --apply # apply MERGE only
177
+ claude-desktop-merge --canonical=A/O --all-others --link --apply # apply MERGE + LINK
178
+ claude-desktop-merge --revert # dry-run: preview the undo
179
+ claude-desktop-merge --revert --apply # restore pre-symlink backups
180
+ `);
181
+ }
182
+ function discoverLocations(base) {
183
+ const map = new Map;
184
+ for (const tree of TREES) {
185
+ const treeDir = path.join(base, tree);
186
+ if (!isDir(treeDir))
187
+ continue;
188
+ for (const account of listDirs(treeDir)) {
189
+ for (const org of listDirs(path.join(treeDir, account))) {
190
+ const key = `${account}/${org}`;
191
+ if (!map.has(key))
192
+ map.set(key, { account, org, key });
193
+ }
194
+ }
195
+ }
196
+ return [...map.values()].sort((x, y) => x.key.localeCompare(y.key));
197
+ }
198
+ function orgDir(base, tree, loc) {
199
+ return path.join(base, tree, loc.account, loc.org);
200
+ }
201
+ function readOrgEntries(dir) {
202
+ const out = { files: [], dirs: [] };
203
+ let dirents;
204
+ try {
205
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
206
+ } catch {
207
+ return out;
208
+ }
209
+ for (const d of dirents) {
210
+ if (d.name === "spaces.json")
211
+ continue;
212
+ const full = path.join(dir, d.name);
213
+ const entryIsDir = d.isDirectory() || d.isSymbolicLink() && isDir(full);
214
+ if (entryIsDir)
215
+ out.dirs.push(d.name);
216
+ else if (d.isFile() && SESSION_FILE_RE.test(d.name))
217
+ out.files.push(d.name);
218
+ }
219
+ out.files.sort();
220
+ out.dirs.sort();
221
+ return out;
222
+ }
223
+ function summarizeLocation(base, loc) {
224
+ const byId = new Map;
225
+ for (const tree of TREES) {
226
+ const dir = orgDir(base, tree, loc);
227
+ const { files } = readOrgEntries(dir);
228
+ for (const name of files) {
229
+ const id = name.replace(/\.json$/, "");
230
+ if (byId.has(id))
231
+ continue;
232
+ const idx = readJson(path.join(dir, name));
233
+ const title = idx && typeof idx.title === "string" && idx.title || id;
234
+ const lastActivityAt = idx && Number(idx.lastActivityAt) || 0;
235
+ byId.set(id, { title, lastActivityAt });
236
+ }
237
+ }
238
+ const recentTitles = [...byId.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).slice(0, 3).map((s) => s.title);
239
+ return { total: byId.size, recentTitles };
240
+ }
241
+ function readSpaces(spacesPath) {
242
+ const data = readJson(spacesPath);
243
+ if (data && Array.isArray(data.spaces))
244
+ return data.spaces;
245
+ return [];
246
+ }
247
+ function buildSourcePlan(base, canon, source) {
248
+ const plan = { source, copies: [], conflicts: [], identical: 0, spacesAdded: [] };
249
+ for (const tree of TREES) {
250
+ const srcDir = orgDir(base, tree, source);
251
+ const canonDir = orgDir(base, tree, canon);
252
+ if (!isDir(srcDir))
253
+ continue;
254
+ const srcEntries = readOrgEntries(srcDir);
255
+ const candidates = [
256
+ ...srcEntries.files.map((name) => ({ name, kind: "file" })),
257
+ ...srcEntries.dirs.map((name) => ({ name, kind: "dir" }))
258
+ ];
259
+ for (const { name, kind } of candidates) {
260
+ const src = path.join(srcDir, name);
261
+ const dest = path.join(canonDir, name);
262
+ if (!exists(dest)) {
263
+ plan.copies.push({ tree, name, kind, src, dest });
264
+ continue;
265
+ }
266
+ if (kind === "dir") {
267
+ plan.conflicts.push({ tree, name, kind, reason: "dir-exists" });
268
+ continue;
269
+ }
270
+ let differs = true;
271
+ try {
272
+ differs = Buffer.compare(fs.readFileSync(src), fs.readFileSync(dest)) !== 0;
273
+ } catch {
274
+ differs = true;
275
+ }
276
+ if (differs)
277
+ plan.conflicts.push({ tree, name, kind, reason: "differs" });
278
+ else
279
+ plan.identical += 1;
280
+ }
281
+ }
282
+ return plan;
283
+ }
284
+ function collectSessionIds(entries) {
285
+ return [
286
+ ...entries.files.map((n) => n.replace(/\.json$/, "")),
287
+ ...entries.dirs.filter((n) => n.startsWith("local_"))
288
+ ];
289
+ }
290
+ function buildPlan(base, canon, sources, linkMode) {
291
+ const sessionIds = new Set;
292
+ const spaceIds = new Set;
293
+ const sourcePlans = sources.map((s) => buildSourcePlan(base, canon, s));
294
+ for (const tree of TREES) {
295
+ for (const id of collectSessionIds(readOrgEntries(orgDir(base, tree, canon))))
296
+ sessionIds.add(id);
297
+ for (const s of sources)
298
+ for (const id of collectSessionIds(readOrgEntries(orgDir(base, tree, s))))
299
+ sessionIds.add(id);
300
+ }
301
+ const agentTree = "local-agent-mode-sessions";
302
+ const canonSpacesPath = path.join(orgDir(base, agentTree, canon), "spaces.json");
303
+ const merged = new Map;
304
+ for (const sp of readSpaces(canonSpacesPath))
305
+ if (sp && sp.id)
306
+ merged.set(sp.id, sp);
307
+ const before = merged.size;
308
+ const sourcePlanBySource = new Map(sourcePlans.map((sp) => [sp.source.key, sp]));
309
+ for (const s of sources) {
310
+ const added2 = [];
311
+ for (const sp of readSpaces(path.join(orgDir(base, agentTree, s), "spaces.json"))) {
312
+ if (sp && sp.id && !merged.has(sp.id)) {
313
+ merged.set(sp.id, sp);
314
+ added2.push(sp);
315
+ }
316
+ }
317
+ const sourcePlan = sourcePlanBySource.get(s.key);
318
+ if (sourcePlan)
319
+ sourcePlan.spacesAdded = added2;
320
+ }
321
+ const mergedArr = [...merged.values()];
322
+ for (const sp of mergedArr)
323
+ if (sp && sp.id)
324
+ spaceIds.add(sp.id);
325
+ const added = mergedArr.length - before;
326
+ const spacesMerge = mergedArr.length === 0 ? null : {
327
+ canonPath: canonSpacesPath,
328
+ before,
329
+ merged: mergedArr,
330
+ added,
331
+ willWrite: added > 0 || !exists(canonSpacesPath)
332
+ };
333
+ const linkPlans = [];
334
+ if (linkMode) {
335
+ const stamp = timestamp();
336
+ for (const s of sources) {
337
+ for (const tree of TREES) {
338
+ const srcDir = orgDir(base, tree, s);
339
+ if (!exists(srcDir))
340
+ continue;
341
+ linkPlans.push({
342
+ tree,
343
+ source: s,
344
+ srcDir,
345
+ canonDir: orgDir(base, tree, canon),
346
+ backupDir: `${srcDir}.bak-${stamp}`,
347
+ alreadySymlink: isSymlink(srcDir)
348
+ });
349
+ }
350
+ }
351
+ }
352
+ return { canonical: canon, sourcePlans, spacesMerge, linkPlans, sessionIds, spaceIds };
353
+ }
354
+ function timestamp() {
355
+ return new Date().toISOString().replace(/[:.]/g, "-");
356
+ }
357
+ function listCapped(items, cap = 20) {
358
+ if (items.length <= cap)
359
+ return items;
360
+ return [...items.slice(0, cap), c.dim(`... and ${items.length - cap} more`)];
361
+ }
362
+ function printPlan(plan, linkMode) {
363
+ const out = process.stdout;
364
+ out.write(`
365
+ ` + c.bold("PLAN") + `
366
+ `);
367
+ out.write(` Canonical: ${c.cyan(plan.canonical.key)}
368
+ `);
369
+ let totalCopyFiles = 0;
370
+ let totalCopyDirs = 0;
371
+ let totalConflicts = 0;
372
+ for (const sp of plan.sourcePlans) {
373
+ out.write(`
374
+ ${c.bold("Source")} ${c.blue(sp.source.key)}
375
+ `);
376
+ if (sp.source.key === plan.canonical.key) {
377
+ out.write(` ${c.dim("(same as canonical — skipped)")}
378
+ `);
379
+ continue;
380
+ }
381
+ for (const tree of TREES) {
382
+ const copies = sp.copies.filter((x) => x.tree === tree);
383
+ const conflicts = sp.conflicts.filter((x) => x.tree === tree);
384
+ if (copies.length === 0 && conflicts.length === 0) {
385
+ out.write(` [${tree}] ${c.dim("nothing to copy")}
386
+ `);
387
+ continue;
388
+ }
389
+ const files = copies.filter((x) => x.kind === "file");
390
+ const dirs = copies.filter((x) => x.kind === "dir");
391
+ totalCopyFiles += files.length;
392
+ totalCopyDirs += dirs.length;
393
+ totalConflicts += conflicts.length;
394
+ out.write(` [${tree}] copy ${c.green(String(files.length))} files, ${c.green(String(dirs.length))} dirs; ` + `${conflicts.length ? c.yellow(String(conflicts.length)) : "0"} conflicts (kept canonical)
395
+ `);
396
+ for (const line of listCapped(files.map((x) => ` + ${x.name}`)))
397
+ out.write(line + `
398
+ `);
399
+ for (const line of listCapped(dirs.map((x) => ` + ${x.name}/ (dir)`)))
400
+ out.write(line + `
401
+ `);
402
+ for (const x of conflicts.slice(0, 20)) {
403
+ const why = x.reason === "differs" ? "differs" : "dir exists";
404
+ out.write(` ${c.yellow("!")} ${x.name}${x.kind === "dir" ? "/" : ""} (${why}, kept canonical)
405
+ `);
406
+ }
407
+ }
408
+ if (sp.identical > 0)
409
+ out.write(` ${c.dim(`(${sp.identical} identical file(s) skipped)`)}
410
+ `);
411
+ if (sp.spacesAdded.length > 0)
412
+ out.write(` spaces.json: ${c.green("+" + sp.spacesAdded.length)} new space(s)
413
+ `);
414
+ }
415
+ if (plan.spacesMerge) {
416
+ const m = plan.spacesMerge;
417
+ out.write(`
418
+ ${c.bold("spaces.json (canonical)")}: ${m.before} existing, ${c.green("+" + m.added)} added, ` + `${m.merged.length} total${m.willWrite ? "" : c.dim(" (no write needed)")}
419
+ `);
420
+ }
421
+ if (linkMode) {
422
+ out.write(`
423
+ ` + c.red(c.bold(" LINK (DESTRUCTIVE) — Step B")) + `
424
+ `);
425
+ out.write(c.dim(" Each source org dir is backed up to ") + c.cyan("*.bak-<timestamp>") + c.dim(` before it becomes a symlink, so this is
426
+ fully reversible — undo it later with `) + c.bold("--revert") + c.dim(` (restores the backups).
427
+ `));
428
+ out.write(c.yellow(` ⚠ QUIT the Claude desktop app before applying LINK, or it may recreate/overwrite these dirs.
429
+ `));
430
+ if (plan.linkPlans.length === 0)
431
+ out.write(` ${c.dim("no source org dirs to link")}
432
+ `);
433
+ for (const lp of plan.linkPlans) {
434
+ if (lp.alreadySymlink) {
435
+ out.write(` [${lp.tree}] ${c.dim(`${lp.srcDir} is already a symlink — skipped`)}
436
+ `);
437
+ continue;
438
+ }
439
+ out.write(` [${lp.tree}] ${c.blue(lp.source.key)}
440
+ `);
441
+ out.write(` backup: ${lp.srcDir}
442
+ `);
443
+ out.write(` -> ${lp.backupDir}
444
+ `);
445
+ out.write(` symlink: ${lp.srcDir} -> ${c.cyan(lp.canonDir)}
446
+ `);
447
+ }
448
+ }
449
+ out.write(`
450
+ ${c.bold("Totals")}: ${totalCopyFiles} file(s) + ${totalCopyDirs} dir(s) to copy, ` + `${totalConflicts} conflict(s) kept, ${plan.spacesMerge ? plan.spacesMerge.added : 0} space(s) added` + (linkMode ? `, ${plan.linkPlans.filter((l) => !l.alreadySymlink).length} dir(s) to symlink` : "") + `
451
+ `);
452
+ }
453
+ function executePlan(plan, linkMode) {
454
+ const r = { copiedFiles: 0, copiedDirs: 0, spacesWritten: false, linked: 0, backedUp: 0, errors: [] };
455
+ for (const sp of plan.sourcePlans) {
456
+ if (sp.source.key === plan.canonical.key)
457
+ continue;
458
+ for (const item of sp.copies) {
459
+ try {
460
+ fs.mkdirSync(path.dirname(item.dest), { recursive: true });
461
+ if (item.kind === "dir") {
462
+ fs.cpSync(item.src, item.dest, { recursive: true });
463
+ r.copiedDirs += 1;
464
+ } else {
465
+ fs.copyFileSync(item.src, item.dest);
466
+ r.copiedFiles += 1;
467
+ }
468
+ } catch (err) {
469
+ r.errors.push(`copy ${item.src} -> ${item.dest}: ${err.message}`);
470
+ }
471
+ }
472
+ }
473
+ if (plan.spacesMerge && plan.spacesMerge.willWrite) {
474
+ try {
475
+ fs.mkdirSync(path.dirname(plan.spacesMerge.canonPath), { recursive: true });
476
+ fs.writeFileSync(plan.spacesMerge.canonPath, JSON.stringify({ spaces: plan.spacesMerge.merged }, null, 2));
477
+ r.spacesWritten = true;
478
+ } catch (err) {
479
+ r.errors.push(`write ${plan.spacesMerge.canonPath}: ${err.message}`);
480
+ }
481
+ }
482
+ if (linkMode) {
483
+ for (const lp of plan.linkPlans) {
484
+ if (lp.alreadySymlink)
485
+ continue;
486
+ try {
487
+ fs.mkdirSync(lp.canonDir, { recursive: true });
488
+ fs.renameSync(lp.srcDir, lp.backupDir);
489
+ r.backedUp += 1;
490
+ fs.symlinkSync(lp.canonDir, lp.srcDir);
491
+ r.linked += 1;
492
+ } catch (err) {
493
+ r.errors.push(`link ${lp.srcDir}: ${err.message}`);
494
+ }
495
+ }
496
+ }
497
+ return r;
498
+ }
499
+ function levelDbReport(base, plan) {
500
+ const out = process.stdout;
501
+ out.write(`
502
+ ` + c.bold("LevelDB pin/group report") + c.dim(" (read-only, best-effort)") + `
503
+ `);
504
+ const dbDir = path.join(base, "Local Storage", "leveldb");
505
+ try {
506
+ if (!isDir(dbDir)) {
507
+ out.write(` ${c.dim("no leveldb store found — skipping")}
508
+ `);
509
+ return;
510
+ }
511
+ const files = fs.readdirSync(dbDir).filter((n) => n.endsWith(".ldb") || n.endsWith(".log")).map((n) => path.join(dbDir, n));
512
+ const needles = [...plan.sessionIds, ...plan.spaceIds];
513
+ const found = new Set;
514
+ for (const file of files) {
515
+ let hay;
516
+ try {
517
+ hay = fs.readFileSync(file).toString("latin1");
518
+ } catch {
519
+ continue;
520
+ }
521
+ for (const id of needles)
522
+ if (!found.has(id) && hay.includes(id))
523
+ found.add(id);
524
+ }
525
+ if (needles.length === 0) {
526
+ out.write(` ${c.dim("no session/space ids in scope")}
527
+ `);
528
+ return;
529
+ }
530
+ out.write(` ${found.size}/${needles.length} merged id(s) are referenced by leveldb (pins/groups/stars/sort).
531
+ `);
532
+ for (const id of [...found].slice(0, 15))
533
+ out.write(` ${c.cyan("•")} ${id}
534
+ `);
535
+ if (found.size > 15)
536
+ out.write(` ${c.dim(`... and ${found.size - 15} more`)}
537
+ `);
538
+ out.write(c.dim(` Note: leveldb is a single global store shared app-wide; it is NEVER modified by this tool.
539
+ ` + ` Pins/groups reference sessions by id, so they follow the merged sessions automatically.
540
+ `));
541
+ } catch (err) {
542
+ out.write(` ${c.yellow("leveldb scan failed:")} ${err.message}
543
+ `);
544
+ }
545
+ }
546
+ function requireTty(what) {
547
+ if (stdin.isTTY !== true)
548
+ fail(`${what} requires an interactive terminal. Pass it via flags instead (see --help).`);
549
+ }
550
+ function matchLocation(key, locations) {
551
+ const found = locations.find((l) => l.key === key);
552
+ if (!found)
553
+ fail(`location not found: ${key} (must be one of the discovered <account>/<org> pairs)`);
554
+ return found;
555
+ }
556
+ async function pickCanonical(rl, locations) {
557
+ while (true) {
558
+ const ans = (await rl.question(c.bold(`
559
+ Choose CANONICAL location (number): `))).trim();
560
+ const n = Number(ans);
561
+ if (Number.isInteger(n) && n >= 1 && n <= locations.length)
562
+ return locations[n - 1];
563
+ process.stdout.write(c.yellow(` enter a number between 1 and ${locations.length}
564
+ `));
565
+ }
566
+ }
567
+ async function pickSources(rl, locations, canonical) {
568
+ const others = locations.filter((l) => l.key !== canonical.key);
569
+ if (others.length === 0)
570
+ return [];
571
+ while (true) {
572
+ const ans = (await rl.question(c.bold("Choose SOURCE location(s) — comma-separated numbers, or 'a' for all others: "))).trim();
573
+ if (ans.toLowerCase() === "a")
574
+ return others;
575
+ const nums = ans.split(",").map((s) => Number(s.trim()));
576
+ const valid = nums.every((n) => Number.isInteger(n) && n >= 1 && n <= locations.length);
577
+ if (valid && nums.length > 0) {
578
+ const picked = [...new Set(nums)].map((n) => locations[n - 1]).filter((l) => l.key !== canonical.key);
579
+ if (picked.length > 0)
580
+ return picked;
581
+ }
582
+ process.stdout.write(c.yellow(` enter valid numbers (not the canonical), or 'a'
583
+ `));
584
+ }
585
+ }
586
+ async function pickLinkMode(rl) {
587
+ while (true) {
588
+ const ans = (await rl.question(c.bold(`
589
+ 1) MERGE only 2) MERGE then LINK (destructive) — choose [1/2]: `))).trim();
590
+ if (ans === "1")
591
+ return false;
592
+ if (ans === "2")
593
+ return true;
594
+ process.stdout.write(c.yellow(` enter 1 or 2
595
+ `));
596
+ }
597
+ }
598
+ async function confirm(rl, message) {
599
+ const ans = (await rl.question(c.bold(message + " Type 'yes' to proceed: "))).trim();
600
+ return ans.toLowerCase() === "yes";
601
+ }
602
+ function readLinkSafe(p) {
603
+ try {
604
+ return fs.readlinkSync(p);
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+ var BACKUP_RE = /^(.*)\.bak-(.+)$/;
610
+ function discoverBackups(base) {
611
+ const out = [];
612
+ for (const tree of TREES) {
613
+ const treeDir = path.join(base, tree);
614
+ if (!isDir(treeDir))
615
+ continue;
616
+ for (const account of listDirs(treeDir)) {
617
+ const accDir = path.join(treeDir, account);
618
+ let dirents;
619
+ try {
620
+ dirents = fs.readdirSync(accDir, { withFileTypes: true });
621
+ } catch {
622
+ continue;
623
+ }
624
+ for (const d of dirents) {
625
+ const m = d.name.match(BACKUP_RE);
626
+ if (!m)
627
+ continue;
628
+ const backupDir = path.join(accDir, d.name);
629
+ if (!isDir(backupDir))
630
+ continue;
631
+ const originalDir = path.join(accDir, m[1]);
632
+ const currentExists = exists(originalDir);
633
+ const currentIsSymlink = isSymlink(originalDir);
634
+ out.push({
635
+ tree,
636
+ account,
637
+ originalName: m[1],
638
+ stamp: m[2],
639
+ backupDir,
640
+ originalDir,
641
+ currentExists,
642
+ currentIsSymlink,
643
+ currentIsRealDir: currentExists && !currentIsSymlink && isDir(originalDir),
644
+ symlinkTarget: currentIsSymlink ? readLinkSafe(originalDir) : null
645
+ });
646
+ }
647
+ }
648
+ }
649
+ return out.sort((a, b) => a.tree.localeCompare(b.tree) || a.account.localeCompare(b.account) || a.originalName.localeCompare(b.originalName) || a.stamp.localeCompare(b.stamp));
650
+ }
651
+ function buildRevertItems(backups) {
652
+ const groups = new Map;
653
+ for (const b of backups) {
654
+ const key = `${b.tree}\x00${b.account}\x00${b.originalName}`;
655
+ const arr = groups.get(key) ?? [];
656
+ arr.push(b);
657
+ groups.set(key, arr);
658
+ }
659
+ const items = [];
660
+ for (const arr of groups.values()) {
661
+ arr.sort((a, b) => b.stamp.localeCompare(a.stamp));
662
+ const [chosen, ...older] = arr;
663
+ let action = "restore";
664
+ let reason;
665
+ if (chosen.currentIsRealDir) {
666
+ action = "conflict";
667
+ reason = "a real directory (not a symlink) sits at the original path";
668
+ } else if (chosen.currentExists && !chosen.currentIsSymlink) {
669
+ action = "conflict";
670
+ reason = "an unexpected non-directory entry sits at the original path";
671
+ }
672
+ items.push({ chosen, older, action, reason });
673
+ }
674
+ return items.sort((a, b) => a.chosen.tree.localeCompare(b.chosen.tree) || a.chosen.account.localeCompare(b.chosen.account) || a.chosen.originalName.localeCompare(b.chosen.originalName));
675
+ }
676
+ function printRevertPlan(items) {
677
+ const out = process.stdout;
678
+ out.write(`
679
+ ` + c.bold("REVERT PLAN") + c.dim(" (undo --link)") + `
680
+ `);
681
+ if (items.length === 0) {
682
+ out.write(` ${c.dim("no *.bak-<stamp> backups found — nothing to revert.")}
683
+ `);
684
+ return;
685
+ }
686
+ let restores = 0;
687
+ let conflicts = 0;
688
+ for (const it of items) {
689
+ const b = it.chosen;
690
+ const loc = `${b.account}/${b.originalName}`;
691
+ if (it.action === "conflict") {
692
+ conflicts += 1;
693
+ out.write(` [${b.tree}] ${c.yellow("!")} ${c.blue(loc)} ${c.yellow("(skipped: " + it.reason + ")")}
694
+ `);
695
+ } else {
696
+ restores += 1;
697
+ const cur = b.currentIsSymlink ? `symlink -> ${b.symlinkTarget ?? "?"}` : b.currentExists ? "existing entry" : c.dim("(nothing there)");
698
+ out.write(` [${b.tree}] ${c.green("↺")} ${c.blue(loc)}
699
+ `);
700
+ out.write(` current: ${cur}
701
+ `);
702
+ out.write(` restore: ${c.cyan(b.backupDir)}
703
+ `);
704
+ out.write(` -> ${b.originalDir}
705
+ `);
706
+ }
707
+ if (it.older.length > 0)
708
+ out.write(` ${c.dim(`(${it.older.length} older backup(s) left in place)`)}
709
+ `);
710
+ }
711
+ out.write(`
712
+ ${c.bold("Totals")}: ${c.green(String(restores))} to restore, ` + `${conflicts ? c.yellow(String(conflicts)) : "0"} conflict(s) skipped
713
+ `);
714
+ }
715
+ function executeRevert(items) {
716
+ const r = { restored: 0, removedSymlinks: 0, errors: [] };
717
+ for (const it of items) {
718
+ if (it.action !== "restore")
719
+ continue;
720
+ const b = it.chosen;
721
+ try {
722
+ if (b.currentIsSymlink) {
723
+ fs.unlinkSync(b.originalDir);
724
+ r.removedSymlinks += 1;
725
+ }
726
+ if (exists(b.originalDir)) {
727
+ r.errors.push(`restore ${b.originalDir}: path still exists after clearing symlink — skipped`);
728
+ continue;
729
+ }
730
+ fs.renameSync(b.backupDir, b.originalDir);
731
+ r.restored += 1;
732
+ } catch (err) {
733
+ r.errors.push(`restore ${b.backupDir} -> ${b.originalDir}: ${err.message}`);
734
+ }
735
+ }
736
+ return r;
737
+ }
738
+ async function runRevert(base, args) {
739
+ process.stdout.write(c.bold("claude-desktop-merge") + c.dim(" — revert (macOS)") + `
740
+ `);
741
+ process.stdout.write(`BASE: ${c.cyan(base)}
742
+ `);
743
+ const items = buildRevertItems(discoverBackups(base));
744
+ printRevertPlan(items);
745
+ const restorable = items.filter((it) => it.action === "restore");
746
+ if (!args.apply) {
747
+ process.stdout.write(`
748
+ ` + c.bold(c.yellow("DRY RUN — no filesystem changes were made.")) + `
749
+ `);
750
+ if (restorable.length > 0)
751
+ process.stdout.write(c.dim(`Re-run with --revert --apply to restore the backups above.
752
+ `));
753
+ return;
754
+ }
755
+ if (restorable.length === 0) {
756
+ process.stdout.write(`
757
+ ` + c.green("Nothing to restore — done.") + `
758
+ `);
759
+ return;
760
+ }
761
+ process.stdout.write(`
762
+ ` + c.yellow(`⚠ QUIT the Claude desktop app before reverting, or it may recreate the symlinked dirs.
763
+ `));
764
+ if (!args.yes) {
765
+ requireTty("Confirmation");
766
+ const rl = readline.createInterface({ input: stdin, output: stdout });
767
+ try {
768
+ const ok = await confirm(rl, "Proceed with REVERT (restore pre-symlink backups)?");
769
+ if (!ok) {
770
+ process.stdout.write(c.yellow(`Aborted — no changes made.
771
+ `));
772
+ return;
773
+ }
774
+ } finally {
775
+ rl.close();
776
+ }
777
+ }
778
+ const result = executeRevert(items);
779
+ process.stdout.write(`
780
+ ` + c.bold("SUMMARY") + `
781
+ `);
782
+ process.stdout.write(` Restored: ${c.green(String(result.restored))} dir(s) (${result.removedSymlinks} symlink(s) removed)
783
+ `);
784
+ if (result.errors.length) {
785
+ process.stdout.write(c.red(` ${result.errors.length} error(s):
786
+ `));
787
+ for (const e of result.errors)
788
+ process.stdout.write(c.red(` - ${e}
789
+ `));
790
+ }
791
+ process.stdout.write(`
792
+ ` + c.cyan("Reopen the Claude desktop app to confirm each account shows its own sessions again.") + `
793
+ `);
794
+ }
795
+ async function main() {
796
+ const args = parseArgs(process.argv.slice(2));
797
+ if (args.help) {
798
+ printHelp();
799
+ return;
800
+ }
801
+ if (process.platform !== "darwin")
802
+ fail(`this tool is macOS-only (detected platform: ${process.platform}).`);
803
+ const base = path.join(os.homedir(), "Library/Application Support/Claude");
804
+ if (!isDir(base))
805
+ fail(`Claude desktop data dir not found at:
806
+ ${base}
807
+ Is the Claude desktop app installed for this user?`);
808
+ if (!args.noLog) {
809
+ initLogging(args.logFile, process.argv.slice(2));
810
+ if (logFilePath)
811
+ process.stdout.write(c.dim(`Logging to: ${logFilePath}`) + `
812
+ `);
813
+ }
814
+ if (args.revert) {
815
+ try {
816
+ await runRevert(base, args);
817
+ } finally {
818
+ closeLogging();
819
+ }
820
+ return;
821
+ }
822
+ const config = readJson(path.join(base, "config.json")) || {};
823
+ const activeAccount = config.lastKnownAccountUuid;
824
+ process.stdout.write(c.bold("claude-desktop-merge") + c.dim(" (macOS)") + `
825
+ `);
826
+ process.stdout.write(`BASE: ${c.cyan(base)}
827
+ `);
828
+ process.stdout.write(`Active account (lastKnownAccountUuid): ${activeAccount ? c.green(activeAccount) : c.yellow("unknown")}
829
+ `);
830
+ const locations = discoverLocations(base);
831
+ if (locations.length === 0)
832
+ fail("no session locations discovered under either tree.");
833
+ process.stdout.write(`
834
+ ` + c.bold("Discovered locations:") + `
835
+ `);
836
+ locations.forEach((loc, i) => {
837
+ const s = summarizeLocation(base, loc);
838
+ const activeMark = loc.account === activeAccount ? c.green(" [ACTIVE]") : "";
839
+ process.stdout.write(` ${c.bold(String(i + 1))}) ${c.cyan(loc.key)} ${c.dim(`(${s.total} sessions)`)}${activeMark}
840
+ `);
841
+ for (const t of s.recentTitles)
842
+ process.stdout.write(` ${c.dim("· " + t)}
843
+ `);
844
+ });
845
+ const needCanonicalPick = !args.canonical;
846
+ const needSourcePick = !args.sources && !args.allOthers;
847
+ const interactive = needCanonicalPick || needSourcePick;
848
+ let rl = null;
849
+ const getRl = () => {
850
+ if (!rl)
851
+ rl = readline.createInterface({ input: stdin, output: stdout });
852
+ return rl;
853
+ };
854
+ try {
855
+ let canonical;
856
+ if (args.canonical)
857
+ canonical = matchLocation(args.canonical, locations);
858
+ else {
859
+ requireTty("Choosing the canonical location");
860
+ canonical = await pickCanonical(getRl(), locations);
861
+ }
862
+ let sources;
863
+ if (args.allOthers)
864
+ sources = locations.filter((l) => l.key !== canonical.key);
865
+ else if (args.sources) {
866
+ sources = args.sources.map((k) => matchLocation(k, locations)).filter((l) => l.key !== canonical.key);
867
+ } else {
868
+ requireTty("Choosing source locations");
869
+ sources = await pickSources(getRl(), locations, canonical);
870
+ }
871
+ let linkMode;
872
+ if (args.link)
873
+ linkMode = true;
874
+ else if (interactive)
875
+ linkMode = await pickLinkMode(getRl());
876
+ else
877
+ linkMode = false;
878
+ process.stdout.write(`
879
+ ` + c.bold("Selection:") + `
880
+ `);
881
+ process.stdout.write(` Canonical: ${c.cyan(canonical.key)}
882
+ `);
883
+ process.stdout.write(` Sources: ${sources.length ? sources.map((s) => c.blue(s.key)).join(", ") : c.dim("(none)")}
884
+ `);
885
+ process.stdout.write(` Mode: ${linkMode ? c.red("MERGE + LINK (destructive)") : c.green("MERGE only")}
886
+ `);
887
+ if (sources.length === 0)
888
+ process.stdout.write(c.yellow(`
889
+ No source locations differ from canonical — nothing to merge. (This is a valid no-op.)
890
+ `));
891
+ const plan = buildPlan(base, canonical, sources, linkMode);
892
+ printPlan(plan, linkMode);
893
+ if (!args.apply) {
894
+ process.stdout.write(`
895
+ ` + c.bold(c.yellow("DRY RUN — no filesystem changes were made.")) + `
896
+ `);
897
+ process.stdout.write(c.dim(`Re-run with --apply to execute the plan above.
898
+ `));
899
+ levelDbReport(base, plan);
900
+ return;
901
+ }
902
+ const hasWork = plan.sourcePlans.some((sp) => sp.copies.length > 0) || (plan.spacesMerge?.willWrite ?? false) || plan.linkPlans.some((lp) => !lp.alreadySymlink);
903
+ if (!hasWork) {
904
+ process.stdout.write(`
905
+ ` + c.green("Nothing to apply — plan is a no-op. Done.") + `
906
+ `);
907
+ levelDbReport(base, plan);
908
+ return;
909
+ }
910
+ if (linkMode)
911
+ process.stdout.write(`
912
+ ` + c.red(c.bold("⚠ DESTRUCTIVE: LINK will back up and REPLACE source org dirs with symlinks.")) + `
913
+ ` + c.dim(" A ") + c.cyan("*.bak-<timestamp>") + c.dim(" copy of each dir is kept — reverse it anytime with ") + c.bold("--revert") + c.dim(`.
914
+ `) + c.yellow(` QUIT the Claude desktop app now, before proceeding.
915
+ `));
916
+ if (!args.yes) {
917
+ requireTty("Confirmation");
918
+ const ok = await confirm(getRl(), linkMode ? "Proceed with MERGE + LINK?" : "Proceed with MERGE?");
919
+ if (!ok) {
920
+ process.stdout.write(c.yellow(`Aborted — no changes made.
921
+ `));
922
+ return;
923
+ }
924
+ }
925
+ const result = executePlan(plan, linkMode);
926
+ process.stdout.write(`
927
+ ` + c.bold("SUMMARY") + `
928
+ `);
929
+ process.stdout.write(` Copied: ${c.green(String(result.copiedFiles))} file(s), ${c.green(String(result.copiedDirs))} dir(s)
930
+ `);
931
+ process.stdout.write(` spaces.json: ${result.spacesWritten ? c.green("written") : c.dim("unchanged")}
932
+ `);
933
+ if (linkMode)
934
+ process.stdout.write(` Linked: ${c.green(String(result.linked))} dir(s) symlinked, ${result.backedUp} backed up
935
+ `);
936
+ if (linkMode && result.linked > 0)
937
+ process.stdout.write(c.dim(" Undo anytime with ") + c.bold("--revert --apply") + c.dim(` (restores the *.bak-<ts> backups).
938
+ `));
939
+ if (result.errors.length) {
940
+ process.stdout.write(c.red(` ${result.errors.length} error(s):
941
+ `));
942
+ for (const e of result.errors)
943
+ process.stdout.write(c.red(` - ${e}
944
+ `));
945
+ }
946
+ levelDbReport(base, plan);
947
+ process.stdout.write(`
948
+ ` + c.cyan("Reopen the Claude desktop app to see the merged sessions.") + `
949
+ `);
950
+ } finally {
951
+ if (rl)
952
+ rl.close();
953
+ closeLogging();
954
+ }
955
+ }
956
+ main().catch((err) => {
957
+ process.stderr.write(c.red(`
958
+ unexpected error: ${err.stack || err}`) + `
959
+ `);
960
+ process.exit(1);
961
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "claude-desktop-merge",
3
+ "version": "1.0.0",
4
+ "description": "Merge & sync Claude desktop-app sessions across accounts (macOS, zero-dependency, dry-run by default).",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-desktop",
8
+ "sessions",
9
+ "merge",
10
+ "accounts",
11
+ "macos",
12
+ "cli"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "os": [
17
+ "darwin"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/sebryu/claude-desktop-merge.git"
25
+ },
26
+ "homepage": "https://github.com/sebryu/claude-desktop-merge#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/sebryu/claude-desktop-merge/issues"
29
+ },
30
+ "bin": {
31
+ "claude-desktop-merge": "dist/claude-desktop-merge.mjs"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "bun build ./claude-desktop-merge.ts --target=node --outfile dist/claude-desktop-merge.mjs && chmod +x dist/claude-desktop-merge.mjs",
40
+ "prepublishOnly": "bun run build",
41
+ "start": "bun claude-desktop-merge.ts"
42
+ }
43
+ }