Encrypted cross-device sync for Claude Code sessions
| Home | How It Works | Security |
Claude Sync follows a layered architecture with a pluggable storage abstraction:
┌─────────────────────────────────────────────────────────┐
│ CLI Layer │
│ cmd/claude-sync/main.go (~1500 lines) │
│ │
│ Commands: init, push, pull, status, diff, conflicts, │
│ reset, update, version │
│ UI: Interactive prompts (survey), progress reporting │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ Sync Layer │
│ internal/sync/ │
│ │
│ sync.go - Syncer struct, push/pull orchestration │
│ state.go - SyncState, FileState, change detection │
└────────────────────────┬────────────────────────────────┘
│
┌─────────────┴─────────────┐
│ │
┌──────────▼──────────┐ ┌───────────▼───────────┐
│ Crypto Layer │ │ Storage Layer │
│ internal/crypto/ │ │ internal/storage/ │
│ │ │ │
│ encrypt.go │ │ storage.go (interface)│
│ - Encrypt() │ │ config.go (unified) │
│ - Decrypt() │ │ │
│ - GenerateKey() │ │ ┌─────────────────┐ │
│ - DeriveKey() │ │ │ Adapters │ │
│ │ │ ├─────────────────┤ │
│ │ │ │ r2/r2.go │ │
│ │ │ │ s3/s3.go │ │
│ │ │ │ gcs/gcs.go │ │
│ │ │ └─────────────────┘ │
└──────────┬──────────┘ └───────────┬───────────┘
│ │
┌──────────▼───────────────────────────▼───────────┐
│ Config Layer │
│ internal/config/ │
│ │
│ config.go - YAML config, path resolution │
│ Backward compatible with legacy R2-only format │
└──────────────────────────────────────────────────┘
The storage layer uses an interface-based adapter pattern to support multiple cloud providers:
// internal/storage/storage.go
type Storage interface {
Upload(ctx context.Context, key string, data []byte) error
Download(ctx context.Context, key string) ([]byte, error)
Delete(ctx context.Context, key string) error
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
Head(ctx context.Context, key string) (*ObjectInfo, error)
BucketExists(ctx context.Context) (bool, error)
}
| Adapter | File | SDK Used |
|---|---|---|
| R2 | internal/storage/r2/r2.go |
AWS SDK v2 (S3-compatible) |
| S3 | internal/storage/s3/s3.go |
AWS SDK v2 |
| GCS | internal/storage/gcs/gcs.go |
Google Cloud Storage SDK |
// internal/storage/config.go
type StorageConfig struct {
Provider Provider // r2, s3, or gcs
Bucket string
// R2/S3 common
AccessKeyID string
SecretAccessKey string
Endpoint string
Region string
// R2-specific
AccountID string
// GCS-specific
ProjectID string
CredentialsFile string
CredentialsJSON string
UseDefaultCredentials bool
}
func New(cfg *StorageConfig) (Storage, error) {
switch cfg.Provider {
case ProviderR2:
return NewR2(cfg)
case ProviderS3:
return NewS3(cfg)
case ProviderGCS:
return NewGCS(cfg)
default:
return nil, fmt.Errorf("unsupported provider: %s", cfg.Provider)
}
}
Adapters self-register using Go’s init() pattern:
// internal/storage/r2/r2.go
func init() {
storage.NewR2 = NewR2Adapter
}
This allows the main binary to include only needed adapters via imports:
// cmd/claude-sync/main.go
import (
_ "github.com/tawanorg/claude-sync/internal/storage/gcs"
_ "github.com/tawanorg/claude-sync/internal/storage/r2"
_ "github.com/tawanorg/claude-sync/internal/storage/s3"
)
cmd/claude-sync/main.goThe CLI entry point using Cobra and Survey.
Key Components:
runR2Wizard, runS3Wizard, runGCSWizard)Interactive Wizards:
init → Select Provider → Provider-specific wizard → Encryption setup → Test connection
↓
┌────┴────┬────────────┐
│ │ │
R2 S3 GCS
wizard wizard wizard
internal/sync/Orchestrates synchronization operations.
sync.go - Syncer struct:
type Syncer struct {
claudeDir string // ~/.claude
storage storage.Storage // Provider-agnostic interface
keyPath string // Path to age key
state *SyncState
statePath string // ~/.claude-sync/state.json
quiet bool
progressFn func(ProgressEvent)
}
Key Methods:
Push(ctx) - Detect changes, encrypt, uploadPull(ctx) - Fetch remote state, download, decryptStatus(ctx) - List pending local changesDiff(ctx) - Compare local vs remotestate.go - State Management:
type FileState struct {
Path string // Relative path (e.g., "projects/foo/session.json")
Hash string // SHA256 hash of file contents
Size int64 // File size in bytes
ModTime time.Time // Local modification time
Uploaded time.Time // When last pushed to storage
}
type SyncState struct {
Files map[string]*FileState
LastSync time.Time
DeviceID string // Hostname
LastPush time.Time
LastPull time.Time
}
internal/crypto/Handles all encryption operations using the age library.
Key Functions:
func GenerateKey(keyPath string) error
func GenerateKeyFromPassphrase(keyPath, passphrase string) error
func ValidatePassphraseStrength(passphrase string) error
func KeyExists(keyPath string) bool
func Encrypt(data []byte, keyPath string) ([]byte, error)
func Decrypt(data []byte, keyPath string) ([]byte, error)
internal/config/Configuration management with backward compatibility.
Config struct:
type Config struct {
// New format (preferred)
Storage *storage.StorageConfig `yaml:"storage,omitempty"`
// Legacy R2 fields (backward compatible)
AccountID string `yaml:"account_id,omitempty"`
AccessKeyID string `yaml:"access_key_id,omitempty"`
SecretAccessKey string `yaml:"secret_access_key,omitempty"`
Bucket string `yaml:"bucket,omitempty"`
EncryptionKey string `yaml:"encryption_key_path"`
}
Sync Paths:
var SyncPaths = []string{
"CLAUDE.md",
"settings.json",
"settings.local.json",
"agents",
"skills",
"plugins",
"projects",
"history.jsonl",
"rules",
}
Local Files (~/.claude/)
│
▼
┌───────────────────┐
│ Change Detection │ Compare with SyncState (hash, modtime)
│ │ Result: add/modify/delete lists
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Read & Encrypt │ For each changed file:
│ (age encryption) │ Read → Encrypt → Bytes
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Upload via │ storage.Upload(key, data)
│ Storage Interface│ Provider handles specifics
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Update State │ Record hash, size, upload time
│ (state.json) │ Persist to ~/.claude-sync/state.json
└───────────────────┘
Cloud Storage (via Storage interface)
│
▼
┌───────────────────┐
│ List Objects │ storage.List("")
│ │ Compare with local state
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Conflict Check │ If local AND remote changed:
│ │ → Keep local, save remote as .conflict
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Download & │ storage.Download(key) → Decrypt → Write
│ Decrypt │
└────────┬──────────┘
│
▼
┌───────────────────┐
│ Update State │ Record new hash, pull time
└───────────────────┘
~/.claude-sync/
├── config.yaml # Storage + encryption config (0600)
├── age-key.txt # Encryption key (0600)
└── state.json # Sync state (file hashes, timestamps)
~/.claude/ # Claude Code directory (synced)
├── CLAUDE.md
├── settings.json
├── settings.local.json
├── history.jsonl
├── agents/
├── skills/
├── plugins/
├── projects/
│ └── <project-hash>/
│ ├── session.json
│ └── auto-memory.jsonl
└── rules/
storage:
provider: r2 # or s3, gcs
bucket: claude-sync
account_id: abc123 # R2 only
access_key_id: AKIA...
secret_access_key: xxx
region: us-east-1 # S3 only
project_id: my-project # GCS only
encryption_key_path: ~/.claude-sync/age-key.txt
# Still supported for backward compatibility
account_id: abc123
access_key_id: AKIA...
secret_access_key: xxx
bucket: claude-sync
encryption_key_path: ~/.claude-sync/age-key.txt
| Package | Version | Purpose |
|---|---|---|
filippo.io/age |
v1.3.1 | File encryption |
golang.org/x/crypto/argon2 |
v0.45.0+ | Key derivation |
aws/aws-sdk-go-v2 |
v1.41.1+ | R2/S3 storage |
cloud.google.com/go/storage |
v1.50.0+ | GCS storage |
spf13/cobra |
v1.10.2 | CLI framework |
AlecAivazis/survey/v2 |
v2.3.7 | Interactive prompts |
gopkg.in/yaml.v3 |
v3.0.1 | Config parsing |