claude-code-backup 1.0.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.
@@ -0,0 +1,583 @@
1
+ import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import simpleGit from 'simple-git';
5
+ import { Octokit } from '@octokit/rest';
6
+ import { REPO_DIR } from '../core/config.js';
7
+ import { collectFiles, buildManifest } from '../core/collector.js';
8
+ import { log, spinner } from '../utils/logger.js';
9
+
10
+ function remoteUrl(config) {
11
+ return `https://${config.github.pat}@github.com/${config.github.repo}.git`;
12
+ }
13
+
14
+ function repoGit() {
15
+ return simpleGit(REPO_DIR);
16
+ }
17
+
18
+ async function configureGit(g) {
19
+ await g.addConfig('user.email', 'claude-backup@local', false, 'local');
20
+ await g.addConfig('user.name', 'claude-backup', false, 'local');
21
+ }
22
+
23
+ async function syncRemote(g, config) {
24
+ const remotes = await g.getRemotes();
25
+ if (remotes.find(r => r.name === 'origin')) {
26
+ await g.remote(['set-url', 'origin', remoteUrl(config)]);
27
+ } else {
28
+ await g.addRemote('origin', remoteUrl(config));
29
+ }
30
+ }
31
+
32
+ function buildReadme(config) {
33
+ const now = new Date().toISOString().slice(0, 10);
34
+ const watchedList = config.watched_dirs.map(d => `- \`${d}\``).join('\n');
35
+ const excludeList = config.exclude.map(e => `- \`${e}\``).join('\n');
36
+ const claudeMdDirs = config.claude_md_dirs || [];
37
+ const claudeMdList = claudeMdDirs.length
38
+ ? claudeMdDirs.map(d => `- \`${d}/CLAUDE.md\``).join('\n')
39
+ : '_No project CLAUDE.md dirs configured yet._\n\nAdd one with: `claude-backup config add-project /path/to/project`';
40
+
41
+ return `# Claude Code Backup
42
+
43
+ > Auto-synced by [claude-backup](https://www.npmjs.com/package/claude-backup) — a CLI tool that watches your Claude Code data directories and commits every change to this private repo.
44
+
45
+ **Repo:** \`${config.github.repo}\` · **Branch:** \`${config.github.branch}\` · **Last setup:** ${now}
46
+
47
+ ---
48
+
49
+ ## What Is This Repo?
50
+
51
+ This is a private, automated backup of all your [Claude Code](https://claude.ai/code) data — including:
52
+
53
+ | What | Why it matters |
54
+ |---|---|
55
+ | **Memory files** | Claude's persistent knowledge about you, your projects, and your preferences |
56
+ | **Settings** | Global and project-level Claude Code configuration |
57
+ | **Keybindings** | Your custom keyboard shortcuts |
58
+ | **Custom commands** | Slash commands and skills you've defined |
59
+ | **Project \`CLAUDE.md\` files** | Per-project instructions that Claude reads at the start of every session |
60
+
61
+ Without a backup, all of this is lost if you get a new machine, accidentally delete \`~/.claude/\`, or need to set up Claude Code on a second device.
62
+
63
+ ---
64
+
65
+ ## Watched Directories
66
+
67
+ The following directories are fully mirrored here (recursive):
68
+
69
+ ${watchedList}
70
+
71
+ Files excluded from backup:
72
+
73
+ ${excludeList}
74
+
75
+ ## Project CLAUDE.md Files
76
+
77
+ Only the \`CLAUDE.md\` file is collected from each of these project roots
78
+ (the rest of the project source code is not touched):
79
+
80
+ ${claudeMdList}
81
+
82
+ Add or remove projects at any time:
83
+ \`\`\`bash
84
+ claude-backup config add-project /path/to/project
85
+ claude-backup config remove-project /path/to/project
86
+ \`\`\`
87
+
88
+ ---
89
+
90
+ ## Repo Structure
91
+
92
+ \`\`\`
93
+ /
94
+ ├── README.md ← this file (regenerated on every push)
95
+ ├── backup-manifest.json ← maps each folder label → original source path
96
+
97
+ ├── Users_<name>_.claude/ ← full mirror of ~/.claude/
98
+ │ ├── settings.json ← global Claude Code settings
99
+ │ ├── keybindings.json ← custom keyboard shortcuts
100
+ │ ├── projects/ ← per-project memory and settings
101
+ │ │ └── <project-hash>/
102
+ │ │ └── memory/
103
+ │ │ ├── MEMORY.md ← memory index for that project
104
+ │ │ └── *.md ← individual memory files
105
+ │ └── commands/ ← custom slash commands / skills
106
+ │ └── *.md
107
+
108
+ ├── claude_md_Users_<name>_myapp/ ← CLAUDE.md from a project root
109
+ │ └── CLAUDE.md
110
+
111
+ └── claude_md_Users_<name>_other/ ← CLAUDE.md from another project
112
+ └── CLAUDE.md
113
+ \`\`\`
114
+
115
+ **Directory labels** are created by replacing \`/\` with \`_\` in the source path.
116
+ Labels starting with \`claude_md_\` are project CLAUDE.md entries — only that single file
117
+ is stored, not the whole project. \`backup-manifest.json\` records the original path for
118
+ every label so restore always knows exactly where to copy files back.
119
+
120
+ ---
121
+
122
+ ## Claude Code Memory — Quick Primer
123
+
124
+ Claude Code has a file-based memory system stored under \`~/.claude/projects/<hash>/memory/\`:
125
+
126
+ - **\`MEMORY.md\`** — the index file, loaded into every conversation with that project
127
+ - **\`*.md\` files** — individual memory entries (user profile, feedback, project context, references)
128
+
129
+ Memory entries have types:
130
+ | Type | Contains |
131
+ |---|---|
132
+ | \`user\` | Who you are, your role, expertise level |
133
+ | \`feedback\` | How Claude should behave — what to avoid, what to repeat |
134
+ | \`project\` | Active work, goals, deadlines, architectural decisions |
135
+ | \`reference\` | Where to find things — Linear projects, Grafana dashboards, Slack channels |
136
+
137
+ These files are the most valuable thing to back up because they accumulate over months and are impossible to recreate from scratch.
138
+
139
+ ---
140
+
141
+ ## Installation
142
+
143
+ \`\`\`bash
144
+ npm install -g claude-backup
145
+ \`\`\`
146
+
147
+ Requires Node.js 18 or later.
148
+
149
+ ---
150
+
151
+ ## Command Reference
152
+
153
+ ### \`claude-backup init\`
154
+
155
+ Interactive setup wizard. Run this once (or again to reconfigure).
156
+
157
+ \`\`\`
158
+ $ claude-backup init
159
+
160
+ Step 1 of 3 — GitHub Personal Access Token
161
+ Create your token here: https://github.com/settings/tokens/new
162
+ Required scope: repo (top-level checkbox)
163
+
164
+ ? Paste your GitHub PAT: ****
165
+
166
+ Step 2 of 3 — Repository & Branch
167
+ ? GitHub repo name: yourname/claude-backup
168
+ ? Branch name: main
169
+
170
+ Step 3 of 3 — Watched Directories & Filters
171
+ ? Directories to watch: ~/.claude
172
+ ? Files to exclude: settings.local.json, *.log, .DS_Store
173
+ ? Debounce delay in ms: 2000
174
+ \`\`\`
175
+
176
+ What it does:
177
+ - Saves config to \`~/.config/claude-backup/config.json\` (chmod 600)
178
+ - Creates this GitHub repo as private if it doesn't exist
179
+ - Clones the repo locally to \`~/.config/claude-backup/repo/\`
180
+ - Writes this README into the repo
181
+
182
+ ---
183
+
184
+ ### \`claude-backup push\`
185
+
186
+ Manually push a backup right now.
187
+
188
+ \`\`\`bash
189
+ claude-backup push # auto commit message: "backup: <ISO timestamp>"
190
+ claude-backup push -m "before upgrade" # custom commit message
191
+ \`\`\`
192
+
193
+ What it does:
194
+ 1. Walks all watched directories, applies exclude filters
195
+ 2. Copies every file into the local repo clone under its label directory
196
+ 3. Writes \`backup-manifest.json\` with the source-path mapping
197
+ 4. \`git add -A\` → \`git commit\` → \`git push\`
198
+ 5. Skips the push if nothing changed
199
+
200
+ ---
201
+
202
+ ### \`claude-backup pull\`
203
+
204
+ Restore files from this repo back to their original locations.
205
+
206
+ \`\`\`bash
207
+ claude-backup pull # restore the latest backup
208
+ claude-backup pull --history # browse commits, pick a specific version
209
+ claude-backup pull --dry-run # preview what would be restored — no files written
210
+ \`\`\`
211
+
212
+ **Safety:** before writing anything, \`pull\` creates a timestamped snapshot of your current state at \`~/.config/claude-backup/pre-restore-<timestamp>/\` so you can always undo.
213
+
214
+ Restore flow:
215
+ 1. \`git fetch origin\` to get latest from GitHub
216
+ 2. If \`--history\`: show commit list → you pick one
217
+ 3. Checkout the selected commit's files into the local clone
218
+ 4. Read \`backup-manifest.json\` to determine target paths
219
+ 5. Ask for confirmation, show a preview of affected files
220
+ 6. Create safety snapshot, then copy files to their source locations
221
+
222
+ ---
223
+
224
+ ### \`claude-backup watch\`
225
+
226
+ Start the real-time file watcher. Detects changes in watched directories and auto-pushes to GitHub.
227
+
228
+ \`\`\`bash
229
+ claude-backup watch # real-time via chokidar (recommended)
230
+ claude-backup watch --interval 5 # poll every 5 minutes instead
231
+ \`\`\`
232
+
233
+ The watcher:
234
+ - Uses [chokidar](https://github.com/paulmillr/chokidar) for efficient, cross-platform file watching
235
+ - Debounces rapid changes (default: 2000 ms) so a burst of saves becomes one commit
236
+ - Ignores \`.git\` subdirectories to avoid feedback loops
237
+ - Handles \`SIGTERM\` and \`SIGINT\` gracefully (important for launchd)
238
+ - Logs every sync with a timestamp
239
+
240
+ In normal use you don't run this directly — the launchd service runs it for you in the background.
241
+
242
+ ---
243
+
244
+ ### \`claude-backup status\`
245
+
246
+ Show a summary of the current state.
247
+
248
+ \`\`\`
249
+ $ claude-backup status
250
+
251
+ Claude Backup Status
252
+
253
+ Watched dirs:
254
+ /Users/you/.claude
255
+
256
+ Files tracked: 42
257
+
258
+ GitHub: yourname/claude-backup [main]
259
+ Last backup: 2026-05-18 10:34:22
260
+ Commit: a3f9c1d backup: 2026-05-18T10:34:22.000Z
261
+ Sync status: Up to date
262
+
263
+ Auto-sync service: running (PID 1234)
264
+ \`\`\`
265
+
266
+ ---
267
+
268
+ ### \`claude-backup service <action>\`
269
+
270
+ Manage the macOS launchd background service that runs the file watcher automatically on every login.
271
+
272
+ \`\`\`bash
273
+ claude-backup service install # write plist + load service now
274
+ claude-backup service uninstall # stop service + remove plist
275
+ claude-backup service status # check if running, show PID
276
+ claude-backup service logs # tail the watcher log in real-time
277
+ \`\`\`
278
+
279
+ The service plist is installed at:
280
+ \`\`\`
281
+ ~/Library/LaunchAgents/com.claude-backup.watch.plist
282
+ \`\`\`
283
+
284
+ Key plist settings:
285
+ - \`RunAtLoad: true\` — starts when the service is loaded (immediately on install)
286
+ - \`KeepAlive: true\` — launchd restarts the watcher if it ever crashes
287
+ - \`ThrottleInterval: 10\` — prevents rapid restart loops if something goes wrong
288
+ - Stdout → \`~/.config/claude-backup/watch.log\`
289
+ - Stderr → \`~/.config/claude-backup/watch.error.log\`
290
+
291
+ ---
292
+
293
+ ### \`claude-backup config <action>\`
294
+
295
+ View or modify configuration without re-running \`init\`.
296
+
297
+ \`\`\`bash
298
+ claude-backup config show # print current config (PAT masked)
299
+ claude-backup config set auto_sync.debounce_ms 3000 # change a value
300
+ claude-backup config add-dir /path/to/project # watch an extra directory
301
+ claude-backup config remove-dir /path/to/project # stop watching a directory
302
+ \`\`\`
303
+
304
+ Nested keys use dot notation. Values are auto-cast (numbers, booleans).
305
+
306
+ ---
307
+
308
+ ## Auto-Sync Setup (Recommended)
309
+
310
+ After running \`init\` and \`push\`, install the background service:
311
+
312
+ \`\`\`bash
313
+ claude-backup service install
314
+ \`\`\`
315
+
316
+ From this point on:
317
+ - The watcher starts automatically every time you log in to macOS
318
+ - Any change to a watched directory is committed and pushed to GitHub within ~2 seconds
319
+ - If the watcher crashes, launchd restarts it automatically
320
+ - You never have to think about backups again
321
+
322
+ To verify it's working:
323
+
324
+ \`\`\`bash
325
+ claude-backup status # should show "running (PID ...)"
326
+ claude-backup service logs # live log of every sync
327
+ \`\`\`
328
+
329
+ ---
330
+
331
+ ## Restore Guide
332
+
333
+ ### Restore to the same machine (after accidental deletion)
334
+
335
+ \`\`\`bash
336
+ claude-backup pull
337
+ \`\`\`
338
+
339
+ Confirm the preview, and your files are back.
340
+
341
+ ### Restore to a new machine
342
+
343
+ \`\`\`bash
344
+ # 1. Install Node.js (18+) and claude-backup
345
+ npm install -g claude-backup
346
+
347
+ # 2. Run init with the same GitHub repo
348
+ claude-backup init
349
+ # → Same repo name (yourname/claude-backup)
350
+ # → New GitHub PAT (generate a new one)
351
+
352
+ # 3. Pull the latest backup
353
+ claude-backup pull
354
+
355
+ # 4. Install the background service on the new machine
356
+ claude-backup service install
357
+ \`\`\`
358
+
359
+ ### Restore a specific historical version
360
+
361
+ \`\`\`bash
362
+ claude-backup pull --history
363
+ \`\`\`
364
+
365
+ This shows a list of all commits in this repo. Select the one you want — the timestamp and commit message help identify when each backup was taken.
366
+
367
+ ---
368
+
369
+ ## Configuration Reference
370
+
371
+ Config file location: \`~/.config/claude-backup/config.json\`
372
+
373
+ | Key | Type | Default | Description |
374
+ |---|---|---|---|
375
+ | \`backend\` | string | \`"github"\` | Storage backend (currently only \`github\`) |
376
+ | \`github.repo\` | string | — | GitHub repo in \`owner/name\` format |
377
+ | \`github.branch\` | string | \`"main"\` | Branch to push to |
378
+ | \`github.pat\` | string | — | Personal Access Token (stored chmod 600) |
379
+ | \`watched_dirs\` | string[] | \`["~/.claude"]\` | Directories to fully mirror |
380
+ | \`claude_md_dirs\` | string[] | \`[]\` | Project roots — only \`CLAUDE.md\` is backed up from each |
381
+ | \`exclude\` | string[] | see below | Filenames/globs to skip |
382
+ | \`auto_sync.debounce_ms\` | number | \`2000\` | ms to wait after last change before pushing |
383
+
384
+ Default excludes: \`settings.local.json\`, \`*.log\`, \`.DS_Store\`
385
+
386
+ The local repo clone lives at: \`~/.config/claude-backup/repo/\`
387
+
388
+ ---
389
+
390
+ ## Security Notes
391
+
392
+ - This repo is **private** — only you and anyone you explicitly invite can see it
393
+ - Your GitHub PAT is stored locally at \`~/.config/claude-backup/config.json\` with \`chmod 600\` (owner-read only)
394
+ - The PAT is never committed to this repo
395
+ - \`settings.local.json\` is excluded by default because it may contain machine-specific or sensitive values
396
+ - If you ever rotate your PAT, run \`claude-backup config set github.pat <new-token>\` then \`claude-backup service install\` to restart the watcher with the new token
397
+
398
+ ---
399
+
400
+ ## Troubleshooting
401
+
402
+ **"Not configured. Run: claude-backup init"**
403
+ The config file is missing or incomplete. Re-run \`claude-backup init\`.
404
+
405
+ **Push fails with authentication error**
406
+ Your PAT may have expired or been revoked. Generate a new one at
407
+ https://github.com/settings/tokens/new and run:
408
+ \`\`\`bash
409
+ claude-backup config set github.pat <new-token>
410
+ \`\`\`
411
+
412
+ **Service shows "loaded but not running"**
413
+ Check the error log:
414
+ \`\`\`bash
415
+ claude-backup service logs
416
+ cat ~/.config/claude-backup/watch.error.log
417
+ \`\`\`
418
+ Then reinstall the service: \`claude-backup service install\`
419
+
420
+ **Files not being detected by the watcher**
421
+ Confirm the directory is in your watch list:
422
+ \`\`\`bash
423
+ claude-backup config show
424
+ claude-backup config add-dir /path/to/missing/dir
425
+ \`\`\`
426
+
427
+ **Restore wrote wrong files / I want to undo**
428
+ Every restore creates a safety snapshot before touching anything:
429
+ \`\`\`bash
430
+ ls ~/.config/claude-backup/pre-restore-*/
431
+ \`\`\`
432
+ Copy the files you need back from there manually.
433
+
434
+ ---
435
+
436
+ *This README is auto-generated by \`claude-backup init\` and reflects the configuration at setup time.*
437
+ `;
438
+ }
439
+
440
+ export async function ensureRepo(config) {
441
+ const octokit = new Octokit({ auth: config.github.pat });
442
+ const [owner, repo] = config.github.repo.split('/');
443
+
444
+ // Check if repo exists, create it if not
445
+ let repoExists = false;
446
+ try {
447
+ await octokit.repos.get({ owner, repo });
448
+ repoExists = true;
449
+ } catch (err) {
450
+ if (err.status !== 404) throw err;
451
+ }
452
+
453
+ if (!repoExists) {
454
+ const spin = spinner('Creating private GitHub repo...').start();
455
+ await octokit.repos.createForAuthenticatedUser({
456
+ name: repo,
457
+ private: true,
458
+ description: 'Claude Code backup — memory, settings, commands',
459
+ auto_init: true,
460
+ });
461
+ spin.succeed(`Created: github.com/${config.github.repo}`);
462
+ } else {
463
+ log.info(`Repo exists: github.com/${config.github.repo}`);
464
+ }
465
+
466
+ // Clone locally if not already done
467
+ if (!existsSync(REPO_DIR)) {
468
+ mkdirSync(REPO_DIR, { recursive: true });
469
+ const spin = spinner('Cloning repo...').start();
470
+ await simpleGit().clone(remoteUrl(config), REPO_DIR);
471
+ spin.succeed('Repo cloned to: ' + REPO_DIR);
472
+ } else {
473
+ log.info('Local repo clone already exists');
474
+ }
475
+
476
+ // Seed the README so it exists in the clone from day one
477
+ writeFileSync(join(REPO_DIR, 'README.md'), buildReadme(config), 'utf8');
478
+ }
479
+
480
+ export async function push(config, commitMessage) {
481
+ if (!existsSync(REPO_DIR)) {
482
+ log.error('Repo not set up. Run: claude-backup init');
483
+ process.exit(1);
484
+ }
485
+
486
+ const files = collectFiles(config);
487
+ if (files.length === 0) {
488
+ log.warn('No files found — check your watched_dirs in config');
489
+ return 0;
490
+ }
491
+
492
+ const spin = spinner(`Staging ${files.length} files...`).start();
493
+
494
+ // Copy all watched files into the repo mirror
495
+ for (const { src, dest } of files) {
496
+ const target = join(REPO_DIR, dest);
497
+ mkdirSync(dirname(target), { recursive: true });
498
+ try {
499
+ copyFileSync(src, target);
500
+ } catch {
501
+ // Skip unreadable files silently
502
+ }
503
+ }
504
+
505
+ // Write the manifest so pull knows where to restore each label dir
506
+ writeFileSync(
507
+ join(REPO_DIR, 'backup-manifest.json'),
508
+ JSON.stringify(buildManifest(config), null, 2),
509
+ 'utf8'
510
+ );
511
+
512
+ // Always regenerate README so it stays current
513
+ writeFileSync(join(REPO_DIR, 'README.md'), buildReadme(config), 'utf8');
514
+
515
+ const g = repoGit();
516
+ await configureGit(g);
517
+ await syncRemote(g, config);
518
+
519
+ const status = await g.status();
520
+ if (status.files.length === 0) {
521
+ spin.succeed('Everything up to date — nothing to push');
522
+ return 0;
523
+ }
524
+
525
+ spin.text = `Committing ${status.files.length} change(s)...`;
526
+ await g.add('-A');
527
+ await g.commit(commitMessage || `backup: ${new Date().toISOString()}`);
528
+
529
+ spin.text = 'Pushing to GitHub...';
530
+ await g.raw(['push', '-u', 'origin', config.github.branch]);
531
+
532
+ spin.succeed(`Pushed ${status.files.length} file(s) → github.com/${config.github.repo}`);
533
+ return status.files.length;
534
+ }
535
+
536
+ export async function getStatus(config) {
537
+ if (!existsSync(REPO_DIR)) return null;
538
+ const g = repoGit();
539
+ try {
540
+ const logResult = await g.log(['--max-count=1']);
541
+ const status = await g.status();
542
+ return {
543
+ lastCommit: logResult.latest,
544
+ pending: status.files.length,
545
+ };
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ export async function getHistory(config, limit = 15) {
552
+ if (!existsSync(REPO_DIR)) return [];
553
+ const g = repoGit();
554
+ await syncRemote(g, config);
555
+ await g.fetch('origin');
556
+ const logResult = await g.log({ maxCount: limit });
557
+ return logResult.all;
558
+ }
559
+
560
+ export async function fetchForRestore(config, ref) {
561
+ if (!existsSync(REPO_DIR)) {
562
+ log.error('Repo not set up. Run: claude-backup init');
563
+ process.exit(1);
564
+ }
565
+
566
+ const g = repoGit();
567
+ await configureGit(g);
568
+ await syncRemote(g, config);
569
+
570
+ const spin = spinner('Fetching from GitHub...').start();
571
+ await g.fetch('origin');
572
+ spin.stop();
573
+
574
+ // Checkout the target ref files into the working tree
575
+ const targetRef = ref || `origin/${config.github.branch}`;
576
+ await g.checkout([targetRef, '--', '.']);
577
+
578
+ // Read the manifest that was just checked out
579
+ const manifestPath = join(REPO_DIR, 'backup-manifest.json');
580
+ if (!existsSync(manifestPath)) return null;
581
+
582
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
583
+ }