agent-carry 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Plugin 0.2.0: `/carry:share` and `/carry:pickup` ask what they need (session, encryption, passphrase source, destination folder, desktop app and account) instead of expecting flags. Needs the CLI from this release.
6
+ - `export --generate-passphrase` (saved to `FILE.pass`, never printed); `carry bundles`; `carry desktop-dirs`.
7
+ - `--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.
8
+
9
+ - Plugin installable from this repo: `/plugin install carry --marketplace harshhh28/agent-carry`.
10
+ - `export --latest` skips sessions with no prompts, so an opened-and-closed session no longer shadows the real one.
11
+ - `--desktop` strips terminal colour codes from newly imported text, which the app shows as literal characters.
12
+ - `carry import --desktop` adds the session to the Claude desktop app's Code tab (macOS).
5
13
  - `agent-carry` works as a second command name for `carry`.
6
14
  - README trimmed; plugin author set.
7
15
 
package/README.md CHANGED
@@ -28,6 +28,20 @@ claude --resume <id> # printed by import
28
28
  Encrypt a bundle with `CARRY_PASSPHRASE` or `--passphrase-file FILE` (AES-256-GCM, scrypt key). Never pass the
29
29
  passphrase as an argument.
30
30
 
31
+ ### Claude desktop app (Code tab)
32
+
33
+ The Code tab lists sessions from its own index, so an imported session resumes in a terminal but doesn't appear
34
+ there. Add `--desktop` to also write the index entry (macOS; the signed-in account is used):
35
+
36
+ ```
37
+ carry import session.carry.gz --desktop
38
+ ```
39
+
40
+ 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
+ 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
+
43
+ The session is added to the account that is signed in (read from the app's log). If it can't tell, or you want another account, use `--desktop-dir DIR`; `carry desktop-dirs` lists the choices. 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
+
31
45
  ### Sync through a shared folder
32
46
 
33
47
  Point both machines at a folder your sync tool already replicates (Dropbox, iCloud, Syncthing, a git tree).
@@ -41,17 +55,26 @@ Projects are matched by git remote, not path. Each file has one writer, so there
41
55
 
42
56
  ### Claude Code plugin
43
57
 
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).
58
+ ```
59
+ /plugin install carry --marketplace harshhh28/agent-carry
60
+ ```
61
+
62
+ Needs `npm i -g agent-carry` (or falls back to `npx`). Works the same in the terminal and in the desktop app's Code tab.
63
+
64
+ - `/carry:share` asks which session, whether to encrypt (generate a passphrase, use your own file, or type one), and where to save.
65
+ - `/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).
66
+
67
+ Passphrases are never put on a command line. A generated one is saved beside the bundle as `FILE.pass` (mode 600) and never printed; a typed one is stored in that conversation's history, so use it only for throwaway passphrases. With `CARRY_SYNC_DIR` set, bundled hooks pull on session start and push on session end (hooks not yet exercised live).
46
68
 
47
69
  ## Commands
48
70
 
49
71
  | command | |
50
72
  |---|---|
51
73
  | `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` |
74
+ | `carry export <id\|--latest> [-o FILE]` | `--generate-passphrase`, `--no-redact`, `--redact-emails`, `--strip-thinking`, `--allow-thinking-secrets`, `--project-root DIR` |
53
75
  | `carry inspect FILE` | header, parts, redaction counts |
54
76
  | `carry import FILE [--project-root DIR] [--dry-run]` | write into the target folder's session store |
77
+ | `carry bundles` / `carry desktop-dirs` | bundle files nearby; desktop-app account folders |
55
78
  | `carry push DIR` / `carry pull DIR` | folder sync (`--name`, `--dry-run`, `--quiet`) |
56
79
 
57
80
  ## Limits
@@ -59,13 +82,14 @@ start and push on session end (hooks not yet exercised in a live Claude Code).
59
82
  - **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
83
  - **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
84
  - **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.
85
+ - `--desktop` writes the app's index format, which is internal and undocumented; it is tested against fixtures and a dry run on a real Code-tab folder, but not yet confirmed by resuming from the app itself.
86
+ - Not handled: Windows paths, Cowork, file-history (rewind) backups.
63
87
  - `push` re-uploads pulled sessions, so a sync folder grows with devices × sessions.
64
88
 
65
89
  ## Development
66
90
 
67
91
  ```
68
- npm test # 36 tests, synthetic two-machine setups
92
+ npm test # 49 tests, synthetic two-machine setups
69
93
  npm run check:real # read-only round trip of your own ~/.claude sessions
70
94
  ```
71
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-carry",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carry",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Move coding-agent sessions between machines and folders: /carry:share exports a redacted, path-portable bundle, /carry:pickup imports one.",
5
5
  "author": { "name": "Harsh Gajjar", "email": "harshgajjar2812@gmail.com" },
6
6
  "license": "MIT"
@@ -1,13 +1,32 @@
1
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:*)
2
+ description: Import a carry bundle into a folder (and optionally the Claude desktop app); asks which bundle, passphrase, and where
3
+ argument-hint: [bundle-file]
4
+ allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), Bash(ls:*), Bash(pwd), AskUserQuestion
5
5
  ---
6
6
 
7
- Import result:
7
+ Bundle files nearby (this folder, ~/Downloads, ~/Desktop), newest first:
8
8
 
9
- !`carry import $ARGUMENTS 2>&1 || echo "carry is not installed: run npm i -g agent-carry"`
9
+ !`carry bundles 2>&1 || npx -y agent-carry@latest bundles 2>&1`
10
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.
11
+ Current folder: !`pwd`
12
+
13
+ Claude desktop app account folders (macOS only; an error here just means no desktop app):
14
+
15
+ !`carry desktop-dirs 2>&1`
16
+
17
+ Arguments the user typed: $ARGUMENTS
18
+
19
+ You are importing one session with the `carry` CLI. If `carry` was not found above, use `npx -y agent-carry@latest` in its place; if that also fails, tell the user to run `npm i -g agent-carry` and stop. If a command says it is unknown, the CLI is old: tell the user to run `npm i -g agent-carry@latest`.
20
+
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 as a short numbered list in plain text and wait.
22
+
23
+ 1. **Which bundle.** If the arguments name a file, use it. Otherwise offer up to four of the listed bundles (label: file name; description: date, size and what it holds). If none were found, ask for a path.
24
+ 2. **Passphrase, only if the bundle is encrypted.** Options:
25
+ - "Use the .pass file next to it": offer only if `ls <bundle>.pass` shows it exists.
26
+ - "A passphrase file at another path": ask for the path, then `--passphrase-file PATH`.
27
+ - "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.
28
+ (If `CARRY_PASSPHRASE` is already set in the environment, nothing is needed.) On a wrong-passphrase error, say so and ask once more.
29
+ 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`.
30
+ 4. **Claude desktop app (only if account folders are listed above).** Ask "Also show it in the Claude desktop app's Code tab?" If yes and more than one folder is listed, ask which account: the one marked "* signed in" is "(Recommended)"; the rest are listed by their folder name and session count. Use `--desktop` for the signed-in account, or `--desktop-dir <folder>` for another one. If the import says it cannot tell which account is signed in, ask the user to choose from the list it printed.
31
+ 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).
32
+ 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.
@@ -1,13 +1,27 @@
1
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:*)
2
+ description: Export a session as a portable, redacted bundle; asks which session, whether to encrypt, and where to save
3
+ argument-hint: [optional carry export flags, e.g. -o FILE --strip-thinking]
4
+ allowed-tools: Bash(carry:*), Bash(npx -y agent-carry@latest:*), AskUserQuestion
5
5
  ---
6
6
 
7
- Export result (this runs the `carry` CLI from `npm i -g agent-carry`):
7
+ Sessions in this folder, newest first (the top one is usually this conversation):
8
8
 
9
- !`carry export --latest $ARGUMENTS 2>&1 || echo "carry is not installed: run npm i -g agent-carry"`
9
+ !`carry list 2>&1 || npx -y agent-carry@latest list 2>&1`
10
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.
11
+ Arguments the user typed: $ARGUMENTS
12
+
13
+ You are exporting one session with the `carry` CLI. If `carry` was not found above, use `npx -y agent-carry@latest` in its place; if that also fails, tell the user to run `npm i -g agent-carry` and stop. If a command below says it is unknown, the CLI is old: tell the user to run `npm i -g agent-carry@latest`.
14
+
15
+ 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.
16
+
17
+ 1. **Which session.** Ignore rows with 0 turns. If only one has turns, use it without asking. Otherwise ask, offering the newest first as "(Recommended)" and up to three more; label each with its id prefix and title, and describe it with its date and turn count.
18
+ 2. **Encrypt?** Options:
19
+ - "No, plain file": readable by anyone who gets it (secrets are still redacted).
20
+ - "Yes, generate a passphrase (Recommended when sending it to someone)": run with `--generate-passphrase`.
21
+ - "Yes, use my passphrase file": ask for the path, then use `--passphrase-file PATH`.
22
+ - "Yes, I'll type one here": warn that whatever is typed is stored in this conversation's history, so use only a throwaway passphrase. Take it from a follow-up question and pass it as `CARRY_PASSPHRASE='…' carry export …`. Never repeat it back, never put it in a file name.
23
+ 3. **Where to save.** Options: this folder (default), Desktop, or another path. Name the file `<first 8 chars of the id>.carry.gz`, or `.carry.enc` when encrypting, and pass it with `-o`.
24
+ 4. **Run** `carry export <id> -o <file> [flags]` (with `--project-root` only if the user asked). If it refuses because signed thinking blocks contain credential-like strings, ask: "Drop thinking blocks (Recommended)" or "Keep them as they are (the strings stay in the file)", then re-run with `--strip-thinking` or `--allow-thinking-secrets`.
25
+ 5. **Report in at most five lines:** the file path, how many secrets were redacted, whether it is encrypted, and any warning. If a passphrase was generated, say it is saved in `<file>.pass` (mode 600) and must be sent by a different channel than the bundle; do not print it. Tell them the other side runs `/carry:pickup` or `carry import <file>`.
26
+
27
+ Never open or print the bundle's contents, and never put a passphrase on a command line other than as `CARRY_PASSPHRASE` above.
package/src/bundle.js CHANGED
@@ -170,11 +170,15 @@ export function readBundle(file, { passphrase } = {}) {
170
170
  * Write a bundle into a Claude home for a target project root. Returns a plan;
171
171
  * with dryRun it writes nothing.
172
172
  */
173
- export function importBundle({ bundle, claudeHome, root, home, dryRun = false }) {
173
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
174
+
175
+ /** `stripAnsi` drops terminal colour codes from incoming text, for apps that show them as literal characters. */
176
+ export function importBundle({ bundle, claudeHome, root, home, dryRun = false, stripAnsi = false }) {
174
177
  const realRoot = realpathOr(root);
175
178
  const folder = path.join(projectsDir(claudeHome), mangle(realRoot));
176
179
  const where = { store: folder, root: realRoot, home };
177
180
  const back = fromPortable(where);
181
+ const text = stripAnsi ? (v) => back.fn(v).replace(ANSI, '') : back.fn;
178
182
  const id = bundle.header.sessionId;
179
183
  const plan = { id, folder, root: realRoot, files: [], warnings: [] };
180
184
  if (mangle(realRoot).length > 200) {
@@ -195,7 +199,7 @@ export function importBundle({ bundle, claudeHome, root, home, dryRun = false })
195
199
  let stat;
196
200
  if (kind === 'jsonl') {
197
201
  const incoming = rows.map((r) => {
198
- const raw = r.l !== undefined ? JSON.stringify(mapStrings(r.l, back.fn)) : back.fn(r.x);
202
+ const raw = r.l !== undefined ? JSON.stringify(mapStrings(r.l, text)) : back.fn(r.x);
199
203
  return { raw, rec: r.l !== undefined ? JSON.parse(raw) : null };
200
204
  });
201
205
  const local = fs.existsSync(dest) ? parseJsonl(fs.readFileSync(dest, 'utf8')) : [];
package/src/cli.js CHANGED
@@ -6,16 +6,22 @@ import { projectFolders, listSessions, findSession } from './store.js';
6
6
  import { buildBundle, writeBundle, readBundle, importBundle } from './bundle.js';
7
7
  import { parseJsonl, summarize } from './transcript.js';
8
8
  import fs from 'node:fs';
9
+ import crypto from 'node:crypto';
10
+ import { isEncrypted } from './crypto.js';
9
11
  import { pushProject, pullProject } from './sync.js';
12
+ import { registerDesktopSession, listDesktopDirs } from './desktop.js';
10
13
 
11
14
  const HELP = `carry (agent-carry): move coding-agent sessions between machines and folders. Supported agent: claude-code
12
15
 
13
16
  carry list [--all] [--project DIR]
14
17
  carry export <id|prefix|--latest> [-o FILE] [--project-root DIR]
15
18
  [--no-redact] [--redact-emails] [--strip-thinking | --allow-thinking-secrets]
19
+ [--generate-passphrase] encrypt with a random passphrase saved next to the bundle as FILE.pass
20
+ carry bundles bundle files in this folder, ~/Downloads and ~/Desktop
16
21
  carry inspect <bundle>
17
- carry import <bundle> [--project-root DIR] [--dry-run]
22
+ carry import <bundle> [--project-root DIR] [--dry-run] [--desktop [--desktop-dir DIR]]
18
23
  carry push <dir> [--name PROJECT] [--quiet] sync every session of this project to a shared folder
24
+ carry desktop-dirs desktop-app account folders (which one is signed in)
19
25
  carry pull <dir> [--name PROJECT] [--dry-run] [--quiet]
20
26
 
21
27
  Encryption (export, import, push, pull): set CARRY_PASSPHRASE or pass --passphrase-file FILE.
@@ -26,7 +32,7 @@ Global: --claude-home DIR (default $CLAUDE_CONFIG_DIR or ~/.claude), --home DIR
26
32
  function parseArgs(argv) {
27
33
  const flags = {};
28
34
  const pos = [];
29
- const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file']);
35
+ const valued = new Set(['-o', '--out', '--project', '--project-root', '--claude-home', '--home', '--name', '--passphrase-file', '--desktop-dir']);
30
36
  for (let i = 0; i < argv.length; i++) {
31
37
  const a = argv[i];
32
38
  if (a.startsWith('-') && a !== '-') {
@@ -52,7 +58,7 @@ function main() {
52
58
  const home = flags['claude-home'] ? path.resolve(flags['claude-home']) : claudeHome();
53
59
  const userHome = flags.home ? path.resolve(flags.home) : os.homedir();
54
60
  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;
61
+ let passphrase = flags['passphrase-file'] ? fs.readFileSync(flags['passphrase-file'], 'utf8').replace(/\r?\n$/, '') : process.env.CARRY_PASSPHRASE || undefined;
56
62
  const say = (m) => flags.quiet || process.stdout.write(`${m}\n`);
57
63
 
58
64
  try {
@@ -78,8 +84,12 @@ function main() {
78
84
  let target;
79
85
  if (flags.latest) {
80
86
  const here = path.join(projectsDir(home), mangle(realpathOr(cwd)));
81
- const s = listSessions(here)[0];
82
- if (!s) fail(`no sessions for ${realpathOr(cwd)}`);
87
+ const all = listSessions(here);
88
+ if (!all.length) fail(`no sessions for ${realpathOr(cwd)}`);
89
+ // A session opened and closed without a prompt has nothing to carry; don't let it shadow the real one.
90
+ const s = all.find((x) => x.turns > 0);
91
+ if (!s) fail(`every session for ${realpathOr(cwd)} is empty (no prompts yet); run \`carry list\``);
92
+ if (s !== all[0]) process.stderr.write(`carry: skipped ${all.indexOf(s)} newer empty session(s); exporting ${s.id.slice(0, 8)}\n`);
83
93
  target = { folder: here, id: s.id };
84
94
  } else {
85
95
  if (!pos[0]) fail('export needs a session id (see `carry list`) or --latest');
@@ -106,13 +116,21 @@ function main() {
106
116
  stripThinking: !!flags['strip-thinking'],
107
117
  allowThinkingSecrets: !!flags['allow-thinking-secrets'],
108
118
  });
119
+ let generated = null;
120
+ if (flags['generate-passphrase']) {
121
+ if (passphrase) fail('use either --generate-passphrase or a passphrase of your own, not both');
122
+ generated = crypto.randomBytes(18).toString('base64url');
123
+ passphrase = generated;
124
+ }
109
125
  const out = flags.o || flags.out || `${target.id.slice(0, 8)}${passphrase ? '.carry.enc' : '.carry.gz'}`;
110
126
  writeBundle(out, text, { passphrase });
127
+ if (generated) fs.writeFileSync(`${out}.pass`, `${generated}\n`, { mode: 0o600 });
111
128
  const red = Object.entries(header.redaction.counts || {}).map(([k, v]) => `${k}:${v}`).join(' ') || 'none found';
112
129
  process.stdout.write(
113
130
  `exported ${header.sessionId.slice(0, 8)} "${header.title ?? 'untitled'}" -> ${out}\n` +
114
131
  ` parts: ${header.parts.length} paths rewritten: ${header.pathsRewritten}\n` +
115
- ` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n`,
132
+ ` redacted: ${header.redaction.enabled ? red : 'OFF'} encrypted: ${passphrase ? 'yes' : 'no'}\n` +
133
+ (generated ? ` passphrase saved to ${out}.pass (not printed); share it by a different channel than the bundle\n` : ''),
116
134
  );
117
135
  if (header.residualHomePaths > 0) {
118
136
  process.stderr.write(` warning: ${header.residualHomePaths} other absolute home path(s) remain in the bundle (other users or tools). Review with \`carry inspect\`.\n`);
@@ -124,6 +142,48 @@ function main() {
124
142
  return;
125
143
  }
126
144
 
145
+ if (cmd === 'bundles') {
146
+ const seen = new Set();
147
+ const found = [];
148
+ for (const dir of [process.cwd(), path.join(userHome, 'Downloads'), path.join(userHome, 'Desktop')]) {
149
+ let names = [];
150
+ try {
151
+ names = fs.readdirSync(dir);
152
+ } catch {
153
+ continue;
154
+ }
155
+ for (const n of names.filter((n) => /\.carry\.(gz|enc)$/.test(n))) {
156
+ const file = path.join(dir, n);
157
+ if (seen.has(file)) continue;
158
+ seen.add(file);
159
+ found.push({ file, st: fs.statSync(file) });
160
+ }
161
+ }
162
+ found.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
163
+ for (const { file, st } of found.slice(0, 10)) {
164
+ const enc = isEncrypted(fs.readFileSync(file).subarray(0, 64));
165
+ let what = 'encrypted: needs its passphrase';
166
+ if (!enc) {
167
+ try {
168
+ const { header } = readBundle(file, {});
169
+ what = `"${header.title ?? 'untitled'}", ${header.turns} turn(s), session ${header.sessionId.slice(0, 8)}`;
170
+ } catch {
171
+ what = 'unreadable';
172
+ }
173
+ }
174
+ process.stdout.write(`${file}\n ${enc ? 'encrypted' : 'plain'} ${st.mtime.toISOString().slice(0, 16).replace('T', ' ')} ${(st.size / 1024).toFixed(1)} KB ${what}\n`);
175
+ }
176
+ if (!found.length) process.stdout.write('no .carry.gz or .carry.enc files found here, in ~/Downloads or ~/Desktop\n');
177
+ return;
178
+ }
179
+
180
+ if (cmd === 'desktop-dirs') {
181
+ const list = listDesktopDirs({ home: userHome });
182
+ for (const x of list) process.stdout.write(`${x.active ? '* signed in' : ' other '} ${x.entries} session${x.entries === 1 ? ' ' : 's'} ${x.dir}\n`);
183
+ if (!list.length) process.stdout.write('no desktop account folders found\n');
184
+ return;
185
+ }
186
+
127
187
  if (cmd === 'inspect') {
128
188
  if (!pos[0]) fail('inspect needs a bundle file');
129
189
  const { header } = readBundle(pos[0], { passphrase });
@@ -134,7 +194,7 @@ function main() {
134
194
  if (cmd === 'import') {
135
195
  if (!pos[0]) fail('import needs a bundle file');
136
196
  const bundle = readBundle(pos[0], { passphrase });
137
- const plan = importBundle({ bundle, claudeHome: home, root: cwd, home: userHome, dryRun: !!flags['dry-run'] });
197
+ const plan = importBundle({ bundle, claudeHome: home, root: cwd, home: userHome, dryRun: !!flags['dry-run'], stripAnsi: !!(flags.desktop || flags['desktop-dir']) });
138
198
  process.stdout.write(`${flags['dry-run'] ? 'would import' : 'imported'} ${plan.id.slice(0, 8)} into ${plan.folder}\n`);
139
199
  for (const f of plan.files.filter((f) => f.kind === 'jsonl')) {
140
200
  process.stdout.write(` ${f.rel}: +${f.added} new, ${f.skipped} already present\n`);
@@ -142,6 +202,19 @@ function main() {
142
202
  const side = plan.files.filter((f) => f.kind !== 'jsonl');
143
203
  if (side.length) process.stdout.write(` sidecar files: ${side.filter((f) => f.added).length} new, ${side.filter((f) => !f.added).length} kept\n`);
144
204
  for (const w of plan.warnings) process.stderr.write(` warning: ${w}\n`);
205
+ if (flags.desktop || flags['desktop-dir']) {
206
+ try {
207
+ const main = plan.files.find((f) => f.rel === 'main.jsonl');
208
+ const d = registerDesktopSession({ id: plan.id, root: plan.root, mainFile: main.dest, home: userHome, dir: flags['desktop-dir'], dryRun: !!flags['dry-run'] });
209
+ 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
+ process.stdout.write(` ${verb} (${d.file}); restart the Claude desktop app if it does not appear\n`);
211
+ process.stdout.write(` desktop folder: ${d.dir}\n`);
212
+ 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
+ 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
+ } catch (e) {
215
+ process.stderr.write(` desktop: ${e.message}\n the session itself was imported and resumes from a terminal\n`);
216
+ }
217
+ }
145
218
  if (!flags['dry-run']) process.stdout.write(`\nresume with:\n cd ${plan.root} && claude --resume ${plan.id}\n`);
146
219
  return;
147
220
  }
package/src/desktop.js ADDED
@@ -0,0 +1,191 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { parseJsonl, summarize } from './transcript.js';
5
+
6
+ /**
7
+ * The Claude desktop app's Code tab lists sessions from its own index, one
8
+ * `local_<uuid>.json` per session, whose `cliSessionId` points at the normal
9
+ * Claude Code transcript. Importing a transcript does not add it to that
10
+ * index, so it would resume in a terminal but not show up in the Code tab.
11
+ *
12
+ * This writes the missing index entry. App-level settings (MCP servers,
13
+ * prompt snapshot, ...) are copied from the newest existing entry rather than
14
+ * invented, so a Code tab that has never been used cannot be targeted.
15
+ */
16
+
17
+ // Settings that belong to the app, not to an account: safe to borrow from a session of another account.
18
+ const GENERIC_KEYS = ['effort', 'classifierSummaryEnabled', 'reportFindingsCard', 'promptAppendSnapshot', 'chromePermissionMode'];
19
+
20
+ // Everything below also reflects one account's MCP connectors and tool toggles, so it is copied only from the same account.
21
+ const KEEP_FROM_TEMPLATE = [
22
+ 'effort',
23
+ 'permissionMode',
24
+ 'enabledMcpTools',
25
+ 'remoteMcpServersConfig',
26
+ 'chromePermissionMode',
27
+ 'classifierSummaryEnabled',
28
+ 'reportFindingsCard',
29
+ 'promptAppendSnapshot',
30
+ 'toolSurfaceSnapshot',
31
+ ];
32
+
33
+ export function desktopRoot(home, platform = process.platform) {
34
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions');
35
+ throw new Error('automatic desktop-app lookup is only implemented for macOS; pass --desktop-dir');
36
+ }
37
+
38
+ const subdirs = (d) => fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(d, e.name));
39
+
40
+ /**
41
+ * The app's most recently active account/org, read from its own log: the app starts every session under
42
+ * scratch-workspaces/<account>/<org>/, so the last such path names whoever is signed in now.
43
+ */
44
+ export function activeAccountFromLog(home, platform = process.platform) {
45
+ if (platform !== 'darwin') return null;
46
+ const log = path.join(home, 'Library', 'Logs', 'Claude', 'main.log');
47
+ try {
48
+ const size = fs.statSync(log).size;
49
+ const fd = fs.openSync(log, 'r');
50
+ const len = Math.min(size, 4 * 1024 * 1024);
51
+ const buf = Buffer.alloc(len);
52
+ fs.readSync(fd, buf, 0, len, size - len);
53
+ fs.closeSync(fd);
54
+ const all = [...buf.toString('utf8').matchAll(/scratch-workspaces\/([0-9a-f-]{36})\/([0-9a-f-]{36})/g)];
55
+ const last = all[all.length - 1];
56
+ return last ? { account: last[1], org: last[2] } : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** Every <root>/<account>/<org> folder, with how many sessions it holds and whether it is the signed-in one. */
63
+ export function listDesktopDirs({ home, platform }) {
64
+ const root = desktopRoot(home, platform);
65
+ if (!fs.existsSync(root)) throw new Error(`no Claude desktop Code-tab data at ${root}; open the Code tab once first`);
66
+ const active = activeAccountFromLog(home, platform);
67
+ return subdirs(root).flatMap((a) =>
68
+ subdirs(a).map((d) => {
69
+ const files = fs.readdirSync(d).filter((f) => /^local_.*\.json$/.test(f));
70
+ const newest = Math.max(0, ...files.map((f) => fs.statSync(path.join(d, f)).mtimeMs));
71
+ return { dir: d, account: path.basename(a), org: path.basename(d), entries: files.length, newest, active: !!active && active.account === path.basename(a) && active.org === path.basename(d) };
72
+ }),
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Which folder to write to: --desktop-dir if given, else the signed-in account/org (from the app log), else
78
+ * the only folder if there is just one. With several and no signal it refuses rather than guess, because a
79
+ * session written into another account's folder would never be seen.
80
+ */
81
+ export function resolveDesktopDir({ home, dir, platform }) {
82
+ if (dir) {
83
+ if (!fs.existsSync(dir)) throw new Error(`desktop folder not found: ${dir}`);
84
+ return { dir: path.resolve(dir), how: 'given' };
85
+ }
86
+ const all = listDesktopDirs({ home, platform });
87
+ if (all.length === 0) throw new Error('no account folder in the desktop app data; open the Code tab once first');
88
+ const active = all.find((x) => x.active);
89
+ if (active) return { dir: active.dir, how: 'signed-in account (from the app log)' };
90
+ if (all.length === 1) return { dir: all[0].dir, how: 'only account folder' };
91
+ const rows = all.map((x) => ` ${x.dir} (${x.entries} session${x.entries === 1 ? '' : 's'})`).join('\n');
92
+ throw new Error(`cannot tell which desktop account is signed in; pick one with --desktop-dir:\n${rows}`);
93
+ }
94
+
95
+ function readEntries(dir) {
96
+ const out = [];
97
+ for (const f of fs.readdirSync(dir)) {
98
+ if (!/^local_.*\.json$/.test(f)) continue;
99
+ try {
100
+ out.push({ file: f, json: JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) });
101
+ } catch {
102
+ // an unreadable entry is not ours to judge; skip it
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ function lastModel(entries) {
109
+ let model = null;
110
+ for (const { rec } of entries) if (rec?.type === 'assistant' && typeof rec.message?.model === 'string') model = rec.message.model;
111
+ return model;
112
+ }
113
+
114
+ function firstPrompt(entries) {
115
+ for (const { rec } of entries) {
116
+ if (rec?.type !== 'user' || rec.isMeta || rec.isSidechain) continue;
117
+ const c = rec.message?.content;
118
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((p) => p?.type === 'text')?.text : null;
119
+ if (text && !text.startsWith('<')) return text.replace(/\s+/g, ' ').trim().slice(0, 60);
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** Build the index entry for an already-imported session. Pure; no I/O. */
125
+ export function buildDesktopEntry({ template, cliSessionId, root, transcript, now = Date.now(), uuid = crypto.randomUUID() }) {
126
+ const s = summarize(transcript);
127
+ const at = (iso, fallback) => (iso && Number.isFinite(Date.parse(iso)) ? Date.parse(iso) : fallback);
128
+ const entry = {
129
+ sessionId: `local_${uuid}`,
130
+ cliSessionId,
131
+ cwd: root,
132
+ originCwd: root,
133
+ createdAt: at(s.first, now),
134
+ lastActivityAt: at(s.last, now),
135
+ lastFocusedAt: at(s.last, now),
136
+ model: lastModel(transcript) || template.model,
137
+ isArchived: false,
138
+ title: s.title || firstPrompt(transcript) || `Imported session ${cliSessionId.slice(0, 8)}`,
139
+ titleSource: 'auto',
140
+ completedTurns: s.turns,
141
+ lastSpawnRootDetected: false,
142
+ alwaysAllowedReasons: [],
143
+ sessionPermissionUpdates: [],
144
+ spawnSeed: {},
145
+ };
146
+ if (template.generic) {
147
+ // Another account's session: take app settings only. Connectors and permission grants are account-specific,
148
+ // and the safest permission mode is the default one.
149
+ for (const k of GENERIC_KEYS) if (template[k] !== undefined) entry[k] = template[k];
150
+ entry.permissionMode = 'default';
151
+ entry.enabledMcpTools = {};
152
+ entry.remoteMcpServersConfig = [];
153
+ } else {
154
+ for (const k of KEEP_FROM_TEMPLATE) if (template[k] !== undefined) entry[k] = template[k];
155
+ }
156
+ return entry;
157
+ }
158
+
159
+ /**
160
+ * Register an imported session with the desktop app. Idempotent: a session
161
+ * that already has an index entry is left alone.
162
+ * Returns { status: 'registered' | 'exists' | 'would-register', file, dir }.
163
+ */
164
+ export function registerDesktopSession({ id, root, mainFile, home, dir, platform, dryRun = false }) {
165
+ const { dir: target, how } = resolveDesktopDir({ home, dir, platform });
166
+ const existing = readEntries(target);
167
+ 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 };
169
+ const newest = (list) => list.filter(({ json }) => !json.isArchived).sort((a, b) => (b.json.lastActivityAt || 0) - (a.json.lastActivityAt || 0))[0]?.json;
170
+ let template = newest(existing);
171
+ let borrowed = false;
172
+ if (!template) {
173
+ // A freshly signed-in account has no sessions yet; borrow app-level settings from any other account's.
174
+ const others = listDesktopDirs({ home, platform }).filter((x) => x.dir !== target).flatMap((x) => readEntries(x.dir));
175
+ const t = newest(others);
176
+ if (t) {
177
+ template = { ...t, generic: true };
178
+ borrowed = true;
179
+ }
180
+ }
181
+ 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 };
183
+
184
+ const entry = buildDesktopEntry({ template, cliSessionId: id, root, transcript: parseJsonl(fs.readFileSync(mainFile, 'utf8')) });
185
+ const file = `${entry.sessionId}.json`;
186
+ const dest = path.join(target, file);
187
+ const tmp = `${dest}.carry-tmp`;
188
+ fs.writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 });
189
+ fs.renameSync(tmp, dest);
190
+ return { status: 'registered', file, dir: target, how, borrowed };
191
+ }