claude-desktop-merge 1.0.0 → 1.1.0
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.
- package/README.md +2 -1
- package/dist/claude-desktop-merge.mjs +92 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,12 +98,13 @@ The macOS desktop app keeps two different things in two different places:
|
|
|
98
98
|
|---|---|---|
|
|
99
99
|
| **Transcripts** (the actual conversation) | `~/.claude/projects/<encoded-cwd>/<id>.jsonl` | **No** — global, shared with the CLI |
|
|
100
100
|
| **Session index** (pointer files: title, model, cwd, archive state) | `~/Library/Application Support/Claude/{claude-code-sessions,local-agent-mode-sessions}/<accountUuid>/<orgUuid>/local_<id>.json` | **Yes** — one folder per account/org |
|
|
101
|
+
| **Routines** (scheduled tasks: cron, model, enabled state) | `…/{claude-code-sessions,local-agent-mode-sessions}/<accountUuid>/<orgUuid>/scheduled-tasks.json` | **Yes** — one file per account/org, per tree |
|
|
101
102
|
|
|
102
103
|
Because the transcripts are global, switching accounts never loses a conversation — the desktop UI just stops *listing* it, since it reads a different `<account>/<org>` folder. This tool only reorganizes the small pointer files. **It never reads, writes, moves, or deletes anything under `~/.claude` or any `.jsonl`.**
|
|
103
104
|
|
|
104
105
|
## How it works — canonical merge, then link
|
|
105
106
|
|
|
106
|
-
1. **MERGE** (non-destructive) — pick one **canonical** `<account>/<org>`. Copy the pointer files that exist only in other accounts *into* canonical. Union `spaces.json` by id. Archive flags ride along inside each pointer file.
|
|
107
|
+
1. **MERGE** (non-destructive) — pick one **canonical** `<account>/<org>`. Copy the pointer files that exist only in other accounts *into* canonical. Union `spaces.json` and `scheduled-tasks.json` (your **routines**) by id — canonical wins on any collision. Archive flags ride along inside each pointer file.
|
|
107
108
|
2. **LINK** (opt-in, `--link`) — back up each other account's folder to `*.bak-<timestamp>` and replace it with a symlink to canonical, so all accounts share one live set going forward.
|
|
108
109
|
|
|
109
110
|
The one rule that keeps it consistent: **copy _into_ canonical; symlink every other account _at_ canonical.** Never copy into a folder you then link away.
|
|
@@ -166,9 +166,10 @@ ${b("FLAGS")}
|
|
|
166
166
|
|
|
167
167
|
${b("CONCEPT")}
|
|
168
168
|
A "location" is an <account>/<org> pair under the two session trees. MERGE (non-destructive) copies
|
|
169
|
-
pointer files/dirs from sources INTO canonical across both trees and unions spaces.json
|
|
170
|
-
(canonical wins). LINK (destructive, opt-in) replaces each
|
|
171
|
-
REVERT undoes LINK by restoring the *.bak-<ts> backups it
|
|
169
|
+
pointer files/dirs from sources INTO canonical across both trees and unions spaces.json plus
|
|
170
|
+
scheduled-tasks.json ("routines") by id (canonical wins). LINK (destructive, opt-in) replaces each
|
|
171
|
+
source org dir with a symlink to canonical. REVERT undoes LINK by restoring the *.bak-<ts> backups it
|
|
172
|
+
left behind. Every run is logged to a file.
|
|
172
173
|
|
|
173
174
|
${b("EXAMPLES")}
|
|
174
175
|
claude-desktop-merge # interactive, dry-run
|
|
@@ -207,7 +208,7 @@ function readOrgEntries(dir) {
|
|
|
207
208
|
return out;
|
|
208
209
|
}
|
|
209
210
|
for (const d of dirents) {
|
|
210
|
-
if (d.name === "spaces.json")
|
|
211
|
+
if (d.name === "spaces.json" || d.name === "scheduled-tasks.json")
|
|
211
212
|
continue;
|
|
212
213
|
const full = path.join(dir, d.name);
|
|
213
214
|
const entryIsDir = d.isDirectory() || d.isSymbolicLink() && isDir(full);
|
|
@@ -244,8 +245,14 @@ function readSpaces(spacesPath) {
|
|
|
244
245
|
return data.spaces;
|
|
245
246
|
return [];
|
|
246
247
|
}
|
|
248
|
+
function readScheduledTasks(tasksPath) {
|
|
249
|
+
const data = readJson(tasksPath);
|
|
250
|
+
const tasks = data && Array.isArray(data.scheduledTasks) ? data.scheduledTasks : [];
|
|
251
|
+
const recordedSkips = data && data.recordedSkips && typeof data.recordedSkips === "object" && !Array.isArray(data.recordedSkips) ? data.recordedSkips : {};
|
|
252
|
+
return { tasks, recordedSkips };
|
|
253
|
+
}
|
|
247
254
|
function buildSourcePlan(base, canon, source) {
|
|
248
|
-
const plan = { source, copies: [], conflicts: [], identical: 0, spacesAdded: [] };
|
|
255
|
+
const plan = { source, copies: [], conflicts: [], identical: 0, spacesAdded: [], routinesAdded: [] };
|
|
249
256
|
for (const tree of TREES) {
|
|
250
257
|
const srcDir = orgDir(base, tree, source);
|
|
251
258
|
const canonDir = orgDir(base, tree, canon);
|
|
@@ -330,6 +337,47 @@ function buildPlan(base, canon, sources, linkMode) {
|
|
|
330
337
|
added,
|
|
331
338
|
willWrite: added > 0 || !exists(canonSpacesPath)
|
|
332
339
|
};
|
|
340
|
+
const routinesMerges = [];
|
|
341
|
+
for (const tree of TREES) {
|
|
342
|
+
const canonTasksPath = path.join(orgDir(base, tree, canon), "scheduled-tasks.json");
|
|
343
|
+
const canonTasks = readScheduledTasks(canonTasksPath);
|
|
344
|
+
const mergedTasks = new Map;
|
|
345
|
+
for (const t of canonTasks.tasks)
|
|
346
|
+
if (t && t.id)
|
|
347
|
+
mergedTasks.set(t.id, t);
|
|
348
|
+
const tasksBefore = mergedTasks.size;
|
|
349
|
+
const mergedSkips = { ...canonTasks.recordedSkips };
|
|
350
|
+
const skipsBefore = Object.keys(mergedSkips).length;
|
|
351
|
+
for (const s of sources) {
|
|
352
|
+
const srcTasks = readScheduledTasks(path.join(orgDir(base, tree, s), "scheduled-tasks.json"));
|
|
353
|
+
const sourcePlan = sourcePlanBySource.get(s.key);
|
|
354
|
+
for (const t of srcTasks.tasks) {
|
|
355
|
+
if (t && t.id && !mergedTasks.has(t.id)) {
|
|
356
|
+
mergedTasks.set(t.id, t);
|
|
357
|
+
if (sourcePlan)
|
|
358
|
+
sourcePlan.routinesAdded.push({ tree, task: t });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const [k, v] of Object.entries(srcTasks.recordedSkips)) {
|
|
362
|
+
if (!(k in mergedSkips))
|
|
363
|
+
mergedSkips[k] = v;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const tasksArr = [...mergedTasks.values()];
|
|
367
|
+
const skipCount = Object.keys(mergedSkips).length;
|
|
368
|
+
if (tasksArr.length === 0 && skipCount === 0)
|
|
369
|
+
continue;
|
|
370
|
+
routinesMerges.push({
|
|
371
|
+
tree,
|
|
372
|
+
canonPath: canonTasksPath,
|
|
373
|
+
before: tasksBefore,
|
|
374
|
+
mergedTasks: tasksArr,
|
|
375
|
+
addedTasks: tasksArr.length - tasksBefore,
|
|
376
|
+
recordedSkips: mergedSkips,
|
|
377
|
+
skipsAdded: skipCount - skipsBefore,
|
|
378
|
+
willWrite: tasksArr.length - tasksBefore > 0 || skipCount - skipsBefore > 0 || !exists(canonTasksPath)
|
|
379
|
+
});
|
|
380
|
+
}
|
|
333
381
|
const linkPlans = [];
|
|
334
382
|
if (linkMode) {
|
|
335
383
|
const stamp = timestamp();
|
|
@@ -349,7 +397,7 @@ function buildPlan(base, canon, sources, linkMode) {
|
|
|
349
397
|
}
|
|
350
398
|
}
|
|
351
399
|
}
|
|
352
|
-
return { canonical: canon, sourcePlans, spacesMerge, linkPlans, sessionIds, spaceIds };
|
|
400
|
+
return { canonical: canon, sourcePlans, spacesMerge, routinesMerges, linkPlans, sessionIds, spaceIds };
|
|
353
401
|
}
|
|
354
402
|
function timestamp() {
|
|
355
403
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
@@ -411,11 +459,23 @@ function printPlan(plan, linkMode) {
|
|
|
411
459
|
if (sp.spacesAdded.length > 0)
|
|
412
460
|
out.write(` spaces.json: ${c.green("+" + sp.spacesAdded.length)} new space(s)
|
|
413
461
|
`);
|
|
462
|
+
if (sp.routinesAdded.length > 0) {
|
|
463
|
+
out.write(` scheduled-tasks.json: ${c.green("+" + sp.routinesAdded.length)} new routine(s)
|
|
464
|
+
`);
|
|
465
|
+
for (const line of listCapped(sp.routinesAdded.map((r) => ` + ${r.task?.id ?? "(unnamed)"} ${c.dim("[" + r.tree + "]")}`)))
|
|
466
|
+
out.write(line + `
|
|
467
|
+
`);
|
|
468
|
+
}
|
|
414
469
|
}
|
|
415
470
|
if (plan.spacesMerge) {
|
|
416
471
|
const m = plan.spacesMerge;
|
|
417
472
|
out.write(`
|
|
418
473
|
${c.bold("spaces.json (canonical)")}: ${m.before} existing, ${c.green("+" + m.added)} added, ` + `${m.merged.length} total${m.willWrite ? "" : c.dim(" (no write needed)")}
|
|
474
|
+
`);
|
|
475
|
+
}
|
|
476
|
+
for (const rm of plan.routinesMerges) {
|
|
477
|
+
out.write(`
|
|
478
|
+
${c.bold("scheduled-tasks.json (canonical)")} ${c.dim("[" + rm.tree + "]")}: ${rm.before} existing, ` + `${c.green("+" + rm.addedTasks)} added, ${rm.mergedTasks.length} total` + (rm.skipsAdded > 0 ? `, ${c.green("+" + rm.skipsAdded)} skip record(s)` : "") + `${rm.willWrite ? "" : c.dim(" (no write needed)")}
|
|
419
479
|
`);
|
|
420
480
|
}
|
|
421
481
|
if (linkMode) {
|
|
@@ -446,12 +506,21 @@ function printPlan(plan, linkMode) {
|
|
|
446
506
|
`);
|
|
447
507
|
}
|
|
448
508
|
}
|
|
509
|
+
const totalRoutinesAdded = plan.routinesMerges.reduce((n, rm) => n + rm.addedTasks, 0);
|
|
449
510
|
out.write(`
|
|
450
|
-
${c.bold("Totals")}: ${totalCopyFiles} file(s) + ${totalCopyDirs} dir(s) to copy, ` + `${totalConflicts} conflict(s) kept, ${plan.spacesMerge ? plan.spacesMerge.added : 0} space(s) added` + (linkMode ? `, ${plan.linkPlans.filter((l) => !l.alreadySymlink).length} dir(s) to symlink` : "") + `
|
|
511
|
+
${c.bold("Totals")}: ${totalCopyFiles} file(s) + ${totalCopyDirs} dir(s) to copy, ` + `${totalConflicts} conflict(s) kept, ${plan.spacesMerge ? plan.spacesMerge.added : 0} space(s) added, ` + `${totalRoutinesAdded} routine(s) added` + (linkMode ? `, ${plan.linkPlans.filter((l) => !l.alreadySymlink).length} dir(s) to symlink` : "") + `
|
|
451
512
|
`);
|
|
452
513
|
}
|
|
453
514
|
function executePlan(plan, linkMode) {
|
|
454
|
-
const r = {
|
|
515
|
+
const r = {
|
|
516
|
+
copiedFiles: 0,
|
|
517
|
+
copiedDirs: 0,
|
|
518
|
+
spacesWritten: false,
|
|
519
|
+
routinesWritten: 0,
|
|
520
|
+
linked: 0,
|
|
521
|
+
backedUp: 0,
|
|
522
|
+
errors: []
|
|
523
|
+
};
|
|
455
524
|
for (const sp of plan.sourcePlans) {
|
|
456
525
|
if (sp.source.key === plan.canonical.key)
|
|
457
526
|
continue;
|
|
@@ -479,6 +548,17 @@ function executePlan(plan, linkMode) {
|
|
|
479
548
|
r.errors.push(`write ${plan.spacesMerge.canonPath}: ${err.message}`);
|
|
480
549
|
}
|
|
481
550
|
}
|
|
551
|
+
for (const rm of plan.routinesMerges) {
|
|
552
|
+
if (!rm.willWrite)
|
|
553
|
+
continue;
|
|
554
|
+
try {
|
|
555
|
+
fs.mkdirSync(path.dirname(rm.canonPath), { recursive: true });
|
|
556
|
+
fs.writeFileSync(rm.canonPath, JSON.stringify({ scheduledTasks: rm.mergedTasks, recordedSkips: rm.recordedSkips }, null, 2));
|
|
557
|
+
r.routinesWritten += 1;
|
|
558
|
+
} catch (err) {
|
|
559
|
+
r.errors.push(`write ${rm.canonPath}: ${err.message}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
482
562
|
if (linkMode) {
|
|
483
563
|
for (const lp of plan.linkPlans) {
|
|
484
564
|
if (lp.alreadySymlink)
|
|
@@ -899,7 +979,7 @@ No source locations differ from canonical — nothing to merge. (This is a valid
|
|
|
899
979
|
levelDbReport(base, plan);
|
|
900
980
|
return;
|
|
901
981
|
}
|
|
902
|
-
const hasWork = plan.sourcePlans.some((sp) => sp.copies.length > 0) || (plan.spacesMerge?.willWrite ?? false) || plan.linkPlans.some((lp) => !lp.alreadySymlink);
|
|
982
|
+
const hasWork = plan.sourcePlans.some((sp) => sp.copies.length > 0) || (plan.spacesMerge?.willWrite ?? false) || plan.routinesMerges.some((rm) => rm.willWrite) || plan.linkPlans.some((lp) => !lp.alreadySymlink);
|
|
903
983
|
if (!hasWork) {
|
|
904
984
|
process.stdout.write(`
|
|
905
985
|
` + c.green("Nothing to apply — plan is a no-op. Done.") + `
|
|
@@ -929,6 +1009,8 @@ No source locations differ from canonical — nothing to merge. (This is a valid
|
|
|
929
1009
|
process.stdout.write(` Copied: ${c.green(String(result.copiedFiles))} file(s), ${c.green(String(result.copiedDirs))} dir(s)
|
|
930
1010
|
`);
|
|
931
1011
|
process.stdout.write(` spaces.json: ${result.spacesWritten ? c.green("written") : c.dim("unchanged")}
|
|
1012
|
+
`);
|
|
1013
|
+
process.stdout.write(` scheduled-tasks.json: ${result.routinesWritten > 0 ? c.green(result.routinesWritten + " file(s) written") : c.dim("unchanged")}
|
|
932
1014
|
`);
|
|
933
1015
|
if (linkMode)
|
|
934
1016
|
process.stdout.write(` Linked: ${c.green(String(result.linked))} dir(s) symlinked, ${result.backedUp} backed up
|
|
@@ -945,7 +1027,7 @@ No source locations differ from canonical — nothing to merge. (This is a valid
|
|
|
945
1027
|
}
|
|
946
1028
|
levelDbReport(base, plan);
|
|
947
1029
|
process.stdout.write(`
|
|
948
|
-
` + c.cyan("Reopen the Claude desktop app to see the merged sessions.") + `
|
|
1030
|
+
` + c.cyan("Reopen the Claude desktop app to see the merged sessions and routines.") + `
|
|
949
1031
|
`);
|
|
950
1032
|
} finally {
|
|
951
1033
|
if (rl)
|