Skip to content
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- **Selective sync**: Choose `--scope sessions` to sync only conversation data (skip plugins/node_modules)
- **Interactive wizard**: Arrow-key driven setup with validation
- **Secure self-updating**: `claude-sync update` downloads and verifies SHA256 checksums
- **Desktop sidebar**: Restores the desktop app's conversation list, not just the CLI's session picker
- **Simple CLI**: `push`, `pull`, `status`, `diff`, `conflicts` commands
- **Compression**: Gzip compression before encryption for faster syncs
- **Shell integration**: Optional shell hooks for automatic push/pull
Expand Down Expand Up @@ -195,6 +196,9 @@ claude-sync pull
| `~/.claude/settings.local.json` | Local settings |
| `~/.claude/CLAUDE.md` | Global instructions |

Plus the desktop app's sidebar index, which lives **outside** `~/.claude` — see
[Desktop app sidebar](#desktop-app-sidebar).

### Sync scope

`init` asks whether to sync everything or just conversation data; you can also set it with `--scope`:
Expand Down Expand Up @@ -249,6 +253,7 @@ claude-sync pull # Download remote changes from cloud storage
claude-sync status # Show pending local changes
claude-sync diff # Show differences between local and remote
claude-sync conflicts # List and resolve conflicts
claude-sync desktop # Sync the desktop app's sidebar index
claude-sync rebuild-history # Rebuild ~/.claude/history.jsonl from session files
claude-sync reset # Reset configuration (forgot passphrase)
claude-sync migrate # Convert legacy remote keys to portable path-mapped keys
Expand All @@ -264,6 +269,7 @@ claude-sync pull # Normal pull (prompts if existing files)
claude-sync pull --dry-run # Preview what would change
claude-sync pull --force # Skip confirmation prompts
claude-sync pull --rebuild-history # Also rebuild history.jsonl after pulling
claude-sync pull --desktop # Also restore desktop app sidebar entries
```

### Rebuilding Prompt History
Expand All @@ -281,6 +287,61 @@ claude-sync pull --rebuild-history # Rebuild automatically after a pull
Every existing entry is preserved, recovered prompts are merged in and sorted by
timestamp, and the previous file is kept as `history.jsonl.bak`.

### Desktop app sidebar

The Claude desktop app does not render its sidebar from `~/.claude/projects`. It
keeps a separate index of small pointer records — one per conversation, each
naming a transcript — in its own support directory:

| Platform | Location | Status |
|----------|----------|--------|
| macOS | `~/Library/Application Support/Claude/claude-code-sessions/` | Verified |
| Linux | `~/.config/Claude/claude-code-sessions/` | Untested |
| Windows | `%APPDATA%\Claude\claude-code-sessions\` | Untested |

> The Linux and Windows locations follow Electron's `userData` convention, which
> the desktop app is built on, but have not been confirmed on those platforms.
> Reports welcome. On a machine where the index is not found, the desktop
> commands report that and change nothing.

Syncing `~/.claude` alone therefore restores conversations for `claude --resume`
while leaving the desktop sidebar empty. `push` carries this index too, and
`pull --desktop` restores it:

```bash
claude-sync pull --desktop # Pull conversations, then restore sidebar entries
claude-sync desktop pull # Restore sidebar entries only, no file sync
claude-sync desktop push # Upload this device's index only
```

Restart the desktop app afterwards — it reads the index at startup.

Conversations that already have a sidebar entry are left untouched. Matching is
by transcript id rather than the app's per-device record id, so repeated pulls
never duplicate rows.

A sidebar entry points at a transcript by id, so entries are only written for
conversations this machine actually has. Sessions whose transcript is absent are
skipped and reported — without that, the entry would appear in the sidebar and
open onto "Session not found on disk". Pull the conversations first, or use
`pull --desktop`, which pulls them before hydrating.

**Archived conversations.** The archived flag exists only in this index; nothing
in a transcript records it. Archive state travels with every push, and the more
recent change wins when two devices disagree. To keep archived conversations'
transcripts out of the upload while still syncing the fact that they are
archived:

```bash
claude-sync push --skip-archived
```

The label is always pushed even when the transcript is not, so a conversation
archived after its first sync still disappears from the other device's sidebar.

A device that has never opened the desktop app has no index, contributes no
archive state, and is unaffected by any of this.

### Init Options

```bash
Expand Down
28 changes: 26 additions & 2 deletions SECURITY-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@

## Executive Summary

claude-sync is a Go CLI tool (with npm wrapper) that syncs `~/.claude` directories across devices using cloud storage (R2/S3/GCS) with age encryption. **No critical or high vulnerabilities found.** Five medium findings relate to supply chain integrity and cryptographic design tradeoffs. Five low findings cover file permissions and path handling.
claude-sync is a Go CLI tool (with npm wrapper) that syncs `~/.claude` directories across devices using cloud storage (R2/S3/GCS) with age encryption. **No critical or high vulnerabilities found.** Five medium findings relate to supply chain integrity and cryptographic design tradeoffs. Six low findings cover file permissions, path handling, and write scope.

| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 0 |
| Medium | 5 |
| Low | 5 |
| Low | 6 |
| Info | 5 |

---
Expand Down Expand Up @@ -139,6 +139,29 @@ CI builds and tests against Go 1.21 while the module requires Go 1.24. Tests may

---

### L6 — Desktop Index Writes Outside `~/.claude`

**File:** `internal/desktop/apply.go`, `internal/desktop/record.go`

`pull --desktop` and `desktop pull` write index records into the desktop app's
support directory. This is the tool's first write target outside `~/.claude`.
Record filenames derive from a `sessionId` field carried in remote data, which
makes them attacker-influenced input to a filesystem write.

**Mitigating factors:** Filenames are refused rather than sanitized — any value
containing a path separator or `..`, or which differs from its own
`filepath.Base`, is rejected and the record skipped (covered by
`TestApplyRejectsTraversingSessionID`). The destination directory is discovered
on the local machine and never taken from remote data. Both entry points are
opt-in; an ordinary `pull` does not write here.

**Residual risk:** An actor with bucket write access can still add sidebar
entries naming arbitrary transcript ids. These surface as rows in the app's
sidebar. No content is executed and no file outside the index directory is
written, so the effect is limited to sidebar clutter.

---

## Clean Areas

| Area | Status |
Expand All @@ -149,6 +172,7 @@ CI builds and tests against Go 1.21 while the module requires Go 1.24. Tests may
| eval/exec injection | No dynamic code execution |
| Encryption | Sound — age v1.3.1, X25519, Argon2id |
| Input validation | CLI inputs properly handled |
| Index record filenames | Validated — traversal refused rather than sanitized (L6) |

---

Expand Down
42 changes: 42 additions & 0 deletions cmd/claude-sync/desktop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"sort"
"testing"
)

// Sidebar hydration is wired inside `pull`, so exercising it otherwise means
// running a full sync — downloading conversations just to fix a sidebar. MCP
// solved the same problem with standalone subcommands; desktop mirrors that so
// the index can be pushed or hydrated on its own.
func TestDesktopCmdExposesPushAndPull(t *testing.T) {
cmd := desktopCmd()

if cmd.Use != "desktop" {
t.Errorf("Use = %q, want %q", cmd.Use, "desktop")
}

found := map[string]bool{}
for _, sub := range cmd.Commands() {
found[sub.Name()] = true
}
for _, want := range []string{"push", "pull"} {
if !found[want] {
names := make([]string, 0, len(found))
for n := range found {
names = append(names, n)
}
sort.Strings(names)
t.Errorf("desktop has no %q subcommand; got %v", want, names)
}
}
}

// Every subcommand needs a one-line Short, or it renders blank in help output.
func TestDesktopSubcommandsAreDocumented(t *testing.T) {
for _, sub := range desktopCmd().Commands() {
if sub.Short == "" {
t.Errorf("subcommand %q has no Short description", sub.Name())
}
}
}
133 changes: 133 additions & 0 deletions cmd/claude-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func main() {
updateCmd(),
changelogCmd(),
mcpCmd(),
desktopCmd(),
autoCmd(),
pathsCmd(),
)
Expand Down Expand Up @@ -1012,6 +1013,7 @@ func runWebDAVWizard(webdavURL, username, password, pathPrefix string) (*storage

func pushCmd() *cobra.Command {
var includeMCP bool
var skipArchived bool

cmd := &cobra.Command{
Use: "push",
Expand All @@ -1027,6 +1029,7 @@ func pushCmd() *cobra.Command {
if err != nil {
return err
}
syncer.SetSkipArchived(skipArchived)

if !quiet {
syncer.SetProgressFunc(func(event sync.ProgressEvent) {
Expand Down Expand Up @@ -1109,16 +1112,25 @@ func pushCmd() *cobra.Command {
}
}

// The sidebar index always travels with a push so archive state can
// reach other devices. Without it, an archive decision made here would
// never be visible anywhere else.
if err := runDesktopPush(ctx, syncer); err != nil {
return err
}

return nil
},
}

cmd.Flags().BoolVar(&includeMCP, "include-mcp", false, "Also sync MCP server configs from ~/.claude.json")
cmd.Flags().BoolVar(&skipArchived, "skip-archived", false, "Do not upload transcripts of archived conversations (their archived state is still synced)")
return cmd
}

func pullCmd() *cobra.Command {
var dryRun, force, includeMCP, rebuildHistory bool
var desktopSync bool

cmd := &cobra.Command{
Use: "pull",
Expand Down Expand Up @@ -1247,6 +1259,14 @@ Examples:
}
}

// Hydrate the desktop sidebar, which reads its own index rather than
// ~/.claude/projects. Skipped on a dry run: it writes to disk.
if desktopSync && !dryRun {
if err := runDesktopPull(ctx, syncer); err != nil {
return err
}
}

// Rebuild prompt history from the freshly-pulled session files.
if rebuildHistory && !dryRun {
if err := runHistoryRebuild(); err != nil {
Expand All @@ -1262,6 +1282,7 @@ Examples:
cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing files without confirmation")
cmd.Flags().BoolVar(&includeMCP, "include-mcp", false, "Also sync MCP server configs from ~/.claude.json")
cmd.Flags().BoolVar(&rebuildHistory, "rebuild-history", false, "Rebuild ~/.claude/history.jsonl from session files after pulling")
cmd.Flags().BoolVar(&desktopSync, "desktop", false, "Also restore the Claude desktop app's sidebar entries for pulled conversations")

return cmd
}
Expand Down Expand Up @@ -2734,6 +2755,57 @@ func printReleaseBody(body string) {

// MCP sync commands

func desktopCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "desktop",
Short: "Manage Claude desktop app sidebar sync",
Long: `Sync the Claude desktop app's sidebar session index.

The desktop app renders its sidebar from its own index, not from
~/.claude/projects. Conversations carried by push/pull are therefore resumable
with 'claude --resume' but stay invisible in the app until that index travels
too. These subcommands move the index on its own, without syncing conversations.`,
}
cmd.AddCommand(desktopPushCmd(), desktopPullCmd())
return cmd
}

func desktopPushCmd() *cobra.Command {
return &cobra.Command{
Use: "push",
Short: "Upload this device's sidebar index",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
syncer, err := sync.NewSyncer(cfg, quiet)
if err != nil {
return err
}
return runDesktopPush(context.Background(), syncer)
},
}
}

func desktopPullCmd() *cobra.Command {
return &cobra.Command{
Use: "pull",
Short: "Restore sidebar entries from another device (leaves conversations untouched)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
syncer, err := sync.NewSyncer(cfg, quiet)
if err != nil {
return err
}
return runDesktopPull(context.Background(), syncer)
},
}
}

func mcpCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Expand Down Expand Up @@ -2932,6 +3004,67 @@ func mcpPullCmd() *cobra.Command {
}
}

// runDesktopPush uploads the desktop sidebar index alongside the file push.
//
// The sidebar renders from an index that lives outside ~/.claude, so a push
// carrying only transcripts leaves conversations resumable from the CLI but
// absent from the app on every other machine.
func runDesktopPush(ctx context.Context, syncer *sync.Syncer) error {
result, err := syncer.PushDesktop(ctx)
if err != nil {
return fmt.Errorf("desktop index push failed: %w", err)
}
if quiet {
return nil
}
switch {
case result.NoIndex:
// No desktop app on this machine; nothing worth reporting.
case result.Unchanged:
fmt.Printf("%s\u2713%s Desktop sidebar: no changes to push\n", colorGreen, colorReset)
default:
fmt.Printf("%s\u2713%s Desktop sidebar: %s%d records pushed%s\n",
colorGreen, colorReset, colorGreen, result.RecordsPushed, colorReset)
}
return nil
}

// runDesktopPull applies the remote desktop index to this machine's sidebar.
func runDesktopPull(ctx context.Context, syncer *sync.Syncer) error {
result, err := syncer.PullDesktop(ctx)
if err != nil {
return fmt.Errorf("desktop index pull failed: %w", err)
}
if quiet {
return nil
}
switch {
case result.NoIndex:
fmt.Printf("%s!%s Desktop sidebar: no local index found (is the desktop app installed?)\n", colorYellow, colorReset)
case result.NoRemote:
fmt.Printf("%s!%s Desktop sidebar: nothing pushed from another device yet\n", colorYellow, colorReset)
default:
fmt.Printf("%s\u2713%s Desktop sidebar: %s%d added%s, %d archive %s updated\n",
colorGreen, colorReset, colorGreen, result.Written, colorReset,
result.Updated, pluralRecords(result.Updated))
if result.MissingTranscript > 0 {
fmt.Printf(" %s%d skipped — no transcript on this machine; run 'claude-sync pull' first%s\n",
colorYellow, result.MissingTranscript, colorReset)
}
if result.Written > 0 || result.Updated > 0 {
fmt.Printf(" %sRestart Claude to see the changes.%s\n", colorDim, colorReset)
}
}
return nil
}

func pluralRecords(n int) string {
if n == 1 {
return "state"
}
return "states"
}

func runMCPPush(ctx context.Context, syncer *sync.Syncer) error {
result, err := syncer.PushMCP(ctx)
if err != nil {
Expand Down
Loading