Last active
April 28, 2026 19:17
-
-
Save harryi3t/ea591e5bd510f5fd5f6c4f7b4d6f1567 to your computer and use it in GitHub Desktop.
sync-cc-sessions: Sync Claude Code CLI sessions into the Claude Desktop app sidebar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * sync-cc-sessions | |
| * | |
| * Syncs Claude Code CLI sessions (~/.claude/projects/) into the Claude Desktop | |
| * app so they appear in the sidebar and can be continued from the desktop UI. | |
| * | |
| * Requirements: | |
| * - macOS only (Claude Desktop stores sessions under ~/Library/Application Support/Claude/) | |
| * - Node.js >= 16 (no dependencies beyond stdlib) | |
| * - Claude Desktop app installed and at least one session created in it | |
| * | |
| * Usage: | |
| * sync-cc-sessions # sync last 7 days (default) | |
| * sync-cc-sessions -d 14 # sync last 14 days | |
| * sync-cc-sessions -d all # sync all sessions | |
| * sync-cc-sessions --dry-run # preview without writing anything | |
| */ | |
| 'use strict'; | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const os = require('os'); | |
| const crypto = require('crypto'); | |
| const readline = require('readline'); | |
| // ── Platform guard ──────────────────────────────────────────────────────────── | |
| if (process.platform !== 'darwin') { | |
| console.error('sync-cc-sessions: macOS only (Claude Desktop session path is macOS-specific).'); | |
| process.exit(1); | |
| } | |
| // ── Paths ───────────────────────────────────────────────────────────────────── | |
| const CLI_PROJECTS = path.join(os.homedir(), '.claude', 'projects'); | |
| const DESKTOP_SESSIONS = path.join( | |
| os.homedir(), 'Library', 'Application Support', 'Claude', 'claude-code-sessions' | |
| ); | |
| // ── Args ────────────────────────────────────────────────────────────────────── | |
| function parseArgs() { | |
| const args = process.argv.slice(2); | |
| const dryRun = args.includes('--dry-run'); | |
| const dIdx = args.indexOf('-d'); | |
| let days = 7; | |
| if (dIdx !== -1) { | |
| const val = args[dIdx + 1]; | |
| if (!val) { console.error('Error: -d requires a value (number or "all")'); process.exit(1); } | |
| if (val === 'all') { | |
| days = Infinity; | |
| } else { | |
| days = parseInt(val, 10); | |
| if (isNaN(days) || days <= 0) { console.error(`Error: invalid days value "${val}"`); process.exit(1); } | |
| } | |
| } | |
| return { days, dryRun }; | |
| } | |
| // ── Desktop workspace/env detection ────────────────────────────────────────── | |
| function detectDesktopEnv() { | |
| if (!fs.existsSync(DESKTOP_SESSIONS)) { | |
| throw new Error( | |
| `Claude Desktop session dir not found:\n ${DESKTOP_SESSIONS}\n\n` + | |
| `Make sure the Claude Desktop app is installed and you have created at least one session in it.` | |
| ); | |
| } | |
| const workspaces = fs.readdirSync(DESKTOP_SESSIONS).filter(w => { | |
| const p = path.join(DESKTOP_SESSIONS, w); | |
| return fs.statSync(p).isDirectory(); | |
| }); | |
| if (workspaces.length === 0) { | |
| throw new Error('No workspaces found. Create at least one session in the Claude Desktop app first.'); | |
| } | |
| // Pick the most-recently-modified workspace | |
| const workspace = workspaces.sort((a, b) => { | |
| return fs.statSync(path.join(DESKTOP_SESSIONS, b)).mtimeMs | |
| - fs.statSync(path.join(DESKTOP_SESSIONS, a)).mtimeMs; | |
| })[0]; | |
| const wPath = path.join(DESKTOP_SESSIONS, workspace); | |
| const envs = fs.readdirSync(wPath).filter(e => fs.statSync(path.join(wPath, e)).isDirectory()); | |
| if (envs.length === 0) { | |
| throw new Error(`No env dirs found under workspace ${workspace}.`); | |
| } | |
| const env = envs[0]; | |
| return { workspace, env, envPath: path.join(wPath, env) }; | |
| } | |
| // ── Decode CLI project encoded path ────────────────────────────────────────── | |
| // | |
| // CLI stores sessions under ~/.claude/projects/<encoded-path>/ where every '/' | |
| // in the original absolute path is replaced with '-'. Directory names that | |
| // contain hyphens (e.g. "my-project") are indistinguishable from path | |
| // separators in the encoding, so we reconstruct the real path by walking the | |
| // filesystem level by level. | |
| function decodeCLIPath(encoded) { | |
| if (!encoded.startsWith('-')) return encoded; | |
| const remaining = encoded.slice(1); // strip leading '-' | |
| function walk(currentPath, rem) { | |
| if (!rem) return currentPath; | |
| let children; | |
| try { children = fs.readdirSync(currentPath || '/'); } catch (_) { children = []; } | |
| for (const child of children) { | |
| if (rem === child) return (currentPath || '') + '/' + child; | |
| if (rem.startsWith(child + '-')) { | |
| const result = walk((currentPath || '') + '/' + child, rem.slice(child.length + 1)); | |
| if (result) return result; | |
| } | |
| } | |
| // Filesystem match failed — fall back to naive replacement | |
| return (currentPath || '') + '/' + rem.replace(/-/g, '/'); | |
| } | |
| return walk('', remaining); | |
| } | |
| // ── Load existing session IDs already in desktop (dedup) ───────────────────── | |
| function loadExistingSessionIds(envPath) { | |
| const seen = new Set(); | |
| for (const f of fs.readdirSync(envPath)) { | |
| if (!f.endsWith('.json')) continue; | |
| try { | |
| const meta = JSON.parse(fs.readFileSync(path.join(envPath, f), 'utf8')); | |
| if (meta.sessionId) seen.add(meta.sessionId); | |
| if (meta.cliSessionId) seen.add(meta.cliSessionId); | |
| } catch (_) {} | |
| } | |
| return seen; | |
| } | |
| // ── Extract session title + metadata from CLI JSONL ────────────────────────── | |
| async function extractSessionInfo(jsonlPath) { | |
| let customTitle = null; // set by /rename — highest priority | |
| let summaryTitle = null; // set by AI auto-summary | |
| let model = null; | |
| let permissionMode = 'auto'; | |
| let firstUserMsg = null; | |
| let createdAtMs = null; | |
| let lastActivityAtMs = null; | |
| const rl = readline.createInterface({ input: fs.createReadStream(jsonlPath), crlfDelay: Infinity }); | |
| for await (const line of rl) { | |
| if (!line.trim()) continue; | |
| let entry; | |
| try { entry = JSON.parse(line); } catch (_) { continue; } | |
| // Normalise timestamps to ms epoch | |
| const ts = entry.timestamp | |
| ? (typeof entry.timestamp === 'number' ? entry.timestamp : new Date(entry.timestamp).getTime()) | |
| : null; | |
| if (ts && !isNaN(ts)) { | |
| if (!createdAtMs) createdAtMs = ts; | |
| lastActivityAtMs = ts; | |
| } | |
| // /rename stores the last custom-title entry — keep updating so we get the latest rename | |
| if (entry.type === 'custom-title' && entry.customTitle) customTitle = entry.customTitle; | |
| if (entry.type === 'summary' && entry.summary) summaryTitle = entry.summary; | |
| if (entry.type === 'permission-mode' && entry.permissionMode) { | |
| permissionMode = entry.permissionMode; | |
| } | |
| if (!firstUserMsg && entry.type === 'user') { | |
| const content = entry.message?.content; | |
| if (typeof content === 'string' && content.trim()) { | |
| firstUserMsg = content.trim().slice(0, 80); | |
| } else if (Array.isArray(content)) { | |
| const text = content.find(c => c.type === 'text'); | |
| if (text?.text?.trim()) firstUserMsg = text.text.trim().slice(0, 80); | |
| } | |
| } | |
| if (!model && entry.message?.model) model = entry.message.model; | |
| } | |
| const stat = fs.statSync(jsonlPath); | |
| if (!createdAtMs || isNaN(createdAtMs)) createdAtMs = stat.birthtimeMs; | |
| if (!lastActivityAtMs || isNaN(lastActivityAtMs)) lastActivityAtMs = stat.mtimeMs; | |
| // Priority: /rename > AI summary > first user message | |
| const title = customTitle || summaryTitle || firstUserMsg || 'CLI session'; | |
| const titleSource = customTitle ? 'user' : 'auto'; | |
| return { | |
| title, | |
| titleSource, | |
| model: model || 'claude-sonnet-4-6', | |
| permissionMode, | |
| createdAtMs: Math.round(createdAtMs), | |
| lastActivityAtMs: Math.round(lastActivityAtMs), | |
| }; | |
| } | |
| // ── Main ────────────────────────────────────────────────────────────────────── | |
| async function main() { | |
| const { days, dryRun } = parseArgs(); | |
| const cutoff = days === Infinity ? 0 : Date.now() - days * 24 * 60 * 60 * 1000; | |
| const rangeLabel = days === Infinity ? 'all time' : `last ${days} day${days === 1 ? '' : 's'}`; | |
| if (dryRun) console.log('[dry-run] No files will be written.\n'); | |
| let envPath; | |
| try { | |
| const detected = detectDesktopEnv(); | |
| envPath = detected.envPath; | |
| console.log(`Desktop env: ${detected.workspace}/${detected.env}`); | |
| } catch (e) { | |
| console.error(e.message); | |
| process.exit(1); | |
| } | |
| const existingIds = loadExistingSessionIds(envPath); | |
| console.log(`Already in desktop: ${existingIds.size} session IDs`); | |
| console.log(`Range: ${rangeLabel}\n`); | |
| if (!fs.existsSync(CLI_PROJECTS)) { | |
| console.error(`CLI projects dir not found: ${CLI_PROJECTS}\nIs Claude Code CLI installed?`); | |
| process.exit(1); | |
| } | |
| let synced = 0; | |
| let skipped = 0; | |
| for (const projectDir of fs.readdirSync(CLI_PROJECTS)) { | |
| const projectPath = path.join(CLI_PROJECTS, projectDir); | |
| let stat; | |
| try { stat = fs.statSync(projectPath); } catch (_) { continue; } | |
| if (!stat.isDirectory()) continue; | |
| const realPath = decodeCLIPath(projectDir); | |
| const jsonlFiles = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl')); | |
| for (const jsonlFile of jsonlFiles) { | |
| const cliSessionId = jsonlFile.replace('.jsonl', ''); | |
| const jsonlPath = path.join(projectPath, jsonlFile); | |
| // Skip old sessions | |
| const mtime = fs.statSync(jsonlPath).mtime.getTime(); | |
| if (mtime < cutoff) { skipped++; continue; } | |
| // Skip already synced | |
| if (existingIds.has(cliSessionId)) { skipped++; continue; } | |
| let info; | |
| try { info = await extractSessionInfo(jsonlPath); } | |
| catch (_) { skipped++; continue; } | |
| // sessionId must be local_<uuid> — this is what the desktop app uses as the record key. | |
| // cliSessionId links back to ~/.claude/projects/<encoded>/<cliSessionId>.jsonl | |
| const sessionId = `local_${crypto.randomUUID()}`; | |
| const meta = { | |
| sessionId, | |
| cliSessionId, | |
| cwd: realPath, | |
| originCwd: realPath, | |
| createdAt: info.createdAtMs, | |
| lastActivityAt: info.lastActivityAtMs, | |
| model: info.model, | |
| effort: 'medium', | |
| isArchived: false, | |
| title: info.title, | |
| titleSource: info.titleSource, | |
| permissionMode: info.permissionMode, | |
| enabledMcpTools: {}, | |
| remoteMcpServersConfig: {}, | |
| }; | |
| const label = `${info.title.slice(0, 55)} [${realPath.split('/').pop()}]`; | |
| if (!dryRun) { | |
| fs.writeFileSync(path.join(envPath, `${sessionId}.json`), JSON.stringify(meta, null, 2)); | |
| existingIds.add(cliSessionId); | |
| } | |
| console.log(` ${dryRun ? '~' : '✓'} ${label}`); | |
| synced++; | |
| } | |
| } | |
| console.log(`\n${dryRun ? '[dry-run] Would sync' : 'Synced'}: ${synced}, Skipped: ${skipped}`); | |
| if (synced > 0 && !dryRun) { | |
| console.log('Restart the Claude Desktop app to see the new sessions.'); | |
| } | |
| } | |
| main().catch(e => { console.error('Error:', e.message); process.exit(1); }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment