@askalf/claude-sync 0.0.2 → 0.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 +76 -33
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +222 -25
- package/dist/cli.js.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/format.d.ts +2 -2
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +16 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/project.d.ts +39 -8
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +98 -15
- package/dist/project.js.map +1 -1
- package/dist/session.d.ts +21 -5
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +59 -14
- package/dist/session.js.map +1 -1
- package/dist/transport.d.ts +12 -4
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +33 -4
- package/dist/transport.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @askalf/claude-sync
|
|
2
2
|
|
|
3
|
+
> _claude-sync — own your sessions — move Claude Code sessions across machines. Part of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it by the token._
|
|
4
|
+
|
|
3
5
|
> Sync Claude Code sessions across machines. Pack a session into a portable `.ccsync` file, ship it via Dropbox / iCloud / Syncthing / a USB stick, unpack on the other side. Path-hash mismatches solved via git-remote-url as the canonical project key.
|
|
4
6
|
|
|
5
7
|
```bash
|
|
@@ -19,8 +21,8 @@ Claude Code stores sessions locally at `~/.claude/projects/<encoded-cwd>/<sessio
|
|
|
19
21
|
|
|
20
22
|
`claude-sync` solves both:
|
|
21
23
|
|
|
22
|
-
- **Canonical project key** = `git:<remote
|
|
23
|
-
- **
|
|
24
|
+
- **Canonical project key** = `git:<host>/<owner>/<repo>`, derived from the git remote. Same logical repo → same key, regardless of where it's checked out or which protocol you cloned with — SSH and HTTPS remotes normalize to the same key. (Falls back to `name:<basename>` when there's no git remote.)
|
|
25
|
+
- **You choose the cadence.** `push` from machine A and `pull` on machine B by hand, or run `claude-sync watch` to push-on-change and pull-on-interval automatically. The transport is a directory you nominate (Dropbox / iCloud / Syncthing / a USB stick); each machine writes its own file under its own machine name, so two machines pushing the same session don't collide.
|
|
24
26
|
|
|
25
27
|
## Use it
|
|
26
28
|
|
|
@@ -57,12 +59,54 @@ claude --resume sess-abc123
|
|
|
57
59
|
|
|
58
60
|
That's the whole loop.
|
|
59
61
|
|
|
62
|
+
### Or let it run: watch mode
|
|
63
|
+
|
|
64
|
+
Tired of remembering to `push` and `pull`? Run a daemon per project:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd ~/code/myapp
|
|
68
|
+
claude-sync watch
|
|
69
|
+
# claude-sync watch — desktop
|
|
70
|
+
# project: git:github.com/you/myapp
|
|
71
|
+
# cwd: /Users/you/code/myapp
|
|
72
|
+
# syncDir: /Users/you/Dropbox/claude-sync
|
|
73
|
+
# every 10s · push most-recent changed session · pull all projects
|
|
74
|
+
# Ctrl-C to stop.
|
|
75
|
+
#
|
|
76
|
+
# ↑ pushed sess-abc123 (142 lines)
|
|
77
|
+
# Imported sess-def456 from laptop → …/sess-def456.jsonl
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every interval (default 10s, `--interval <seconds>`) it pushes the active
|
|
81
|
+
session whenever it has grown and pulls anything newer from your other
|
|
82
|
+
machines. It watches the **most-recent** session by default; pass a session-id
|
|
83
|
+
to pin one, or `--all` to push every changed session in the project. `--once`
|
|
84
|
+
runs a single cycle and exits — handy from `cron`. Ctrl-C stops cleanly.
|
|
85
|
+
|
|
86
|
+
It's still poll-based, not real-time co-editing: don't run the *same* session
|
|
87
|
+
live on two machines at once (see [Concurrency](#concurrency)). Watch is for
|
|
88
|
+
"pick up where the other machine left off without thinking about it."
|
|
89
|
+
|
|
90
|
+
### Starting on a fresh clone
|
|
91
|
+
|
|
92
|
+
`pull` can only deliver a session to a directory it knows about. On a machine
|
|
93
|
+
that has never had a session for this project yet, register the directory once:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd ~/code/myapp
|
|
97
|
+
claude-sync register # binds this cwd to the project key — no session needed
|
|
98
|
+
claude-sync pull # now resolves
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
(`watch` registers automatically on start, and `import … --cwd <path>` registers
|
|
102
|
+
the target, so you usually only need `register` for a manual `pull`-first flow.)
|
|
103
|
+
|
|
60
104
|
## How path resolution works
|
|
61
105
|
|
|
62
106
|
When you `push` from a project with a git remote, claude-sync:
|
|
63
107
|
|
|
64
108
|
1. Reads `git remote get-url origin` from your cwd.
|
|
65
|
-
2.
|
|
109
|
+
2. Canonicalizes it into a project key: `git:github.com/you/myapp` (SSH and HTTPS remotes land on the same key).
|
|
66
110
|
3. Registers the local cwd under that key in `~/.claude-sync/projects.json`.
|
|
67
111
|
4. Writes the `.ccsync` into `<syncDir>/<encoded-key>/<session>-<machine>.ccsync`.
|
|
68
112
|
|
|
@@ -71,36 +115,36 @@ When you `pull`, the receiving machine:
|
|
|
71
115
|
1. Scans `<syncDir>/` for project subdirs.
|
|
72
116
|
2. For each, looks up the encoded key in its own `projects.json`.
|
|
73
117
|
3. If a local cwd is registered for that key, installs the session there.
|
|
74
|
-
4. If not — claude-sync hasn't seen this project on this machine yet — it skips with a warning.
|
|
118
|
+
4. If not — claude-sync hasn't seen this project on this machine yet — it skips with a warning. Run `claude-sync register` in the cwd once (binds the key, no session needed), or `claude-sync import <file> --cwd <path>` to install a specific file (which also registers the directory).
|
|
75
119
|
|
|
76
|
-
In practice: clone the repo on the new machine, `cd` in,
|
|
77
|
-
|
|
78
|
-
A cleaner way: just run any `claude` command in the cwd once, then `claude-sync export` (which registers without pushing).
|
|
120
|
+
In practice: clone the repo on the new machine, `cd` in, `claude-sync register`, then `claude-sync pull`. Or just start `claude-sync watch`, which registers on launch.
|
|
79
121
|
|
|
80
122
|
## Concurrency
|
|
81
123
|
|
|
82
|
-
|
|
124
|
+
`watch` automates *when* to push and pull, but it's still poll-based, not a locking protocol. If two machines edit the **same** session concurrently:
|
|
83
125
|
|
|
84
126
|
- Two `.ccsync` files end up in `<syncDir>/<project>/`, named with each machine's name.
|
|
85
127
|
- The receiving `pull` skips files older or shorter than the local copy.
|
|
86
128
|
- Newer/longer files install with `--overwrite`. The losing machine's edits become a `<session-id>-copy.jsonl` (you can manually inspect + reconcile).
|
|
87
129
|
|
|
88
|
-
|
|
130
|
+
So: use `watch` to hand a project *back and forth* between machines hands-free; don't drive the same live session from two machines at once. A lock-file protocol for true real-time co-editing is a possible future addition.
|
|
89
131
|
|
|
90
132
|
## CLI reference
|
|
91
133
|
|
|
92
134
|
```text
|
|
93
135
|
claude-sync init <syncDir> [--name <machine>]
|
|
94
136
|
claude-sync list
|
|
137
|
+
claude-sync register [cwd]
|
|
95
138
|
claude-sync export [session-id] [-o <file>]
|
|
96
139
|
claude-sync import <file> [--cwd <path>] [--overwrite]
|
|
97
140
|
claude-sync push [session-id]
|
|
98
141
|
claude-sync pull
|
|
142
|
+
claude-sync watch [session-id] [--interval <seconds>] [--all] [--once]
|
|
99
143
|
claude-sync doctor
|
|
100
144
|
claude-sync --help / --version
|
|
101
145
|
```
|
|
102
146
|
|
|
103
|
-
`doctor` prints config, machine name, registered projects, and warns if any registered cwd no longer exists on disk.
|
|
147
|
+
`doctor` prints config, machine name, registered projects, and transport peers — and warns if any registered cwd no longer exists on disk, or if every file in the transport carries this machine's own name (which makes `pull` a silent no-op — give each machine a distinct `--name`).
|
|
104
148
|
|
|
105
149
|
## File format
|
|
106
150
|
|
|
@@ -110,7 +154,7 @@ claude-sync --help / --version
|
|
|
110
154
|
{
|
|
111
155
|
"_schemaVersion": 1,
|
|
112
156
|
"sessionId": "...",
|
|
113
|
-
"projectKey": "git:
|
|
157
|
+
"projectKey": "git:github.com/you/myapp",
|
|
114
158
|
"originalCwd": "C:\\Users\\you\\code\\myapp",
|
|
115
159
|
"machineName": "desktop",
|
|
116
160
|
"exportedAt": 1715380000000,
|
|
@@ -119,13 +163,12 @@ claude-sync --help / --version
|
|
|
119
163
|
}
|
|
120
164
|
```
|
|
121
165
|
|
|
122
|
-
Schema-versioned. Version 1 is the only thing
|
|
166
|
+
Schema-versioned. Version 1 is the only thing the current release understands; importers refuse unknown versions explicitly rather than silently dropping fields.
|
|
123
167
|
|
|
124
168
|
## What it isn't
|
|
125
169
|
|
|
126
|
-
- **Not
|
|
127
|
-
- **Not a relay service.**
|
|
128
|
-
- **Not real-time.** If both machines are CC-active simultaneously, the second to `pull` overwrites their in-progress session unless they noticed and stopped first. Use one machine at a time.
|
|
170
|
+
- **Not real-time co-editing.** `watch` polls on an interval; it isn't a locking protocol. If both machines drive the *same* session simultaneously, the second to sync overwrites the other's in-progress copy (the loser is kept as `<id>-copy.jsonl`). Use it to hand a project between machines, not to co-edit one session live.
|
|
171
|
+
- **Not a relay service.** The current release ships only the filesystem transport — point it at any synced folder. SSH / S3 / gist / WebSocket transports would be straightforward additions; not shipped yet.
|
|
129
172
|
- **Not a CC re-implementation.** It just shuffles JSONL. CC's session schema can change without breaking claude-sync — we never parse the events.
|
|
130
173
|
|
|
131
174
|
## Library API
|
|
@@ -134,13 +177,15 @@ For embedders (custom transports, watch daemons, etc.):
|
|
|
134
177
|
|
|
135
178
|
```ts
|
|
136
179
|
import {
|
|
137
|
-
listSessions, readSession, writeSession,
|
|
180
|
+
listSessions, listSessionStats, countSessionLines, readSession, writeSession,
|
|
138
181
|
buildCcsync, parseCcsync, readCcsyncFile, writeCcsyncFile,
|
|
139
|
-
projectKey, registerProject, lookupCwd,
|
|
140
|
-
pushToTransport, listTransport,
|
|
182
|
+
projectKey, normalizeGitRemote, registerProject, registerProjectKey, lookupCwd,
|
|
183
|
+
pushToTransport, listTransport, listTransportMachines,
|
|
141
184
|
} from '@askalf/claude-sync';
|
|
142
185
|
```
|
|
143
186
|
|
|
187
|
+
The `watch` command itself is built entirely on these exports — `listSessionStats` for cheap change detection, `pushToTransport` + `listTransport` for the sync, `registerProject` for first-contact — so a custom daemon or transport has everything it needs.
|
|
188
|
+
|
|
144
189
|
See `src/index.ts` for the full surface. Zero runtime dependencies.
|
|
145
190
|
|
|
146
191
|
## Environment
|
|
@@ -153,18 +198,16 @@ See `src/index.ts` for the full surface. Zero runtime dependencies.
|
|
|
153
198
|
|
|
154
199
|
MIT — see [LICENSE](LICENSE).
|
|
155
200
|
|
|
156
|
-
##
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
| [pgflex](https://github.com/askalf/pgflex) | One Postgres API. Two modes (real PG ↔ PGlite WASM). |
|
|
170
|
-
| [redisflex](https://github.com/askalf/redisflex) | One Redis API. Two modes (ioredis ↔ in-process). |
|
|
201
|
+
## Own Your Stack
|
|
202
|
+
|
|
203
|
+
Part of **[Own Your Stack](https://github.com/askalf)** — open tools for owning your AI infrastructure instead of renting it by the token. One subscription. Your box. Your terms.
|
|
204
|
+
|
|
205
|
+
- **[dario](https://github.com/askalf/dario)** — own your routing
|
|
206
|
+
- **[deepdive](https://github.com/askalf/deepdive)** — own your research
|
|
207
|
+
- **[hands](https://github.com/askalf/hands)** — own your computer-use
|
|
208
|
+
- **[agent](https://github.com/askalf/agent)** — own your fleet
|
|
209
|
+
- **[browser-bridge](https://github.com/askalf/browser-bridge)** — own your browser
|
|
210
|
+
- **claude-sync** — own your sessions _(you are here)_
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
Part of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it. Built by Thomas Sprayberry.
|
package/dist/cli.d.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Subcommands:
|
|
6
6
|
* init <syncDir> [--name <machine>] scaffold ~/.claude-sync/config.json
|
|
7
7
|
* list list local CC sessions for the cwd
|
|
8
|
+
* register [cwd] register a cwd's project key (no session needed)
|
|
8
9
|
* export [session-id] [-o <file>] pack a session into a .ccsync file
|
|
9
10
|
* import <file> [--cwd <path>] install a .ccsync file locally
|
|
10
11
|
* push [session-id] export + ship to configured syncDir
|
|
11
12
|
* pull import any newer .ccsync files
|
|
13
|
+
* watch [session-id] [--interval <s>] daemon: auto-push changes + auto-pull peers
|
|
12
14
|
* doctor diagnose config + registry health
|
|
13
15
|
*
|
|
14
16
|
* Common flags: --help, --version. No external arg-parser dep — the
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;GAiBG"}
|
package/dist/cli.js
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Subcommands:
|
|
6
6
|
* init <syncDir> [--name <machine>] scaffold ~/.claude-sync/config.json
|
|
7
7
|
* list list local CC sessions for the cwd
|
|
8
|
+
* register [cwd] register a cwd's project key (no session needed)
|
|
8
9
|
* export [session-id] [-o <file>] pack a session into a .ccsync file
|
|
9
10
|
* import <file> [--cwd <path>] install a .ccsync file locally
|
|
10
11
|
* push [session-id] export + ship to configured syncDir
|
|
11
12
|
* pull import any newer .ccsync files
|
|
13
|
+
* watch [session-id] [--interval <s>] daemon: auto-push changes + auto-pull peers
|
|
12
14
|
* doctor diagnose config + registry health
|
|
13
15
|
*
|
|
14
16
|
* Common flags: --help, --version. No external arg-parser dep — the
|
|
@@ -16,13 +18,13 @@
|
|
|
16
18
|
* dep budget at zero.
|
|
17
19
|
*/
|
|
18
20
|
import { existsSync } from 'node:fs';
|
|
19
|
-
import { resolve
|
|
21
|
+
import { resolve } from 'node:path';
|
|
20
22
|
import { VERSION } from './index.js';
|
|
21
23
|
import { loadConfig, saveConfig, configExists, buildDefaultConfig, } from './config.js';
|
|
22
|
-
import { encodeProjectDir, registerProject, loadRegistry, lookupCwd, claudeProjectsRoot, } from './project.js';
|
|
23
|
-
import { listSessions, readSession, writeSession, assignFreshId, } from './session.js';
|
|
24
|
+
import { encodeProjectDir, registerProject, registerProjectKey, loadRegistry, lookupCwd, claudeProjectsRoot, } from './project.js';
|
|
25
|
+
import { listSessions, listSessionStats, countSessionLines, readSession, writeSession, assignFreshId, } from './session.js';
|
|
24
26
|
import { buildCcsync, readCcsyncFile, writeCcsyncFile, } from './format.js';
|
|
25
|
-
import { pushToTransport, listTransport, listProjectKeys, } from './transport.js';
|
|
27
|
+
import { pushToTransport, listTransport, listTransportMachines, listProjectKeys, } from './transport.js';
|
|
26
28
|
const HELP = `claude-sync ${VERSION}
|
|
27
29
|
|
|
28
30
|
Usage: claude-sync <command> [options]
|
|
@@ -37,6 +39,12 @@ Commands:
|
|
|
37
39
|
List Claude Code sessions for the current working directory,
|
|
38
40
|
sorted by recency.
|
|
39
41
|
|
|
42
|
+
register [cwd]
|
|
43
|
+
Register a directory's project key so \`pull\` can deliver peers'
|
|
44
|
+
sessions here — even before this machine has any session of its
|
|
45
|
+
own. Defaults to the current directory. Run this once on a fresh
|
|
46
|
+
clone, then \`pull\`.
|
|
47
|
+
|
|
40
48
|
export [session-id] [-o <file>]
|
|
41
49
|
Pack a session into a portable .ccsync file. Default session-id
|
|
42
50
|
is the most recent one for the current cwd. Default output path
|
|
@@ -55,6 +63,14 @@ Commands:
|
|
|
55
63
|
each into the matching local cwd. Skips sessions that already
|
|
56
64
|
exist locally with the same line count (idempotent re-runs).
|
|
57
65
|
|
|
66
|
+
watch [session-id] [--interval <seconds>] [--all] [--once]
|
|
67
|
+
Run a sync daemon for the current directory: every <interval>
|
|
68
|
+
seconds (default 10), push the active session whenever it grows
|
|
69
|
+
and pull any newer sessions from peers. Watches the most-recent
|
|
70
|
+
session by default; pass a session-id to pin one, or --all to push
|
|
71
|
+
every changed session in this project. --once runs a single
|
|
72
|
+
push+pull cycle and exits. Ctrl-C to stop.
|
|
73
|
+
|
|
58
74
|
doctor
|
|
59
75
|
Print a health report: config, machine name, registered projects,
|
|
60
76
|
Claude Code projects dir, and any obvious mismatches.
|
|
@@ -76,10 +92,12 @@ async function main(argv) {
|
|
|
76
92
|
switch (cmd) {
|
|
77
93
|
case 'init': return cmdInit(rest);
|
|
78
94
|
case 'list': return cmdList(rest);
|
|
95
|
+
case 'register': return cmdRegister(rest);
|
|
79
96
|
case 'export': return cmdExport(rest);
|
|
80
97
|
case 'import': return cmdImport(rest);
|
|
81
98
|
case 'push': return cmdPush(rest);
|
|
82
99
|
case 'pull': return cmdPull(rest);
|
|
100
|
+
case 'watch': return cmdWatch(rest);
|
|
83
101
|
case 'doctor': return cmdDoctor(rest);
|
|
84
102
|
default:
|
|
85
103
|
process.stderr.write(`Unknown command: ${cmd}\nRun \`claude-sync --help\` for usage.\n`);
|
|
@@ -126,6 +144,26 @@ function cmdList(_args) {
|
|
|
126
144
|
}
|
|
127
145
|
return 0;
|
|
128
146
|
}
|
|
147
|
+
// ── register ───────────────────────────────────────────────────────
|
|
148
|
+
function cmdRegister(args) {
|
|
149
|
+
const arg = args[0];
|
|
150
|
+
const cwd = arg && !arg.startsWith('-') ? resolve(arg) : process.cwd();
|
|
151
|
+
if (!existsSync(cwd)) {
|
|
152
|
+
process.stderr.write(`Error: path does not exist: ${cwd}\n`);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
const { key } = registerProject(cwd);
|
|
156
|
+
const sessions = listSessions(cwd);
|
|
157
|
+
process.stdout.write(`Registered ${cwd}\n` +
|
|
158
|
+
` project key: ${key}\n` +
|
|
159
|
+
` local sessions: ${sessions.length}\n`);
|
|
160
|
+
if (sessions.length === 0) {
|
|
161
|
+
process.stdout.write(`\n No sessions here yet — that's fine. \`pull\` can now deliver peers'\n` +
|
|
162
|
+
` work to this directory. \`push\`/\`export\` start working once you've\n` +
|
|
163
|
+
` run a Claude Code session in this directory.\n`);
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
129
167
|
// ── export ─────────────────────────────────────────────────────────
|
|
130
168
|
function cmdExport(args) {
|
|
131
169
|
const cwd = process.cwd();
|
|
@@ -164,7 +202,15 @@ function cmdImport(args) {
|
|
|
164
202
|
return 1;
|
|
165
203
|
}
|
|
166
204
|
const cwdIdx = args.indexOf('--cwd');
|
|
167
|
-
|
|
205
|
+
let explicitCwd = null;
|
|
206
|
+
if (cwdIdx >= 0) {
|
|
207
|
+
const cwdArg = args[cwdIdx + 1];
|
|
208
|
+
if (!cwdArg || cwdArg.startsWith('--')) {
|
|
209
|
+
process.stderr.write('Error: --cwd requires a path argument.\n');
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
explicitCwd = resolve(cwdArg);
|
|
213
|
+
}
|
|
168
214
|
const overwrite = args.includes('--overwrite');
|
|
169
215
|
const ccsync = readCcsyncFile(filePath);
|
|
170
216
|
return installCcsync(ccsync, explicitCwd, overwrite);
|
|
@@ -179,17 +225,7 @@ function cmdPush(args) {
|
|
|
179
225
|
const session = pickSession(cwd, sessionId);
|
|
180
226
|
if (!session)
|
|
181
227
|
return 1;
|
|
182
|
-
const
|
|
183
|
-
const ccsync = buildCcsync({
|
|
184
|
-
sessionId: session.id,
|
|
185
|
-
projectKey: key,
|
|
186
|
-
originalCwd: cwd,
|
|
187
|
-
machineName: cfg.machineName,
|
|
188
|
-
exportedAt: Date.now(),
|
|
189
|
-
lineCount: session.lineCount,
|
|
190
|
-
jsonl: readSession(session.path),
|
|
191
|
-
});
|
|
192
|
-
const written = pushToTransport(cfg.syncDir, ccsync);
|
|
228
|
+
const written = exportToTransport(cfg, cwd, session);
|
|
193
229
|
process.stdout.write(`Pushed ${session.id} → ${written}\n`);
|
|
194
230
|
return 0;
|
|
195
231
|
}
|
|
@@ -198,25 +234,40 @@ function cmdPull(_args) {
|
|
|
198
234
|
const cfg = mustLoadConfig();
|
|
199
235
|
if (!cfg)
|
|
200
236
|
return 1;
|
|
201
|
-
|
|
202
|
-
const projectKeys = listProjectKeys(cfg.syncDir);
|
|
203
|
-
if (projectKeys.length === 0) {
|
|
237
|
+
if (listProjectKeys(cfg.syncDir).length === 0) {
|
|
204
238
|
process.stdout.write(`Nothing to pull — ${cfg.syncDir} is empty.\n`);
|
|
205
239
|
return 0;
|
|
206
240
|
}
|
|
241
|
+
const { imported, skipped } = runPull(cfg, {
|
|
242
|
+
onSkip: (msg) => process.stdout.write(` ${msg}\n`),
|
|
243
|
+
});
|
|
244
|
+
process.stdout.write(`\nDone — imported ${imported} session(s), skipped ${skipped}.\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
/** Core of `pull`, shared with `watch`. Scans the transport across every
|
|
248
|
+
* project and imports any session newer (more lines) than the local
|
|
249
|
+
* copy. `onSkip` is called with a human message for each transport
|
|
250
|
+
* project key that has no resolvable local cwd — `pull` prints them;
|
|
251
|
+
* `watch` omits the callback to stay quiet. Genuine imports are always
|
|
252
|
+
* announced by `installCcsync` itself. Idempotent: a session already at
|
|
253
|
+
* or beyond the transport's line count is skipped, so repeated calls
|
|
254
|
+
* (every watch tick) do nothing once converged. */
|
|
255
|
+
function runPull(cfg, opts = {}) {
|
|
256
|
+
const reg = loadRegistry();
|
|
257
|
+
const projectKeys = listProjectKeys(cfg.syncDir);
|
|
207
258
|
let imported = 0;
|
|
208
259
|
let skipped = 0;
|
|
209
260
|
for (const encodedKey of projectKeys) {
|
|
210
261
|
// We have the encoded form; find the matching raw key in the registry.
|
|
211
262
|
const rawKey = Object.keys(reg.projects).find((k) => encodeProjectDir(k) === encodedKey);
|
|
212
263
|
if (!rawKey) {
|
|
213
|
-
|
|
264
|
+
opts.onSkip?.(`skip ${encodedKey} (no local project registered for this key)`);
|
|
214
265
|
skipped++;
|
|
215
266
|
continue;
|
|
216
267
|
}
|
|
217
268
|
const cwd = lookupCwd(rawKey, reg);
|
|
218
269
|
if (!cwd) {
|
|
219
|
-
|
|
270
|
+
opts.onSkip?.(`skip ${encodedKey} (registered cwds no longer exist on disk)`);
|
|
220
271
|
skipped++;
|
|
221
272
|
continue;
|
|
222
273
|
}
|
|
@@ -236,9 +287,114 @@ function cmdPull(_args) {
|
|
|
236
287
|
imported++;
|
|
237
288
|
}
|
|
238
289
|
}
|
|
239
|
-
|
|
290
|
+
return { imported, skipped };
|
|
291
|
+
}
|
|
292
|
+
// ── watch ──────────────────────────────────────────────────────────
|
|
293
|
+
async function cmdWatch(args) {
|
|
294
|
+
const cfg = mustLoadConfig();
|
|
295
|
+
if (!cfg)
|
|
296
|
+
return 1;
|
|
297
|
+
const once = args.includes('--once');
|
|
298
|
+
const pushAll = args.includes('--all');
|
|
299
|
+
let intervalSec = 10;
|
|
300
|
+
const intervalIdx = args.indexOf('--interval');
|
|
301
|
+
if (intervalIdx >= 0) {
|
|
302
|
+
const v = Number(args[intervalIdx + 1]);
|
|
303
|
+
if (!Number.isFinite(v) || v < 2) {
|
|
304
|
+
process.stderr.write('Error: --interval requires a number of seconds >= 2.\n');
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
intervalSec = v;
|
|
308
|
+
}
|
|
309
|
+
const first = args[0];
|
|
310
|
+
const sessionId = first && !first.startsWith('-') ? first : undefined;
|
|
311
|
+
const cwd = process.cwd();
|
|
312
|
+
// Register up front so a fresh clone with zero sessions can still
|
|
313
|
+
// RECEIVE peers' work via the pull half of the loop while we watch.
|
|
314
|
+
const { key } = registerProject(cwd);
|
|
315
|
+
if (!once) {
|
|
316
|
+
const pushDesc = sessionId
|
|
317
|
+
? `session ${sessionId}`
|
|
318
|
+
: pushAll ? 'every changed session' : 'most-recent changed session';
|
|
319
|
+
process.stdout.write(`claude-sync watch — ${cfg.machineName}\n` +
|
|
320
|
+
` project: ${key}\n` +
|
|
321
|
+
` cwd: ${cwd}\n` +
|
|
322
|
+
` syncDir: ${cfg.syncDir}\n` +
|
|
323
|
+
` every ${intervalSec}s · push ${pushDesc} · pull all projects\n` +
|
|
324
|
+
` Ctrl-C to stop.\n\n`);
|
|
325
|
+
}
|
|
326
|
+
// sessionId -> mtimeMs at last push. Seeds empty, so the first tick
|
|
327
|
+
// pushes the current state once, then only on subsequent growth.
|
|
328
|
+
const lastPushed = new Map();
|
|
329
|
+
let stopping = false;
|
|
330
|
+
const onSignal = () => { stopping = true; };
|
|
331
|
+
process.on('SIGINT', onSignal);
|
|
332
|
+
process.on('SIGTERM', onSignal);
|
|
333
|
+
try {
|
|
334
|
+
do {
|
|
335
|
+
try {
|
|
336
|
+
watchPushTick(cfg, cwd, { sessionId, pushAll, lastPushed });
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
process.stderr.write(` ! push error: ${err instanceof Error ? err.message : err}\n`);
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
runPull(cfg);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
process.stderr.write(` ! pull error: ${err instanceof Error ? err.message : err}\n`);
|
|
346
|
+
}
|
|
347
|
+
if (once || stopping)
|
|
348
|
+
break;
|
|
349
|
+
await delay(intervalSec * 1000, () => stopping);
|
|
350
|
+
} while (!stopping);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
process.off('SIGINT', onSignal);
|
|
354
|
+
process.off('SIGTERM', onSignal);
|
|
355
|
+
}
|
|
356
|
+
if (!once)
|
|
357
|
+
process.stdout.write(`\nStopped.\n`);
|
|
240
358
|
return 0;
|
|
241
359
|
}
|
|
360
|
+
/** One push half-cycle for watch: find sessions whose mtime grew since we
|
|
361
|
+
* last pushed them and ship each. Uses the stat-only listing (no line
|
|
362
|
+
* reads) for change detection, only counting lines for the few that
|
|
363
|
+
* actually changed. */
|
|
364
|
+
function watchPushTick(cfg, cwd, state) {
|
|
365
|
+
const stats = listSessionStats(cwd);
|
|
366
|
+
let candidates = stats;
|
|
367
|
+
if (state.sessionId) {
|
|
368
|
+
candidates = stats.filter((s) => s.id === state.sessionId);
|
|
369
|
+
}
|
|
370
|
+
else if (!state.pushAll) {
|
|
371
|
+
// Default: just the active (most-recent) session. `stats` is mtime-desc.
|
|
372
|
+
candidates = stats.slice(0, 1);
|
|
373
|
+
}
|
|
374
|
+
for (const s of candidates) {
|
|
375
|
+
const prev = state.lastPushed.get(s.id);
|
|
376
|
+
if (prev !== undefined && prev >= s.modifiedAt)
|
|
377
|
+
continue; // unchanged since last push
|
|
378
|
+
const lineCount = countSessionLines(s.path);
|
|
379
|
+
exportToTransport(cfg, cwd, { id: s.id, path: s.path, lineCount });
|
|
380
|
+
state.lastPushed.set(s.id, s.modifiedAt);
|
|
381
|
+
process.stdout.write(` ↑ pushed ${s.id} (${lineCount} lines)\n`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Resolve after `ms`, or early as soon as `shouldStop()` flips true, so
|
|
385
|
+
* Ctrl-C interrupts the inter-tick sleep without waiting out the
|
|
386
|
+
* interval. Polls a coarse 200ms so a `watch` process doesn't busy-spin. */
|
|
387
|
+
function delay(ms, shouldStop) {
|
|
388
|
+
return new Promise((resolve) => {
|
|
389
|
+
const start = Date.now();
|
|
390
|
+
const id = setInterval(() => {
|
|
391
|
+
if (shouldStop() || Date.now() - start >= ms) {
|
|
392
|
+
clearInterval(id);
|
|
393
|
+
resolve();
|
|
394
|
+
}
|
|
395
|
+
}, 200);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
242
398
|
// ── doctor ─────────────────────────────────────────────────────────
|
|
243
399
|
function cmdDoctor(_args) {
|
|
244
400
|
const ok = (m) => { process.stdout.write(` ✓ ${m}\n`); };
|
|
@@ -247,8 +403,29 @@ function cmdDoctor(_args) {
|
|
|
247
403
|
if (configExists()) {
|
|
248
404
|
const cfg = loadConfig();
|
|
249
405
|
ok(`config: ${cfg.machineName} → ${cfg.syncDir}`);
|
|
250
|
-
if (!existsSync(cfg.syncDir))
|
|
406
|
+
if (!existsSync(cfg.syncDir)) {
|
|
251
407
|
warn(`syncDir does not exist: ${cfg.syncDir}`);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
// Duplicate-machineName footgun: pull self-filters files whose
|
|
411
|
+
// machineName matches ours, so if every file in the transport
|
|
412
|
+
// carries this machine's name (two machines sharing a hostname),
|
|
413
|
+
// pull silently imports nothing.
|
|
414
|
+
const machines = listTransportMachines(cfg.syncDir);
|
|
415
|
+
const total = [...machines.values()].reduce((acc, n) => acc + n, 0);
|
|
416
|
+
if (total > 0) {
|
|
417
|
+
const peers = [...machines.keys()].filter((m) => m !== cfg.machineName);
|
|
418
|
+
if (peers.length === 0) {
|
|
419
|
+
warn(`all ${total} file(s) in the transport use machineName "${cfg.machineName}" — ` +
|
|
420
|
+
`the same as this machine. \`pull\` self-filters these, so it imports nothing. ` +
|
|
421
|
+
`Give each machine a distinct name: re-run \`init --name <name>\` on a fresh ` +
|
|
422
|
+
`machine, or edit machineName in ~/.claude-sync/config.json.`);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
ok(`transport peers: ${peers.sort().join(', ')} (${total} file(s) total)`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
252
429
|
}
|
|
253
430
|
else {
|
|
254
431
|
warn(`no config (run \`claude-sync init <syncDir>\`)`);
|
|
@@ -278,6 +455,23 @@ function mustLoadConfig() {
|
|
|
278
455
|
}
|
|
279
456
|
return loadConfig();
|
|
280
457
|
}
|
|
458
|
+
/** Build a `.ccsync` for `session` (in `cwd`) and write it to the
|
|
459
|
+
* configured transport, returning the written path. Registers the cwd's
|
|
460
|
+
* project key as a side effect, so pushing from a never-before-seen
|
|
461
|
+
* directory self-registers. Shared by `push` and `watch`. */
|
|
462
|
+
function exportToTransport(cfg, cwd, session) {
|
|
463
|
+
const { key } = registerProject(cwd);
|
|
464
|
+
const ccsync = buildCcsync({
|
|
465
|
+
sessionId: session.id,
|
|
466
|
+
projectKey: key,
|
|
467
|
+
originalCwd: cwd,
|
|
468
|
+
machineName: cfg.machineName,
|
|
469
|
+
exportedAt: Date.now(),
|
|
470
|
+
lineCount: session.lineCount,
|
|
471
|
+
jsonl: readSession(session.path),
|
|
472
|
+
});
|
|
473
|
+
return pushToTransport(cfg.syncDir, ccsync);
|
|
474
|
+
}
|
|
281
475
|
function pickSession(cwd, requestedId) {
|
|
282
476
|
const sessions = listSessions(cwd);
|
|
283
477
|
if (sessions.length === 0) {
|
|
@@ -313,6 +507,11 @@ function installCcsync(ccsync, explicitCwd, overwrite) {
|
|
|
313
507
|
}
|
|
314
508
|
try {
|
|
315
509
|
const writtenTo = writeSession(targetCwd, sessionId, ccsync.jsonl, { overwrite });
|
|
510
|
+
// Bind the peer's project key to this local cwd so a later `pull`
|
|
511
|
+
// resolves it without a manual --cwd. This closes the first-contact
|
|
512
|
+
// gap: `import <file> --cwd <path>` now leaves the project registered.
|
|
513
|
+
// Idempotent — `pull` re-imports already register via this path too.
|
|
514
|
+
registerProjectKey(ccsync.projectKey, targetCwd);
|
|
316
515
|
process.stdout.write(`Imported ${sessionId} from ${ccsync.machineName} → ${writtenTo}\n` +
|
|
317
516
|
` (${ccsync.lineCount} lines, project: ${ccsync.projectKey})\n`);
|
|
318
517
|
return 0;
|
|
@@ -329,8 +528,6 @@ function formatBytes(n) {
|
|
|
329
528
|
return `${(n / 1024).toFixed(1)} KB`;
|
|
330
529
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
331
530
|
}
|
|
332
|
-
// We accept basename in some args; suppress unused-import warnings.
|
|
333
|
-
void basename;
|
|
334
531
|
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
335
532
|
process.stderr.write(`claude-sync: ${err.message}\n`);
|
|
336
533
|
process.exit(1);
|