agent-carry 0.2.1 → 0.4.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 +25 -8
- package/README.md +11 -8
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +5 -2
- package/plugin/commands/pickup.md +5 -5
- package/plugin/commands/share.md +7 -5
- package/src/browse.js +69 -0
- package/src/cli.js +61 -18
- package/src/desktop.js +98 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Plugin, marketplace entry and package share one version number; `npm version` keeps them in step.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
-
|
|
5
|
+
## 0.4.1
|
|
6
|
+
- `/carry:pickup` no longer asks which account to use or shows ids. `--desktop` picks the account the desktop app is signed in to (read from the app's own config, with the log as a fallback), and in a terminal asks only when the terminal and the desktop app are logged in as different accounts.
|
|
7
|
+
- Accounts are described by name and email where known (the terminal's login), otherwise by the sessions they hold, never by id: `carry desktop-dirs` and the import output now say which account's Code tab got the session.
|
|
8
|
+
|
|
9
|
+
## 0.4.0
|
|
10
|
+
|
|
11
|
+
Finding sessions without knowing where they are stored. (0.3.0 was tagged but never published to npm.)
|
|
12
|
+
|
|
13
|
+
- `carry list --all` shows the most recent sessions across every folder, newest first, each tagged by where it was started (`[desktop]`, `[terminal]`, `[print mode]`) with the title the desktop app shows. Background-agent sessions (for example a memory plugin's observer) are hidden unless `--everything`.
|
|
14
|
+
- `carry list` options: `--limit N|all`, `--project DIR` (one folder), `--match TEXT` (title, folder or id), `--desktop` / `--terminal` (only sessions started there). Archived desktop sessions are marked; a desktop conversation with no project folder shows as "desktop scratch folder".
|
|
15
|
+
- `carry export` with no id offers a numbered picker that takes the same options.
|
|
16
|
+
- `/carry:share` uses the list; the plugin commands need CLI 0.4.0 or newer.
|
|
17
|
+
|
|
18
|
+
## 0.2.1
|
|
19
|
+
|
|
20
|
+
- Plugin commands check the CLI version first and say how to upgrade if it is older, instead of aborting on an unknown-command error.
|
|
21
|
+
|
|
22
|
+
## 0.2.0
|
|
23
|
+
|
|
24
|
+
- `carry import --desktop` adds the session to the Claude desktop app's Code tab (macOS). It targets the signed-in account (read from the app's log) and works for a freshly signed-in account by borrowing only app-level settings. Terminal colour codes in newly imported text are stripped, since the app shows them as literal characters.
|
|
25
|
+
- `export --latest` skips sessions with no prompts.
|
|
26
|
+
- Interactive plugin commands: `/carry:share` and `/carry:pickup` ask what they need (session, encryption, passphrase source, destination folder, desktop app and account). Installable with `/plugin install carry --marketplace harshhh28/agent-carry`.
|
|
7
27
|
- `export --generate-passphrase` (saved to `FILE.pass`, never printed); `carry bundles`; `carry desktop-dirs`.
|
|
8
|
-
- `--desktop` targets the signed-in account (read from the app's log) instead of any folder with sessions, and works for a freshly signed-in account by borrowing only app-level settings.
|
|
9
28
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- `--desktop` strips terminal colour codes from newly imported text, which the app shows as literal characters.
|
|
13
|
-
- `carry import --desktop` adds the session to the Claude desktop app's Code tab (macOS).
|
|
29
|
+
## 0.1.1
|
|
30
|
+
|
|
14
31
|
- `agent-carry` works as a second command name for `carry`.
|
|
15
32
|
- README trimmed; plugin author set.
|
|
16
33
|
|
package/README.md
CHANGED
|
@@ -16,9 +16,11 @@ copied session can't be resumed elsewhere. `carry` fixes that:
|
|
|
16
16
|
```
|
|
17
17
|
npm i -g agent-carry # installs `carry` (also available as `agent-carry`)
|
|
18
18
|
|
|
19
|
-
# machine A,
|
|
20
|
-
carry
|
|
21
|
-
carry
|
|
19
|
+
# machine A (any folder): pick a session from a numbered list, with desktop-app titles
|
|
20
|
+
carry export -o session.carry.gz
|
|
21
|
+
# or: carry list --all then carry export <id> -o session.carry.gz
|
|
22
|
+
# more rows: --limit 50 | --limit all one folder: --project DIR search: --match word
|
|
23
|
+
# only desktop-app sessions: --desktop only terminal ones: --terminal (each row is tagged [desktop] / [terminal])
|
|
22
24
|
|
|
23
25
|
# machine B, in the same project (any path)
|
|
24
26
|
carry import session.carry.gz
|
|
@@ -40,7 +42,7 @@ carry import session.carry.gz --desktop
|
|
|
40
42
|
App settings are copied from that account's newest Code-tab session (or, for a new account, another account's), so use the Code tab at least once first.
|
|
41
43
|
Terminal colour codes in the imported text are stripped (the app would show them as literal `[38;2;…m`). Restart the desktop app if the session doesn't show.
|
|
42
44
|
|
|
43
|
-
The session is added to the account
|
|
45
|
+
The session is added to the account the desktop app is signed in to. From a terminal that is logged in as a different account, `carry` stops and asks which Code tab you mean, naming the accounts (by name and email where known, otherwise by the sessions they hold). To choose explicitly use `--desktop-dir DIR`; `carry desktop-dirs` lists the accounts. A freshly signed-in account with no sessions borrows only app-level settings from another account, never its connectors or permissions. Cowork is a different store and isn't supported.
|
|
44
46
|
|
|
45
47
|
### Sync through a shared folder
|
|
46
48
|
|
|
@@ -59,7 +61,7 @@ Projects are matched by git remote, not path. Each file has one writer, so there
|
|
|
59
61
|
/plugin install carry --marketplace harshhh28/agent-carry
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
Needs `npm i -g agent-carry
|
|
64
|
+
Needs the CLI (`npm i -g agent-carry`, 0.4.0 or newer). Works the same in the terminal and in the desktop app's Code tab. To update later: `npm i -g agent-carry@latest`, then `/plugin marketplace update agent-carry` and `/reload-plugins`.
|
|
63
65
|
|
|
64
66
|
- `/carry:share` asks which session, whether to encrypt (generate a passphrase, use your own file, or type one), and where to save.
|
|
65
67
|
- `/carry:pickup` lists bundles in this folder, ~/Downloads and ~/Desktop, then asks which one, the passphrase source, which folder to import into, and whether to add it to the desktop app (and which account).
|
|
@@ -70,8 +72,8 @@ Passphrases are never put on a command line. A generated one is saved beside the
|
|
|
70
72
|
|
|
71
73
|
| command | |
|
|
72
74
|
|---|---|
|
|
73
|
-
| `carry list [--all]` | sessions for this folder, or all |
|
|
74
|
-
| `carry export
|
|
75
|
+
| `carry list [--all] [--project DIR] [--desktop\|--terminal] [--match TEXT] [--limit N]` | sessions for this folder, or `--all`: recent sessions from every folder, each tagged by where it was started (`[desktop]`, `[terminal]`, `[print mode]`) with the desktop app's title. `--desktop` / `--terminal` keep only those. Background-agent sessions are hidden; `--everything` shows them |
|
|
76
|
+
| `carry export [id\|--latest] [-o FILE]` | with no id, pick from a numbered list (takes the same `--desktop`, `--terminal`, `--project`, `--match`). Also `--generate-passphrase`, `--no-redact`, `--redact-emails`, `--strip-thinking`, `--allow-thinking-secrets`, `--project-root DIR` |
|
|
75
77
|
| `carry inspect FILE` | header, parts, redaction counts |
|
|
76
78
|
| `carry import FILE [--project-root DIR] [--dry-run]` | write into the target folder's session store |
|
|
77
79
|
| `carry bundles` / `carry desktop-dirs` | bundle files nearby; desktop-app account folders |
|
|
@@ -79,6 +81,7 @@ Passphrases are never put on a command line. A generated one is saved beside the
|
|
|
79
81
|
|
|
80
82
|
## Limits
|
|
81
83
|
|
|
84
|
+
- **Usage limit:** the CLI needs no Claude usage, so it still works when you have hit your limit; the plugin commands do not, because Claude has to run them.
|
|
82
85
|
- **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.
|
|
83
86
|
- **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.
|
|
84
87
|
- **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.
|
|
@@ -89,7 +92,7 @@ Passphrases are never put on a command line. A generated one is saved beside the
|
|
|
89
92
|
## Development
|
|
90
93
|
|
|
91
94
|
```
|
|
92
|
-
npm test #
|
|
95
|
+
npm test # 56 tests, synthetic two-machine setups
|
|
93
96
|
npm run check:real # read-only round trip of your own ~/.claude sessions
|
|
94
97
|
```
|
|
95
98
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-carry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "node --test \"test/*.test.js\"",
|
|
23
|
-
"check:real": "node scripts/check-real.js"
|
|
23
|
+
"check:real": "node scripts/check-real.js",
|
|
24
|
+
"preversion": "npm test",
|
|
25
|
+
"version": "node scripts/sync-version.js && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json CHANGELOG.md"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"claude-code",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "carry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Move coding-agent sessions between machines and folders: /carry:share exports a redacted, path-portable bundle, /carry:pickup imports one.",
|
|
5
|
-
"author": {
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Harsh Gajjar",
|
|
7
|
+
"email": "harshgajjar2812@gmail.com"
|
|
8
|
+
},
|
|
6
9
|
"license": "MIT"
|
|
7
10
|
}
|
|
@@ -4,7 +4,7 @@ argument-hint: [bundle-file]
|
|
|
4
4
|
allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), Bash(ls:*), Bash(pwd), AskUserQuestion
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
carry CLI version (this command needs 0.
|
|
7
|
+
carry CLI version (this command needs 0.4.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
|
|
8
8
|
|
|
9
9
|
Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
|
|
10
10
|
|
|
@@ -12,13 +12,13 @@ Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
|
|
|
12
12
|
|
|
13
13
|
Current folder: !`pwd`
|
|
14
14
|
|
|
15
|
-
Claude desktop app
|
|
15
|
+
Claude desktop app accounts (macOS only; an error here just means no desktop app):
|
|
16
16
|
|
|
17
17
|
!`carry desktop-dirs 2>&1 || true`
|
|
18
18
|
|
|
19
19
|
Arguments the user typed: $ARGUMENTS
|
|
20
20
|
|
|
21
|
-
**Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.
|
|
21
|
+
**Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.4.0, stop and say exactly this, with nothing else: "This command needs the carry CLI 0.4.0 or newer. Run `npm i -g agent-carry@latest`, then try again." (If that install still gives an older version, 0.4.0 has not been published to npm yet.) Any error text shown in the listings below comes from the same cause; do not try to work around it.
|
|
22
22
|
|
|
23
23
|
You are importing one session with the `carry` CLI.
|
|
24
24
|
|
|
@@ -31,6 +31,6 @@ Ask with the AskUserQuestion tool, one short question at a time, and skip any qu
|
|
|
31
31
|
- "I'll type it here": warn that it is stored in this conversation's history. Pass it as `CARRY_PASSPHRASE='…' carry import …` and never repeat it back.
|
|
32
32
|
(If `CARRY_PASSPHRASE` is already set in the environment, nothing is needed.) On a wrong-passphrase error, say so and ask once more.
|
|
33
33
|
3. **Which folder.** Claude Code finds a session by the folder you run `claude` in, so this should be the folder they will work in. Offer the current folder as "(Recommended)" or another path (create it with `mkdir -p` only if the user says so). Pass it as `--project-root`.
|
|
34
|
-
4. **Claude desktop app (only if account folders are listed above).** Ask "Also show it in the Claude desktop app's Code tab?"
|
|
34
|
+
4. **Claude desktop app (only if account folders are listed above).** Ask only "Also show it in the Claude desktop app's Code tab?" Do not ask which account: `--desktop` picks the right one by itself (the account the desktop app is signed in to; in a terminal, the one that matches this terminal's login). Never show the user folder paths or ids. If the import fails with "this terminal is logged in as one Claude account but the desktop app is signed in as another", or "cannot tell which desktop account is signed in", ask once with AskUserQuestion, labelling each option with the account name and email, session count and example titles taken from that error (never ids or paths), and re-run with `--desktop-dir <that option's folder>`. A line starting with `*` in the listing above marks the account the desktop app is signed in to.
|
|
35
35
|
5. **Run** `carry import <bundle> --project-root <folder> [--passphrase-file F] [--desktop | --desktop-dir D]` (add `--dry-run` if the user typed it, and say nothing was written).
|
|
36
|
-
6. **Report in at most five lines:** the session id, the folder it went into, and any warning. Then the next step: exit this session, then `cd <folder> && claude --resume <id>`; for the desktop app, restart it if the session does not appear and open the Code tab. A running session cannot swap its own history, so do not run `claude --resume` yourself.
|
|
36
|
+
6. **Report in at most five lines:** the session id, the folder it went into, which account's Code tab it was added to (use the "Code tab of:" line from the import, e.g. a name and email; say it can be redone for another account if that is wrong), and any warning. Then the next step: exit this session, then `cd <folder> && claude --resume <id>`; for the desktop app, restart it if the session does not appear and open the Code tab. A running session cannot swap its own history, so do not run `claude --resume` yourself.
|
package/plugin/commands/share.md
CHANGED
|
@@ -4,21 +4,23 @@ argument-hint: [optional carry export flags, e.g. -o FILE --strip-thinking]
|
|
|
4
4
|
allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), AskUserQuestion
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
carry CLI version (this command needs 0.
|
|
7
|
+
carry CLI version (this command needs 0.4.0 or newer): !`carry --version 2>&1 || echo 'not installed'`
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Recent sessions from every folder, newest first, including ones started in the Claude desktop app (the top one is usually this conversation):
|
|
10
10
|
|
|
11
|
-
!`carry list
|
|
11
|
+
!`carry list --all --limit 8 2>&1 || true`
|
|
12
|
+
|
|
13
|
+
Current folder: !`pwd`
|
|
12
14
|
|
|
13
15
|
Arguments the user typed: $ARGUMENTS
|
|
14
16
|
|
|
15
|
-
**Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.
|
|
17
|
+
**Step 0.** Look at the version line above. If it says "not installed" or shows anything older than 0.4.0, stop and say exactly this, with nothing else: "This command needs the carry CLI 0.4.0 or newer. Run `npm i -g agent-carry@latest`, then try again." (If that install still gives an older version, 0.4.0 has not been published to npm yet.) Any error text shown in the listings below comes from the same cause; do not try to work around it.
|
|
16
18
|
|
|
17
19
|
You are exporting one session with the `carry` CLI.
|
|
18
20
|
|
|
19
21
|
Ask with the AskUserQuestion tool, one short question at a time, and skip any question the typed arguments already answer. If AskUserQuestion is not available, ask the same questions as a short numbered list in plain text and wait for the reply.
|
|
20
22
|
|
|
21
|
-
1. **Which session.**
|
|
23
|
+
1. **Which session.** The user may not know where sessions are stored, so offer them from the list above, never ask for a folder. If the arguments name a session or `--latest`, use it. Otherwise ask, offering the newest first as "(Recommended)" and up to three more; label each with its title, and describe it with its folder, date, turn count and `[desktop app]` when shown. If none fit, offer "show more" (`carry list --all --limit 30`), or search by a word from the title or folder (`carry list --all --match WORD`), or one folder (`carry list --project DIR`). If the user only wants sessions from the Claude desktop app (or only terminal ones), list them with `carry list --all --desktop` (or `--terminal`); each row's tag in brackets shows where it was started. Export by id: `carry export <id>` works from any folder.
|
|
22
24
|
2. **Encrypt?** Options:
|
|
23
25
|
- "No, plain file": readable by anyone who gets it (secrets are still redacted).
|
|
24
26
|
- "Yes, generate a passphrase (Recommended when sending it to someone)": run with `--generate-passphrase`.
|
package/src/browse.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectFolders } from './store.js';
|
|
4
|
+
import { parseJsonl, summarize } from './transcript.js';
|
|
5
|
+
import { desktopSessionInfo, firstPrompt } from './desktop.js';
|
|
6
|
+
|
|
7
|
+
// The desktop app runs a conversation that has no project folder in a scratch folder with a long account/org path.
|
|
8
|
+
const SCRATCH = /\/scratch-workspaces\/[0-9a-f-]{36}\/[0-9a-f-]{36}(?:\/(.*))?$/;
|
|
9
|
+
const tilde = (p, home) => {
|
|
10
|
+
const scratch = p && SCRATCH.exec(p);
|
|
11
|
+
if (scratch) return `(desktop scratch folder${scratch[1] ? `: ${scratch[1]}` : ''})`;
|
|
12
|
+
return p && home && (p === home || p.startsWith(home + path.sep)) ? `~${p.slice(home.length)}` : p;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Programmatic agents (for example a memory plugin's background observer) record this entrypoint. They are
|
|
16
|
+
// not conversations a person had, and on a machine with such a plugin they can outnumber real sessions 50 to 1.
|
|
17
|
+
/**
|
|
18
|
+
* Where a session was started, from the entrypoint Claude records on every line: `claude-desktop` is the desktop
|
|
19
|
+
* app, `cli` the terminal, `sdk-cli` a `claude -p` run. A session the desktop app lists in its index counts as
|
|
20
|
+
* desktop even if it began elsewhere (for example one imported with --desktop).
|
|
21
|
+
*/
|
|
22
|
+
export function sourceOf(sum, inDesktopIndex) {
|
|
23
|
+
const e = sum.entrypoints;
|
|
24
|
+
if (inDesktopIndex || e.includes('claude-desktop')) return 'desktop';
|
|
25
|
+
if (e.includes('cli')) return 'terminal';
|
|
26
|
+
if (e.some((x) => x.startsWith('sdk'))) return 'print mode';
|
|
27
|
+
return e[0] ?? 'unknown';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isBackgroundAgent = (sum) => sum.entrypoints.length > 0 && sum.entrypoints.every((e) => e === 'sdk-ts');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The most recent sessions across every project folder, so nobody needs to know where Claude keeps them.
|
|
34
|
+
* Skips sessions with no prompts and background-agent sessions (unless `everything`). Each gets the title the
|
|
35
|
+
* desktop app shows for it when there is one, else Claude's own title, else the first prompt.
|
|
36
|
+
* `source` ('desktop' or 'terminal') keeps only sessions started there. `folders` restricts the search to those project folders; `match` keeps rows whose title, folder or id contains the text.
|
|
37
|
+
*/
|
|
38
|
+
export function recentSessions({ home, userHome, limit = 20, everything = false, folders, match, source }) {
|
|
39
|
+
const files = [];
|
|
40
|
+
for (const folder of folders ?? projectFolders(home)) {
|
|
41
|
+
if (!fs.existsSync(folder)) continue;
|
|
42
|
+
for (const n of fs.readdirSync(folder)) {
|
|
43
|
+
if (!n.endsWith('.jsonl')) continue;
|
|
44
|
+
const file = path.join(folder, n);
|
|
45
|
+
files.push({ file, folder, id: n.slice(0, -'.jsonl'.length), mtimeMs: fs.statSync(file).mtimeMs });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
49
|
+
const desk = desktopSessionInfo({ home: userHome });
|
|
50
|
+
const needle = match ? String(match).toLowerCase() : null;
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const x of files) {
|
|
53
|
+
if (out.length >= limit) break;
|
|
54
|
+
const entries = parseJsonl(fs.readFileSync(x.file, 'utf8'));
|
|
55
|
+
const sum = summarize(entries);
|
|
56
|
+
if (sum.turns === 0 || (!everything && isBackgroundAgent(sum))) continue;
|
|
57
|
+
const d = desk.get(x.id);
|
|
58
|
+
const row = { ...x, ...sum, source: sourceOf(sum, !!d), archived: !!d?.archived, label: d?.title || sum.title || firstPrompt(entries) || '(untitled)', where: tilde(sum.dominantCwd ?? x.folder, userHome) };
|
|
59
|
+
if (source && row.source !== source) continue;
|
|
60
|
+
if (needle && !`${row.label} ${row.where} ${row.id}`.toLowerCase().includes(needle)) continue;
|
|
61
|
+
out.push(row);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const formatSession = (s, n) => {
|
|
67
|
+
const when = new Date(s.mtimeMs).toISOString().slice(0, 16).replace('T', ' ');
|
|
68
|
+
return `${n === undefined ? '' : `${String(n).padStart(2)}. `}${s.id.slice(0, 8)} ${when} ${String(s.turns).padStart(3)} turns ${s.label} [${s.source}${s.archived ? ', archived' : ''}] ${s.where}`;
|
|
69
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -10,11 +10,14 @@ import crypto from 'node:crypto';
|
|
|
10
10
|
import { isEncrypted } from './crypto.js';
|
|
11
11
|
import { pushProject, pullProject } from './sync.js';
|
|
12
12
|
import { registerDesktopSession, listDesktopDirs } from './desktop.js';
|
|
13
|
+
import { recentSessions, formatSession } from './browse.js';
|
|
13
14
|
|
|
14
15
|
const HELP = `carry (agent-carry): move coding-agent sessions between machines and folders. Supported agent: claude-code
|
|
15
16
|
|
|
16
|
-
carry list [--all] [--project DIR]
|
|
17
|
-
|
|
17
|
+
carry list [--all] [--project DIR] [--desktop | --terminal] [--match TEXT] [--limit N|all] [--everything]
|
|
18
|
+
(this folder, or --project DIR; --all: recent sessions from every folder; --desktop: only sessions
|
|
19
|
+
from the Claude desktop app; --terminal: only terminal ones; --match: title, folder or id contains TEXT)
|
|
20
|
+
carry export [id|prefix|--latest] [-o FILE] no id: pick from a numbered list (also --desktop, --terminal, --project DIR, --match TEXT, --limit N) [--project-root DIR]
|
|
18
21
|
[--no-redact] [--redact-emails] [--strip-thinking | --allow-thinking-secrets]
|
|
19
22
|
[--generate-passphrase] encrypt with a random passphrase saved next to the bundle as FILE.pass
|
|
20
23
|
carry bundles bundle files in this folder, ~/Downloads and ~/Desktop
|
|
@@ -32,7 +35,7 @@ Global: --claude-home DIR (default $CLAUDE_CONFIG_DIR or ~/.claude), --home DIR
|
|
|
32
35
|
function parseArgs(argv) {
|
|
33
36
|
const flags = {};
|
|
34
37
|
const pos = [];
|
|
35
|
-
const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file', '--desktop-dir']);
|
|
38
|
+
const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file', '--desktop-dir', '--limit', '--match']);
|
|
36
39
|
for (let i = 0; i < argv.length; i++) {
|
|
37
40
|
const a = argv[i];
|
|
38
41
|
if (a.startsWith('-') && a !== '-') {
|
|
@@ -43,6 +46,29 @@ function parseArgs(argv) {
|
|
|
43
46
|
return { flags, pos };
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** One line from stdin, or null at end of input. Synchronous so the rest of the CLI stays simple. */
|
|
50
|
+
function readLine() {
|
|
51
|
+
const buf = Buffer.alloc(1);
|
|
52
|
+
let line = '';
|
|
53
|
+
let got = false;
|
|
54
|
+
for (;;) {
|
|
55
|
+
let n;
|
|
56
|
+
try {
|
|
57
|
+
n = fs.readSync(0, buf, 0, 1, null);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (e.code === 'EAGAIN') continue;
|
|
60
|
+
if (e.code === 'EOF') break;
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
if (n === 0) break;
|
|
64
|
+
got = true;
|
|
65
|
+
const c = buf.toString();
|
|
66
|
+
if (c === '\n') return line.trim();
|
|
67
|
+
line += c;
|
|
68
|
+
}
|
|
69
|
+
return got ? line.trim() : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
function fail(msg) {
|
|
47
73
|
process.stderr.write(`carry: ${msg}\n`);
|
|
48
74
|
process.exit(1);
|
|
@@ -60,22 +86,25 @@ function main() {
|
|
|
60
86
|
const cwd = flags['project-root'] || flags.project || process.cwd();
|
|
61
87
|
let passphrase = flags['passphrase-file'] ? fs.readFileSync(flags['passphrase-file'], 'utf8').replace(/\r?\n$/, '') : process.env.CARRY_PASSPHRASE || undefined;
|
|
62
88
|
const say = (m) => flags.quiet || process.stdout.write(`${m}\n`);
|
|
89
|
+
// --desktop / --terminal on list and export: only sessions started in the desktop app / in a terminal.
|
|
90
|
+
const sourceFlag = () => {
|
|
91
|
+
if (flags.desktop && flags.terminal) fail('use --desktop or --terminal, not both');
|
|
92
|
+
return flags.desktop ? 'desktop' : flags.terminal ? 'terminal' : undefined;
|
|
93
|
+
};
|
|
63
94
|
|
|
64
95
|
try {
|
|
65
96
|
if (!cmd || cmd === 'help' || flags.help) return process.stdout.write(HELP);
|
|
66
97
|
|
|
67
98
|
if (cmd === 'list') {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
if (!n) process.stdout.write(`no sessions found for ${flags.all ? 'any project' : realpathOr(cwd)}\n`);
|
|
99
|
+
// Default: this folder (or --project DIR), every session. --all: recent sessions from every folder.
|
|
100
|
+
const scope = flags.all ? undefined : [path.join(projectsDir(home), mangle(realpathOr(cwd)))];
|
|
101
|
+
const limit = flags.limit === 'all' ? Infinity : Number(flags.limit) || (flags.all ? 20 : Infinity);
|
|
102
|
+
const list = recentSessions({ home, userHome, limit, everything: !!flags.everything, folders: scope, match: flags.match, source: sourceFlag() });
|
|
103
|
+
for (const x of list) process.stdout.write(`${formatSession(x)}\n`);
|
|
104
|
+
const where = flags.all ? 'any project' : realpathOr(cwd);
|
|
105
|
+
const what = sourceFlag() ? `${sourceFlag()} ` : '';
|
|
106
|
+
if (!list.length) process.stdout.write(`no ${what}sessions found for ${where}${flags.match ? ` matching "${flags.match}"` : ''}${flags.all ? '' : '; try: carry list --all'}\n`);
|
|
107
|
+
else if (list.length === limit) process.stdout.write(`(showing the ${limit} most recent; --limit N or --limit all for more)\n`);
|
|
79
108
|
return;
|
|
80
109
|
}
|
|
81
110
|
|
|
@@ -91,8 +120,19 @@ function main() {
|
|
|
91
120
|
if (!s) fail(`every session for ${realpathOr(cwd)} is empty (no prompts yet); run \`carry list\``);
|
|
92
121
|
if (s !== all[0]) process.stderr.write(`carry: skipped ${all.indexOf(s)} newer empty session(s); exporting ${s.id.slice(0, 8)}\n`);
|
|
93
122
|
target = { folder: here, id: s.id };
|
|
123
|
+
} else if (!pos[0]) {
|
|
124
|
+
// No id and no --latest: pick from recent sessions anywhere, so nobody needs to know where they are stored.
|
|
125
|
+
const scope = flags.project ? [path.join(projectsDir(home), mangle(realpathOr(flags.project)))] : undefined;
|
|
126
|
+
const list = recentSessions({ home, userHome, limit: Number(flags.limit) || 10, folders: scope, match: flags.match, source: sourceFlag() });
|
|
127
|
+
if (!list.length) fail('no sessions found' + (flags.match ? ` matching "${flags.match}"` : '') + '; nothing to export');
|
|
128
|
+
list.forEach((x, i) => process.stdout.write(`${formatSession(x, i + 1)}\n`));
|
|
129
|
+
process.stdout.write('Export which one? [1] ');
|
|
130
|
+
const ans = readLine();
|
|
131
|
+
if (ans === null) fail('no session chosen; pass an id (see `carry list --all`) or --latest');
|
|
132
|
+
const pick = list[(ans === '' ? 1 : Number(ans)) - 1];
|
|
133
|
+
if (!pick) fail(`"${ans}" is not one of the numbers above`);
|
|
134
|
+
target = { folder: pick.folder, id: pick.id };
|
|
94
135
|
} else {
|
|
95
|
-
if (!pos[0]) fail('export needs a session id (see `carry list`) or --latest');
|
|
96
136
|
// Same id can exist in several project folders (a session that was carried). Prefer the folder
|
|
97
137
|
// named by --project, else the current one, and only then search everything.
|
|
98
138
|
const scoped = path.join(projectsDir(home), mangle(realpathOr(flags.project || process.cwd())));
|
|
@@ -127,7 +167,7 @@ function main() {
|
|
|
127
167
|
if (generated) fs.writeFileSync(`${out}.pass`, `${generated}\n`, { mode: 0o600 });
|
|
128
168
|
const red = Object.entries(header.redaction.counts || {}).map(([k, v]) => `${k}:${v}`).join(' ') || 'none found';
|
|
129
169
|
process.stdout.write(
|
|
130
|
-
`exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${out}\n` +
|
|
170
|
+
`exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${path.resolve(out)}\n` +
|
|
131
171
|
` parts: ${header.parts.length} paths rewritten: ${header.pathsRewritten}\n` +
|
|
132
172
|
` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n` +
|
|
133
173
|
(generated ? ` passphrase saved to ${out}.pass (not printed); share it by a different channel than the bundle\n` : ''),
|
|
@@ -179,7 +219,10 @@ function main() {
|
|
|
179
219
|
|
|
180
220
|
if (cmd === 'desktop-dirs') {
|
|
181
221
|
const list = listDesktopDirs({ home: userHome });
|
|
182
|
-
for (const x of list)
|
|
222
|
+
for (const x of list) {
|
|
223
|
+
const tags = [x.active ? 'signed in to the desktop app' : null, x.cli ? 'this terminal\'s login' : null].filter(Boolean).join(', ');
|
|
224
|
+
process.stdout.write(`${x.active ? '*' : ' '} ${x.label}${tags ? ` [${tags}]` : ''}\n --desktop-dir "${x.dir}"\n`);
|
|
225
|
+
}
|
|
183
226
|
if (!list.length) process.stdout.write('no desktop account folders found\n');
|
|
184
227
|
return;
|
|
185
228
|
}
|
|
@@ -208,7 +251,7 @@ function main() {
|
|
|
208
251
|
const d = registerDesktopSession({ id: plan.id, root: plan.root, mainFile: main.dest, home: userHome, dir: flags['desktop-dir'], dryRun: !!flags['dry-run'] });
|
|
209
252
|
const verb = { registered: 'added to the desktop Code tab', exists: 'already in the desktop Code tab', 'would-register': 'would be added to the desktop Code tab' }[d.status];
|
|
210
253
|
process.stdout.write(` ${verb} (${d.file}); restart the Claude desktop app if it does not appear\n`);
|
|
211
|
-
process.stdout.write(`
|
|
254
|
+
if (d.label) process.stdout.write(` Code tab of: ${d.label}\n`);
|
|
212
255
|
if (d.status === 'exists' && d.entryCwd && d.entryCwd !== plan.root) process.stdout.write(` note: that Code-tab entry points at ${d.entryCwd}, not ${plan.root}\n`);
|
|
213
256
|
process.stdout.write(` chosen as: ${d.how}${d.borrowed ? '; this account has no sessions yet, so app settings were borrowed from another account' : ''}\n`);
|
|
214
257
|
} catch (e) {
|
package/src/desktop.js
CHANGED
|
@@ -59,37 +59,101 @@ export function activeAccountFromLog(home, platform = process.platform) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/** The account the Claude Code CLI is logged in to, with its readable name, from ~/.claude.json. */
|
|
63
|
+
export function cliAccount(userHome) {
|
|
64
|
+
try {
|
|
65
|
+
const j = JSON.parse(fs.readFileSync(path.join(userHome, '.claude.json'), 'utf8'));
|
|
66
|
+
const a = j.oauthAccount;
|
|
67
|
+
if (!a?.accountUuid) return null;
|
|
68
|
+
return { account: a.accountUuid, org: a.organizationUuid ?? null, name: a.fullName || a.displayName || null, email: a.emailAddress ?? null, orgName: a.organizationName && !(a.emailAddress && a.organizationName.includes(a.emailAddress)) ? a.organizationName : null };
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The account the desktop app is signed in to: its own config.json records the last one. */
|
|
75
|
+
export function desktopSignedInAccount(home, platform = process.platform) {
|
|
76
|
+
if (platform !== 'darwin') return null;
|
|
77
|
+
try {
|
|
78
|
+
const j = JSON.parse(fs.readFileSync(path.join(home, 'Library', 'Application Support', 'Claude', 'config.json'), 'utf8'));
|
|
79
|
+
return typeof j.lastKnownAccountUuid === 'string' ? j.lastKnownAccountUuid : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every <root>/<account>/<org> folder, with a readable label. The app stores accounts by id only, so a name is
|
|
89
|
+
* known for the account this machine's Claude Code CLI is logged in to; any other account is described by how
|
|
90
|
+
* many sessions it holds and a title or two, which is what a person can recognise.
|
|
91
|
+
* `active`: the account the desktop app is signed in to. `cli`: the account the CLI uses.
|
|
92
|
+
*/
|
|
63
93
|
export function listDesktopDirs({ home, platform }) {
|
|
64
94
|
const root = desktopRoot(home, platform);
|
|
65
95
|
if (!fs.existsSync(root)) throw new Error(`no Claude desktop Code-tab data at ${root}; open the Code tab once first`);
|
|
66
|
-
const
|
|
67
|
-
|
|
96
|
+
const me = cliAccount(home);
|
|
97
|
+
const leaves = subdirs(root).flatMap((a) =>
|
|
68
98
|
subdirs(a).map((d) => {
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
99
|
+
const entries = readEntries(d);
|
|
100
|
+
const files = entries.length;
|
|
101
|
+
const newest = Math.max(0, ...fs.readdirSync(d).filter((f) => /^local_.*\.json$/.test(f)).map((f) => fs.statSync(path.join(d, f)).mtimeMs));
|
|
102
|
+
const account = path.basename(a);
|
|
103
|
+
const org = path.basename(d);
|
|
104
|
+
const isMe = !!me && me.account === account;
|
|
105
|
+
const titles = entries.filter(({ json }) => json.title && !json.isArchived).sort((x, y) => (y.json.lastActivityAt || 0) - (x.json.lastActivityAt || 0)).map(({ json }) => String(json.title).slice(0, 40)).filter((t, i, all) => all.indexOf(t) === i).slice(0, 2).map((t) => `“${t}”`);
|
|
106
|
+
const who = isMe ? [me.name, me.email && `<${me.email}>`].filter(Boolean).join(' ') || 'This Claude Code login' : 'A different Claude account';
|
|
107
|
+
const orgName = isMe && me.org === org && me.orgName ? ` · ${me.orgName}` : '';
|
|
108
|
+
const label = `${who}${orgName} (${files === 0 ? 'no sessions in the Code tab yet' : `${plural(files, 'session')} in the Code tab`}${titles.length ? `, e.g. ${titles.join(', ')}` : ''})`;
|
|
109
|
+
return { dir: d, account, org, entries: files, newest, cli: isMe, name: isMe ? me.name : null, email: isMe ? me.email : null, label };
|
|
72
110
|
}),
|
|
73
111
|
);
|
|
112
|
+
// The signed-in desktop account (config.json; the app log as a fallback), and within it the org in use.
|
|
113
|
+
const log = activeAccountFromLog(home, platform);
|
|
114
|
+
const acct = desktopSignedInAccount(home, platform) ?? log?.account ?? null;
|
|
115
|
+
let activeDir = null;
|
|
116
|
+
if (acct) {
|
|
117
|
+
const mine = leaves.filter((x) => x.account === acct);
|
|
118
|
+
const pick = (log && log.account === acct && mine.find((x) => x.org === log.org)) || (me && me.account === acct && mine.find((x) => x.org === me.org)) || [...mine].sort((x, y) => y.newest - x.newest)[0];
|
|
119
|
+
activeDir = pick?.dir ?? null;
|
|
120
|
+
}
|
|
121
|
+
return leaves.map((x) => ({ ...x, active: x.dir === activeDir }));
|
|
74
122
|
}
|
|
75
123
|
|
|
76
124
|
/**
|
|
77
|
-
* Which folder to write to
|
|
78
|
-
*
|
|
79
|
-
*
|
|
125
|
+
* Which folder to write to, so nobody has to know account ids:
|
|
126
|
+
* - --desktop-dir if given;
|
|
127
|
+
* - in the desktop app, or when only one account could be meant: the account the desktop app is signed in to;
|
|
128
|
+
* - in a terminal: that same account if the terminal is logged in to it, but if the terminal and the desktop app
|
|
129
|
+
* are logged in as different accounts, ask (the error lists them by name), because a session written into the
|
|
130
|
+
* wrong account's Code tab would never be seen.
|
|
131
|
+
* `surface` is CLAUDE_CODE_ENTRYPOINT: 'cli' for a terminal, 'claude-desktop' for the desktop app.
|
|
80
132
|
*/
|
|
81
|
-
export function resolveDesktopDir({ home, dir, platform }) {
|
|
133
|
+
export function resolveDesktopDir({ home, dir, platform, surface = process.env.CLAUDE_CODE_ENTRYPOINT }) {
|
|
82
134
|
if (dir) {
|
|
83
135
|
if (!fs.existsSync(dir)) throw new Error(`desktop folder not found: ${dir}`);
|
|
84
|
-
return { dir: path.resolve(dir), how: '
|
|
136
|
+
return { dir: path.resolve(dir), how: 'chosen by you', label: null };
|
|
85
137
|
}
|
|
86
138
|
const all = listDesktopDirs({ home, platform });
|
|
87
139
|
if (all.length === 0) throw new Error('no account folder in the desktop app data; open the Code tab once first');
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
140
|
+
const desk = all.find((x) => x.active);
|
|
141
|
+
const mine = all.filter((x) => x.cli);
|
|
142
|
+
const cli = mine.find((x) => x.active) ?? [...mine].sort((x, y) => y.newest - x.newest)[0];
|
|
143
|
+
if (surface === 'cli' && cli && desk && cli.account !== desk.account) {
|
|
144
|
+
const e = new Error(
|
|
145
|
+
`this terminal is logged in as one Claude account but the desktop app is signed in as another; which Code tab should get the session?\n` +
|
|
146
|
+
` 1. ${cli.label} (--desktop-dir "${cli.dir}")\n 2. ${desk.label} (--desktop-dir "${desk.dir}")`,
|
|
147
|
+
);
|
|
148
|
+
e.choices = [cli, desk];
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
const chosen = desk ?? (surface === 'cli' ? cli : null) ?? (all.length === 1 ? all[0] : null);
|
|
152
|
+
if (chosen) return { dir: chosen.dir, how: chosen === desk ? 'the signed-in desktop account' : 'the only account folder', label: chosen.label };
|
|
153
|
+
const rows = all.map((x, i) => ` ${i + 1}. ${x.label} (--desktop-dir "${x.dir}")`).join('\n');
|
|
154
|
+
const e = new Error(`cannot tell which desktop account is signed in; choose one:\n${rows}`);
|
|
155
|
+
e.choices = all;
|
|
156
|
+
throw e;
|
|
93
157
|
}
|
|
94
158
|
|
|
95
159
|
function readEntries(dir) {
|
|
@@ -111,7 +175,7 @@ function lastModel(entries) {
|
|
|
111
175
|
return model;
|
|
112
176
|
}
|
|
113
177
|
|
|
114
|
-
function firstPrompt(entries) {
|
|
178
|
+
export function firstPrompt(entries) {
|
|
115
179
|
for (const { rec } of entries) {
|
|
116
180
|
if (rec?.type !== 'user' || rec.isMeta || rec.isSidechain) continue;
|
|
117
181
|
const c = rec.message?.content;
|
|
@@ -162,10 +226,10 @@ export function buildDesktopEntry({ template, cliSessionId, root, transcript, no
|
|
|
162
226
|
* Returns { status: 'registered' | 'exists' | 'would-register', file, dir }.
|
|
163
227
|
*/
|
|
164
228
|
export function registerDesktopSession({ id, root, mainFile, home, dir, platform, dryRun = false }) {
|
|
165
|
-
const { dir: target, how } = resolveDesktopDir({ home, dir, platform });
|
|
229
|
+
const { dir: target, how, label } = resolveDesktopDir({ home, dir, platform });
|
|
166
230
|
const existing = readEntries(target);
|
|
167
231
|
const have = existing.find(({ json }) => json.cliSessionId === id || (Array.isArray(json.priorCliSessionIds) && json.priorCliSessionIds.includes(id)));
|
|
168
|
-
if (have) return { status: 'exists', file: have.file, dir: target, how, entryCwd: have.json.cwd };
|
|
232
|
+
if (have) return { status: 'exists', file: have.file, dir: target, how, label, entryCwd: have.json.cwd };
|
|
169
233
|
const newest = (list) => list.filter(({ json }) => !json.isArchived).sort((a, b) => (b.json.lastActivityAt || 0) - (a.json.lastActivityAt || 0))[0]?.json;
|
|
170
234
|
let template = newest(existing);
|
|
171
235
|
let borrowed = false;
|
|
@@ -179,7 +243,7 @@ export function registerDesktopSession({ id, root, mainFile, home, dir, platform
|
|
|
179
243
|
}
|
|
180
244
|
}
|
|
181
245
|
if (!template) throw new Error('no existing Code-tab session anywhere to copy app settings from; start one session in the desktop app Code tab first');
|
|
182
|
-
if (dryRun) return { status: 'would-register', file: 'a new local_*.json', dir: target, how, borrowed };
|
|
246
|
+
if (dryRun) return { status: 'would-register', file: 'a new local_*.json', dir: target, how, label, borrowed };
|
|
183
247
|
|
|
184
248
|
const entry = buildDesktopEntry({ template, cliSessionId: id, root, transcript: parseJsonl(fs.readFileSync(mainFile, 'utf8')) });
|
|
185
249
|
const file = `${entry.sessionId}.json`;
|
|
@@ -187,5 +251,18 @@ export function registerDesktopSession({ id, root, mainFile, home, dir, platform
|
|
|
187
251
|
const tmp = `${dest}.carry-tmp`;
|
|
188
252
|
fs.writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 });
|
|
189
253
|
fs.renameSync(tmp, dest);
|
|
190
|
-
return { status: 'registered', file, dir: target, how, borrowed };
|
|
254
|
+
return { status: 'registered', file, dir: target, how, label, borrowed };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** cliSessionId -> { title, archived } as the desktop app lists it. Empty when there is no desktop app data. */
|
|
258
|
+
export function desktopSessionInfo({ home, platform }) {
|
|
259
|
+
const out = new Map();
|
|
260
|
+
try {
|
|
261
|
+
for (const { dir } of listDesktopDirs({ home, platform })) {
|
|
262
|
+
for (const { json } of readEntries(dir)) if (json.cliSessionId) out.set(json.cliSessionId, { title: json.title || null, archived: !!json.isArchived });
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// no desktop app, unreadable data, or a platform we don't know: this is a nicety, not a requirement
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
191
268
|
}
|