"""migrate_claude_sessions.py (2026-10-14) Total migration of the project's Claude sessions from D:\\SubnauticaCLONE to D:\\HADAL - The Descent, after the folder rename (the user: "I want a total migration", "only subnautica clone not stalker"). Sessions of other folders (D:\\Games II\\STALKER) are not touched: every change below matches only the old project path. Run it with the Claude app AND Blender closed (both keep the files below open and would write their old state back). Win+R: E:\\Tools\\Programs\\Python\\python.exe "D:\\HADAL - The Descent\\.tools\\migrate_claude_sessions.py" --dry-run changes nothing and prints the plan (it also runs while the app is open). Steps: 1. backups: the app's session records and settings files, every transcript file that gets rewritten, and every .blend that gets changed (to models\\_backups\\path-migration_2026-10-14). The old Claude project folder is not deleted: it is moved whole into the backup folder at the end. 2. history: Claude Code keeps a session's transcript under a folder named after the project path (~\\.claude\\projects\\D--SubnauticaCLONE). Sessions opened through the link since the rename already copied their whole transcript to D--HADAL---The-Descent and went on there (checked: the new copy holds every entry of the old one). The rest is copied over. A pair is only resolved automatically when one copy holds every entry of the other; otherwise both are kept and it is reported. 3. (none) the transcripts are copied as they are, never edited: what a session said and did stays as it was written, old paths included. A resumed session works in the new folder; old paths in its history are only history. (A first draft rewrote the paths inside them; the app's safety check flagged that as transcript tampering, so it was taken out.) 4. the app's session records (%APPDATA%\\Claude\\claude-code-sessions\\...\\local_*.json): cwd, originCwd, gitAnchorsFolderRealpath and any other mention move to the new path. 5. the app's settings (claude_desktop_config.json: trusted folders and folder grants; git-worktrees.json; remote-session-spaces.json): the old path is replaced, or dropped where the new one is already listed. 6. .blend files whose data names the old folder (absolute texture and library paths, relative paths that climb out and back in through the old name) are re-pointed by Blender in the background (blend_remap_paths.py) while the link still exists, then scanned again. 7. only if all of that worked: the hidden link D:\\SubnauticaCLONE is removed (the link only, never a recursive delete), and the old Claude project folder is moved into the backup folder. """ import json, os, re, shutil, subprocess, sys, time from compression import zstd OLD = r'D:\SubnauticaCLONE' NEW = r'D:\HADAL - The Descent' HOME = os.path.expanduser('~') STAMP = '2026-10-14' DRY = '--dry-run' in sys.argv def key(p): return re.sub(r'[^a-zA-Z0-9]', '-', p) OLDKEY, NEWKEY = key(OLD), key(NEW) PROJECTS = os.path.join(HOME, '.claude', 'projects') OLDPROJ, NEWPROJ = os.path.join(PROJECTS, OLDKEY), os.path.join(PROJECTS, NEWKEY) def find_app_dir(): # The Claude desktop app is a packaged (MSIX) app: what it sees as %APPDATA%\Claude really lives in # %LOCALAPPDATA%\Packages\Claude_\LocalCache\Roaming\Claude, and a program started outside the app (Win+R) # does not see it under %APPDATA%. The first run (14:27) missed the records and settings for that reason. import glob for d in sorted(glob.glob(os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Packages', 'Claude_*'))): c = os.path.join(d, 'LocalCache', 'Roaming', 'Claude') if os.path.isdir(os.path.join(c, 'claude-code-sessions')): return c return os.path.join(os.environ['APPDATA'], 'Claude') APP = find_app_dir() BACKUP = os.path.join(HOME, '.claude', 'backups', 'migration-' + STAMP) BLEND_BACKUP = os.path.join(NEW, 'models', '_backups', 'path-migration_' + STAMP) BLENDER = r'E:\Steam\steamapps\common\Blender\blender.exe' REMAP = os.path.join(NEW, '.tools', 'blend_remap_paths.py') LOG = os.path.join(NEW, '.tools', 'migrate_claude_sessions.log') LINES = [] def say(m=''): print(m, flush=True) LINES.append(m) # ------------------------------------------------------------------ rewriting rules (bytes) OLDNAME = os.path.basename(OLD).encode() NEWNAME = os.path.basename(NEW).encode() DRIVE = OLD[0].encode() # D:\Sub..., D:\\Sub... (any escape depth), D:/Sub..., /d/Sub... ; only where the folder name ends R_PATH = re.compile(rb'(?i)(? {NEW}') # ------------------------------------------------------------------ 0. checks if not os.path.isdir(NEW): say(f'STOP {NEW} does not exist.'); sys.exit(2) if not DRY: busy = [x for x in ('claude.exe', 'blender.exe', 'UnrealEditor.exe') if running(x)] if busy: say('STOP still running: ' + ', '.join(busy) + '. Quit the Claude app (tray icon > Quit) and close Blender' ' and the Unreal editor, then run this again. Nothing was changed.') if sys.stdin and sys.stdin.isatty(): input('Press Enter to close.') sys.exit(3) # ------------------------------------------------------------------ 2. history: old project folder -> new one say('\n2. session history (' + OLDKEY + ' -> ' + NEWKEY + ')') copied = kept = 0 if os.path.isdir(OLDPROJ) and not os.path.isjunction(OLDPROJ): os.makedirs(NEWPROJ, exist_ok=True) if not DRY else None for entry in sorted(os.listdir(OLDPROJ)): if entry == 'memory': continue src, dst = os.path.join(OLDPROJ, entry), os.path.join(NEWPROJ, entry) if os.path.isdir(src): for dp, dns, fns in os.walk(src): for fn in fns: s = os.path.join(dp, fn) d = os.path.join(dst, os.path.relpath(s, src)) if os.path.exists(d): kept += 1 continue copied += 1 if not DRY: os.makedirs(os.path.dirname(d), exist_ok=True) shutil.copy2(s, d) continue if not os.path.exists(dst): copied += 1 if not DRY: shutil.copy2(src, dst) continue if entry.endswith('.jsonl'): missing = uuids(src) - uuids(dst) if missing: alt = dst[:-6] + '.from-old-folder.jsonl' problems.append(f'{entry}: the new copy lacks {len(missing)} entries of the old one; old one kept as {os.path.basename(alt)}') if not DRY: shutil.copy2(src, alt) continue kept += 1 say(f' copied {copied} file(s) that existed only in the old folder; {kept} already had a full copy in the new one') else: say(' nothing to do (no old project folder)') # ------------------------------------------------------------------ 4. the app's session records say('\n4. the app\'s session records') recs = 0 SESS = os.path.join(APP, 'claude-code-sessions') for dp, dns, fns in os.walk(SESS): for fn in fns: if not (fn.startswith('local_') and fn.endswith('.json')): continue p = os.path.join(dp, fn) with open(p, 'rb') as fh: b = fh.read() b2, n = rewrite_bytes(b) if not n: continue try: j = json.loads(b2) except Exception as e: problems.append(f'record {fn}: would not be valid JSON after the change ({e}); left alone'); continue recs += 1 say(f' {fn[6:14]} {j.get("title", "")} ({n})') if not DRY: backup_copy(p, os.path.join(BACKUP, 'claude-code-sessions'), SESS) with open(p + '.migrating', 'wb') as fh: fh.write(b2) os.replace(p + '.migrating', p) say(f' {recs} record(s) (in {SESS})') if not any(f.startswith('local_') for _, _, fs in os.walk(SESS) for f in fs): problems.append(f'no session records found in {SESS}: the app keeps them somewhere else') # ------------------------------------------------------------------ 5. the app's settings say('\n5. the app\'s settings') def remap_json(o): """replace the old path in every string and key; drop an old entry where the new one is already there""" if isinstance(o, str): return rewrite_str(o)[0] if isinstance(o, list): out = [] for x in o: y = remap_json(x) if isinstance(y, str) and y in out: continue out.append(y) return out if isinstance(o, dict): out = {} for k, v in o.items(): k2 = rewrite_str(k)[0] if k2 != k and k2 in o: continue # the new path has its own entry already out[k2] = remap_json(v) return out return o settings = [os.path.join(APP, 'claude_desktop_config.json'), os.path.join(APP, 'git-worktrees.json')] for dp, dns, fns in os.walk(os.path.join(APP, 'local-agent-mode-sessions')): settings += [os.path.join(dp, f) for f in fns if f == 'remote-session-spaces.json'] for p in settings: if not os.path.isfile(p): continue text = open(p, encoding='utf-8').read() if OLDNAME.decode().lower() not in text.lower(): continue data = json.loads(text) new = remap_json(data) m = re.search(r'\n([ \t]+)"', text) indent = m.group(1) if m else 2 out = json.dumps(new, indent=indent, ensure_ascii=False) + ('\n' if text.endswith('\n') else '') left = out.lower().count(OLDNAME.decode().lower()) say(f' {os.path.relpath(p, APP)}: {text.lower().count(OLDNAME.decode().lower())} mention(s) -> {left} left') if not DRY: backup_copy(p, os.path.join(BACKUP, 'app-settings'), APP) with open(p + '.migrating', 'w', encoding='utf-8', newline='') as fh: fh.write(out) os.replace(p + '.migrating', p) # ------------------------------------------------------------------ 6. Blender files say('\n6. Blender files that name the old folder') SKIP = {'versions', '_backups', 'Intermediate', 'Saved', 'DerivedDataCache', 'Binaries'} blends = [] for dp, dns, fns in os.walk(NEW): dns[:] = [d for d in dns if d not in SKIP and not os.path.isjunction(os.path.join(dp, d))] for fn in fns: if fn.lower().endswith('.blend'): p = os.path.join(dp, fn) try: c = blend_mentions(p) except Exception as e: problems.append(f'{p}: {e}'); continue if c: blends.append((p, c)) for p, c in blends: rel = os.path.relpath(p, NEW) if DRY: say(f' {rel}: {c} mention(s), would be re-pointed by Blender'); continue backup_copy(p, BLEND_BACKUP, NEW) r = subprocess.run([BLENDER, '-b', p, '--python', REMAP, '--', OLD, NEW], capture_output=True, text=True, encoding='utf-8', errors='replace') line = next((l for l in r.stdout.splitlines() if l.startswith('REMAP ')), None) res = json.loads(line[6:]) if line else {} after = blend_mentions(p) say(f' {rel}: {res.get("changed", "?")} path(s) re-pointed, {res.get("paths_left", "?")} path(s) left' f' ({after} mention(s) of the old name left in other data, e.g. text blocks: harmless)') if not line or r.returncode != 0: problems.append(f'{rel}: Blender failed (exit {r.returncode}): ' + (r.stderr or r.stdout)[-400:]) elif res.get('paths_left'): problems.append(f'{rel}: {res["paths_left"]} path(s) still under the old folder: {res.get("left")}') if not blends: say(' none') # ------------------------------------------------------------------ 7. link and old project folder say('\n7. the old name') if problems: say(' NOT removed (see the problems below): the link ' + OLD + ' and the old project folder stay.') elif DRY: say(f' would remove the hidden link {OLD} (the link only) and move {OLDKEY} into {BACKUP}') else: if os.path.isjunction(OLD): os.rmdir(OLD) # removes the link, not what it points to say(f' link {OLD} removed') if os.path.isdir(OLDPROJ): mem = os.path.join(OLDPROJ, 'memory') if os.path.isjunction(mem): os.rmdir(mem) # the link to the real memory folder os.makedirs(BACKUP, exist_ok=True) dst, i = os.path.join(BACKUP, OLDKEY), 1 while os.path.exists(dst): # a second run: keep the first run's backup i += 1 dst = os.path.join(BACKUP, f'{OLDKEY}.{i}') shutil.move(OLDPROJ, dst) say(f' old project folder moved to {dst}') say('\nproblems:' if problems else '\nno problems.') for p in problems: say(' - ' + p) say('backups: ' + BACKUP + ' ; ' + BLEND_BACKUP) if not DRY: with open(LOG, 'a', encoding='utf-8') as fh: fh.write('\n'.join(LINES) + '\n\n') say('done. Open Claude again: the sessions are listed under ' + NEW + '.') if not DRY and sys.stdin and sys.stdin.isatty(): input('Press Enter to close.') sys.exit(1 if problems else 0)