Skip to content

Instantly share code, notes, and snippets.

@harryi3t
Last active April 28, 2026 19:17
Show Gist options
  • Select an option

  • Save harryi3t/ea591e5bd510f5fd5f6c4f7b4d6f1567 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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