agent-carry 0.0.0-stage → 0.1.1
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/package.json +43 -4
- package/plugin/.claude-plugin/plugin.json +7 -0
- package/plugin/commands/pickup.md +13 -0
- package/plugin/commands/share.md +13 -0
- package/plugin/hooks/hooks.json +10 -0
- package/scripts/verify-resume.sh +33 -0
- package/src/bundle.js +221 -0
- package/src/cli.js +172 -0
- package/src/crypto.js +43 -0
- package/src/merge.js +29 -0
- package/src/paths.js +26 -0
- package/src/portable.js +111 -0
- package/src/redact.js +79 -0
- package/src/store.js +84 -0
- package/src/sync.js +147 -0
- package/src/transcript.js +66 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- `agent-carry` works as a second command name for `carry`.
|
|
6
|
+
- README trimmed; plugin author set.
|
|
7
|
+
|
|
8
|
+
## 0.1.0
|
|
9
|
+
|
|
10
|
+
First release. Claude Code adapter.
|
|
11
|
+
|
|
12
|
+
- Export and import a session as a path-portable, redacted, merge-safe bundle (`carry export`, `carry import`, `carry inspect`, `carry list`).
|
|
13
|
+
- Folder sync (`carry push`, `carry pull`) with optional AES-256-GCM encryption.
|
|
14
|
+
- Plugin commands `/carry:share` and `/carry:pickup`, and optional session hooks.
|
|
15
|
+
- Redaction covers: private keys, Anthropic/OpenAI/GitHub/AWS/Google/Slack/Stripe keys, JWTs, Bearer tokens,
|
|
16
|
+
passwords in URLs, `NAME=value` and `"name": "value"` secrets (including after ANSI colour codes, in lower case,
|
|
17
|
+
and inside escaped JSON), and base64 blobs that decode to any of those.
|
|
18
|
+
- Credential-like text inside signed thinking blocks cannot be redacted, so export refuses unless you pass
|
|
19
|
+
`--strip-thinking` (drop thinking blocks) or `--allow-thinking-secrets`. `carry push` skips such sessions.
|
|
20
|
+
- `carry --version`. Bundles larger than 1 GB are refused, and decompression is size-capped.
|
|
21
|
+
- A session id that exists in several project folders is resolved by the current folder or `--project`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agent-carry contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agent-carry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Move a coding-agent session to another machine or folder and keep working. Supports Claude Code today.
|
|
4
|
+
|
|
5
|
+
Claude Code stores sessions per absolute project path, and the transcripts are full of absolute paths, so a
|
|
6
|
+
copied session can't be resumed elsewhere. `carry` fixes that:
|
|
7
|
+
|
|
8
|
+
- **Path-portable:** project, home and store paths become tokens on export and are rewritten on import.
|
|
9
|
+
- **Merge-safe:** import is a union by record `uuid`. Importing twice changes nothing; a session continued on two machines becomes a two-branch history.
|
|
10
|
+
- **Redacted:** credential-shaped strings (cloud keys, tokens, JWTs, private keys, `*_SECRET=...`, URL passwords) are removed on export, with a report.
|
|
11
|
+
- **Complete:** subagent transcripts and tool results travel with the session.
|
|
12
|
+
- **Inspectable:** a bundle is newline-delimited JSON (gzipped).
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
npm i -g agent-carry # installs `carry` (also available as `agent-carry`)
|
|
18
|
+
|
|
19
|
+
# machine A, in the project folder
|
|
20
|
+
carry list
|
|
21
|
+
carry export --latest -o session.carry.gz
|
|
22
|
+
|
|
23
|
+
# machine B, in the same project (any path)
|
|
24
|
+
carry import session.carry.gz
|
|
25
|
+
claude --resume <id> # printed by import
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Encrypt a bundle with `CARRY_PASSPHRASE` or `--passphrase-file FILE` (AES-256-GCM, scrypt key). Never pass the
|
|
29
|
+
passphrase as an argument.
|
|
30
|
+
|
|
31
|
+
### Sync through a shared folder
|
|
32
|
+
|
|
33
|
+
Point both machines at a folder your sync tool already replicates (Dropbox, iCloud, Syncthing, a git tree).
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
carry push ~/Sync/carry # this project's sessions -> folder
|
|
37
|
+
carry pull ~/Sync/carry # other devices' sessions -> this machine
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Projects are matched by git remote, not path. Each file has one writer, so there are no sync conflicts.
|
|
41
|
+
|
|
42
|
+
### Claude Code plugin
|
|
43
|
+
|
|
44
|
+
`plugin/` adds `/carry:share` and `/carry:pickup`. With `CARRY_SYNC_DIR` set, bundled hooks pull on session
|
|
45
|
+
start and push on session end (hooks not yet exercised in a live Claude Code).
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
| command | |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `carry list [--all]` | sessions for this folder, or all |
|
|
52
|
+
| `carry export <id\|--latest> [-o FILE]` | `--no-redact`, `--redact-emails`, `--strip-thinking`, `--allow-thinking-secrets`, `--project-root DIR` |
|
|
53
|
+
| `carry inspect FILE` | header, parts, redaction counts |
|
|
54
|
+
| `carry import FILE [--project-root DIR] [--dry-run]` | write into the target folder's session store |
|
|
55
|
+
| `carry push DIR` / `carry pull DIR` | folder sync (`--name`, `--dry-run`, `--quiet`) |
|
|
56
|
+
|
|
57
|
+
## Limits
|
|
58
|
+
|
|
59
|
+
- **Resume** is verified on one real session (Claude Code 2.1.299); the transcript format is internal and can change. Run `scripts/verify-resume.sh <id>` to check yours.
|
|
60
|
+
- **Thinking blocks are signed**, so they travel verbatim. If one contains a credential-like string, export refuses unless you pass `--strip-thinking` (resume with stripped thinking is untested) or `--allow-thinking-secrets`. `push` skips such sessions.
|
|
61
|
+
- **Redaction is best effort.** It misses passwords that read like sentences and letters-only lowercase `password=word`. Read `carry inspect` before sharing, and encrypt anything you send to someone else.
|
|
62
|
+
- Not handled: Windows paths, Cowork/desktop sessions, file-history (rewind) backups.
|
|
63
|
+
- `push` re-uploads pulled sessions, so a sync folder grows with devices × sessions.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
npm test # 36 tests, synthetic two-machine setups
|
|
69
|
+
npm run check:real # read-only round trip of your own ~/.claude sessions
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To add an agent, supply its session-folder mapping (`src/paths.js`), session reader (`src/store.js`), transcript
|
|
73
|
+
record identity (`src/transcript.js`) and the `AGENT` constant (`src/bundle.js`). Path rewriting, redaction,
|
|
74
|
+
merging, encryption and sync are agent-independent. Codex is not implemented yet.
|
|
75
|
+
|
|
76
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-carry",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Move coding-agent sessions between machines and folders: path-portable, redacted, encrypted, merge-safe bundles. Claude Code today; adapter-ready for Codex and others.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"carry": "src/cli.js",
|
|
8
|
+
"agent-carry": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"plugin",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"scripts/verify-resume.sh",
|
|
16
|
+
"CHANGELOG.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test \"test/*.test.js\"",
|
|
23
|
+
"check:real": "node scripts/check-real.js"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"claude-code",
|
|
27
|
+
"codex",
|
|
28
|
+
"coding-agent",
|
|
29
|
+
"sessions",
|
|
30
|
+
"transcripts",
|
|
31
|
+
"portable",
|
|
32
|
+
"resume",
|
|
33
|
+
"sync"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/harshhh28/agent-carry.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/harshhh28/agent-carry#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/harshhh28/agent-carry/issues"
|
|
43
|
+
},
|
|
44
|
+
"author": "Harsh Gajjar <harshgajjar2812@gmail.com>"
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "carry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Move coding-agent sessions between machines and folders: /carry:share exports a redacted, path-portable bundle, /carry:pickup imports one.",
|
|
5
|
+
"author": { "name": "Harsh Gajjar", "email": "harshgajjar2812@gmail.com" },
|
|
6
|
+
"license": "MIT"
|
|
7
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Import a carry bundle into this folder so it can be resumed
|
|
3
|
+
argument-hint: <bundle-file> [--dry-run]
|
|
4
|
+
allowed-tools: Bash(carry import:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Import result:
|
|
8
|
+
|
|
9
|
+
!`carry import $ARGUMENTS 2>&1 || echo "carry is not installed: run npm i -g agent-carry"`
|
|
10
|
+
|
|
11
|
+
Tell the user the session was imported into this folder and that a running session cannot swap its own
|
|
12
|
+
history: they should exit and run the `claude --resume <id>` line printed above. If `--dry-run` was used,
|
|
13
|
+
say nothing was written.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Export this project's latest session as a portable, redacted carry bundle
|
|
3
|
+
argument-hint: [-o FILE] [--strip-thinking] [--redact-emails]
|
|
4
|
+
allowed-tools: Bash(carry export:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Export result (this runs the `carry` CLI from `npm i -g agent-carry`):
|
|
8
|
+
|
|
9
|
+
!`carry export --latest $ARGUMENTS 2>&1 || echo "carry is not installed: run npm i -g agent-carry"`
|
|
10
|
+
|
|
11
|
+
Report in at most three lines: the file that was written, how many secrets were redacted, and any warning above.
|
|
12
|
+
If there is a warning about credential-like strings in thinking blocks, tell the user to re-run `/carry:share --strip-thinking`.
|
|
13
|
+
Do not open or print the bundle's contents.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{ "hooks": [ { "type": "command", "command": "[ -n \"$CARRY_SYNC_DIR\" ] && carry pull \"$CARRY_SYNC_DIR\" --quiet || true" } ] }
|
|
5
|
+
],
|
|
6
|
+
"SessionEnd": [
|
|
7
|
+
{ "hooks": [ { "type": "command", "command": "[ -n \"$CARRY_SYNC_DIR\" ] && carry push \"$CARRY_SYNC_DIR\" --quiet || true" } ] }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Does `claude --resume` accept a session imported by carry into a folder it never ran in?
|
|
3
|
+
#
|
|
4
|
+
# sh scripts/verify-resume.sh <session-id-prefix> (ids: node src/cli.js list --all)
|
|
5
|
+
#
|
|
6
|
+
# Exports the session, imports it into a throwaway folder, resumes it with one
|
|
7
|
+
# short prompt, then deletes the throwaway folder and its copy of the session.
|
|
8
|
+
# The original session is only read. Costs one small API call.
|
|
9
|
+
set -eu
|
|
10
|
+
|
|
11
|
+
ID=${1:?usage: verify-resume.sh <session-id-prefix>}
|
|
12
|
+
HERE=$(cd "$(dirname "$0")/.." && pwd -P)
|
|
13
|
+
CARRY="node $HERE/src/cli.js"
|
|
14
|
+
CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
|
|
15
|
+
|
|
16
|
+
TRY=$(mktemp -d "${TMPDIR:-/tmp}/carry-try.XXXXXX")
|
|
17
|
+
TRY=$(cd "$TRY" && pwd -P)
|
|
18
|
+
FOLDER="$CLAUDE_DIR/projects/$(printf '%s' "$TRY" | sed 's/[^A-Za-z0-9]/-/g')"
|
|
19
|
+
cleanup() { rm -rf "$TRY" "$FOLDER"; echo "cleaned up $TRY and its session copy"; }
|
|
20
|
+
trap cleanup EXIT
|
|
21
|
+
|
|
22
|
+
echo "1/3 export"
|
|
23
|
+
$CARRY export "$ID" -o "$TRY/s.carry.gz"
|
|
24
|
+
FULL=$($CARRY inspect "$TRY/s.carry.gz" | sed -n 's/.*"sessionId": "\(.*\)".*/\1/p')
|
|
25
|
+
|
|
26
|
+
echo "2/3 import into $TRY"
|
|
27
|
+
cd "$TRY"
|
|
28
|
+
$CARRY import "$TRY/s.carry.gz" >/dev/null
|
|
29
|
+
|
|
30
|
+
echo "3/3 resume $FULL from a folder it never ran in"
|
|
31
|
+
claude -p --resume "$FULL" "In one sentence: what was the very first thing the user asked in this conversation? Also say which directory we are in now." </dev/null
|
|
32
|
+
echo
|
|
33
|
+
echo "PASS if the answer above matches the session's first request and names $TRY."
|
package/src/bundle.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import zlib from 'node:zlib';
|
|
4
|
+
import { parseJsonl, summarize, sha256 } from './transcript.js';
|
|
5
|
+
import { toPortable, fromPortable, mapStrings, residualHomePaths, TOKENS } from './portable.js';
|
|
6
|
+
import { makeRedactor } from './redact.js';
|
|
7
|
+
import { mergeEntries } from './merge.js';
|
|
8
|
+
import { mangle, realpathOr, projectsDir } from './paths.js';
|
|
9
|
+
import { encrypt, decrypt, isEncrypted } from './crypto.js';
|
|
10
|
+
import { readSession } from './store.js';
|
|
11
|
+
|
|
12
|
+
export const FORMAT = 1;
|
|
13
|
+
const MAX_BUNDLE_BYTES = 1024 * 1024 * 1024; // refuse decompression bombs
|
|
14
|
+
|
|
15
|
+
export const TOOL = 'agent-carry/0.1.0';
|
|
16
|
+
|
|
17
|
+
/** Agents this build can read and write. Bundles name their agent so other adapters can be added without a format change. */
|
|
18
|
+
export const AGENT = 'claude-code';
|
|
19
|
+
const SUPPORTED_AGENTS = new Set([AGENT]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bundle = newline-delimited JSON, readable with `less` and `jq`:
|
|
23
|
+
* line 1 header (what is inside, counts, redaction report, body hash)
|
|
24
|
+
* line 2+ {"p": part, "l": record} | {"p": part, "x": unparsable line}
|
|
25
|
+
* | {"p": part, "t": text file} | {"p": part, "b": base64 file}
|
|
26
|
+
* A `.gz` suffix gzips the whole thing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export function buildBundle({ folder, id, where, redact = true, emails = false, stripThinking = false, allowThinkingSecrets = false }) {
|
|
30
|
+
const { parts, skipped } = readSession(folder, id);
|
|
31
|
+
const mapper = toPortable(where);
|
|
32
|
+
const redactor = redact ? makeRedactor({ emails }) : null;
|
|
33
|
+
const transform = (v) => {
|
|
34
|
+
let out = mapStrings(v, mapper.fn);
|
|
35
|
+
if (redactor) out = redactor.apply(out);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const body = [];
|
|
40
|
+
const thinking = { blocks: 0, possibleSecrets: 0 };
|
|
41
|
+
const partsMeta = [];
|
|
42
|
+
const residualSource = [];
|
|
43
|
+
let meta = null;
|
|
44
|
+
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
let count = 0;
|
|
47
|
+
if (part.kind === 'jsonl') {
|
|
48
|
+
const entries = parseJsonl(part.text);
|
|
49
|
+
if (part.rel === 'main.jsonl') meta = summarize(entries);
|
|
50
|
+
for (const { raw, rec } of entries) {
|
|
51
|
+
if (rec === null) {
|
|
52
|
+
const t = transform(raw);
|
|
53
|
+
body.push(JSON.stringify({ p: part.rel, x: t }));
|
|
54
|
+
residualSource.push(t);
|
|
55
|
+
} else {
|
|
56
|
+
let r = stripThinking ? dropThinking(rec) : rec;
|
|
57
|
+
if (!stripThinking) scanThinking(r, thinking);
|
|
58
|
+
r = transform(r);
|
|
59
|
+
body.push(JSON.stringify({ p: part.rel, l: r }));
|
|
60
|
+
residualSource.push(JSON.stringify(r));
|
|
61
|
+
}
|
|
62
|
+
count++;
|
|
63
|
+
}
|
|
64
|
+
} else if (part.kind === 'text') {
|
|
65
|
+
const t = transform(part.text);
|
|
66
|
+
body.push(JSON.stringify({ p: part.rel, t }));
|
|
67
|
+
residualSource.push(t);
|
|
68
|
+
count = 1;
|
|
69
|
+
} else {
|
|
70
|
+
body.push(JSON.stringify({ p: part.rel, b: part.base64 }));
|
|
71
|
+
count = 1;
|
|
72
|
+
}
|
|
73
|
+
partsMeta.push({ rel: part.rel, kind: part.kind, count });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (redact && !stripThinking && !allowThinkingSecrets && thinking.possibleSecrets > 0) {
|
|
77
|
+
const e = new Error(
|
|
78
|
+
`${thinking.possibleSecrets} credential-like string(s) in ${thinking.blocks} signed thinking block(s) cannot be redacted (editing a thinking block invalidates it). ` +
|
|
79
|
+
'Re-run with --strip-thinking to drop thinking blocks, or --allow-thinking-secrets to export them as they are.',
|
|
80
|
+
);
|
|
81
|
+
e.code = 'THINKING_SECRETS';
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
const bodyText = body.join('\n') + '\n';
|
|
85
|
+
const header = {
|
|
86
|
+
carry: FORMAT,
|
|
87
|
+
agent: AGENT,
|
|
88
|
+
tool: TOOL,
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
sessionId: id,
|
|
91
|
+
title: meta?.title ?? null,
|
|
92
|
+
claudeVersions: meta?.versions ?? [],
|
|
93
|
+
entrypoints: meta?.entrypoints ?? [],
|
|
94
|
+
turns: meta?.turns ?? 0,
|
|
95
|
+
tokens: TOKENS,
|
|
96
|
+
parts: partsMeta,
|
|
97
|
+
skippedLarge: skipped,
|
|
98
|
+
redaction: redactor ? { enabled: true, emails, counts: redactor.counts } : { enabled: false },
|
|
99
|
+
thinkingStripped: stripThinking,
|
|
100
|
+
thinking,
|
|
101
|
+
pathsRewritten: mapper.stats.replaced,
|
|
102
|
+
residualHomePaths: residualHomePaths(residualSource).size,
|
|
103
|
+
bodySha256: sha256(bodyText),
|
|
104
|
+
};
|
|
105
|
+
return { text: JSON.stringify(header) + '\n' + bodyText, header };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Thinking blocks are signed, so they travel unmodified: neither path-rewritten
|
|
110
|
+
* nor redacted. Count credential-shaped strings inside them so the exporter can
|
|
111
|
+
* warn instead of silently shipping them.
|
|
112
|
+
*/
|
|
113
|
+
function scanThinking(rec, acc) {
|
|
114
|
+
const c = rec?.message?.content;
|
|
115
|
+
if (!Array.isArray(c)) return;
|
|
116
|
+
for (const b of c) {
|
|
117
|
+
if (!b || b.type !== 'thinking' || typeof b.thinking !== 'string') continue;
|
|
118
|
+
acc.blocks++;
|
|
119
|
+
const probe = makeRedactor();
|
|
120
|
+
probe.fn(b.thinking);
|
|
121
|
+
acc.possibleSecrets += Object.values(probe.counts).reduce((a, n) => a + n, 0);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dropThinking(rec) {
|
|
126
|
+
const c = rec?.message?.content;
|
|
127
|
+
if (!Array.isArray(c)) return rec;
|
|
128
|
+
const kept = c.filter((b) => !(b && (b.type === 'thinking' || b.type === 'redacted_thinking')));
|
|
129
|
+
return kept.length === c.length ? rec : { ...rec, message: { ...rec.message, content: kept } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Atomic (temp + rename) so a file-sync tool never uploads a half-written bundle. */
|
|
133
|
+
export function writeBundle(file, text, { passphrase } = {}) {
|
|
134
|
+
let data = Buffer.from(text);
|
|
135
|
+
if (file.endsWith('.gz') || file.endsWith('.enc') || passphrase) data = zlib.gzipSync(data);
|
|
136
|
+
if (passphrase) data = encrypt(data, passphrase);
|
|
137
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
138
|
+
const tmp = `${file}.carry-tmp`;
|
|
139
|
+
fs.writeFileSync(tmp, data);
|
|
140
|
+
fs.renameSync(tmp, file);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function readBundle(file, { passphrase } = {}) {
|
|
144
|
+
if (fs.statSync(file).size > MAX_BUNDLE_BYTES) throw new Error('bundle is larger than 1 GB; refusing to read it');
|
|
145
|
+
let buf = fs.readFileSync(file);
|
|
146
|
+
if (isEncrypted(buf)) buf = decrypt(buf, passphrase);
|
|
147
|
+
if (buf[0] === 0x1f && buf[1] === 0x8b) buf = zlib.gunzipSync(buf, { maxOutputLength: MAX_BUNDLE_BYTES });
|
|
148
|
+
const text = buf.toString('utf8');
|
|
149
|
+
const nl = text.indexOf('\n');
|
|
150
|
+
if (nl < 0) throw new Error('not a carry bundle (no header)');
|
|
151
|
+
let header;
|
|
152
|
+
try {
|
|
153
|
+
header = JSON.parse(text.slice(0, nl));
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error('not a carry bundle (bad header)');
|
|
156
|
+
}
|
|
157
|
+
if (header.carry !== FORMAT) throw new Error(`unsupported bundle format ${header.carry} (this tool reads ${FORMAT})`);
|
|
158
|
+
// Bundles written before the field existed were all Claude Code.
|
|
159
|
+
const agent = header.agent ?? AGENT;
|
|
160
|
+
if (!SUPPORTED_AGENTS.has(agent)) {
|
|
161
|
+
throw new Error(`bundle is for agent "${agent}"; this version of agent-carry supports: ${[...SUPPORTED_AGENTS].join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
const bodyText = text.slice(nl + 1);
|
|
164
|
+
if (sha256(bodyText) !== header.bodySha256) throw new Error('bundle is corrupt or was modified: body hash mismatch');
|
|
165
|
+
const rows = bodyText.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
166
|
+
return { header, rows };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Write a bundle into a Claude home for a target project root. Returns a plan;
|
|
171
|
+
* with dryRun it writes nothing.
|
|
172
|
+
*/
|
|
173
|
+
export function importBundle({ bundle, claudeHome, root, home, dryRun = false }) {
|
|
174
|
+
const realRoot = realpathOr(root);
|
|
175
|
+
const folder = path.join(projectsDir(claudeHome), mangle(realRoot));
|
|
176
|
+
const where = { store: folder, root: realRoot, home };
|
|
177
|
+
const back = fromPortable(where);
|
|
178
|
+
const id = bundle.header.sessionId;
|
|
179
|
+
const plan = { id, folder, root: realRoot, files: [], warnings: [] };
|
|
180
|
+
if (mangle(realRoot).length > 200) {
|
|
181
|
+
plan.warnings.push('project path is very long; Claude Code may shorten the folder name differently than carry does');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const byPart = new Map();
|
|
185
|
+
for (const row of bundle.rows) {
|
|
186
|
+
if (!byPart.has(row.p)) byPart.set(row.p, []);
|
|
187
|
+
byPart.get(row.p).push(row);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const [rel, rows] of byPart) {
|
|
191
|
+
const dest = rel === 'main.jsonl' ? path.join(folder, `${id}.jsonl`) : path.join(folder, id, rel.replace(/^side\//, ''));
|
|
192
|
+
if (!path.resolve(dest).startsWith(path.resolve(folder) + path.sep)) throw new Error(`refusing path outside project folder: ${rel}`);
|
|
193
|
+
const kind = rows[0].l !== undefined || rows[0].x !== undefined ? 'jsonl' : rows[0].t !== undefined ? 'text' : 'binary';
|
|
194
|
+
let content;
|
|
195
|
+
let stat;
|
|
196
|
+
if (kind === 'jsonl') {
|
|
197
|
+
const incoming = rows.map((r) => {
|
|
198
|
+
const raw = r.l !== undefined ? JSON.stringify(mapStrings(r.l, back.fn)) : back.fn(r.x);
|
|
199
|
+
return { raw, rec: r.l !== undefined ? JSON.parse(raw) : null };
|
|
200
|
+
});
|
|
201
|
+
const local = fs.existsSync(dest) ? parseJsonl(fs.readFileSync(dest, 'utf8')) : [];
|
|
202
|
+
const m = mergeEntries(local, incoming);
|
|
203
|
+
content = m.lines.join('\n') + '\n';
|
|
204
|
+
stat = { existed: local.length > 0, added: m.added, skipped: m.skipped };
|
|
205
|
+
} else if (fs.existsSync(dest)) {
|
|
206
|
+
stat = { existed: true, added: 0, skipped: 1 };
|
|
207
|
+
content = null; // sidecar files are content-addressed by name; keep the local one
|
|
208
|
+
} else {
|
|
209
|
+
content = kind === 'text' ? back.fn(rows[0].t) : Buffer.from(rows[0].b, 'base64');
|
|
210
|
+
stat = { existed: false, added: 1, skipped: 0 };
|
|
211
|
+
}
|
|
212
|
+
plan.files.push({ rel, dest, kind, ...stat });
|
|
213
|
+
if (!dryRun && content !== null && (kind !== 'jsonl' || stat.added > 0 || !stat.existed)) {
|
|
214
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
215
|
+
const tmp = `${dest}.carry-tmp`;
|
|
216
|
+
fs.writeFileSync(tmp, content);
|
|
217
|
+
fs.renameSync(tmp, dest);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return plan;
|
|
221
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { claudeHome, mangle, projectsDir, realpathOr } from './paths.js';
|
|
5
|
+
import { projectFolders, listSessions, findSession } from './store.js';
|
|
6
|
+
import { buildBundle, writeBundle, readBundle, importBundle } from './bundle.js';
|
|
7
|
+
import { parseJsonl, summarize } from './transcript.js';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { pushProject, pullProject } from './sync.js';
|
|
10
|
+
|
|
11
|
+
const HELP = `carry (agent-carry): move coding-agent sessions between machines and folders. Supported agent: claude-code
|
|
12
|
+
|
|
13
|
+
carry list [--all] [--project DIR]
|
|
14
|
+
carry export <id|prefix|--latest> [-o FILE] [--project-root DIR]
|
|
15
|
+
[--no-redact] [--redact-emails] [--strip-thinking | --allow-thinking-secrets]
|
|
16
|
+
carry inspect <bundle>
|
|
17
|
+
carry import <bundle> [--project-root DIR] [--dry-run]
|
|
18
|
+
carry push <dir> [--name PROJECT] [--quiet] sync every session of this project to a shared folder
|
|
19
|
+
carry pull <dir> [--name PROJECT] [--dry-run] [--quiet]
|
|
20
|
+
|
|
21
|
+
Encryption (export, import, push, pull): set CARRY_PASSPHRASE or pass --passphrase-file FILE.
|
|
22
|
+
|
|
23
|
+
Global: --claude-home DIR (default $CLAUDE_CONFIG_DIR or ~/.claude), --home DIR
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const flags = {};
|
|
28
|
+
const pos = [];
|
|
29
|
+
const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file']);
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a.startsWith('-') && a !== '-') {
|
|
33
|
+
if (valued.has(a)) flags[a.replace(/^-+/, '')] = argv[++i];
|
|
34
|
+
else flags[a.replace(/^-+/, '')] = true;
|
|
35
|
+
} else pos.push(a);
|
|
36
|
+
}
|
|
37
|
+
return { flags, pos };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fail(msg) {
|
|
41
|
+
process.stderr.write(`carry: ${msg}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function main() {
|
|
46
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
47
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
48
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
49
|
+
return process.stdout.write(`${pkg.version}\n`);
|
|
50
|
+
}
|
|
51
|
+
const { flags, pos } = parseArgs(rest);
|
|
52
|
+
const home = flags['claude-home'] ? path.resolve(flags['claude-home']) : claudeHome();
|
|
53
|
+
const userHome = flags.home ? path.resolve(flags.home) : os.homedir();
|
|
54
|
+
const cwd = flags['project-root'] || flags.project || process.cwd();
|
|
55
|
+
const passphrase = flags['passphrase-file'] ? fs.readFileSync(flags['passphrase-file'], 'utf8').replace(/\r?\n$/, '') : process.env.CARRY_PASSPHRASE || undefined;
|
|
56
|
+
const say = (m) => flags.quiet || process.stdout.write(`${m}\n`);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (!cmd || cmd === 'help' || flags.help) return process.stdout.write(HELP);
|
|
60
|
+
|
|
61
|
+
if (cmd === 'list') {
|
|
62
|
+
const folders = flags.all ? projectFolders(home) : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
|
|
63
|
+
let n = 0;
|
|
64
|
+
for (const f of folders) {
|
|
65
|
+
for (const s of listSessions(f)) {
|
|
66
|
+
n++;
|
|
67
|
+
const when = new Date(s.mtimeMs).toISOString().slice(0, 16).replace('T', ' ');
|
|
68
|
+
const where = flags.all ? ` ${s.dominantCwd ?? path.basename(f)}` : '';
|
|
69
|
+
process.stdout.write(`${s.id.slice(0, 8)} ${when} ${String(s.turns).padStart(3)} turns ${s.title ?? '(untitled)'}${where}\n`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!n) process.stdout.write(`no sessions found for ${flags.all ? 'any project' : realpathOr(cwd)}\n`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (cmd === 'export') {
|
|
77
|
+
const folders = flags.all || !flags.latest ? projectFolders(home) : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
|
|
78
|
+
let target;
|
|
79
|
+
if (flags.latest) {
|
|
80
|
+
const here = path.join(projectsDir(home), mangle(realpathOr(cwd)));
|
|
81
|
+
const s = listSessions(here)[0];
|
|
82
|
+
if (!s) fail(`no sessions for ${realpathOr(cwd)}`);
|
|
83
|
+
target = { folder: here, id: s.id };
|
|
84
|
+
} else {
|
|
85
|
+
if (!pos[0]) fail('export needs a session id (see `carry list`) or --latest');
|
|
86
|
+
// Same id can exist in several project folders (a session that was carried). Prefer the folder
|
|
87
|
+
// named by --project, else the current one, and only then search everything.
|
|
88
|
+
const scoped = path.join(projectsDir(home), mangle(realpathOr(flags.project || process.cwd())));
|
|
89
|
+
try {
|
|
90
|
+
target = findSession([scoped], pos[0]);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (flags.project) throw e;
|
|
93
|
+
target = findSession(folders, pos[0]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const entries = parseJsonl(fs.readFileSync(path.join(target.folder, `${target.id}.jsonl`), 'utf8'));
|
|
97
|
+
const sum = summarize(entries);
|
|
98
|
+
const root = flags['project-root'] ? realpathOr(flags['project-root']) : sum.dominantCwd;
|
|
99
|
+
if (!root) fail('could not determine the project root from the transcript; pass --project-root');
|
|
100
|
+
const { text, header } = buildBundle({
|
|
101
|
+
folder: target.folder,
|
|
102
|
+
id: target.id,
|
|
103
|
+
where: { store: target.folder, root, home: userHome },
|
|
104
|
+
redact: !flags['no-redact'],
|
|
105
|
+
emails: !!flags['redact-emails'],
|
|
106
|
+
stripThinking: !!flags['strip-thinking'],
|
|
107
|
+
allowThinkingSecrets: !!flags['allow-thinking-secrets'],
|
|
108
|
+
});
|
|
109
|
+
const out = flags.o || flags.out || `${target.id.slice(0, 8)}${passphrase ? '.carry.enc' : '.carry.gz'}`;
|
|
110
|
+
writeBundle(out, text, { passphrase });
|
|
111
|
+
const red = Object.entries(header.redaction.counts || {}).map(([k, v]) => `${k}:${v}`).join(' ') || 'none found';
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
`exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${out}\n` +
|
|
114
|
+
` parts: ${header.parts.length} paths rewritten: ${header.pathsRewritten}\n` +
|
|
115
|
+
` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n`,
|
|
116
|
+
);
|
|
117
|
+
if (header.residualHomePaths > 0) {
|
|
118
|
+
process.stderr.write(` warning: ${header.residualHomePaths} other absolute home path(s) remain in the bundle (other users or tools). Review with \`carry inspect\`.\n`);
|
|
119
|
+
}
|
|
120
|
+
if (header.thinking.possibleSecrets > 0) {
|
|
121
|
+
process.stderr.write(` warning: ${header.thinking.possibleSecrets} credential-like string(s) in ${header.thinking.blocks} signed thinking block(s). These cannot be redacted without invalidating them; re-export with --strip-thinking.\n`);
|
|
122
|
+
}
|
|
123
|
+
if (header.skippedLarge.length) process.stderr.write(` warning: ${header.skippedLarge.length} file(s) over 20MB were not included\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (cmd === 'inspect') {
|
|
128
|
+
if (!pos[0]) fail('inspect needs a bundle file');
|
|
129
|
+
const { header } = readBundle(pos[0], { passphrase });
|
|
130
|
+
process.stdout.write(JSON.stringify(header, null, 2) + '\n');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (cmd === 'import') {
|
|
135
|
+
if (!pos[0]) fail('import needs a bundle file');
|
|
136
|
+
const bundle = readBundle(pos[0], { passphrase });
|
|
137
|
+
const plan = importBundle({ bundle, claudeHome: home, root: cwd, home: userHome, dryRun: !!flags['dry-run'] });
|
|
138
|
+
process.stdout.write(`${flags['dry-run'] ? 'would import' : 'imported'} ${plan.id.slice(0, 8)} into ${plan.folder}\n`);
|
|
139
|
+
for (const f of plan.files.filter((f) => f.kind === 'jsonl')) {
|
|
140
|
+
process.stdout.write(` ${f.rel}: +${f.added} new, ${f.skipped} already present\n`);
|
|
141
|
+
}
|
|
142
|
+
const side = plan.files.filter((f) => f.kind !== 'jsonl');
|
|
143
|
+
if (side.length) process.stdout.write(` sidecar files: ${side.filter((f) => f.added).length} new, ${side.filter((f) => !f.added).length} kept\n`);
|
|
144
|
+
for (const w of plan.warnings) process.stderr.write(` warning: ${w}\n`);
|
|
145
|
+
if (!flags['dry-run']) process.stdout.write(`\nresume with:\n cd ${plan.root} && claude --resume ${plan.id}\n`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (cmd === 'push' || cmd === 'pull') {
|
|
150
|
+
if (!pos[0]) fail(`${cmd} needs a shared folder, e.g. carry ${cmd} ~/Sync/carry`);
|
|
151
|
+
const dir = path.resolve(pos[0]);
|
|
152
|
+
const common = { dir, claudeHome: home, root: cwd, userHome, passphrase, name: flags.name, log: say };
|
|
153
|
+
if (cmd === 'push') {
|
|
154
|
+
const r = pushProject(common);
|
|
155
|
+
if (r.skipped.length) process.stderr.write(` ${r.skipped.length} session(s) skipped: credential-like text in thinking blocks\n`);
|
|
156
|
+
say(`push ${r.key}: ${r.pushed} written, ${r.unchanged} unchanged (device ${r.device}${passphrase ? ', encrypted' : ''})`);
|
|
157
|
+
} else {
|
|
158
|
+
const r = pullProject({ ...common, dryRun: !!flags['dry-run'] });
|
|
159
|
+
say(`pull ${r.key}: ${r.imported} session(s) updated, ${r.upToDate} up to date`);
|
|
160
|
+
for (const e of r.errors) process.stderr.write(` error: ${e}\n`);
|
|
161
|
+
if (r.errors.length) process.exitCode = 1;
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fail(`unknown command "${cmd}"\n\n${HELP}`);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
fail(e.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main();
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passphrase encryption for bundles: scrypt -> AES-256-GCM, node:crypto only.
|
|
5
|
+
* Layout: "CARRYENC" | version(1) | salt(16) | iv(12) | ciphertext | tag(16).
|
|
6
|
+
* The fixed header is bound into the auth tag, so any edit is detected.
|
|
7
|
+
*/
|
|
8
|
+
const MAGIC = Buffer.from('CARRYENC');
|
|
9
|
+
const VERSION = 1;
|
|
10
|
+
const HEADER = MAGIC.length + 1 + 16 + 12;
|
|
11
|
+
const SCRYPT = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
12
|
+
|
|
13
|
+
export const isEncrypted = (buf) => buf.length > HEADER + 16 && buf.subarray(0, MAGIC.length).equals(MAGIC);
|
|
14
|
+
|
|
15
|
+
export function encrypt(plain, passphrase) {
|
|
16
|
+
if (!passphrase) throw new Error('empty passphrase');
|
|
17
|
+
const salt = crypto.randomBytes(16);
|
|
18
|
+
const iv = crypto.randomBytes(12);
|
|
19
|
+
const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
|
|
20
|
+
const head = Buffer.concat([MAGIC, Buffer.from([VERSION]), salt, iv]);
|
|
21
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
22
|
+
c.setAAD(head);
|
|
23
|
+
const ct = Buffer.concat([c.update(plain), c.final()]);
|
|
24
|
+
return Buffer.concat([head, ct, c.getAuthTag()]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decrypt(buf, passphrase) {
|
|
28
|
+
if (!passphrase) throw new Error('this bundle is encrypted: set CARRY_PASSPHRASE or pass --passphrase-file');
|
|
29
|
+
if (buf[MAGIC.length] !== VERSION) throw new Error(`unsupported encryption version ${buf[MAGIC.length]}`);
|
|
30
|
+
const head = buf.subarray(0, HEADER);
|
|
31
|
+
const salt = head.subarray(MAGIC.length + 1, MAGIC.length + 17);
|
|
32
|
+
const iv = head.subarray(MAGIC.length + 17);
|
|
33
|
+
const tag = buf.subarray(buf.length - 16);
|
|
34
|
+
const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
|
|
35
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
36
|
+
d.setAAD(head);
|
|
37
|
+
d.setAuthTag(tag);
|
|
38
|
+
try {
|
|
39
|
+
return Buffer.concat([d.update(buf.subarray(HEADER, buf.length - 16)), d.final()]);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error('wrong passphrase, or the file was modified');
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/merge.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { recordKey } from './transcript.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Union of two transcripts keyed by record identity. The local file is the
|
|
5
|
+
* source of truth: its lines are kept byte for byte and in order, and records
|
|
6
|
+
* that only exist in the incoming copy are appended. A session that continued
|
|
7
|
+
* on two machines therefore becomes a DAG with two branches, which is exactly
|
|
8
|
+
* what it is, and merging the same bundle twice changes nothing.
|
|
9
|
+
*/
|
|
10
|
+
export function mergeEntries(local, incoming) {
|
|
11
|
+
const localKeys = new Set(local.map(recordKey));
|
|
12
|
+
const lines = local.map((e) => e.raw);
|
|
13
|
+
const seenUuid = new Set();
|
|
14
|
+
let added = 0;
|
|
15
|
+
for (const e of incoming) {
|
|
16
|
+
const k = recordKey(e);
|
|
17
|
+
if (localKeys.has(k)) continue;
|
|
18
|
+
// Conversation records are unique by uuid. Bookkeeping records (mode,
|
|
19
|
+
// last-prompt, ...) have no identity and their *order* carries meaning
|
|
20
|
+
// (the last one wins), so repeats inside the incoming copy are kept.
|
|
21
|
+
if (k.startsWith('u:')) {
|
|
22
|
+
if (seenUuid.has(k)) continue;
|
|
23
|
+
seenUuid.add(k);
|
|
24
|
+
}
|
|
25
|
+
lines.push(e.raw);
|
|
26
|
+
added++;
|
|
27
|
+
}
|
|
28
|
+
return { lines, added, kept: local.length, skipped: incoming.length - added };
|
|
29
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Claude Code stores a project's sessions in a folder named after the project's
|
|
7
|
+
* working directory with every non-alphanumeric character replaced by '-'.
|
|
8
|
+
* The mapping is lossy (`/a/b-c` and `/a/b/c` collide), so it can only be
|
|
9
|
+
* computed forward, never inverted. That is why a session cannot simply be
|
|
10
|
+
* copied to another machine: the folder key depends on the absolute path.
|
|
11
|
+
*/
|
|
12
|
+
export const mangle = (absPath) => absPath.replace(/[^A-Za-z0-9]/g, '-');
|
|
13
|
+
|
|
14
|
+
export function claudeHome(env = process.env) {
|
|
15
|
+
return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const projectsDir = (home) => path.join(home, 'projects');
|
|
19
|
+
|
|
20
|
+
export function realpathOr(p) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.realpathSync(p);
|
|
23
|
+
} catch {
|
|
24
|
+
return path.resolve(p);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/portable.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path portability. Absolute paths are swapped for tokens on export and for the
|
|
3
|
+
* target machine's paths on import. Order matters: the project's session store
|
|
4
|
+
* lives under HOME and contains the project root in mangled form, so the
|
|
5
|
+
* longest, most specific prefix is replaced first.
|
|
6
|
+
*/
|
|
7
|
+
export const TOKENS = {
|
|
8
|
+
store: '${CARRY_STORE}',
|
|
9
|
+
root: '${CARRY_ROOT}',
|
|
10
|
+
home: '${CARRY_HOME}',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
|
|
15
|
+
// A path only matches as a whole segment run: `/work/app` must not touch
|
|
16
|
+
// `/work/app-two` or `/work/app.bak`, and must not match inside `/x/work/app`.
|
|
17
|
+
const prefixRe = (abs) =>
|
|
18
|
+
new RegExp(`(?:(?<![A-Za-z0-9])|(?<=\\\\[nrt])|(?<=(?:\\x1b|u001b)\\[[0-9;]*m))${escapeRe(abs)}(?![A-Za-z0-9_-]|\\.[A-Za-z0-9])`, 'g');
|
|
19
|
+
|
|
20
|
+
const THINKING = new Set(['thinking', 'redacted_thinking']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Apply `fn` to every string value in a parsed record. Thinking blocks are
|
|
24
|
+
* skipped on purpose: they carry a cryptographic signature and the API rejects
|
|
25
|
+
* a resumed history in which one was edited.
|
|
26
|
+
*/
|
|
27
|
+
export function mapStrings(value, fn) {
|
|
28
|
+
if (typeof value === 'string') return fn(value);
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
let changed = false;
|
|
31
|
+
const out = value.map((v) => {
|
|
32
|
+
const m = mapStrings(v, fn);
|
|
33
|
+
if (m !== v) changed = true;
|
|
34
|
+
return m;
|
|
35
|
+
});
|
|
36
|
+
return changed ? out : value;
|
|
37
|
+
}
|
|
38
|
+
if (value && typeof value === 'object') {
|
|
39
|
+
if (THINKING.has(value.type)) return value;
|
|
40
|
+
let changed = false;
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(value)) {
|
|
43
|
+
// Keys too: file-history snapshots are maps keyed by absolute path.
|
|
44
|
+
const nk = fn(k);
|
|
45
|
+
const m = mapStrings(v, fn);
|
|
46
|
+
if (m !== v || nk !== k) changed = true;
|
|
47
|
+
out[nk] = m;
|
|
48
|
+
}
|
|
49
|
+
return changed ? out : value;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a string rewriter from a list of [from, to] prefix pairs, applied in order of `from` length. */
|
|
55
|
+
function rewriter(pairs) {
|
|
56
|
+
const ordered = pairs
|
|
57
|
+
.filter(([from]) => from && from !== '/')
|
|
58
|
+
.sort((a, b) => b[0].length - a[0].length)
|
|
59
|
+
.map(([from, to]) => ({ from, to, re: prefixRe(from) }));
|
|
60
|
+
const stats = { replaced: 0 };
|
|
61
|
+
const fn = (s) => {
|
|
62
|
+
let out = s;
|
|
63
|
+
for (const { from, to, re } of ordered) {
|
|
64
|
+
if (!out.includes(from)) continue;
|
|
65
|
+
out = out.replace(re, () => {
|
|
66
|
+
stats.replaced++;
|
|
67
|
+
return to;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
};
|
|
72
|
+
return { fn, stats };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `where` = {store, root, home}: absolute paths on the machine we are on. */
|
|
76
|
+
export function toPortable(where) {
|
|
77
|
+
return rewriter([
|
|
78
|
+
[where.store, TOKENS.store],
|
|
79
|
+
[where.root, TOKENS.root],
|
|
80
|
+
[where.home, TOKENS.home],
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fromPortable(where) {
|
|
85
|
+
// Tokens are unambiguous, so a plain global replace is enough.
|
|
86
|
+
const pairs = [
|
|
87
|
+
[TOKENS.store, where.store],
|
|
88
|
+
[TOKENS.root, where.root],
|
|
89
|
+
[TOKENS.home, where.home],
|
|
90
|
+
];
|
|
91
|
+
const stats = { replaced: 0 };
|
|
92
|
+
const fn = (s) => {
|
|
93
|
+
if (!s.includes('${CARRY_')) return s;
|
|
94
|
+
let out = s;
|
|
95
|
+
for (const [tok, abs] of pairs) {
|
|
96
|
+
if (!out.includes(tok)) continue;
|
|
97
|
+
out = out.split(tok).join(abs);
|
|
98
|
+
stats.replaced++;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
return { fn, stats };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Distinct absolute home-style paths that survived tokenisation (a leak signal, reported to the user). */
|
|
106
|
+
export function residualHomePaths(strings) {
|
|
107
|
+
const found = new Set();
|
|
108
|
+
const re = /(?:\/Users|\/home)\/[^/\s"'`\\]+/g;
|
|
109
|
+
for (const s of strings) for (const m of s.matchAll(re)) found.add(m[0]);
|
|
110
|
+
return found;
|
|
111
|
+
}
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mapStrings } from './portable.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secret scrubbing for text that is about to leave the machine. Deliberately
|
|
5
|
+
* pattern-based and conservative: it removes well-known credential shapes, it
|
|
6
|
+
* does not promise to find every secret. The export report says how many were
|
|
7
|
+
* removed so the user can judge, and `carry inspect` shows what a bundle holds.
|
|
8
|
+
*/
|
|
9
|
+
const RULES = [
|
|
10
|
+
['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, () => '[REDACTED:private-key]'],
|
|
11
|
+
['anthropic-key', /sk-ant-[A-Za-z0-9_-]{20,}/g, () => '[REDACTED:anthropic-key]'],
|
|
12
|
+
['openai-key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g, () => '[REDACTED:openai-key]'],
|
|
13
|
+
['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,})/g, () => '[REDACTED:github-token]'],
|
|
14
|
+
['aws-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, () => '[REDACTED:aws-key]'],
|
|
15
|
+
['google-key', /\bAIza[0-9A-Za-z_-]{35}\b/g, () => '[REDACTED:google-key]'],
|
|
16
|
+
['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, () => '[REDACTED:slack-token]'],
|
|
17
|
+
['stripe-key', /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, () => '[REDACTED:stripe-key]'],
|
|
18
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, () => '[REDACTED:jwt]'],
|
|
19
|
+
['bearer', /(\bBearer\s+)[A-Za-z0-9._~+/-]{20,}=*/g, (_m, p) => `${p}[REDACTED:bearer]`],
|
|
20
|
+
['url-credentials', /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]{3,}(@)/gi, (_m, a, b) => `${a}[REDACTED:password]${b}`],
|
|
21
|
+
[
|
|
22
|
+
'env-secret',
|
|
23
|
+
// Name may follow an ANSI colour code directly ("\x1b[31mDB_PASSWORD=..."), where \b would not fire.
|
|
24
|
+
/(?:(?<![A-Za-z0-9_])|(?<=(?:\x1b|u001b)\[[0-9;]*m))([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|ACCESS_KEY)[A-Za-z0-9_]*)(\s*[=:]\s*)(['"]?)(?!\[REDACTED)[^\s'"\\\x1b]{8,}\3/gi,
|
|
25
|
+
(m, name, sep, q, offset, whole) => {
|
|
26
|
+
// ALL_CAPS is env-var style: always redact. Mixed or lower case is often code (`max_tokens`,
|
|
27
|
+
// `token = parseToken(x)`), so there the value must look like a credential, not an identifier.
|
|
28
|
+
if (name !== name.toUpperCase()) {
|
|
29
|
+
const value = m.slice(name.length + sep.length + q.length, m.length - q.length);
|
|
30
|
+
if (/tokens|count|hash|limit|budget|usage|type|len/i.test(name)) return m;
|
|
31
|
+
if (!/[0-9]/.test(value) || /[()<>{}]/.test(value)) return m;
|
|
32
|
+
}
|
|
33
|
+
return `${name}${sep}${q}[REDACTED:env-secret]${q}`;
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
'json-secret',
|
|
38
|
+
// Tolerates any number of escaping backslashes: a JSON document stored inside a JSON string, and so on.
|
|
39
|
+
/(\\*"[A-Za-z_]*(?:secret|token|password|passwd|api_?key|private_?key)[A-Za-z_]*\\*"\s*:\s*\\*")(?!\[REDACTED)[^"\\]{8,}(\\*")/gi,
|
|
40
|
+
(_m, a, b) => `${a}[REDACTED:json-secret]${b}`,
|
|
41
|
+
],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Base64 blobs are only redacted when they decode to text that the rules above would redact. */
|
|
45
|
+
const looksPrintable = (t) => t.length > 0 && [...t].filter((c) => c >= ' ' && c <= '~' || c === '\n' || c === '\t').length / t.length > 0.95;
|
|
46
|
+
const BASE64 = [
|
|
47
|
+
'base64-secret',
|
|
48
|
+
/(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{24,}={0,2}(?![A-Za-z0-9+/=])/g,
|
|
49
|
+
(m) => {
|
|
50
|
+
let decoded;
|
|
51
|
+
try {
|
|
52
|
+
decoded = Buffer.from(m, 'base64').toString('utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
return m;
|
|
55
|
+
}
|
|
56
|
+
if (!looksPrintable(decoded)) return m;
|
|
57
|
+
const changed = RULES.some(([, re, rep]) => decoded.replace(re, rep) !== decoded);
|
|
58
|
+
return changed ? '[REDACTED:base64-secret]' : m;
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const EMAIL = ['email', /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, () => '[REDACTED:email]'];
|
|
63
|
+
|
|
64
|
+
export function makeRedactor({ emails = false } = {}) {
|
|
65
|
+
const rules = emails ? [...RULES, BASE64, EMAIL] : [...RULES, BASE64];
|
|
66
|
+
const counts = {};
|
|
67
|
+
const fn = (s) => {
|
|
68
|
+
let out = s;
|
|
69
|
+
for (const [kind, re, rep] of rules) {
|
|
70
|
+
out = out.replace(re, (...a) => {
|
|
71
|
+
const r = rep(...a);
|
|
72
|
+
if (r !== a[0]) counts[kind] = (counts[kind] || 0) + 1;
|
|
73
|
+
return r;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
return { fn, counts, apply: (value) => mapStrings(value, fn) };
|
|
79
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectsDir } from './paths.js';
|
|
4
|
+
import { parseJsonl, summarize } from './transcript.js';
|
|
5
|
+
|
|
6
|
+
const MAX_SIDECAR_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
/** Every project folder under <home>/projects. */
|
|
9
|
+
export function projectFolders(home) {
|
|
10
|
+
const dir = projectsDir(home);
|
|
11
|
+
if (!fs.existsSync(dir)) return [];
|
|
12
|
+
return fs
|
|
13
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
14
|
+
.filter((d) => d.isDirectory())
|
|
15
|
+
.map((d) => path.join(dir, d.name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Sessions in one project folder, newest first. */
|
|
19
|
+
export function listSessions(folder) {
|
|
20
|
+
if (!fs.existsSync(folder)) return [];
|
|
21
|
+
return fs
|
|
22
|
+
.readdirSync(folder)
|
|
23
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
24
|
+
.map((f) => {
|
|
25
|
+
const file = path.join(folder, f);
|
|
26
|
+
const st = fs.statSync(file);
|
|
27
|
+
const s = summarize(parseJsonl(fs.readFileSync(file, 'utf8')));
|
|
28
|
+
return { id: f.slice(0, -'.jsonl'.length), file, folder, size: st.size, mtimeMs: st.mtimeMs, ...s };
|
|
29
|
+
})
|
|
30
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve a full id or unique prefix, searching `folders` in order. */
|
|
34
|
+
export function findSession(folders, idOrPrefix) {
|
|
35
|
+
const hits = [];
|
|
36
|
+
for (const folder of folders) {
|
|
37
|
+
if (!fs.existsSync(folder)) continue;
|
|
38
|
+
for (const f of fs.readdirSync(folder)) {
|
|
39
|
+
if (f.endsWith('.jsonl') && f.startsWith(idOrPrefix)) hits.push({ folder, id: f.slice(0, -6) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (hits.length === 0) throw new Error(`no session matches "${idOrPrefix}"`);
|
|
43
|
+
if (hits.length > 1) {
|
|
44
|
+
const where = hits.map((h) => `${h.id.slice(0, 8)} in ${path.basename(h.folder)}`).join('; ');
|
|
45
|
+
throw new Error(`"${idOrPrefix}" matches more than one session (${where}). Narrow it with --project DIR or a longer id.`);
|
|
46
|
+
}
|
|
47
|
+
return hits[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Everything that belongs to a session: its transcript plus the sidecar
|
|
52
|
+
* directory (`<id>/subagents/*.jsonl`, `<id>/tool-results/*`). Transcripts
|
|
53
|
+
* reference tool-results by absolute path, so they must travel together.
|
|
54
|
+
*/
|
|
55
|
+
export function readSession(folder, id) {
|
|
56
|
+
const parts = [{ rel: 'main.jsonl', kind: 'jsonl', text: fs.readFileSync(path.join(folder, `${id}.jsonl`), 'utf8') }];
|
|
57
|
+
const skipped = [];
|
|
58
|
+
const sideDir = path.join(folder, id);
|
|
59
|
+
const walk = (dir, relBase) => {
|
|
60
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
61
|
+
const abs = path.join(dir, e.name);
|
|
62
|
+
const rel = path.posix.join(relBase, e.name);
|
|
63
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
64
|
+
else if (e.isFile()) {
|
|
65
|
+
if (fs.statSync(abs).size > MAX_SIDECAR_BYTES) {
|
|
66
|
+
skipped.push(rel);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const buf = fs.readFileSync(abs);
|
|
70
|
+
if (rel.endsWith('.jsonl')) parts.push({ rel: `side/${rel}`, kind: 'jsonl', text: buf.toString('utf8') });
|
|
71
|
+
else if (isText(buf)) parts.push({ rel: `side/${rel}`, kind: 'text', text: buf.toString('utf8') });
|
|
72
|
+
else parts.push({ rel: `side/${rel}`, kind: 'binary', base64: buf.toString('base64') });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
if (fs.existsSync(sideDir)) walk(sideDir, '');
|
|
77
|
+
return { parts, skipped };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isText(buf) {
|
|
81
|
+
const n = Math.min(buf.length, 4096);
|
|
82
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { mangle, projectsDir, realpathOr } from './paths.js';
|
|
7
|
+
import { listSessions } from './store.js';
|
|
8
|
+
import { buildBundle, writeBundle, readBundle, importBundle } from './bundle.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Folder sync. `push` and `pull` turn any shared folder (Syncthing, Dropbox,
|
|
12
|
+
* iCloud, a git working tree, a network share) into a session sync backend:
|
|
13
|
+
*
|
|
14
|
+
* <dir>/<project>/<session-id>/<device-id>.carry.gz[.enc]
|
|
15
|
+
*
|
|
16
|
+
* Every file has exactly one writer (its device), so the sync tool never sees
|
|
17
|
+
* a write conflict. `pull` unions every other device's file into the local
|
|
18
|
+
* session, which is safe to repeat because merging is idempotent.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export function configDir(env = process.env) {
|
|
22
|
+
if (env.CARRY_HOME) return env.CARRY_HOME;
|
|
23
|
+
if (process.platform === 'win32') return path.join(env.APPDATA || os.homedir(), 'agent-carry');
|
|
24
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'agent-carry');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const readJson = (f, d) => {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function writeJson(f, v) {
|
|
36
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
37
|
+
const tmp = `${f}.tmp`;
|
|
38
|
+
fs.writeFileSync(tmp, JSON.stringify(v, null, 2));
|
|
39
|
+
fs.renameSync(tmp, f);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function deviceInfo(cfg) {
|
|
43
|
+
const f = path.join(cfg, 'device.json');
|
|
44
|
+
let d = readJson(f, null);
|
|
45
|
+
if (!d?.id) {
|
|
46
|
+
const host = os.hostname().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'device';
|
|
47
|
+
d = { id: `${host}-${crypto.randomBytes(3).toString('hex')}` };
|
|
48
|
+
writeJson(f, d);
|
|
49
|
+
}
|
|
50
|
+
return d;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The same project lives at different paths on different machines, so its
|
|
55
|
+
* identity can't be its path. Prefer the git remote; fall back to the folder name.
|
|
56
|
+
*/
|
|
57
|
+
export function projectKey(root, override) {
|
|
58
|
+
const clean = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
59
|
+
if (override) return clean(override);
|
|
60
|
+
const base = clean(path.basename(root)) || 'project';
|
|
61
|
+
try {
|
|
62
|
+
const url = execFileSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
63
|
+
if (url) {
|
|
64
|
+
const norm = url.replace(/^[a-z+]+:\/\//i, '').replace(/^[^@/]+@/, '').replace(/:/g, '/').replace(/\.git$/, '').replace(/\/+$/, '').toLowerCase();
|
|
65
|
+
return `${base}-${crypto.createHash('sha256').update(norm).digest('hex').slice(0, 8)}`;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
/* not a git repo */
|
|
69
|
+
}
|
|
70
|
+
return base;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ext = (passphrase) => (passphrase ? '.carry.enc' : '.carry.gz');
|
|
74
|
+
|
|
75
|
+
export function pushProject({ dir, claudeHome, root, userHome, passphrase, name, cfg = configDir(), log = () => {} }) {
|
|
76
|
+
const realRoot = realpathOr(root);
|
|
77
|
+
const folder = path.join(projectsDir(claudeHome), mangle(realRoot));
|
|
78
|
+
const key = projectKey(realRoot, name);
|
|
79
|
+
const dev = deviceInfo(cfg);
|
|
80
|
+
const out = { key, device: dev.id, pushed: 0, unchanged: 0, skipped: [] };
|
|
81
|
+
for (const s of listSessions(folder)) {
|
|
82
|
+
const target = path.join(dir, key, s.id, `${dev.id}${ext(passphrase)}`);
|
|
83
|
+
let built;
|
|
84
|
+
try {
|
|
85
|
+
built = buildBundle({ folder, id: s.id, where: { store: folder, root: realRoot, home: userHome } });
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e.code !== 'THINKING_SECRETS') throw e;
|
|
88
|
+
out.skipped.push(s.id);
|
|
89
|
+
log(`skipped ${s.id.slice(0, 8)}: credential-like text in thinking blocks (export it by hand with --strip-thinking)`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const { text, header } = built;
|
|
93
|
+
if (fs.existsSync(target)) {
|
|
94
|
+
try {
|
|
95
|
+
if (readBundle(target, { passphrase }).header.bodySha256 === header.bodySha256) {
|
|
96
|
+
out.unchanged++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* unreadable old file: overwrite it */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
writeBundle(target, text, { passphrase });
|
|
104
|
+
out.pushed++;
|
|
105
|
+
log(`pushed ${s.id.slice(0, 8)} ${s.title ?? ''}`);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function pullProject({ dir, claudeHome, root, userHome, passphrase, name, dryRun = false, cfg = configDir(), log = () => {} }) {
|
|
111
|
+
const realRoot = realpathOr(root);
|
|
112
|
+
const key = projectKey(realRoot, name);
|
|
113
|
+
const dev = deviceInfo(cfg);
|
|
114
|
+
const stateFile = path.join(cfg, 'pulled.json');
|
|
115
|
+
const state = readJson(stateFile, {});
|
|
116
|
+
const out = { key, imported: 0, upToDate: 0, sessions: 0, errors: [] };
|
|
117
|
+
const projDir = path.join(dir, key);
|
|
118
|
+
if (!fs.existsSync(projDir)) return out;
|
|
119
|
+
for (const id of fs.readdirSync(projDir)) {
|
|
120
|
+
const sdir = path.join(projDir, id);
|
|
121
|
+
if (!fs.statSync(sdir).isDirectory()) continue;
|
|
122
|
+
for (const f of fs.readdirSync(sdir)) {
|
|
123
|
+
if (!/\.carry\.(gz|enc)$/.test(f) || f.startsWith(`${dev.id}.`)) continue;
|
|
124
|
+
const file = path.join(sdir, f);
|
|
125
|
+
const st = fs.statSync(file);
|
|
126
|
+
const sig = `${st.size}:${Math.round(st.mtimeMs)}`;
|
|
127
|
+
if (state[file] === sig) {
|
|
128
|
+
out.upToDate++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const bundle = readBundle(file, { passphrase });
|
|
133
|
+
const plan = importBundle({ bundle, claudeHome, root: realRoot, home: userHome, dryRun });
|
|
134
|
+
const added = plan.files.filter((x) => x.kind === 'jsonl').reduce((n, x) => n + x.added, 0);
|
|
135
|
+
if (!dryRun) state[file] = sig;
|
|
136
|
+
out.imported += added > 0 ? 1 : 0;
|
|
137
|
+
out.upToDate += added > 0 ? 0 : 1;
|
|
138
|
+
out.sessions++;
|
|
139
|
+
if (added > 0) log(`pulled ${id.slice(0, 8)} from ${f.split('.')[0]} (+${added} records)`);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
out.errors.push(`${id.slice(0, 8)}/${f}: ${e.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!dryRun) writeJson(stateFile, state);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transcripts are internal and undocumented, so parsing is tolerant: a line
|
|
5
|
+
* that is not JSON is kept verbatim and travels with the session untouched.
|
|
6
|
+
*/
|
|
7
|
+
export function parseJsonl(text) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const raw of text.split('\n')) {
|
|
10
|
+
if (raw === '') continue;
|
|
11
|
+
try {
|
|
12
|
+
out.push({ raw, rec: JSON.parse(raw) });
|
|
13
|
+
} catch {
|
|
14
|
+
out.push({ raw, rec: null });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
21
|
+
export const sha256 = sha;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Identity of a record for merging. Conversation records carry a `uuid`, which
|
|
25
|
+
* is stable across machines. Bookkeeping records (titles, modes, snapshots)
|
|
26
|
+
* have none, so they are identified by content.
|
|
27
|
+
*/
|
|
28
|
+
export function recordKey({ raw, rec }) {
|
|
29
|
+
if (rec && typeof rec.uuid === 'string') return `u:${rec.uuid}`;
|
|
30
|
+
return `h:${sha(rec ? JSON.stringify(rec) : raw)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function summarize(entries) {
|
|
34
|
+
const cwds = new Map();
|
|
35
|
+
const versions = new Set();
|
|
36
|
+
const entrypoints = new Set();
|
|
37
|
+
let title = null;
|
|
38
|
+
let customTitle = null;
|
|
39
|
+
let first = null;
|
|
40
|
+
let last = null;
|
|
41
|
+
let turns = 0;
|
|
42
|
+
for (const { rec } of entries) {
|
|
43
|
+
if (!rec) continue;
|
|
44
|
+
if (rec.cwd) cwds.set(rec.cwd, (cwds.get(rec.cwd) || 0) + 1);
|
|
45
|
+
if (rec.version) versions.add(rec.version);
|
|
46
|
+
if (rec.entrypoint) entrypoints.add(rec.entrypoint);
|
|
47
|
+
if (rec.type === 'ai-title' && rec.aiTitle) title = rec.aiTitle;
|
|
48
|
+
if (rec.type === 'custom-title' && rec.customTitle) customTitle = rec.customTitle;
|
|
49
|
+
if (rec.timestamp) {
|
|
50
|
+
first ??= rec.timestamp;
|
|
51
|
+
last = rec.timestamp;
|
|
52
|
+
}
|
|
53
|
+
if (rec.type === 'user' && !rec.isMeta && !rec.isSidechain) turns++;
|
|
54
|
+
}
|
|
55
|
+
const dominantCwd = [...cwds.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
56
|
+
return {
|
|
57
|
+
title: customTitle || title,
|
|
58
|
+
dominantCwd,
|
|
59
|
+
cwds: [...cwds.keys()],
|
|
60
|
+
versions: [...versions],
|
|
61
|
+
entrypoints: [...entrypoints],
|
|
62
|
+
first,
|
|
63
|
+
last,
|
|
64
|
+
turns,
|
|
65
|
+
};
|
|
66
|
+
}
|