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 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 by id
170
- (canonical wins). LINK (destructive, opt-in) replaces each source org dir with a symlink to canonical.
171
- REVERT undoes LINK by restoring the *.bak-<ts> backups it left behind. Every run is logged to a file.
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 = { copiedFiles: 0, copiedDirs: 0, spacesWritten: false, linked: 0, backedUp: 0, errors: [] };
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-desktop-merge",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Merge & sync Claude desktop-app sessions across accounts (macOS, zero-dependency, dry-run by default).",
5
5
  "keywords": [
6
6
  "claude",