agent-decoder 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- clau_decode/watcher.py +63 -0
clau_decode/migrate.py
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
"""``clau-decode migrate`` -- merge & relocate Claude Code chat history + configs.
|
|
2
|
+
|
|
3
|
+
Claude Code keys every session to the *absolute path* of its project directory.
|
|
4
|
+
That path is baked into four places:
|
|
5
|
+
1. the folder name under ``<config>/projects/`` (path with every non-alnum char -> '-')
|
|
6
|
+
2. a ``cwd`` field inside each ``*.jsonl`` line (and nested subagent transcripts)
|
|
7
|
+
3. the ``projects`` keys in ``~/.claude.json``
|
|
8
|
+
4. the ``projects`` keys in ``<config>/.claude.json``
|
|
9
|
+
So when code moves to a new path -- or you're consolidating several Claude config
|
|
10
|
+
trees (a vanilla ``~/.claude`` plus mirrored profiles) onto one machine -- copying
|
|
11
|
+
files verbatim leaves chats that still *view* but won't *resume*: the session folder
|
|
12
|
+
no longer matches and the recorded ``cwd`` no longer exists to ``chdir`` into.
|
|
13
|
+
|
|
14
|
+
This merges one or more source config trees into a destination ``~/.claude``, rewriting
|
|
15
|
+
a path prefix as it goes. The folder encoding was verified as ``[^a-zA-Z0-9] -> '-'``;
|
|
16
|
+
it is lossy, so the true path is always read from a session's ``cwd``, never decoded
|
|
17
|
+
from a folder name.
|
|
18
|
+
|
|
19
|
+
Pure standard library, no web-server imports -- so this module also runs standalone
|
|
20
|
+
(``python3 migrate.py --help``) and is unit-testable in isolation.
|
|
21
|
+
|
|
22
|
+
Safety: dry-run by default; ``--apply`` needs ``--backup`` (make one now) or
|
|
23
|
+
``--i-have-a-backup`` (the guided wizard offers to make a backup automatically);
|
|
24
|
+
non-destructive (a differing destination file is never overwritten -- the incoming
|
|
25
|
+
copy is written as a ``.from-<source>`` sidecar); ``.claude.json``/``history.jsonl``
|
|
26
|
+
writes are atomic and file contents are never printed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import shlex
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from collections import Counter
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
# File extensions treated as text for path rewriting (everything else copied
|
|
44
|
+
# verbatim, so binaries are never corrupted). Covers session JSONL plus cached
|
|
45
|
+
# tool-result/.txt/.json blobs the harness stores under a project dir.
|
|
46
|
+
_TEXT_EXTS = {".jsonl", ".json", ".ndjson", ".txt", ".md", ".log", ".yaml", ".yml"}
|
|
47
|
+
|
|
48
|
+
# Derived / vendored dirs that should never travel with a config merge.
|
|
49
|
+
_SKIP_DIRS = {
|
|
50
|
+
"node_modules",
|
|
51
|
+
".git",
|
|
52
|
+
"__pycache__",
|
|
53
|
+
".venv",
|
|
54
|
+
"venv",
|
|
55
|
+
".cache",
|
|
56
|
+
"dist",
|
|
57
|
+
"build",
|
|
58
|
+
".pytest_cache",
|
|
59
|
+
".mypy_cache",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Human-config items merged from each source (non-destructively).
|
|
63
|
+
_CONFIG_FILES = ("CLAUDE.md", "settings.json")
|
|
64
|
+
_CONFIG_DIRS = ("commands", "skills", "agents", "hooks", "memory")
|
|
65
|
+
|
|
66
|
+
# Vendored/scratch dirs excluded when staging a capture bundle — regenerated or
|
|
67
|
+
# reinstalled on the new machine (gstack, plugins, caches, …), so they don't travel.
|
|
68
|
+
_CAPTURE_EXCLUDES = (
|
|
69
|
+
"node_modules",
|
|
70
|
+
".git",
|
|
71
|
+
"__pycache__",
|
|
72
|
+
"*.pyc",
|
|
73
|
+
".venv",
|
|
74
|
+
"venv",
|
|
75
|
+
".cache",
|
|
76
|
+
"cache",
|
|
77
|
+
"plugins",
|
|
78
|
+
"logs",
|
|
79
|
+
"paste-cache",
|
|
80
|
+
"shell-snapshots",
|
|
81
|
+
"session-env",
|
|
82
|
+
"file-history",
|
|
83
|
+
"backups",
|
|
84
|
+
".DS_Store",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_NON_ALNUM = re.compile(r"[^a-zA-Z0-9]")
|
|
88
|
+
|
|
89
|
+
# The runbook travels INSIDE the bundle (written by --capture), so it can never be
|
|
90
|
+
# lost as a loose file. It documents the no-LLM, one-command flow end to end.
|
|
91
|
+
_HANDOFF = """\
|
|
92
|
+
# Claude move bundle — runbook
|
|
93
|
+
|
|
94
|
+
This bundle was staged by `clau-decode migrate --capture` on the OLD machine.
|
|
95
|
+
It carries your Claude chat history + human configs from both `~/.claude` (native)
|
|
96
|
+
and `~/.cc-mirror/crad/config` (crad), plus a copy of the migrate tool itself.
|
|
97
|
+
|
|
98
|
+
## On the NEW machine
|
|
99
|
+
|
|
100
|
+
1. Copy this whole folder over, then from inside it run ONE of:
|
|
101
|
+
clau-decode migrate # if clau-decode is installed
|
|
102
|
+
python3 migrate.py # install-free (uses the bundled copy)
|
|
103
|
+
It auto-detects the **merge** step and folds everything into native `~/.claude`.
|
|
104
|
+
|
|
105
|
+
2. When asked for a mode, choose **[V]erbatim+symlinks** (the default/recommended).
|
|
106
|
+
This does NOT rewrite paths — so it can never cobble (`/Dev/Dev/...`) or conflate
|
|
107
|
+
your work and personal trees. At the end the wizard writes **`restore-paths.sh`**
|
|
108
|
+
into this bundle and offers to run it. That script does BOTH halves of the restore:
|
|
109
|
+
|
|
110
|
+
# 1. bridge the old roots onto this machine (sudo)
|
|
111
|
+
sudo ln -s ~/ExternalDrive-archive /Volumes/ExternalDrive # external media
|
|
112
|
+
sudo ln -s /Users/<newuser> /Users/<olduser> # username change
|
|
113
|
+
# 2. recreate EVERY project's full working directory under those bridges
|
|
114
|
+
mkdir -p "/Volumes/ExternalDrive/Dev/my-project" # …one per session
|
|
115
|
+
|
|
116
|
+
The second half is the part a root symlink alone misses: `claude --resume` does a
|
|
117
|
+
`chdir` into each session's *full* recorded path, so bridging `/Volumes/ExternalDrive` to an
|
|
118
|
+
empty archive still fails ("working directory no longer exists") until that deep
|
|
119
|
+
path exists. Let the wizard run `restore-paths.sh` (or run it yourself); then `cd`
|
|
120
|
+
into any project's original path and `claude --resume` lists its sessions. Resume
|
|
121
|
+
works with an empty dir — drop the actual code back in only for projects you'll
|
|
122
|
+
keep coding in.
|
|
123
|
+
|
|
124
|
+
3. Only pick **[R]ewrite one prefix** if EVERY chat lived under a single root and you
|
|
125
|
+
want clean native paths. It asks for FROM/TO and rewrites both the folder names
|
|
126
|
+
and the `cwd` inside each transcript.
|
|
127
|
+
|
|
128
|
+
## Backups & re-runs are safe
|
|
129
|
+
|
|
130
|
+
If this machine already has Claude data, the wizard checks for a backup and offers
|
|
131
|
+
to make one (a timestamped `~/.claude.backup-<stamp>`) before it writes anything —
|
|
132
|
+
you don't need to prepare one yourself. The merge is also non-destructive and
|
|
133
|
+
idempotent: existing files are never overwritten, session collisions keep the longer
|
|
134
|
+
(complete) transcript under the real `<uuid>.jsonl` name, and re-running changes
|
|
135
|
+
nothing. Your destination's own chats are preserved.
|
|
136
|
+
|
|
137
|
+
## Configs
|
|
138
|
+
|
|
139
|
+
`commands/ skills/ agents/ hooks/ memory/` are UNION-merged. The singletons
|
|
140
|
+
`CLAUDE.md` and `settings.json` are first-wins: any differing version lands beside the
|
|
141
|
+
live file as `<name>.from-<source>` for you to hand-merge. After migrating, check:
|
|
142
|
+
|
|
143
|
+
cat ~/.claude/CLAUDE.md | head # is this the content you want active?
|
|
144
|
+
ls ~/.claude/*.from-* 2>/dev/null # anything parked to reconcile?
|
|
145
|
+
|
|
146
|
+
## Secrets do NOT travel
|
|
147
|
+
|
|
148
|
+
`.credentials.json` and caches are excluded by design — re-auth Claude on the new
|
|
149
|
+
machine.
|
|
150
|
+
|
|
151
|
+
## After the merge
|
|
152
|
+
|
|
153
|
+
If you use clau-decode: run `clau-decode --force-refresh`, then relaunch. Migrated
|
|
154
|
+
files are copied verbatim and keep their old mtimes, so an incremental scan skips
|
|
155
|
+
them; --force-refresh re-parses every session file. Do NOT delete the index DB —
|
|
156
|
+
that path moved (it now lives under ~/.local/share, not ~/.cache) and the DB holds
|
|
157
|
+
non-regenerable state (stars, archives, custom titles) that a delete would discard.
|
|
158
|
+
"""
|
|
159
|
+
# Characters that could *continue* a longer volume/path component; if the prefix
|
|
160
|
+
# is immediately followed by one of these it is NOT a standalone match (so
|
|
161
|
+
# ``/Volumes/ExternalDriveCard`` is left alone while ``cd /Volumes/ExternalDrive &&`` rewrites).
|
|
162
|
+
_CONT_CHARS = frozenset(
|
|
163
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def encode(path: str) -> str:
|
|
168
|
+
"""Claude's project-folder encoding: every non-alphanumeric char -> '-'."""
|
|
169
|
+
return _NON_ALNUM.sub("-", path)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def rewrite_prefix(text: str, frm: str, to: str) -> tuple[str, int]:
|
|
173
|
+
"""Replace ``frm`` with ``to`` at path boundaries. Returns (new_text, count)."""
|
|
174
|
+
if not frm or frm not in text:
|
|
175
|
+
return text, 0
|
|
176
|
+
out: list[str] = []
|
|
177
|
+
i = 0
|
|
178
|
+
n = len(frm)
|
|
179
|
+
count = 0
|
|
180
|
+
while True:
|
|
181
|
+
j = text.find(frm, i)
|
|
182
|
+
if j == -1:
|
|
183
|
+
out.append(text[i:])
|
|
184
|
+
break
|
|
185
|
+
nxt = text[j + n] if j + n < len(text) else ""
|
|
186
|
+
if nxt not in _CONT_CHARS:
|
|
187
|
+
out.append(text[i:j])
|
|
188
|
+
out.append(to)
|
|
189
|
+
count += 1
|
|
190
|
+
i = j + n
|
|
191
|
+
else: # part of a longer name (e.g. /Volumes/ExternalDriveCard) -- keep verbatim
|
|
192
|
+
out.append(text[i : j + n])
|
|
193
|
+
i = j + n
|
|
194
|
+
return "".join(out), count
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def remap_key(key: str, frm: str, to: str) -> str:
|
|
198
|
+
"""Rewrite a .claude.json project-path key (exact or prefixed)."""
|
|
199
|
+
if not frm:
|
|
200
|
+
return key
|
|
201
|
+
if key == frm:
|
|
202
|
+
return to
|
|
203
|
+
if key.startswith(frm + "/"):
|
|
204
|
+
return to + key[len(frm) :]
|
|
205
|
+
return key
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def read_first_cwd(project_dir: Path) -> str | None:
|
|
209
|
+
"""The true working-directory path for a project dir, read from its JSONL.
|
|
210
|
+
|
|
211
|
+
Searches recursively: a project dir may carry only nested subagent
|
|
212
|
+
transcripts (``<session>/subagents/agent-*.jsonl``) and no top-level file.
|
|
213
|
+
"""
|
|
214
|
+
for jf in sorted(project_dir.rglob("*.jsonl")):
|
|
215
|
+
try:
|
|
216
|
+
with jf.open(errors="replace") as fh:
|
|
217
|
+
for line in fh:
|
|
218
|
+
if '"cwd"' not in line:
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
obj = json.loads(line)
|
|
222
|
+
except Exception:
|
|
223
|
+
continue
|
|
224
|
+
if isinstance(obj, dict) and obj.get("cwd"):
|
|
225
|
+
return obj["cwd"]
|
|
226
|
+
except OSError:
|
|
227
|
+
continue
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
232
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
233
|
+
tmp.write_text(text)
|
|
234
|
+
if path.exists():
|
|
235
|
+
shutil.copymode(path, tmp)
|
|
236
|
+
os.replace(tmp, path)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def files_equal(a: Path, b: Path) -> bool:
|
|
240
|
+
try:
|
|
241
|
+
if a.stat().st_size != b.stat().st_size:
|
|
242
|
+
return False
|
|
243
|
+
return a.read_bytes() == b.read_bytes()
|
|
244
|
+
except OSError:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _pick_superset(dest_text: str, new_text: str) -> tuple[str, str]:
|
|
249
|
+
"""Pick the more complete of two differing versions of the same append-only
|
|
250
|
+
session log. Claude session JSONLs only ever grow (lines are appended), so the
|
|
251
|
+
older version is a line-prefix of the newer — keep the longer under the canonical
|
|
252
|
+
``<uuid>.jsonl`` name (a ``.dup`` rename would be invisible to ``claude --resume``,
|
|
253
|
+
which keys off the filename UUID). Returns (winner_text, why) where why is
|
|
254
|
+
``incoming-extends`` / ``dest-extends`` / ``divergent`` (no prefix relationship —
|
|
255
|
+
essentially never, since session UUIDs are unique; keep the longer and flag it)."""
|
|
256
|
+
d = dest_text.splitlines()
|
|
257
|
+
n = new_text.splitlines()
|
|
258
|
+
if n[: len(d)] == d: # incoming continues dest → incoming is newer
|
|
259
|
+
return new_text, "incoming-extends"
|
|
260
|
+
if d[: len(n)] == n: # dest continues incoming → dest is newer
|
|
261
|
+
return dest_text, "dest-extends"
|
|
262
|
+
return (new_text if len(n) >= len(d) else dest_text), "divergent"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Report:
|
|
266
|
+
def __init__(self) -> None:
|
|
267
|
+
self.lines: list[str] = []
|
|
268
|
+
self.counts: dict[str, int] = {}
|
|
269
|
+
|
|
270
|
+
def bump(self, key: str, by: int = 1) -> None:
|
|
271
|
+
self.counts[key] = self.counts.get(key, 0) + by
|
|
272
|
+
|
|
273
|
+
def note(self, msg: str) -> None:
|
|
274
|
+
self.lines.append(msg)
|
|
275
|
+
|
|
276
|
+
def render(self) -> str:
|
|
277
|
+
out = ["", "=== summary ==="]
|
|
278
|
+
for k in sorted(self.counts):
|
|
279
|
+
out.append(f" {k}: {self.counts[k]}")
|
|
280
|
+
if self.lines:
|
|
281
|
+
out.append("")
|
|
282
|
+
out.append("=== notes (first 40) ===")
|
|
283
|
+
out.extend(" " + ln for ln in self.lines[:40])
|
|
284
|
+
if len(self.lines) > 40:
|
|
285
|
+
out.append(f" ... and {len(self.lines) - 40} more")
|
|
286
|
+
return "\n".join(out)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def merge_projects(
|
|
290
|
+
source_label: str,
|
|
291
|
+
source_projects: Path,
|
|
292
|
+
dest_projects: Path,
|
|
293
|
+
frm: str,
|
|
294
|
+
to: str,
|
|
295
|
+
apply: bool,
|
|
296
|
+
rep: Report,
|
|
297
|
+
) -> None:
|
|
298
|
+
if not source_projects.is_dir():
|
|
299
|
+
return
|
|
300
|
+
for sdir in sorted(p for p in source_projects.iterdir() if p.is_dir()):
|
|
301
|
+
# Copy the WHOLE project subtree, not just top-level *.jsonl: project dirs
|
|
302
|
+
# can hold nested subagent transcripts at <session>/subagents/agent-*.jsonl.
|
|
303
|
+
all_files = [
|
|
304
|
+
p
|
|
305
|
+
for p in sdir.rglob("*")
|
|
306
|
+
if p.is_file() and not p.is_symlink() and p.name != ".DS_Store"
|
|
307
|
+
]
|
|
308
|
+
if not all_files:
|
|
309
|
+
rep.bump("project-dirs-empty-skipped")
|
|
310
|
+
continue
|
|
311
|
+
cwd = read_first_cwd(sdir)
|
|
312
|
+
if cwd and (cwd == frm or cwd.startswith(frm + "/")):
|
|
313
|
+
new_cwd = to + cwd[len(frm) :]
|
|
314
|
+
target_name = encode(new_cwd)
|
|
315
|
+
rewrite = True
|
|
316
|
+
else:
|
|
317
|
+
target_name = sdir.name # passthrough (view-only on the new machine)
|
|
318
|
+
rewrite = False
|
|
319
|
+
if cwd:
|
|
320
|
+
rep.bump("project-dirs-passthrough-not-under-from")
|
|
321
|
+
if rewrite and target_name != sdir.name:
|
|
322
|
+
rep.bump("project-dirs-rewritten")
|
|
323
|
+
if len(rep.lines) < 40:
|
|
324
|
+
rep.note(f"[{source_label}] {sdir.name}\n -> {target_name}")
|
|
325
|
+
target_dir = dest_projects / target_name
|
|
326
|
+
for sf in all_files:
|
|
327
|
+
rel = sf.relative_to(sdir)
|
|
328
|
+
dest_file = target_dir / rel
|
|
329
|
+
is_session = sf.suffix == ".jsonl"
|
|
330
|
+
top_level_session = is_session and rel.parent == Path(".")
|
|
331
|
+
text_mode = sf.suffix in _TEXT_EXTS
|
|
332
|
+
|
|
333
|
+
# Compute the content we WOULD write (rewritten for text files), so the
|
|
334
|
+
# comparison against an existing dest is apples-to-apples even on a
|
|
335
|
+
# re-run — comparing raw source bytes to already-rewritten dest bytes
|
|
336
|
+
# would spuriously "differ" every time.
|
|
337
|
+
new_text: str | None = None
|
|
338
|
+
if text_mode:
|
|
339
|
+
new_text = sf.read_text(errors="replace")
|
|
340
|
+
if frm:
|
|
341
|
+
new_text, _ = rewrite_prefix(new_text, frm, to)
|
|
342
|
+
|
|
343
|
+
if dest_file.exists():
|
|
344
|
+
if text_mode:
|
|
345
|
+
dest_text = dest_file.read_text(errors="replace")
|
|
346
|
+
if dest_text == new_text:
|
|
347
|
+
rep.bump("files-already-present-identical")
|
|
348
|
+
continue
|
|
349
|
+
if is_session:
|
|
350
|
+
# append-only logs: keep the longer (complete) version under
|
|
351
|
+
# the canonical <uuid>.jsonl — never a .dup (resume needs it).
|
|
352
|
+
winner, why = _pick_superset(dest_text, new_text)
|
|
353
|
+
if winner == dest_text:
|
|
354
|
+
rep.bump("sessions-kept-existing")
|
|
355
|
+
if why == "divergent":
|
|
356
|
+
rep.note(
|
|
357
|
+
f"DIVERGENT {target_name}/{rel.name} — kept existing (longer)"
|
|
358
|
+
)
|
|
359
|
+
continue
|
|
360
|
+
new_text = winner
|
|
361
|
+
rep.bump("sessions-updated-newer")
|
|
362
|
+
if why == "divergent":
|
|
363
|
+
rep.note(
|
|
364
|
+
f"DIVERGENT {target_name}/{rel.name} — took incoming (longer)"
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
# non-session text (e.g. a cached tool-result) differs —
|
|
368
|
+
# keep what's there; don't clobber, don't clutter.
|
|
369
|
+
rep.bump("subfiles-kept-existing")
|
|
370
|
+
continue
|
|
371
|
+
else:
|
|
372
|
+
if files_equal(sf, dest_file):
|
|
373
|
+
rep.bump("files-already-present-identical")
|
|
374
|
+
continue
|
|
375
|
+
rep.bump("subfiles-kept-existing") # binary differs → keep dest
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
rep.bump("sessions-merged" if top_level_session else "subfiles-merged")
|
|
379
|
+
if not apply:
|
|
380
|
+
continue
|
|
381
|
+
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
if text_mode:
|
|
383
|
+
dest_file.write_text(new_text)
|
|
384
|
+
else:
|
|
385
|
+
shutil.copy2(sf, dest_file)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _detect_indent(raw: str) -> int | None:
|
|
389
|
+
return 2 if re.search(r'\n\s{2,}"', raw) else None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def merge_claude_json(
|
|
393
|
+
dest_json: Path,
|
|
394
|
+
source_jsons: list[tuple[str, Path]],
|
|
395
|
+
frm: str,
|
|
396
|
+
to: str,
|
|
397
|
+
apply: bool,
|
|
398
|
+
rep: Report,
|
|
399
|
+
) -> None:
|
|
400
|
+
if dest_json.exists():
|
|
401
|
+
raw = dest_json.read_text()
|
|
402
|
+
base = json.loads(raw) if raw.strip() else {}
|
|
403
|
+
indent = _detect_indent(raw)
|
|
404
|
+
else:
|
|
405
|
+
base = {}
|
|
406
|
+
indent = None
|
|
407
|
+
if not isinstance(base, dict):
|
|
408
|
+
rep.note(f"SKIP {dest_json}: not a JSON object")
|
|
409
|
+
return
|
|
410
|
+
projects = base.setdefault("projects", {})
|
|
411
|
+
if not isinstance(projects, dict):
|
|
412
|
+
rep.note(f"SKIP {dest_json}: .projects is not an object")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
for label, sjson in source_jsons:
|
|
416
|
+
if not sjson.exists():
|
|
417
|
+
continue
|
|
418
|
+
try:
|
|
419
|
+
sdata = json.loads(sjson.read_text() or "{}")
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
rep.note(f"SKIP {label} json ({sjson}): {exc}")
|
|
422
|
+
continue
|
|
423
|
+
sprojects = sdata.get("projects")
|
|
424
|
+
if not isinstance(sprojects, dict):
|
|
425
|
+
continue
|
|
426
|
+
for key, val in sprojects.items():
|
|
427
|
+
nk = remap_key(key, frm, to)
|
|
428
|
+
if nk not in projects:
|
|
429
|
+
projects[nk] = val
|
|
430
|
+
rep.bump("claude-json-project-keys-added")
|
|
431
|
+
elif isinstance(projects[nk], dict) and isinstance(val, dict):
|
|
432
|
+
added = 0
|
|
433
|
+
for sk, sv in val.items():
|
|
434
|
+
if sk not in projects[nk]:
|
|
435
|
+
projects[nk][sk] = sv
|
|
436
|
+
added += 1
|
|
437
|
+
if added:
|
|
438
|
+
rep.bump("claude-json-subkeys-filled", added)
|
|
439
|
+
|
|
440
|
+
if apply:
|
|
441
|
+
text = json.dumps(
|
|
442
|
+
base,
|
|
443
|
+
ensure_ascii=False,
|
|
444
|
+
indent=indent,
|
|
445
|
+
separators=None if indent else (",", ":"),
|
|
446
|
+
)
|
|
447
|
+
atomic_write_text(dest_json, text)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def merge_history(
|
|
451
|
+
dest_hist: Path,
|
|
452
|
+
source_hists: list[Path],
|
|
453
|
+
frm: str,
|
|
454
|
+
to: str,
|
|
455
|
+
apply: bool,
|
|
456
|
+
rep: Report,
|
|
457
|
+
) -> None:
|
|
458
|
+
seen: set[str] = set()
|
|
459
|
+
rows: list[tuple[float, str]] = []
|
|
460
|
+
sources = ([dest_hist] if dest_hist.exists() else []) + [
|
|
461
|
+
h for h in source_hists if h.exists()
|
|
462
|
+
]
|
|
463
|
+
for h in sources:
|
|
464
|
+
try:
|
|
465
|
+
with h.open(errors="replace") as fh:
|
|
466
|
+
for line in fh:
|
|
467
|
+
line = line.rstrip("\n")
|
|
468
|
+
if not line.strip():
|
|
469
|
+
continue
|
|
470
|
+
if frm:
|
|
471
|
+
line, _ = rewrite_prefix(line, frm, to)
|
|
472
|
+
if line in seen:
|
|
473
|
+
continue
|
|
474
|
+
seen.add(line)
|
|
475
|
+
ts = 0.0
|
|
476
|
+
try:
|
|
477
|
+
obj = json.loads(line)
|
|
478
|
+
ts = float(obj.get("timestamp") or obj.get("ts") or 0)
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
rows.append((ts, line))
|
|
482
|
+
except OSError:
|
|
483
|
+
continue
|
|
484
|
+
rows.sort(key=lambda r: r[0])
|
|
485
|
+
rep.bump("history-lines-total", len(rows))
|
|
486
|
+
if apply and rows:
|
|
487
|
+
if dest_hist.exists():
|
|
488
|
+
backup = dest_hist.with_suffix(".jsonl.pre-merge.bak")
|
|
489
|
+
if not backup.exists():
|
|
490
|
+
shutil.copy2(dest_hist, backup)
|
|
491
|
+
atomic_write_text(dest_hist, "\n".join(r[1] for r in rows) + "\n")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def copy_file_safe(src: Path, dst: Path, label: str, apply: bool, rep: Report) -> None:
|
|
495
|
+
if not src.exists() or src.is_symlink():
|
|
496
|
+
return
|
|
497
|
+
if dst.exists():
|
|
498
|
+
if files_equal(src, dst):
|
|
499
|
+
return
|
|
500
|
+
sidecar = dst.with_name(dst.name + f".from-{label}")
|
|
501
|
+
rep.bump("config-file-conflict-sidecar")
|
|
502
|
+
rep.note(f"CONFLICT keep dest {dst.name}; wrote {sidecar.name} (review/merge)")
|
|
503
|
+
if apply:
|
|
504
|
+
shutil.copy2(src, sidecar)
|
|
505
|
+
return
|
|
506
|
+
rep.bump("config-files-copied")
|
|
507
|
+
if apply:
|
|
508
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
509
|
+
shutil.copy2(src, dst)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def union_dir_safe(src: Path, dst: Path, label: str, apply: bool, rep: Report) -> None:
|
|
513
|
+
"""Copy files missing from dst; sidecar on content conflict. Skips vendored
|
|
514
|
+
subtrees (node_modules, .git, caches) and symlinks."""
|
|
515
|
+
if not src.is_dir():
|
|
516
|
+
return
|
|
517
|
+
for root, dirs, files in os.walk(src):
|
|
518
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
|
|
519
|
+
root_p = Path(root)
|
|
520
|
+
for name in files:
|
|
521
|
+
if name == ".DS_Store":
|
|
522
|
+
continue
|
|
523
|
+
item = root_p / name
|
|
524
|
+
if item.is_symlink():
|
|
525
|
+
continue
|
|
526
|
+
copy_file_safe(item, dst / item.relative_to(src), label, apply, rep)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def merge_configs(
|
|
530
|
+
sources: list[Path], dest_dir: Path, apply: bool, rep: Report
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Union human-config items from each source into dest, non-destructively.
|
|
533
|
+
|
|
534
|
+
Sources are processed in the order given, so list higher-priority configs
|
|
535
|
+
first: the first source to provide a singleton (CLAUDE.md / settings.json)
|
|
536
|
+
populates dest; any later differing version becomes a ``.from-<label>`` sidecar.
|
|
537
|
+
"""
|
|
538
|
+
for src in sources:
|
|
539
|
+
label = src.name or "source"
|
|
540
|
+
for fname in _CONFIG_FILES:
|
|
541
|
+
copy_file_safe(src / fname, dest_dir / fname, label, apply, rep)
|
|
542
|
+
for dname in _CONFIG_DIRS:
|
|
543
|
+
union_dir_safe(src / dname, dest_dir / dname, label, apply, rep)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
# Self-driving wizard: auto-detect capture vs merge, auto-discover sources,
|
|
548
|
+
# infer the path prefix, and prompt only for the one thing we can't know (--to).
|
|
549
|
+
# All deterministic — no LLM. Power users can still pass explicit flags.
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
def _discover_sources(home: Path | None = None) -> list[Path]:
|
|
552
|
+
"""Claude config trees on this machine: ``~/.claude`` plus every
|
|
553
|
+
``~/.cc-mirror/*/config`` that actually holds a ``projects/`` dir."""
|
|
554
|
+
home = home or Path.home()
|
|
555
|
+
found: list[Path] = []
|
|
556
|
+
if (home / ".claude" / "projects").is_dir():
|
|
557
|
+
found.append(home / ".claude")
|
|
558
|
+
mirror = home / ".cc-mirror"
|
|
559
|
+
if mirror.is_dir():
|
|
560
|
+
for prof in sorted(p for p in mirror.iterdir() if p.is_dir()):
|
|
561
|
+
cfg = prof / "config"
|
|
562
|
+
if (cfg / "projects").is_dir():
|
|
563
|
+
found.append(cfg)
|
|
564
|
+
return found
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _longest_common_dir_prefix(paths: list[str]) -> str:
|
|
568
|
+
"""Longest shared leading directory path across absolute ``paths``."""
|
|
569
|
+
if not paths:
|
|
570
|
+
return ""
|
|
571
|
+
split = [[seg for seg in p.split("/") if seg] for p in paths]
|
|
572
|
+
common: list[str] = []
|
|
573
|
+
for col in zip(*split):
|
|
574
|
+
if all(seg == col[0] for seg in col):
|
|
575
|
+
common.append(col[0])
|
|
576
|
+
else:
|
|
577
|
+
break
|
|
578
|
+
return "/" + "/".join(common) if common else ""
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _infer_from_prefix(sources: list[Path]) -> str | None:
|
|
582
|
+
"""Infer the dominant absolute path prefix of project cwds across ``sources``
|
|
583
|
+
— the proposed ``--from``. Picks the most common depth-2 root (so a stray
|
|
584
|
+
``/Volumes/ExternalDriveCard`` or ``/private/tmp`` doesn't broaden it), then extends to
|
|
585
|
+
the longest common dir prefix within that root. None if no cwds found."""
|
|
586
|
+
cwds: list[str] = []
|
|
587
|
+
for src in sources:
|
|
588
|
+
projects = src / "projects"
|
|
589
|
+
if not projects.is_dir():
|
|
590
|
+
continue
|
|
591
|
+
for pdir in projects.iterdir():
|
|
592
|
+
if pdir.is_dir():
|
|
593
|
+
cwd = read_first_cwd(pdir)
|
|
594
|
+
if cwd:
|
|
595
|
+
cwds.append(cwd)
|
|
596
|
+
if not cwds:
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
def root2(p: str) -> str:
|
|
600
|
+
segs = [s for s in p.split("/") if s]
|
|
601
|
+
return "/" + "/".join(segs[:2]) if len(segs) >= 2 else p
|
|
602
|
+
|
|
603
|
+
best_root = Counter(root2(c) for c in cwds).most_common(1)[0][0]
|
|
604
|
+
under = [c for c in cwds if c == best_root or c.startswith(best_root + "/")]
|
|
605
|
+
return _longest_common_dir_prefix(under) or best_root
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _project_roots(sources: list[Path]) -> Counter[str]:
|
|
609
|
+
"""Distinct depth-2 absolute roots of project cwds across ``sources``, with a
|
|
610
|
+
count each — e.g. ``{'/Volumes/ExternalDrive': 44, '/Users/me': 3}``. This is what tells
|
|
611
|
+
us whether a single prefix rewrite can even work (one root) or whether the chats
|
|
612
|
+
span unrelated trees (many roots) and want verbatim + symlinks instead."""
|
|
613
|
+
roots: Counter[str] = Counter()
|
|
614
|
+
for src in sources:
|
|
615
|
+
projects = src / "projects"
|
|
616
|
+
if not projects.is_dir():
|
|
617
|
+
continue
|
|
618
|
+
for pdir in projects.iterdir():
|
|
619
|
+
if not pdir.is_dir():
|
|
620
|
+
continue
|
|
621
|
+
cwd = read_first_cwd(pdir)
|
|
622
|
+
if not cwd or not cwd.startswith("/"):
|
|
623
|
+
continue
|
|
624
|
+
segs = [s for s in cwd.split("/") if s]
|
|
625
|
+
root = "/" + "/".join(segs[:2]) if len(segs) >= 2 else "/" + "".join(segs)
|
|
626
|
+
roots[root] += 1
|
|
627
|
+
return roots
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _is_throwaway_root(root: str) -> bool:
|
|
631
|
+
"""True for roots no resume should depend on (``/``, ``/private``, ``/tmp``,
|
|
632
|
+
``/var``) — created fresh per run, never worth bridging or recreating."""
|
|
633
|
+
segs = [s for s in root.split("/") if s]
|
|
634
|
+
return not segs or root == "/" or segs[0] in ("private", "tmp", "var")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _root_bridges(
|
|
638
|
+
roots: Counter[str], home: Path | None = None
|
|
639
|
+
) -> list[tuple[str, str, str]]:
|
|
640
|
+
"""``(link, target, why)`` for each old project root that must resolve on THIS
|
|
641
|
+
machine so verbatim-migrated chats stay resumable. ``/Users/<old>`` roots bridge
|
|
642
|
+
to the current home; other roots (external media like ``/Volumes/ExternalDrive``) bridge to a
|
|
643
|
+
local ``<name>-archive`` dir. Throwaway roots are skipped. A same-username
|
|
644
|
+
``/Users/<me>`` root needs no bridge (it already resolves) — but its session
|
|
645
|
+
*leaf* dirs may still be missing, which ``_project_cwds`` + the restore script
|
|
646
|
+
handle separately."""
|
|
647
|
+
home = home or Path.home()
|
|
648
|
+
user = home.name
|
|
649
|
+
out: list[tuple[str, str, str]] = []
|
|
650
|
+
for root in sorted(roots):
|
|
651
|
+
if _is_throwaway_root(root):
|
|
652
|
+
continue
|
|
653
|
+
segs = [s for s in root.split("/") if s]
|
|
654
|
+
if segs[0] == "Users" and len(segs) >= 2:
|
|
655
|
+
old_user = segs[1]
|
|
656
|
+
if old_user != user:
|
|
657
|
+
out.append(
|
|
658
|
+
(
|
|
659
|
+
f"/Users/{old_user}",
|
|
660
|
+
str(home),
|
|
661
|
+
f"old username '{old_user}' -> '{user}'",
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
# same username: root already resolves, no bridge needed.
|
|
665
|
+
else:
|
|
666
|
+
archive = home / (segs[-1] + "-archive")
|
|
667
|
+
out.append((root, str(archive), f"old media path '{root}'"))
|
|
668
|
+
return out
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _project_cwds(sources: list[Path]) -> list[str]:
|
|
672
|
+
"""Every distinct real project ``cwd`` across ``sources`` — the directories that
|
|
673
|
+
must EXIST for ``claude --resume`` to ``chdir`` into. Throwaway roots are dropped.
|
|
674
|
+
Bridging a root (``/Volumes/ExternalDrive``) is not enough on its own: the session's *full*
|
|
675
|
+
path must exist under it, so these are what the restore script recreates."""
|
|
676
|
+
seen: set[str] = set()
|
|
677
|
+
for src in sources:
|
|
678
|
+
projects = src / "projects"
|
|
679
|
+
if not projects.is_dir():
|
|
680
|
+
continue
|
|
681
|
+
for pdir in projects.iterdir():
|
|
682
|
+
if not pdir.is_dir():
|
|
683
|
+
continue
|
|
684
|
+
cwd = read_first_cwd(pdir)
|
|
685
|
+
if not cwd or not cwd.startswith("/") or _is_throwaway_root(cwd):
|
|
686
|
+
continue
|
|
687
|
+
seen.add(cwd)
|
|
688
|
+
return sorted(seen)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _cobble_examples(
|
|
692
|
+
frm: str, to: str, cwds: list[str], limit: int = 6
|
|
693
|
+
) -> list[tuple[str, str]]:
|
|
694
|
+
"""``(original_cwd, cobbled_result)`` for cwds a ``frm -> to`` rewrite would
|
|
695
|
+
cobble — i.e. produce a doubled consecutive path segment (``/Dev/Work/Dev/…``,
|
|
696
|
+
``/Work/Work/…``) that wasn't in the original. This is exactly the mistake that
|
|
697
|
+
happens when ``to`` ends with a segment that also begins the path remainder under
|
|
698
|
+
``frm`` (e.g. ``--from /Volumes/ExternalDrive --to ~/Dev/Work`` over
|
|
699
|
+
``/Volumes/ExternalDrive/Work/x``). Empty when the rewrite is clean."""
|
|
700
|
+
if not frm or not to:
|
|
701
|
+
return []
|
|
702
|
+
|
|
703
|
+
def doubled(path: str) -> bool:
|
|
704
|
+
segs = [s for s in path.split("/") if s]
|
|
705
|
+
return any(a == b for a, b in zip(segs, segs[1:]))
|
|
706
|
+
|
|
707
|
+
out: list[tuple[str, str]] = []
|
|
708
|
+
for cwd in cwds:
|
|
709
|
+
if cwd != frm and not cwd.startswith(frm + "/"):
|
|
710
|
+
continue
|
|
711
|
+
new = to + cwd[len(frm) :]
|
|
712
|
+
if doubled(new) and not doubled(cwd):
|
|
713
|
+
out.append((cwd, new))
|
|
714
|
+
if len(out) >= limit:
|
|
715
|
+
break
|
|
716
|
+
return out
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _restore_script(
|
|
720
|
+
roots: Counter[str], cwds: list[str], home: Path | None = None
|
|
721
|
+
) -> str:
|
|
722
|
+
"""A self-contained, idempotent bash script that makes verbatim-migrated sessions
|
|
723
|
+
resumable: (1) bridge each old root onto this machine, (2) recreate every project
|
|
724
|
+
``cwd`` under it, then (3) link each session into its bridge-resolved project dir.
|
|
725
|
+
Step 2 is the piece a root symlink alone misses — an empty bridged archive has none
|
|
726
|
+
of the deep session paths, so resume still fails. Step 3 is the piece verbatim
|
|
727
|
+
migration alone misses — ``claude`` resolves symlinks in ``getcwd()``, so a chat
|
|
728
|
+
recorded under ``/Volumes/SD/...`` is looked up under the *bridge target's* path
|
|
729
|
+
encoding, not the original; without the link, ``--resume`` reports "No conversation
|
|
730
|
+
found". Guards make it safe to re-run and keep it from clobbering a real mounted volume."""
|
|
731
|
+
bridges = _root_bridges(roots, home)
|
|
732
|
+
lines = [
|
|
733
|
+
"#!/usr/bin/env bash",
|
|
734
|
+
"# Generated by `clau-decode migrate`. Makes verbatim-migrated Claude sessions",
|
|
735
|
+
"# resumable: bridges old project roots, recreates every project working directory",
|
|
736
|
+
"# under them (resume needs the dir to EXIST, not the code), then links each session",
|
|
737
|
+
"# into its bridge-resolved project dir so `claude --resume` finds it. Idempotent.",
|
|
738
|
+
"set -e",
|
|
739
|
+
"",
|
|
740
|
+
]
|
|
741
|
+
if bridges:
|
|
742
|
+
lines.append("# 1. Bridge old project roots onto this machine (needs sudo).")
|
|
743
|
+
for link, target, why in bridges:
|
|
744
|
+
ql, qt = shlex.quote(link), shlex.quote(target)
|
|
745
|
+
lines.append(f"mkdir -p {qt}")
|
|
746
|
+
lines.append(
|
|
747
|
+
f"[ -e {ql} ] || [ -L {ql} ] || sudo ln -s {qt} {ql} # {why}"
|
|
748
|
+
)
|
|
749
|
+
lines.append("")
|
|
750
|
+
lines += [
|
|
751
|
+
"# 2. Ensure each session's working directory exists (resolves through the",
|
|
752
|
+
"# bridges). A cwd that ALREADY resolves to real content (code you restored,",
|
|
753
|
+
"# e.g. an archive you copied back) is left untouched and counted as restored;",
|
|
754
|
+
"# only genuinely-missing paths are created as empty read-only placeholders.",
|
|
755
|
+
"restored=0 created=0",
|
|
756
|
+
"ensure() {",
|
|
757
|
+
' if [ -d "$1" ] && [ -n "$(ls -A "$1" 2>/dev/null)" ]; then',
|
|
758
|
+
" restored=$((restored+1)) # real code already here — not a ghost",
|
|
759
|
+
" else",
|
|
760
|
+
' mkdir -p "$1"; created=$((created+1))',
|
|
761
|
+
" fi",
|
|
762
|
+
"}",
|
|
763
|
+
]
|
|
764
|
+
for cwd in cwds:
|
|
765
|
+
lines.append(f"ensure {shlex.quote(cwd)}")
|
|
766
|
+
lines += [
|
|
767
|
+
'echo "restore-paths: $restored project(s) already had code (fully resumable),"\\',
|
|
768
|
+
' "$created created as empty placeholders (resume opens read-only until you"\\',
|
|
769
|
+
' "restore their code)."',
|
|
770
|
+
"",
|
|
771
|
+
"# 3. Make migrated sessions discoverable to `claude --resume`. claude resolves",
|
|
772
|
+
"# symlinks in getcwd(), so a chat recorded under an old root (e.g. /Volumes/SD)",
|
|
773
|
+
"# is looked up under the BRIDGE TARGET's path encoding, not the original. Link",
|
|
774
|
+
"# each session's JSONL from its stored project dir into the resolved-path one.",
|
|
775
|
+
"# (Encoding mirrors claude: every non-alphanumeric char becomes '-'.)",
|
|
776
|
+
'projects="$HOME/.claude/projects"',
|
|
777
|
+
"encode() { printf %s \"$1\" | sed 's/[^a-zA-Z0-9]/-/g'; }",
|
|
778
|
+
"relink() {",
|
|
779
|
+
' local cwd="$1" real src dst f',
|
|
780
|
+
' real="$(realpath "$cwd" 2>/dev/null)" || return 0',
|
|
781
|
+
' [ "$real" = "$cwd" ] && return 0 # path unchanged → no bridge in play',
|
|
782
|
+
' src="$projects/$(encode "$cwd")"; dst="$projects/$(encode "$real")"',
|
|
783
|
+
' [ -d "$src" ] || return 0',
|
|
784
|
+
' mkdir -p "$dst"',
|
|
785
|
+
' for f in "$src"/*.jsonl; do',
|
|
786
|
+
' [ -e "$f" ] || continue # no sessions in this dir',
|
|
787
|
+
' [ -e "$dst/$(basename "$f")" ] || ln -s "$f" "$dst/$(basename "$f")"',
|
|
788
|
+
" done",
|
|
789
|
+
"}",
|
|
790
|
+
]
|
|
791
|
+
for cwd in cwds:
|
|
792
|
+
lines.append(f"relink {shlex.quote(cwd)}")
|
|
793
|
+
lines += [
|
|
794
|
+
'echo "resume-index: linked migrated sessions into their bridge-resolved project dirs."',
|
|
795
|
+
"",
|
|
796
|
+
]
|
|
797
|
+
return "\n".join(lines)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _find_existing_backup(dest_dir: Path) -> Path | None:
|
|
801
|
+
"""Most recent sibling backup of ``dest_dir`` (``<name>.backup-*``), if any."""
|
|
802
|
+
name = dest_dir.name
|
|
803
|
+
backups = sorted(
|
|
804
|
+
(p for p in dest_dir.parent.glob(f"{name}.backup-*") if p.is_dir()),
|
|
805
|
+
reverse=True,
|
|
806
|
+
)
|
|
807
|
+
return backups[0] if backups else None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _backup_dest(dest_dir: Path, dest_json: Path) -> list[Path]:
|
|
811
|
+
"""Copy the destination config tree + its ``.claude.json`` to timestamped
|
|
812
|
+
siblings (caches/vendored dirs skipped, symlinks preserved). Returns the paths
|
|
813
|
+
created. This is the safety net the wizard offers in place of demanding the
|
|
814
|
+
user assert ``--i-have-a-backup``."""
|
|
815
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
816
|
+
ignore = shutil.ignore_patterns(*(set(_SKIP_DIRS) | set(_CAPTURE_EXCLUDES)))
|
|
817
|
+
made: list[Path] = []
|
|
818
|
+
if dest_dir.is_dir():
|
|
819
|
+
b = dest_dir.with_name(f"{dest_dir.name}.backup-{stamp}")
|
|
820
|
+
shutil.copytree(dest_dir, b, ignore=ignore, symlinks=True)
|
|
821
|
+
made.append(b)
|
|
822
|
+
if dest_json.is_file():
|
|
823
|
+
bj = dest_json.with_name(f"{dest_json.name}.backup-{stamp}")
|
|
824
|
+
shutil.copy2(dest_json, bj)
|
|
825
|
+
made.append(bj)
|
|
826
|
+
return made
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _detect_phase(bundle_dir: Path, dest_dir: Path) -> str:
|
|
830
|
+
"""Heuristic: ``merge`` only when a staged bundle is present AND the destination
|
|
831
|
+
``~/.claude`` is still sparse (new machine importing the bundle). A staged bundle
|
|
832
|
+
next to a *rich* ``~/.claude`` means the old machine already has one staged →
|
|
833
|
+
``capture`` (re-capture). No bundle at all → ``capture``. The wizard always
|
|
834
|
+
confirms, so a wrong guess is harmless."""
|
|
835
|
+
staged = any(
|
|
836
|
+
(bundle_dir / sub / "projects").is_dir()
|
|
837
|
+
for sub in ("native/dot-claude", "crad-config")
|
|
838
|
+
)
|
|
839
|
+
if not staged:
|
|
840
|
+
return "capture"
|
|
841
|
+
dest_projects = dest_dir / "projects"
|
|
842
|
+
dest_sessions = (
|
|
843
|
+
sum(1 for _ in dest_projects.glob("*/*.jsonl")) if dest_projects.is_dir() else 0
|
|
844
|
+
)
|
|
845
|
+
return "merge" if dest_sessions < 5 else "capture"
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _prompt(question: str, default: str = "") -> str:
|
|
849
|
+
suffix = f" [{default}]" if default else ""
|
|
850
|
+
ans = input(f"{question}{suffix}: ").strip()
|
|
851
|
+
return ans or default
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _confirm(question: str, default: bool = False) -> bool:
|
|
855
|
+
ans = input(f"{question} [{'Y/n' if default else 'y/N'}]: ").strip().lower()
|
|
856
|
+
return default if not ans else ans in ("y", "yes")
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _capture(args: argparse.Namespace) -> int:
|
|
860
|
+
"""Stage a transport bundle (phase 1, OLD machine). Read-only on sources."""
|
|
861
|
+
bundle: Path = args.bundle
|
|
862
|
+
home = Path.home()
|
|
863
|
+
native, native_json = home / ".claude", home / ".claude.json"
|
|
864
|
+
crad = home / ".cc-mirror" / "crad" / "config"
|
|
865
|
+
if not (native / "projects").is_dir() and not (crad / "projects").is_dir():
|
|
866
|
+
print("error: nothing to capture — no ~/.claude or ~/.cc-mirror/crad/config")
|
|
867
|
+
return 1
|
|
868
|
+
if not shutil.which("rsync"):
|
|
869
|
+
print("error: rsync is required for --capture")
|
|
870
|
+
return 1
|
|
871
|
+
|
|
872
|
+
excl: list[str] = []
|
|
873
|
+
for pat in _CAPTURE_EXCLUDES:
|
|
874
|
+
excl += ["--exclude", pat]
|
|
875
|
+
|
|
876
|
+
def _stage(src: Path, dst: Path) -> None:
|
|
877
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
878
|
+
subprocess.run(
|
|
879
|
+
["rsync", "-a", "--delete", *excl, f"{src}/", f"{dst}/"], check=True
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
print(f"Staging bundle at {bundle} (sources are READ-ONLY)…")
|
|
883
|
+
(bundle / "native").mkdir(parents=True, exist_ok=True)
|
|
884
|
+
if (native / "projects").is_dir():
|
|
885
|
+
print(" → ~/.claude")
|
|
886
|
+
_stage(native, bundle / "native" / "dot-claude")
|
|
887
|
+
if native_json.is_file():
|
|
888
|
+
shutil.copy2(native_json, bundle / "native" / "dot-claude.json")
|
|
889
|
+
if (crad / "projects").is_dir():
|
|
890
|
+
print(" → ~/.cc-mirror/crad/config")
|
|
891
|
+
_stage(crad, bundle / "crad-config")
|
|
892
|
+
# The bundle carries its own copy of this tool for the install-free merge,
|
|
893
|
+
# and a generated runbook so the instructions can never be lost as a loose file.
|
|
894
|
+
shutil.copy2(Path(__file__), bundle / "migrate.py")
|
|
895
|
+
atomic_write_text(bundle / "HANDOFF.md", _HANDOFF)
|
|
896
|
+
|
|
897
|
+
# Break the .jsonl files into the categories that actually matter, so the
|
|
898
|
+
# report can't be mistaken for a session count. A flat total counts nested
|
|
899
|
+
# <session>/subagents/*.jsonl and .bak backups alongside real conversations,
|
|
900
|
+
# which over-reports resumable sessions by ~4x and reads like data loss.
|
|
901
|
+
def _counts(p: Path) -> tuple[int, int, int]:
|
|
902
|
+
"""(resumable sessions, subagent transcripts, backups) under projects/."""
|
|
903
|
+
if not p.is_dir():
|
|
904
|
+
return (0, 0, 0)
|
|
905
|
+
sessions = subagents = backups = 0
|
|
906
|
+
for jf in p.rglob("*.jsonl"):
|
|
907
|
+
if ".bak." in jf.name:
|
|
908
|
+
backups += 1
|
|
909
|
+
elif jf.parent.name == "subagents":
|
|
910
|
+
subagents += 1
|
|
911
|
+
elif jf.parent.parent == p: # top-level <project>/<uuid>.jsonl
|
|
912
|
+
sessions += 1
|
|
913
|
+
else:
|
|
914
|
+
subagents += 1 # any other nested transcript
|
|
915
|
+
return (sessions, subagents, backups)
|
|
916
|
+
|
|
917
|
+
ns, nsub, nbak = _counts(bundle / "native" / "dot-claude" / "projects")
|
|
918
|
+
cs, csub, cbak = _counts(bundle / "crad-config" / "projects")
|
|
919
|
+
sessions, subagents, backups = ns + cs, nsub + csub, nbak + cbak
|
|
920
|
+
total = sessions + subagents + backups
|
|
921
|
+
print(
|
|
922
|
+
f"\nStaged: {sessions} resumable sessions (native {ns} + crad {cs})\n"
|
|
923
|
+
f" + {subagents} subagent transcripts, {backups} backups"
|
|
924
|
+
f" -> {total} files total"
|
|
925
|
+
)
|
|
926
|
+
print(
|
|
927
|
+
f"Carry '{bundle}' to the new machine, then run `clau-decode migrate` "
|
|
928
|
+
"there (it will detect the merge step). See HANDOFF.md in the bundle."
|
|
929
|
+
)
|
|
930
|
+
return 0
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _guided_merge(args: argparse.Namespace, bundle: Path) -> int:
|
|
934
|
+
"""Phase 2 (NEW machine): discover sources, infer --from, prompt for --to,
|
|
935
|
+
dry-run, confirm, apply."""
|
|
936
|
+
staged_native = bundle / "native" / "dot-claude"
|
|
937
|
+
staged_crad = bundle / "crad-config"
|
|
938
|
+
staged_json = bundle / "native" / "dot-claude.json"
|
|
939
|
+
if (staged_native / "projects").is_dir() or (staged_crad / "projects").is_dir():
|
|
940
|
+
sources = [p for p in (staged_native, staged_crad) if (p / "projects").is_dir()]
|
|
941
|
+
source_jsons = [staged_json] if staged_json.is_file() else []
|
|
942
|
+
print(f"Using staged bundle at {bundle}.")
|
|
943
|
+
else:
|
|
944
|
+
sources = _discover_sources()
|
|
945
|
+
hj = Path.home() / ".claude.json"
|
|
946
|
+
source_jsons = [hj] if hj.is_file() else []
|
|
947
|
+
print("No staged bundle found — merging local config trees directly.")
|
|
948
|
+
if not sources:
|
|
949
|
+
print("error: no Claude config trees found to merge.")
|
|
950
|
+
return 1
|
|
951
|
+
|
|
952
|
+
# Survey the roots the chats actually live under. One root → a single prefix
|
|
953
|
+
# rewrite can work cleanly. Many roots (e.g. /Volumes/ExternalDrive + /Users/<old>) → a
|
|
954
|
+
# single prefix WILL cobble paths (doubled segments, work/personal conflated),
|
|
955
|
+
# so verbatim + symlinks is the robust default.
|
|
956
|
+
roots = _project_roots(sources)
|
|
957
|
+
real_roots = [
|
|
958
|
+
r
|
|
959
|
+
for r in roots
|
|
960
|
+
if r not in ("/",) and not r.startswith(("/private", "/tmp", "/var"))
|
|
961
|
+
]
|
|
962
|
+
if roots:
|
|
963
|
+
print("\nProject paths span these roots:")
|
|
964
|
+
for r, n in roots.most_common():
|
|
965
|
+
print(f" {n:4d} {r}")
|
|
966
|
+
print(
|
|
967
|
+
"\nRecommended: migrate VERBATIM (no path rewrite) and bridge the old paths\n"
|
|
968
|
+
"with symlinks on this machine — robust across external media and a changed\n"
|
|
969
|
+
"username, and it can't cobble or conflate paths. A single-prefix rewrite is\n"
|
|
970
|
+
"only safe when every chat lives under ONE root."
|
|
971
|
+
)
|
|
972
|
+
default_mode = "V" if len(real_roots) != 1 else "V"
|
|
973
|
+
choice = (
|
|
974
|
+
_prompt(
|
|
975
|
+
"Mode: [V]erbatim+symlinks (recommended) or [R]ewrite one prefix",
|
|
976
|
+
default_mode,
|
|
977
|
+
)
|
|
978
|
+
.strip()
|
|
979
|
+
.lower()
|
|
980
|
+
)
|
|
981
|
+
frm = to = ""
|
|
982
|
+
if choice.startswith("r"):
|
|
983
|
+
inferred = _infer_from_prefix(sources)
|
|
984
|
+
if inferred:
|
|
985
|
+
print(f"Detected old project-path prefix: {inferred}")
|
|
986
|
+
frm = _prompt("Old path prefix to rewrite FROM", inferred or "")
|
|
987
|
+
if frm:
|
|
988
|
+
default_to = frm if Path(frm).is_dir() else ""
|
|
989
|
+
to = _prompt(
|
|
990
|
+
"Where does that code live on THIS machine now (rewrite TO)",
|
|
991
|
+
default_to,
|
|
992
|
+
)
|
|
993
|
+
if not to:
|
|
994
|
+
print("error: a TO prefix is required once a FROM prefix is set.")
|
|
995
|
+
return 2
|
|
996
|
+
# Cobble guard: a TO ending in a segment that also begins the path
|
|
997
|
+
# remainder under FROM doubles segments (/Dev/Work/Dev/…) and leaves every
|
|
998
|
+
# session pointing at a path that never existed. Show concrete examples and
|
|
999
|
+
# require explicit confirmation before going ahead.
|
|
1000
|
+
cobbles = _cobble_examples(frm, to, _project_cwds(sources))
|
|
1001
|
+
if cobbles:
|
|
1002
|
+
print(
|
|
1003
|
+
f"\n⚠ This rewrite would COBBLE paths — '{to}' duplicates a segment\n"
|
|
1004
|
+
f" already under '{frm}', producing doubled dirs that won't exist:"
|
|
1005
|
+
)
|
|
1006
|
+
for orig, new in cobbles:
|
|
1007
|
+
print(f" {orig}\n -> {new}")
|
|
1008
|
+
print(
|
|
1009
|
+
" This is the classic mistake that scrambles history. Verbatim+\n"
|
|
1010
|
+
" symlinks (restart and pick [V]) avoids it entirely."
|
|
1011
|
+
)
|
|
1012
|
+
if not _confirm(
|
|
1013
|
+
"Proceed with this cobbling rewrite anyway?", default=False
|
|
1014
|
+
):
|
|
1015
|
+
print("Aborted — nothing written. Re-run and choose [V]erbatim.")
|
|
1016
|
+
return 0
|
|
1017
|
+
|
|
1018
|
+
merge_args = argparse.Namespace(
|
|
1019
|
+
source=sources,
|
|
1020
|
+
source_json=source_jsons,
|
|
1021
|
+
dest_dir=args.dest_dir,
|
|
1022
|
+
dest_json=args.dest_json,
|
|
1023
|
+
frm=frm,
|
|
1024
|
+
to=to,
|
|
1025
|
+
no_configs=args.no_configs,
|
|
1026
|
+
apply=False,
|
|
1027
|
+
i_have_a_backup=True,
|
|
1028
|
+
)
|
|
1029
|
+
print("\n--- DRY RUN (nothing written yet) ---")
|
|
1030
|
+
rc = run(merge_args)
|
|
1031
|
+
if rc != 0:
|
|
1032
|
+
return rc
|
|
1033
|
+
print()
|
|
1034
|
+
# Safety net: rather than make the user assert they have a backup, check for
|
|
1035
|
+
# one and offer to make it. Only worth it when the destination already holds
|
|
1036
|
+
# data (a greenfield ~/.claude has nothing to lose).
|
|
1037
|
+
dest = args.dest_dir
|
|
1038
|
+
if (dest / "projects").is_dir() and any((dest / "projects").iterdir()):
|
|
1039
|
+
existing = _find_existing_backup(dest)
|
|
1040
|
+
if existing:
|
|
1041
|
+
print(f"Backup already present: {existing.name}")
|
|
1042
|
+
elif _confirm(f"No backup of {dest} found. Make one now?", default=True):
|
|
1043
|
+
try:
|
|
1044
|
+
for b in _backup_dest(dest, args.dest_json):
|
|
1045
|
+
print(f" backed up -> {b}")
|
|
1046
|
+
except OSError as exc:
|
|
1047
|
+
print(f" backup failed ({exc}) — not applying.")
|
|
1048
|
+
return 1
|
|
1049
|
+
else:
|
|
1050
|
+
print(" proceeding WITHOUT a backup (your choice).")
|
|
1051
|
+
if not _confirm("Apply these changes now?", default=False):
|
|
1052
|
+
print("Aborted — nothing written.")
|
|
1053
|
+
return 0
|
|
1054
|
+
merge_args.apply = True
|
|
1055
|
+
rc = run(merge_args)
|
|
1056
|
+
if rc == 0 and not frm:
|
|
1057
|
+
cwds = _project_cwds(sources)
|
|
1058
|
+
bridges = _root_bridges(roots)
|
|
1059
|
+
if cwds:
|
|
1060
|
+
script_dir = bundle if bundle.is_dir() else Path.cwd()
|
|
1061
|
+
script_path = script_dir / "restore-paths.sh"
|
|
1062
|
+
atomic_write_text(script_path, _restore_script(roots, cwds))
|
|
1063
|
+
os.chmod(script_path, 0o755)
|
|
1064
|
+
print(
|
|
1065
|
+
"\nFinal step — verbatim sessions resume only once their working\n"
|
|
1066
|
+
"directories EXIST. Bridging a root (e.g. /Volumes/ExternalDrive) is NOT enough on\n"
|
|
1067
|
+
"its own: each project's full path must be recreated under it, or resume\n"
|
|
1068
|
+
f"fails with 'working directory no longer exists'. I wrote a script that\n"
|
|
1069
|
+
f"bridges the roots and recreates all {len(cwds)} project dirs:\n\n"
|
|
1070
|
+
f" {script_path}\n"
|
|
1071
|
+
)
|
|
1072
|
+
if bridges:
|
|
1073
|
+
print("It runs these root bridges (sudo) first:")
|
|
1074
|
+
for link, target, why in bridges:
|
|
1075
|
+
print(f" sudo ln -s {target} {link} # {why}")
|
|
1076
|
+
print()
|
|
1077
|
+
if _confirm(
|
|
1078
|
+
f"Run {script_path.name} now? (uses sudo for the symlinks)",
|
|
1079
|
+
default=True,
|
|
1080
|
+
):
|
|
1081
|
+
rc2 = subprocess.run(["bash", str(script_path)]).returncode
|
|
1082
|
+
if rc2 == 0:
|
|
1083
|
+
print(
|
|
1084
|
+
"\nDone — `cd` into any project's original path and "
|
|
1085
|
+
"`claude --resume` will list its sessions."
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
print(
|
|
1089
|
+
f"\nScript exited {rc2}. Re-run manually: bash {script_path}"
|
|
1090
|
+
)
|
|
1091
|
+
else:
|
|
1092
|
+
print(f"Run it when ready: bash {script_path}")
|
|
1093
|
+
return rc
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _guided(args: argparse.Namespace) -> int:
|
|
1097
|
+
"""Single self-driving entry: detect capture vs merge, confirm, then guide."""
|
|
1098
|
+
if not sys.stdin.isatty():
|
|
1099
|
+
print("guided mode needs an interactive terminal. Use explicit flags:")
|
|
1100
|
+
print(" capture (old machine): clau-decode migrate --capture")
|
|
1101
|
+
print(
|
|
1102
|
+
" merge (new machine): clau-decode migrate --source <dir> "
|
|
1103
|
+
"--apply --backup"
|
|
1104
|
+
)
|
|
1105
|
+
return 2
|
|
1106
|
+
bundle: Path = args.bundle
|
|
1107
|
+
phase = _detect_phase(bundle, args.dest_dir)
|
|
1108
|
+
blurb = (
|
|
1109
|
+
"old machine — stage a bundle to carry over"
|
|
1110
|
+
if phase == "capture"
|
|
1111
|
+
else "new machine — fold a staged bundle into ~/.claude"
|
|
1112
|
+
)
|
|
1113
|
+
print("clau-decode migrate — guided\n")
|
|
1114
|
+
print(f"Detected the **{phase}** step ({blurb}).")
|
|
1115
|
+
if not _confirm(f"Proceed with {phase}?", default=True):
|
|
1116
|
+
phase = "merge" if phase == "capture" else "capture"
|
|
1117
|
+
print(f"OK — doing {phase} instead.")
|
|
1118
|
+
return _capture(args) if phase == "capture" else _guided_merge(args, bundle)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
1122
|
+
"""Register ``migrate`` flags on the given (sub)parser."""
|
|
1123
|
+
parser.add_argument(
|
|
1124
|
+
"--source",
|
|
1125
|
+
action="append",
|
|
1126
|
+
default=[],
|
|
1127
|
+
metavar="DIR",
|
|
1128
|
+
type=Path,
|
|
1129
|
+
help="a Claude config dir to merge FROM (repeatable). Its .claude.json is "
|
|
1130
|
+
"read automatically. e.g. ~/.claude or ~/.cc-mirror/<profile>/config",
|
|
1131
|
+
)
|
|
1132
|
+
parser.add_argument(
|
|
1133
|
+
"--source-json",
|
|
1134
|
+
action="append",
|
|
1135
|
+
default=[],
|
|
1136
|
+
metavar="PATH",
|
|
1137
|
+
type=Path,
|
|
1138
|
+
help="extra .claude.json to union project entries from (repeatable) -- e.g. "
|
|
1139
|
+
"the home-level ~/.claude.json that sits outside a source dir",
|
|
1140
|
+
)
|
|
1141
|
+
parser.add_argument(
|
|
1142
|
+
"--dest-dir",
|
|
1143
|
+
type=Path,
|
|
1144
|
+
default=Path("~/.claude").expanduser(),
|
|
1145
|
+
help="destination Claude dir (default: ~/.claude)",
|
|
1146
|
+
)
|
|
1147
|
+
parser.add_argument(
|
|
1148
|
+
"--dest-json",
|
|
1149
|
+
type=Path,
|
|
1150
|
+
default=Path("~/.claude.json").expanduser(),
|
|
1151
|
+
help="destination home .claude.json (default: ~/.claude.json)",
|
|
1152
|
+
)
|
|
1153
|
+
parser.add_argument(
|
|
1154
|
+
"--from",
|
|
1155
|
+
dest="frm",
|
|
1156
|
+
default="",
|
|
1157
|
+
metavar="PREFIX",
|
|
1158
|
+
help="path prefix to rewrite FROM, e.g. /Volumes/ExternalDrive",
|
|
1159
|
+
)
|
|
1160
|
+
parser.add_argument(
|
|
1161
|
+
"--to",
|
|
1162
|
+
dest="to",
|
|
1163
|
+
default="",
|
|
1164
|
+
metavar="PREFIX",
|
|
1165
|
+
help="path prefix to rewrite TO, e.g. /Users/you/Dev",
|
|
1166
|
+
)
|
|
1167
|
+
parser.add_argument(
|
|
1168
|
+
"--no-configs",
|
|
1169
|
+
action="store_true",
|
|
1170
|
+
help="merge chat history only (skip human configs)",
|
|
1171
|
+
)
|
|
1172
|
+
parser.add_argument(
|
|
1173
|
+
"--apply", action="store_true", help="actually write changes (default: dry-run)"
|
|
1174
|
+
)
|
|
1175
|
+
parser.add_argument(
|
|
1176
|
+
"--i-have-a-backup",
|
|
1177
|
+
action="store_true",
|
|
1178
|
+
help="required with --apply; confirm dest is backed up",
|
|
1179
|
+
)
|
|
1180
|
+
parser.add_argument(
|
|
1181
|
+
"--backup",
|
|
1182
|
+
action="store_true",
|
|
1183
|
+
help="before --apply, copy the destination to a timestamped backup "
|
|
1184
|
+
"(satisfies the backup requirement automatically)",
|
|
1185
|
+
)
|
|
1186
|
+
parser.add_argument(
|
|
1187
|
+
"--capture",
|
|
1188
|
+
action="store_true",
|
|
1189
|
+
help="stage a move bundle (run on the OLD machine) instead of merging",
|
|
1190
|
+
)
|
|
1191
|
+
parser.add_argument(
|
|
1192
|
+
"--bundle",
|
|
1193
|
+
type=Path,
|
|
1194
|
+
default=Path("~/move-bundle").expanduser(),
|
|
1195
|
+
help="bundle dir to write (capture) or read (guided merge); default ~/move-bundle",
|
|
1196
|
+
)
|
|
1197
|
+
parser.add_argument(
|
|
1198
|
+
"--guided",
|
|
1199
|
+
action="store_true",
|
|
1200
|
+
help="force the interactive wizard (default when no --source/--capture given)",
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def run(args: argparse.Namespace) -> int:
|
|
1205
|
+
"""Execute a migrate run from parsed args. Returns a process exit code.
|
|
1206
|
+
|
|
1207
|
+
Dispatch: ``--guided`` (or no ``--source``/``--capture``) → the self-driving
|
|
1208
|
+
wizard; ``--capture`` → stage a bundle; otherwise → the explicit merge below.
|
|
1209
|
+
"""
|
|
1210
|
+
sources: list[Path] = list(getattr(args, "source", []) or [])
|
|
1211
|
+
if getattr(args, "guided", False) or (
|
|
1212
|
+
not sources and not getattr(args, "capture", False)
|
|
1213
|
+
):
|
|
1214
|
+
return _guided(args)
|
|
1215
|
+
if getattr(args, "capture", False):
|
|
1216
|
+
return _capture(args)
|
|
1217
|
+
if not sources:
|
|
1218
|
+
print("error: at least one --source DIR is required", flush=True)
|
|
1219
|
+
return 2
|
|
1220
|
+
if bool(args.frm) != bool(args.to):
|
|
1221
|
+
print("error: --from and --to must be given together (or neither)", flush=True)
|
|
1222
|
+
return 2
|
|
1223
|
+
if args.apply and not args.i_have_a_backup and not getattr(args, "backup", False):
|
|
1224
|
+
print(
|
|
1225
|
+
"error: --apply needs either --backup (make one now) or "
|
|
1226
|
+
"--i-have-a-backup (you already have one)",
|
|
1227
|
+
flush=True,
|
|
1228
|
+
)
|
|
1229
|
+
return 2
|
|
1230
|
+
|
|
1231
|
+
apply = args.apply
|
|
1232
|
+
# --backup: make the safety copy before touching anything.
|
|
1233
|
+
if apply and getattr(args, "backup", False):
|
|
1234
|
+
try:
|
|
1235
|
+
for b in _backup_dest(args.dest_dir, args.dest_json):
|
|
1236
|
+
print(f"backed up -> {b}", flush=True)
|
|
1237
|
+
except OSError as exc:
|
|
1238
|
+
print(f"error: backup failed ({exc}) — aborting", flush=True)
|
|
1239
|
+
return 1
|
|
1240
|
+
mode = "APPLY" if apply else "DRY-RUN"
|
|
1241
|
+
print(
|
|
1242
|
+
f"[{mode}] merging {len(sources)} source(s) into {args.dest_dir} "
|
|
1243
|
+
f"(rewrite '{args.frm}' -> '{args.to}')"
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
rep = Report()
|
|
1247
|
+
dest_projects = args.dest_dir / "projects"
|
|
1248
|
+
if apply:
|
|
1249
|
+
dest_projects.mkdir(parents=True, exist_ok=True)
|
|
1250
|
+
|
|
1251
|
+
# 1. projects/ union + rewrite
|
|
1252
|
+
for src in sources:
|
|
1253
|
+
merge_projects(
|
|
1254
|
+
src.name or "source",
|
|
1255
|
+
src / "projects",
|
|
1256
|
+
dest_projects,
|
|
1257
|
+
args.frm,
|
|
1258
|
+
args.to,
|
|
1259
|
+
apply,
|
|
1260
|
+
rep,
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
# 2. .claude.json project-entry union (each source dir's .claude.json + extras)
|
|
1264
|
+
source_jsons: list[tuple[str, Path]] = []
|
|
1265
|
+
for src in sources:
|
|
1266
|
+
source_jsons.append((src.name or "source", src / ".claude.json"))
|
|
1267
|
+
for extra in getattr(args, "source_json", []) or []:
|
|
1268
|
+
source_jsons.append((extra.name, extra))
|
|
1269
|
+
merge_claude_json(args.dest_json, source_jsons, args.frm, args.to, apply, rep)
|
|
1270
|
+
|
|
1271
|
+
# 3. history.jsonl
|
|
1272
|
+
merge_history(
|
|
1273
|
+
args.dest_dir / "history.jsonl",
|
|
1274
|
+
[src / "history.jsonl" for src in sources],
|
|
1275
|
+
args.frm,
|
|
1276
|
+
args.to,
|
|
1277
|
+
apply,
|
|
1278
|
+
rep,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
# 4. human configs
|
|
1282
|
+
if not args.no_configs:
|
|
1283
|
+
merge_configs(sources, args.dest_dir, apply, rep)
|
|
1284
|
+
|
|
1285
|
+
print(rep.render())
|
|
1286
|
+
if not apply:
|
|
1287
|
+
print(
|
|
1288
|
+
"\nDry-run only -- nothing was written. "
|
|
1289
|
+
"Re-run with --apply --i-have-a-backup to commit."
|
|
1290
|
+
)
|
|
1291
|
+
else:
|
|
1292
|
+
print(
|
|
1293
|
+
"\nDone. If you use clau-decode: run `clau-decode --force-refresh` "
|
|
1294
|
+
"(migrated files keep their old mtimes, so an incremental scan skips "
|
|
1295
|
+
"them), then launch clau-decode. Don't delete the index DB — that "
|
|
1296
|
+
"discards stars/archives/custom titles."
|
|
1297
|
+
)
|
|
1298
|
+
return 0
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1302
|
+
parser = argparse.ArgumentParser(
|
|
1303
|
+
prog="clau-decode migrate",
|
|
1304
|
+
description="Merge & relocate Claude Code chat history + configs across machines.",
|
|
1305
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1306
|
+
epilog=__doc__,
|
|
1307
|
+
)
|
|
1308
|
+
add_arguments(parser)
|
|
1309
|
+
return run(parser.parse_args(argv))
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
if __name__ == "__main__":
|
|
1313
|
+
raise SystemExit(main())
|