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.
Files changed (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. 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())