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 +21 -0
- package/README.md +188 -0
- package/dist/claude-desktop-merge.mjs +961 -0
- package/package.json +43 -0
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
|
+
}
|