csession 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamie Steiner
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 ADDED
@@ -0,0 +1,25 @@
1
+ # csession
2
+
3
+ Move a Claude Code session from one machine to another — or hand one to a peer
4
+ so they can pick up the work.
5
+
6
+ A session is a JSONL transcript that lives only on the machine that made it.
7
+ Copying the file is not enough: it is full of that machine's absolute paths, it
8
+ assumes a specific git commit, and it contains every secret ever printed into
9
+ it. `csession` packages a session into one file that survives the trip.
10
+
11
+ ```
12
+ csession export [id] -o work.ccsession # redacts secrets, records git state
13
+ csession inspect work.ccsession # look before you send
14
+ csession import work.ccsession # rewrites paths, refuses to guess
15
+ ```
16
+
17
+ Status: **phase 1 implemented.** `list`, `export`, `inspect` and `import` work.
18
+ Deferred: `csession send <host>`, a2a drop-board publishing, and a slash-command wrapper.
19
+
20
+ ```bash
21
+ npm install && npm test # build and run the suite
22
+ node dist/cli.js --help
23
+ ```
24
+
25
+ Design: [docs/superpowers/specs/2026-08-31-csession-design.md](docs/superpowers/specs/2026-08-31-csession-design.md)
package/dist/args.js ADDED
@@ -0,0 +1,50 @@
1
+ import { run } from "./git.js";
2
+ const SHORT = { o: "out" };
3
+ export function parseArgs(argv) {
4
+ if (argv.length === 0)
5
+ return { command: "help", positional: [], flags: {} };
6
+ const [command, ...rest] = argv;
7
+ const positional = [];
8
+ const flags = {};
9
+ for (let i = 0; i < rest.length; i++) {
10
+ const tok = rest[i];
11
+ if (tok.startsWith("--")) {
12
+ const body = tok.slice(2);
13
+ const eq = body.indexOf("=");
14
+ if (eq !== -1) {
15
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
16
+ }
17
+ else {
18
+ const next = rest[i + 1];
19
+ if (next !== undefined && !next.startsWith("-")) {
20
+ flags[body] = next;
21
+ i++;
22
+ }
23
+ else {
24
+ flags[body] = true;
25
+ }
26
+ }
27
+ }
28
+ else if (tok.startsWith("-") && tok.length === 2) {
29
+ const name = SHORT[tok.slice(1)] ?? tok.slice(1);
30
+ const next = rest[i + 1];
31
+ if (next !== undefined && !next.startsWith("-")) {
32
+ flags[name] = next;
33
+ i++;
34
+ }
35
+ else {
36
+ flags[name] = true;
37
+ }
38
+ }
39
+ else {
40
+ positional.push(tok);
41
+ }
42
+ }
43
+ return { command: command, positional, flags };
44
+ }
45
+ /** The git root of `cwd`, or `cwd` itself when it is not in a repo. */
46
+ export function resolveProjectRoot(cwd) {
47
+ const r = run(cwd, ["rev-parse", "--show-toplevel"]);
48
+ return r.ok && r.stdout ? r.stdout : cwd;
49
+ }
50
+ //# sourceMappingURL=args.js.map
package/dist/bundle.js ADDED
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, basename } from "node:path";
5
+ import { CorruptBundleError } from "./errors.js";
6
+ /** Write via a sibling temp file then rename, so a failure never leaves a partial artefact. */
7
+ export function atomicWrite(dest, data) {
8
+ const dir = dirname(dest);
9
+ mkdirSync(dir, { recursive: true });
10
+ const tmp = join(dir, `.${basename(dest)}.${process.pid}.tmp`);
11
+ try {
12
+ writeFileSync(tmp, data);
13
+ renameSync(tmp, dest);
14
+ }
15
+ catch (e) {
16
+ rmSync(tmp, { force: true });
17
+ throw e;
18
+ }
19
+ }
20
+ /** A gzipped tar of exactly the named files, flat, no directory entries. */
21
+ export function writeBundle(outPath, files) {
22
+ const staging = mkdtempSync(join(tmpdir(), "csession-stage-"));
23
+ try {
24
+ for (const [name, content] of Object.entries(files)) {
25
+ writeFileSync(join(staging, name), content);
26
+ }
27
+ const tarOut = join(staging, ".bundle.tgz");
28
+ const names = Object.keys(files);
29
+ const r = spawnSync("tar", ["-czf", tarOut, "-C", staging, ...names], { encoding: "utf8" });
30
+ if (r.status !== 0) {
31
+ throw new Error(`tar failed: ${r.stderr ?? ""}`);
32
+ }
33
+ atomicWrite(outPath, readFileSync(tarOut));
34
+ }
35
+ finally {
36
+ rmSync(staging, { recursive: true, force: true });
37
+ }
38
+ }
39
+ export function readBundle(bundlePath) {
40
+ const staging = mkdtempSync(join(tmpdir(), "csession-unstage-"));
41
+ try {
42
+ const r = spawnSync("tar", ["-xzf", bundlePath, "-C", staging], { encoding: "utf8" });
43
+ if (r.status !== 0) {
44
+ throw new CorruptBundleError(`not a readable .ccsession bundle: ${(r.stderr ?? "").trim()}`);
45
+ }
46
+ const out = {};
47
+ // Bundles are untrusted input from another machine. Every malformed shape must become
48
+ // a typed refusal (exit code 3) rather than a crash that touches the filesystem unexpectedly.
49
+ const entries = readdirSync(staging, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ if (!entry.isFile()) {
52
+ throw new CorruptBundleError(`bundle contains a non-regular entry: "${entry.name}" (not a regular file)`);
53
+ }
54
+ try {
55
+ out[entry.name] = readFileSync(join(staging, entry.name), "utf8");
56
+ }
57
+ catch (e) {
58
+ throw new CorruptBundleError(`failed to read "${entry.name}" from bundle: ${e.message}`);
59
+ }
60
+ }
61
+ if (!("manifest.json" in out)) {
62
+ throw new CorruptBundleError("bundle contains no manifest.json");
63
+ }
64
+ if (!("session.jsonl" in out)) {
65
+ throw new CorruptBundleError("bundle contains no session.jsonl");
66
+ }
67
+ return out;
68
+ }
69
+ finally {
70
+ rmSync(staging, { recursive: true, force: true });
71
+ }
72
+ }
73
+ //# sourceMappingURL=bundle.js.map
package/dist/cli.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "./args.js";
3
+ import { CsError } from "./errors.js";
4
+ import { listCommand } from "./commands/list.js";
5
+ import { exportCommand } from "./commands/export.js";
6
+ import { inspectCommand } from "./commands/inspect.js";
7
+ import { importCommand } from "./commands/import.js";
8
+ const HELP = `csession - move a Claude Code session between machines
9
+
10
+ csession list [--project PATH] sessions for a project, newest first
11
+ csession export [id] -o FILE [options] build a bundle
12
+ csession inspect FILE print manifest and reports
13
+ csession import FILE [options] receive a bundle
14
+
15
+ export options: --dry-run --include-untracked --paranoid --no-redact
16
+ import options: --root PATH --new-id --force
17
+
18
+ exit codes: 0 ok, 1 user error, 2 safety refusal, 3 corrupt bundle`;
19
+ function main() {
20
+ const args = parseArgs(process.argv.slice(2));
21
+ switch (args.command) {
22
+ case "list":
23
+ console.log(listCommand(args.flags, process.cwd()));
24
+ return 0;
25
+ case "export":
26
+ console.log(exportCommand(args.positional, args.flags, process.cwd(), new Date()));
27
+ return 0;
28
+ case "inspect":
29
+ console.log(inspectCommand(args.positional));
30
+ return 0;
31
+ case "import":
32
+ console.log(importCommand(args.positional, args.flags, process.cwd()));
33
+ return 0;
34
+ case "help":
35
+ case "--help":
36
+ case "-h":
37
+ console.log(HELP);
38
+ return 0;
39
+ default:
40
+ console.error(`unknown command: ${args.command}\n\n${HELP}`);
41
+ return 1;
42
+ }
43
+ }
44
+ try {
45
+ process.exit(main());
46
+ }
47
+ catch (e) {
48
+ if (e instanceof CsError) {
49
+ console.error(`error: ${e.message}`);
50
+ process.exit(e.exitCode);
51
+ }
52
+ else {
53
+ // An untyped error is still a user-facing failure, and a stack trace is never the
54
+ // right thing to show someone for one. transcript.ts, git.ts and bundle.ts all throw
55
+ // plain Errors - several are genuine programmer-error guards that must stay loud in
56
+ // tests, so they are not being converted; this net is what keeps them from reaching
57
+ // a person as a wall of Node internals. Exit 1: an untyped failure is not a safety
58
+ // refusal (2) and not a diagnosed corrupt bundle (3).
59
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
60
+ process.exit(1);
61
+ }
62
+ }
63
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { listSessionFiles, sessionFilePath } from "../paths.js";
3
+ import { readLines, statTranscript, deriveProjectRoot, sha256 } from "../transcript.js";
4
+ import { redact } from "../redact.js";
5
+ import { probe, diffPatch, untrackedPatch } from "../git.js";
6
+ import { writeBundle } from "../bundle.js";
7
+ import { SCHEMA } from "../manifest.js";
8
+ import { UserError } from "../errors.js";
9
+ import { resolveProjectRoot } from "../args.js";
10
+ export function exportCommand(positional, flags, cwd, now) {
11
+ const out = flags["out"];
12
+ const dryRun = flags["dry-run"] === true;
13
+ if (typeof out !== "string" && !dryRun) {
14
+ throw new UserError("export needs an output path: csession export [id] -o FILE");
15
+ }
16
+ const projectRoot = resolveProjectRoot(cwd);
17
+ const available = listSessionFiles(projectRoot);
18
+ if (available.length === 0) {
19
+ throw new UserError(`no sessions found for ${projectRoot}`);
20
+ }
21
+ const requested = positional[0];
22
+ const chosen = requested
23
+ ? available.find((s) => s.id === requested)
24
+ : available[0];
25
+ if (!chosen) {
26
+ throw new UserError(`no session "${requested}" for ${projectRoot}. Run: csession list`);
27
+ }
28
+ const raw = readFileSync(sessionFilePath(projectRoot, chosen.id), "utf8");
29
+ const originalLines = readLines(raw);
30
+ const stats = statTranscript(originalLines);
31
+ const derived = deriveProjectRoot(stats);
32
+ const notes = [];
33
+ if (!requested)
34
+ notes.push(`No id given; chose the newest session ${chosen.id}.`);
35
+ if (derived.ambiguous) {
36
+ notes.push(`Records disagree on cwd; using the most common: ${derived.root}`);
37
+ }
38
+ const applyRedaction = flags["no-redact"] !== true;
39
+ const paranoid = flags["paranoid"] === true;
40
+ const { lines, hits } = applyRedaction
41
+ ? redact(originalLines, { paranoid })
42
+ : { lines: originalLines, hits: [] };
43
+ const sessionJsonl = lines.join("\n") + "\n";
44
+ const git = probe(derived.root);
45
+ const includeUntracked = flags["include-untracked"] === true;
46
+ let patch = git.dirty || includeUntracked ? diffPatch(derived.root) : "";
47
+ if (includeUntracked) {
48
+ patch += untrackedPatch(derived.root, git.untrackedFiles);
49
+ }
50
+ const manifest = {
51
+ schema: SCHEMA,
52
+ createdAt: now.toISOString(),
53
+ session: {
54
+ id: chosen.id,
55
+ projectRoot: derived.root,
56
+ recordCount: lines.length,
57
+ sha256: sha256(sessionJsonl),
58
+ claudeVersions: stats.versions,
59
+ },
60
+ git: {
61
+ remote: git.remote,
62
+ branch: git.branch,
63
+ commit: git.commit,
64
+ dirty: git.dirty,
65
+ untrackedFiles: git.untrackedFiles,
66
+ includedUntracked: includeUntracked,
67
+ },
68
+ redaction: { applied: applyRedaction, paranoid, hits },
69
+ };
70
+ const report = buildReport(manifest, patch, notes, dryRun, includeUntracked);
71
+ if (dryRun)
72
+ return report;
73
+ const files = {
74
+ "manifest.json": JSON.stringify(manifest, null, 2) + "\n",
75
+ "session.jsonl": sessionJsonl,
76
+ };
77
+ if (patch.trim().length > 0)
78
+ files["uncommitted.patch"] = patch;
79
+ writeBundle(out, files);
80
+ return `${report}\n\nWrote ${out}`;
81
+ }
82
+ function buildReport(m, patch, notes, dryRun, includeUntracked) {
83
+ const l = [];
84
+ if (dryRun)
85
+ l.push("DRY RUN - nothing was written.");
86
+ l.push(...notes);
87
+ l.push(`session ${m.session.id} (${m.session.recordCount} records)`);
88
+ l.push(`root ${m.session.projectRoot}`);
89
+ l.push(`git ${m.git.branch ?? "?"} @ ${(m.git.commit ?? "?").slice(0, 12)} dirty=${m.git.dirty}`);
90
+ if (patch.trim().length > 0) {
91
+ const kb = Math.round(Buffer.byteLength(patch) / 1024);
92
+ l.push(`patch ${kb}K`);
93
+ if (kb > 1024)
94
+ l.push(`WARNING: the patch is ${kb}K. Consider committing first.`);
95
+ }
96
+ if (m.git.untrackedFiles.length > 0) {
97
+ const verb = includeUntracked ? "included" : "NOT included";
98
+ l.push(`untracked ${m.git.untrackedFiles.length} file(s) ${verb}: ${m.git.untrackedFiles.join(", ")}`);
99
+ }
100
+ if (m.redaction.applied) {
101
+ l.push(m.redaction.hits.length === 0
102
+ ? "redaction no matches"
103
+ : `redaction ${m.redaction.hits.map((h) => `${h.rule}x${h.count}`).join(", ")}`);
104
+ }
105
+ else {
106
+ l.push("redaction DISABLED (--no-redact). This bundle may contain secrets.");
107
+ }
108
+ l.push("");
109
+ l.push("Redaction is best-effort. A secret that is split across lines, encoded,");
110
+ l.push("or oddly shaped can still get through. Inspect before you send.");
111
+ return l.join("\n");
112
+ }
113
+ //# sourceMappingURL=export.js.map
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFileSync, mkdtempSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { readBundle, atomicWrite } from "../bundle.js";
6
+ import { parseManifest } from "../manifest.js";
7
+ import { readLines, rewritePrefix, replaceAllText, unresolvedAbsolutePaths, sha256 } from "../transcript.js";
8
+ import { probe, applyCheck } from "../git.js";
9
+ import { sessionFilePath } from "../paths.js";
10
+ import { resolveProjectRoot } from "../args.js";
11
+ import { UserError, SafetyError, CorruptBundleError } from "../errors.js";
12
+ import { safe } from "../sanitize.js";
13
+ import { existsSync } from "node:fs";
14
+ /**
15
+ * Claude Code session ids are UUIDs. Anything else is a malformed bundle.
16
+ *
17
+ * This is a hard gate rather than a nicety because the id is attacker-controlled and
18
+ * gets used in two dangerous places: sessionFilePath() interpolates it into a
19
+ * filesystem path (an id of "../../../../tmp/pwned" escapes the sessions folder
20
+ * entirely, and atomicWrite's recursive mkdir happily creates the way there, with the
21
+ * equally attacker-controlled transcript as the content), and buildReport prints it
22
+ * inside a `claude --resume <id>` command we invite the reader to run.
23
+ */
24
+ const SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
25
+ export function importCommand(positional, flags, cwd) {
26
+ const path = positional[0];
27
+ if (!path)
28
+ throw new UserError("import needs a bundle path: csession import FILE");
29
+ const files = readBundle(path);
30
+ const m = parseManifest(files["manifest.json"]);
31
+ // Before the id is used for ANYTHING - see SESSION_ID_RE above.
32
+ if (!SESSION_ID_RE.test(m.session.id)) {
33
+ throw new CorruptBundleError(`manifest session id is not a UUID: "${safe(m.session.id, 80)}". ` +
34
+ `That string would become a file path and a command to run; refusing.`);
35
+ }
36
+ const sessionJsonl = files["session.jsonl"];
37
+ const actual = sha256(sessionJsonl);
38
+ if (actual !== m.session.sha256) {
39
+ throw new CorruptBundleError(`transcript checksum does not match the manifest (expected ${m.session.sha256}, got ${actual})`);
40
+ }
41
+ const root = resolveLocalRoot(flags, cwd, m.git.remote);
42
+ const force = flags["force"] === true;
43
+ const local = probe(root);
44
+ // Every `m.*` below is a manifest string - see sanitize.ts. `local.*` and `root` are
45
+ // ours: read from the receiver's own repo, or validated here.
46
+ if (m.git.remote && local.remote && m.git.remote !== local.remote && !force) {
47
+ throw new SafetyError(`remote mismatch.\n bundle: ${safe(m.git.remote)}\n local: ${local.remote}\n` +
48
+ `This may be a different repository. Re-run with --force if you are sure.`);
49
+ }
50
+ if (m.git.commit && local.commit && m.git.commit !== local.commit && !force) {
51
+ throw new SafetyError(`commit mismatch.\n bundle: ${safe(m.git.commit)}\n local: ${local.commit}\n\n` +
52
+ `The conversation assumes the bundle's tree. To match it:\n` +
53
+ ` git -C ${root} checkout ${safe(m.git.commit)}\n\n` +
54
+ `Or re-run with --force to import anyway.`);
55
+ }
56
+ const newId = flags["new-id"] === true ? randomUUID() : m.session.id;
57
+ const dest = sessionFilePath(root, newId);
58
+ if (existsSync(dest)) {
59
+ throw new SafetyError(`session ${newId} already exists at ${dest}. Re-run with --new-id to import it under a fresh id.`);
60
+ }
61
+ let lines = readLines(sessionJsonl);
62
+ const rewritten = rewritePrefix(lines, m.session.projectRoot, root);
63
+ lines = rewritten.lines;
64
+ if (newId !== m.session.id) {
65
+ lines = replaceAllText(lines, m.session.id, newId).lines;
66
+ }
67
+ const unresolved = unresolvedAbsolutePaths(lines, root);
68
+ atomicWrite(dest, lines.join("\n") + "\n");
69
+ return buildReport(m, root, newId, rewritten.replaced, unresolved, files, force);
70
+ }
71
+ function resolveLocalRoot(flags, cwd, bundleRemote) {
72
+ const explicit = flags["root"];
73
+ if (typeof explicit === "string") {
74
+ // A relative or non-existent --root is worse than an error: probe() returns all
75
+ // nulls for it, so every safety check below passes VACUOUSLY, the transcript lands
76
+ // in a folder Claude Code will never read, and we report success with a resume
77
+ // command that cannot work. Resolve it too - the value ends up in a session folder
78
+ // name and in that printed command, both of which must be absolute.
79
+ const abs = resolve(explicit);
80
+ let isDir = false;
81
+ try {
82
+ isDir = statSync(abs).isDirectory();
83
+ }
84
+ catch {
85
+ isDir = false;
86
+ }
87
+ if (!isDir) {
88
+ throw new UserError(`--root ${explicit} is not an existing directory (resolved to ${abs})`);
89
+ }
90
+ return abs;
91
+ }
92
+ const here = resolveProjectRoot(cwd);
93
+ const local = probe(here);
94
+ if (bundleRemote && local.remote === bundleRemote)
95
+ return here;
96
+ throw new UserError(`cannot work out where this repository lives here.\n` +
97
+ ` bundle remote: ${bundleRemote === null ? "none recorded" : safe(bundleRemote)}\n` +
98
+ ` current dir: ${here} (remote: ${local.remote ?? "none"})\n\n` +
99
+ `Re-run from inside the right checkout, or pass --root PATH.`);
100
+ }
101
+ function buildReport(m, root, newId, replaced, unresolved, files, force) {
102
+ const l = [];
103
+ l.push(`session ${newId} (${m.session.recordCount} records)`);
104
+ if (newId !== m.session.id)
105
+ l.push(` (original id was ${safe(m.session.id)})`);
106
+ l.push(`${replaced} paths rewritten to ${root}`);
107
+ if (unresolved.length > 0) {
108
+ l.push(`${unresolved.length} path(s) left as-is (not under the project root):`);
109
+ for (const p of unresolved.slice(0, 20))
110
+ l.push(` ${p}`);
111
+ if (unresolved.length > 20)
112
+ l.push(` ... and ${unresolved.length - 20} more`);
113
+ }
114
+ if (m.git.untrackedFiles.length > 0) {
115
+ const n = m.git.untrackedFiles.length;
116
+ // Saying "not in the bundle" unconditionally was wrong whenever the sender used
117
+ // --include-untracked: the contents are right there in uncommitted.patch.
118
+ l.push(m.git.includedUntracked
119
+ ? `NOTE: ${n} untracked file(s) referenced by this session ARE included in this bundle, in uncommitted.patch:`
120
+ : `NOTE: ${n} untracked file(s) referenced by this session are NOT included in this bundle:`);
121
+ for (const f of m.git.untrackedFiles)
122
+ l.push(` ${safe(f)}`);
123
+ }
124
+ const patch = files["uncommitted.patch"];
125
+ if (patch) {
126
+ // A predictable path in the shared /tmp namespace (e.g. csession-<id>.patch) lets
127
+ // anyone who has seen the bundle pre-plant a symlink there for writeFileSync to
128
+ // follow. mkdtempSync makes a private, unpredictable directory first.
129
+ const patchDir = mkdtempSync(join(tmpdir(), "csession-patch-"));
130
+ const patchPath = join(patchDir, `csession-${newId}.patch`);
131
+ writeFileSync(patchPath, patch);
132
+ const check = applyCheck(root, patchPath);
133
+ l.push("");
134
+ l.push(`This session had uncommitted changes. They were NOT applied.`);
135
+ l.push(` patch (temporary file): ${patchPath}`);
136
+ l.push(` applies: ${check.ok ? "cleanly" : `NO - ${check.output}`}`);
137
+ l.push(` to apply: git -C ${root} apply ${patchPath}`);
138
+ }
139
+ if (force) {
140
+ l.push("");
141
+ l.push("WARNING: --force was used. The history may describe a tree you do not have.");
142
+ }
143
+ l.push("");
144
+ l.push(`Resume with: cd ${root} && claude --resume ${newId}`);
145
+ return l.join("\n");
146
+ }
147
+ //# sourceMappingURL=import.js.map
@@ -0,0 +1,35 @@
1
+ import { readBundle } from "../bundle.js";
2
+ import { parseManifest } from "../manifest.js";
3
+ import { sha256 } from "../transcript.js";
4
+ import { UserError } from "../errors.js";
5
+ import { safe } from "../sanitize.js";
6
+ export function inspectCommand(positional) {
7
+ const path = positional[0];
8
+ if (!path)
9
+ throw new UserError("inspect needs a bundle path: csession inspect FILE");
10
+ const files = readBundle(path);
11
+ const m = parseManifest(files["manifest.json"]);
12
+ const actual = sha256(files["session.jsonl"]);
13
+ const shaLine = actual === m.session.sha256 ? "sha256 OK" : `sha256 MISMATCH (expected ${m.session.sha256}, got ${actual})`;
14
+ const l = [];
15
+ l.push(`bundle ${path}`);
16
+ l.push(`created ${m.createdAt}`);
17
+ l.push(`session ${safe(m.session.id)} (${m.session.recordCount} records)`);
18
+ l.push(`root ${safe(m.session.projectRoot)}`);
19
+ l.push(`versions ${m.session.claudeVersions.map((v) => safe(v)).join(", ") || "unknown"}`);
20
+ l.push(`git ${safe(m.git.branch ?? "?")} @ ${safe((m.git.commit ?? "?").slice(0, 12))} dirty=${m.git.dirty}`);
21
+ l.push(`remote ${safe(m.git.remote ?? "none")}`);
22
+ l.push(`patch ${"uncommitted.patch" in files ? `${Math.round(Buffer.byteLength(files["uncommitted.patch"]) / 1024)}K` : "none"}`);
23
+ if (m.git.untrackedFiles.length > 0) {
24
+ // Whether the contents actually shipped is recorded by export; saying "not in this
25
+ // bundle" unconditionally was a lie half the time.
26
+ const where = m.git.includedUntracked ? "included in this bundle" : "NOT included in this bundle";
27
+ l.push(`untracked ${where}: ${m.git.untrackedFiles.map((f) => safe(f)).join(", ")}`);
28
+ }
29
+ l.push(m.redaction.applied
30
+ ? `redaction ${m.redaction.hits.map((h) => `${safe(h.rule)}x${h.count}`).join(", ") || "no matches"}${m.redaction.paranoid ? " (paranoid)" : ""}`
31
+ : "redaction DISABLED - this bundle may contain secrets");
32
+ l.push(shaLine);
33
+ return l.join("\n");
34
+ }
35
+ //# sourceMappingURL=inspect.js.map
@@ -0,0 +1,17 @@
1
+ import { listSessionFiles } from "../paths.js";
2
+ import { resolveProjectRoot } from "../args.js";
3
+ export function listCommand(flags, cwd) {
4
+ const explicit = typeof flags["project"] === "string" ? flags["project"] : undefined;
5
+ const root = explicit ?? resolveProjectRoot(cwd);
6
+ const sessions = listSessionFiles(root);
7
+ if (sessions.length === 0) {
8
+ return `No sessions found for ${root}`;
9
+ }
10
+ const rows = sessions.map((s) => {
11
+ const when = new Date(s.mtimeMs).toISOString().replace("T", " ").slice(0, 16);
12
+ const kb = `${Math.round(s.bytes / 1024)}K`.padStart(7);
13
+ return ` ${s.id} ${when} ${kb}`;
14
+ });
15
+ return [`Sessions for ${root} (newest first):`, ...rows].join("\n");
16
+ }
17
+ //# sourceMappingURL=list.js.map
package/dist/errors.js ADDED
@@ -0,0 +1,27 @@
1
+ export class CsError extends Error {
2
+ exitCode;
3
+ constructor(message, exitCode) {
4
+ super(message);
5
+ this.exitCode = exitCode;
6
+ this.name = new.target.name;
7
+ }
8
+ }
9
+ /** Bad arguments, missing file, unresolvable root. */
10
+ export class UserError extends CsError {
11
+ constructor(message) {
12
+ super(message, 1);
13
+ }
14
+ }
15
+ /** A refusal: commit mismatch, remote mismatch, id collision. */
16
+ export class SafetyError extends CsError {
17
+ constructor(message) {
18
+ super(message, 2);
19
+ }
20
+ }
21
+ /** Bad sha, unreadable manifest, unknown schema. */
22
+ export class CorruptBundleError extends CsError {
23
+ constructor(message) {
24
+ super(message, 3);
25
+ }
26
+ }
27
+ //# sourceMappingURL=errors.js.map
package/dist/git.js ADDED
@@ -0,0 +1,68 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export function run(root, args) {
3
+ const r = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
4
+ return {
5
+ ok: r.status === 0,
6
+ stdout: (r.stdout ?? "").trimEnd(),
7
+ stderr: (r.stderr ?? "").trimEnd(),
8
+ };
9
+ }
10
+ export function probe(root) {
11
+ if (!run(root, ["rev-parse", "--is-inside-work-tree"]).ok) {
12
+ return { remote: null, branch: null, commit: null, dirty: false, untrackedFiles: [] };
13
+ }
14
+ const remote = run(root, ["remote", "get-url", "origin"]);
15
+ const branch = run(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
16
+ const commit = run(root, ["rev-parse", "HEAD"]);
17
+ // --quiet exits non-zero when there is a tracked change. Untracked and
18
+ // ignored files do not affect it, which is exactly what we want.
19
+ const dirty = !run(root, ["diff", "--quiet", "HEAD"]).ok;
20
+ // --exclude-standard honours .gitignore, so ignored files never appear.
21
+ const untracked = run(root, ["ls-files", "--others", "--exclude-standard"]);
22
+ return {
23
+ remote: remote.ok ? remote.stdout : null,
24
+ branch: branch.ok ? branch.stdout : null,
25
+ commit: commit.ok ? commit.stdout : null,
26
+ dirty,
27
+ untrackedFiles: untracked.ok && untracked.stdout ? untracked.stdout.split("\n") : [],
28
+ };
29
+ }
30
+ /** Every change to a file git already knows about. Binary-safe so it applies cleanly. */
31
+ export function diffPatch(root) {
32
+ const r = run(root, ["diff", "--binary", "HEAD"]);
33
+ // A failed diff is not "no changes" — conflating them yields a bundle that
34
+ // claims dirty with no patch. Throw loudly instead of silently returning "".
35
+ if (!r.ok) {
36
+ throw new Error(`git diff failed in ${root}: ${r.stderr || "(no stderr)"}`);
37
+ }
38
+ return r.stdout + "\n";
39
+ }
40
+ /**
41
+ * A patch for untracked, non-ignored files, built WITHOUT touching the index.
42
+ *
43
+ * `git diff HEAD` cannot see untracked files at all, so --include-untracked would
44
+ * otherwise be a no-op. `git add -N` would work but mutates the user's index as a
45
+ * side effect of an export, which an export must never do. `--no-index` against
46
+ * /dev/null gives the same hunks with no state change.
47
+ *
48
+ * The file list comes from probe().untrackedFiles, i.e. `ls-files --others
49
+ * --exclude-standard`, so anything matched by .gitignore is already excluded and
50
+ * stays excluded. This flag must never become --include-secrets.
51
+ */
52
+ export function untrackedPatch(root, files) {
53
+ let out = "";
54
+ for (const file of files) {
55
+ // --no-index exits 1 when the inputs differ, which is always true here.
56
+ const r = spawnSync("git", ["diff", "--no-index", "--binary", "--", "/dev/null", file], { cwd: root, encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
57
+ if (r.status !== 0 && r.status !== 1) {
58
+ throw new Error(`git diff --no-index failed for ${file}: ${r.stderr ?? ""}`);
59
+ }
60
+ out += r.stdout ?? "";
61
+ }
62
+ return out;
63
+ }
64
+ export function applyCheck(root, patchPath) {
65
+ const r = run(root, ["apply", "--check", patchPath]);
66
+ return { ok: r.ok, output: r.stderr || r.stdout };
67
+ }
68
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,91 @@
1
+ import { CorruptBundleError } from "./errors.js";
2
+ export const SCHEMA = 1;
3
+ function req(obj, key, kind, path) {
4
+ const v = obj[key];
5
+ const actual = v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
6
+ const want = kind === "array" ? "array" : kind;
7
+ const ok = kind === "array" ? Array.isArray(v) : kind === "nullable-string" ? v === null || typeof v === "string" : actual === want;
8
+ if (!ok)
9
+ throw new CorruptBundleError(`manifest: ${path}.${key} must be ${kind}, got ${actual}`);
10
+ return v;
11
+ }
12
+ /**
13
+ * `Array.isArray` says nothing about what is IN the array. Every consumer of these
14
+ * arrays treats the elements as strings - inspect maps them through safe(), which calls
15
+ * `.replace()` - so a non-string element becomes a TypeError with no exitCode: a
16
+ * stack trace and exit 1, when the contract says a malformed bundle is exit 3.
17
+ */
18
+ function stringArray(o, key, path) {
19
+ const arr = req(o, key, "array", path);
20
+ arr.forEach((v, i) => {
21
+ if (typeof v !== "string") {
22
+ const actual = v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
23
+ throw new CorruptBundleError(`manifest: ${path}.${key}[${i}] must be string, got ${actual}`);
24
+ }
25
+ });
26
+ return arr;
27
+ }
28
+ function obj(v, path) {
29
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
30
+ throw new CorruptBundleError(`manifest: ${path} must be an object`);
31
+ }
32
+ return v;
33
+ }
34
+ export function parseManifest(text) {
35
+ let raw;
36
+ try {
37
+ raw = JSON.parse(text);
38
+ }
39
+ catch {
40
+ throw new CorruptBundleError("manifest.json is not valid JSON");
41
+ }
42
+ const m = obj(raw, "manifest");
43
+ const schema = req(m, "schema", "number", "manifest");
44
+ if (schema !== SCHEMA) {
45
+ throw new CorruptBundleError(`bundle uses schema ${schema}; this csession understands schema ${SCHEMA}. Upgrade csession.`);
46
+ }
47
+ const s = obj(req(m, "session", "object", "manifest"), "manifest.session");
48
+ const g = obj(req(m, "git", "object", "manifest"), "manifest.git");
49
+ const r = obj(req(m, "redaction", "object", "manifest"), "manifest.redaction");
50
+ const hits = req(r, "hits", "array", "manifest.redaction").map((h, i) => {
51
+ const hit = obj(h, `manifest.redaction.hits[${i}]`);
52
+ const extra = Object.keys(hit).filter((k) => k !== "rule" && k !== "count");
53
+ if (extra.length > 0) {
54
+ throw new CorruptBundleError(`manifest.redaction.hits[${i}] has unexpected field(s): ${extra.join(", ")}. Hits carry counts only.`);
55
+ }
56
+ return {
57
+ rule: req(hit, "rule", "string", `manifest.redaction.hits[${i}]`),
58
+ count: req(hit, "count", "number", `manifest.redaction.hits[${i}]`),
59
+ };
60
+ });
61
+ return {
62
+ schema,
63
+ createdAt: req(m, "createdAt", "string", "manifest"),
64
+ session: {
65
+ id: req(s, "id", "string", "manifest.session"),
66
+ projectRoot: req(s, "projectRoot", "string", "manifest.session"),
67
+ recordCount: req(s, "recordCount", "number", "manifest.session"),
68
+ sha256: req(s, "sha256", "string", "manifest.session"),
69
+ claudeVersions: stringArray(s, "claudeVersions", "manifest.session"),
70
+ },
71
+ git: {
72
+ remote: req(g, "remote", "nullable-string", "manifest.git"),
73
+ branch: req(g, "branch", "nullable-string", "manifest.git"),
74
+ commit: req(g, "commit", "nullable-string", "manifest.git"),
75
+ dirty: req(g, "dirty", "boolean", "manifest.git"),
76
+ untrackedFiles: stringArray(g, "untrackedFiles", "manifest.git"),
77
+ // Absent in bundles written before this field existed, so it defaults rather than
78
+ // failing - schema 1 stays schema 1. False is the conservative default: unless the
79
+ // bundle says the contents shipped, both readers tell the receiver they did not.
80
+ includedUntracked: g["includedUntracked"] === undefined
81
+ ? false
82
+ : req(g, "includedUntracked", "boolean", "manifest.git"),
83
+ },
84
+ redaction: {
85
+ applied: req(r, "applied", "boolean", "manifest.redaction"),
86
+ paranoid: req(r, "paranoid", "boolean", "manifest.redaction"),
87
+ hits,
88
+ },
89
+ };
90
+ }
91
+ //# sourceMappingURL=manifest.js.map
package/dist/paths.js ADDED
@@ -0,0 +1,42 @@
1
+ import { homedir } from "node:os";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Claude Code names a project folder by taking the working directory and
6
+ * replacing every "/" with "-". This is lossy: "/a/b-c" and "/a/b/c" both
7
+ * encode to "-a-b-c". Encoding is safe; decoding is not, which is why the
8
+ * project root is derived from the transcript instead.
9
+ */
10
+ export function encodeProjectDir(root) {
11
+ const trimmed = root.endsWith("/") && root.length > 1 ? root.slice(0, -1) : root;
12
+ return trimmed.replaceAll("/", "-");
13
+ }
14
+ export function claudeProjectsDir() {
15
+ return join(homedir(), ".claude", "projects");
16
+ }
17
+ export function sessionDirFor(root) {
18
+ return join(claudeProjectsDir(), encodeProjectDir(root));
19
+ }
20
+ export function sessionFilePath(root, sessionId) {
21
+ return join(sessionDirFor(root), `${sessionId}.jsonl`);
22
+ }
23
+ /** Sessions for a project root, newest first. Returns [] if the folder is absent. */
24
+ export function listSessionFiles(root) {
25
+ const dir = sessionDirFor(root);
26
+ let names;
27
+ try {
28
+ names = readdirSync(dir);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ return names
34
+ .filter((n) => n.endsWith(".jsonl"))
35
+ .map((n) => {
36
+ const path = join(dir, n);
37
+ const st = statSync(path);
38
+ return { id: n.slice(0, -".jsonl".length), path, mtimeMs: st.mtimeMs, bytes: st.size };
39
+ })
40
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
41
+ }
42
+ //# sourceMappingURL=paths.js.map
package/dist/redact.js ADDED
@@ -0,0 +1,54 @@
1
+ const RULES = [
2
+ // Only exclude `"`, not `\`. PEM bodies inside JSON strings contain `\n` escapes;
3
+ // excluding backslash would stop the rule matching real keys inside strings.
4
+ // Quotes alone bound the JSON string, which is the property we need.
5
+ { name: "private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[^"]*?-----END [A-Z ]*PRIVATE KEY-----/g },
6
+ { name: "anthropic-key", pattern: /sk-ant-[A-Za-z0-9_\-]{20,}/g },
7
+ { name: "tavily-key", pattern: /tvly-[A-Za-z0-9_\-]{20,}/g },
8
+ { name: "openai-key", pattern: /sk-(?:proj-)?[A-Za-z0-9]{32,}/g },
9
+ { name: "github-token", pattern: /gh[pousr]_[A-Za-z0-9]{30,}/g },
10
+ { name: "aws-access-key-id", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
11
+ { name: "google-api-key", pattern: /\bAIza[0-9A-Za-z_\-]{35}\b/g },
12
+ { name: "slack-token", pattern: /\bxox[baprs]-[0-9A-Za-z\-]{10,}/g },
13
+ { name: "stripe-key", pattern: /\b[sr]k_live_[0-9A-Za-z]{16,}/g },
14
+ // Only the password group is replaced, so the host stays readable.
15
+ { name: "connection-string-password", pattern: /([a-z][a-z0-9+.\-]*:\/\/[^\s:@"\\]+:)([^\s@"\\]+)(@)/g, group: 2 },
16
+ { name: "named-secret", pattern: /((?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD)[A-Z_]*\s*[=:]\s*)([A-Za-z0-9_\-+/.]{16,})/g, group: 2 },
17
+ ];
18
+ const PARANOID = [
19
+ { name: "high-entropy", pattern: /\b[A-Za-z0-9+/_\-]{40,}\b/g },
20
+ ];
21
+ function isLowEntropy(s) {
22
+ // Hex-only strings are almost always commit SHAs or checksums, not secrets.
23
+ return /^[0-9a-f]+$/i.test(s);
24
+ }
25
+ export function redact(lines, opts) {
26
+ const rules = opts.paranoid ? [...RULES, ...PARANOID] : RULES;
27
+ const counts = new Map();
28
+ const out = lines.map((line) => {
29
+ let current = line;
30
+ for (const rule of rules) {
31
+ const re = new RegExp(rule.pattern.source, rule.pattern.flags);
32
+ current = current.replace(re, (...args) => {
33
+ const groups = args.slice(0, -2);
34
+ const whole = groups[0];
35
+ if (rule.name === "high-entropy" && isLowEntropy(whole))
36
+ return whole;
37
+ counts.set(rule.name, (counts.get(rule.name) ?? 0) + 1);
38
+ const token = `[REDACTED:${rule.name}]`;
39
+ if (rule.group === undefined)
40
+ return token;
41
+ return groups
42
+ .slice(1)
43
+ .map((g, i) => (i + 1 === rule.group ? token : (g ?? "")))
44
+ .join("");
45
+ });
46
+ }
47
+ return current;
48
+ });
49
+ const hits = [...counts.entries()]
50
+ .map(([rule, count]) => ({ rule, count }))
51
+ .sort((a, b) => a.rule.localeCompare(b.rule));
52
+ return { lines: out, hits };
53
+ }
54
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Manifest strings come from whoever built the bundle. parseManifest checks their
3
+ * TYPE, never their CONTENT. Printed raw, an embedded ANSI or control sequence can
4
+ * visually overwrite earlier lines of a report - including inspect's sha verdict, or
5
+ * import's "This session had uncommitted changes. They were NOT applied." - and both
6
+ * commands exist precisely so a person can trust what they are reading before acting
7
+ * on it. `import` is the more dangerous of the two: its report ends in a command the
8
+ * reader is invited to run.
9
+ *
10
+ * Apply this to every string that came out of a bundle. Do NOT apply it to values the
11
+ * tool computed itself - counts, the locally-computed sha, literal labels, the temp
12
+ * patch path we created, the validated local root - because those are trusted and
13
+ * passing them through here only risks mangling legitimate output.
14
+ *
15
+ * Call it as `arr.map((s) => safe(s))`, NEVER as `arr.map(safe)`: map passes the index
16
+ * as the second argument, so `max` becomes 0 for the first element and every string is
17
+ * truncated to "…(truncated)".
18
+ */
19
+ export function safe(s, max = 200) {
20
+ // eslint-disable-next-line no-control-regex
21
+ const stripped = s.replace(/[\x00-\x1f\x7f-\x9f]/g, "?");
22
+ return stripped.length > max ? stripped.slice(0, max) + "…(truncated)" : stripped;
23
+ }
24
+ //# sourceMappingURL=sanitize.js.map
@@ -0,0 +1,105 @@
1
+ import { createHash } from "node:crypto";
2
+ export function readLines(text) {
3
+ return text.split("\n").filter((l) => l.length > 0);
4
+ }
5
+ /**
6
+ * Records are read one line at a time and never re-serialised. A line that
7
+ * does not parse is still counted and still shipped — the tool does not get
8
+ * to decide that a transcript is malformed.
9
+ */
10
+ export function statTranscript(lines) {
11
+ const cwdCounts = {};
12
+ const versions = new Set();
13
+ for (const line of lines) {
14
+ let rec;
15
+ try {
16
+ rec = JSON.parse(line);
17
+ }
18
+ catch {
19
+ continue;
20
+ }
21
+ if (rec && typeof rec === "object") {
22
+ const r = rec;
23
+ if (typeof r["cwd"] === "string") {
24
+ const c = r["cwd"];
25
+ cwdCounts[c] = (cwdCounts[c] ?? 0) + 1;
26
+ }
27
+ if (typeof r["version"] === "string")
28
+ versions.add(r["version"]);
29
+ }
30
+ }
31
+ return { recordCount: lines.length, cwdCounts, versions: [...versions].sort() };
32
+ }
33
+ export function deriveProjectRoot(stats) {
34
+ const entries = Object.entries(stats.cwdCounts);
35
+ if (entries.length === 0) {
36
+ throw new Error("no record in this transcript carries a cwd field; cannot determine the project root");
37
+ }
38
+ entries.sort((a, b) => b[1] - a[1]);
39
+ return { root: entries[0][0], ambiguous: entries.length > 1 };
40
+ }
41
+ function escapeRegExp(s) {
42
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43
+ }
44
+ /**
45
+ * Swaps one prefix, everywhere, as plain text. Validates that from and to
46
+ * contain no quote or backslash, which would break JSON. Anchors the match
47
+ * to path boundaries to prevent sibling paths from being rewritten.
48
+ */
49
+ export function rewritePrefix(lines, from, to) {
50
+ if (from.includes('"') || from.includes("\\")) {
51
+ throw new Error(`rewritePrefix: from argument contains quote or backslash: ${from}`);
52
+ }
53
+ if (to.includes('"') || to.includes("\\")) {
54
+ throw new Error(`rewritePrefix: to argument contains quote or backslash: ${to}`);
55
+ }
56
+ const re = new RegExp(escapeRegExp(from) + "(?![A-Za-z0-9._-])", "g");
57
+ let replaced = 0;
58
+ const out = lines.map((line) => line.replace(re, () => {
59
+ replaced += 1;
60
+ return to;
61
+ }));
62
+ return { lines: out, replaced };
63
+ }
64
+ // Matches paths that are sender-specific and not portable. Excludes /usr, /etc,
65
+ // /bin, /opt: those are system paths that mean the same on both machines and
66
+ // would flood the report if listed.
67
+ const ABS_PATH = /(?:\/Users\/|\/home\/|\/root\/|\/tmp\/|\/private\/var\/|\/var\/folders\/)[A-Za-z0-9._+\-\/]+/g;
68
+ /** Distinct absolute home-rooted or temp paths that are NOT under `root`, sorted. */
69
+ export function unresolvedAbsolutePaths(lines, root) {
70
+ const found = new Set();
71
+ for (const line of lines) {
72
+ for (const m of line.matchAll(ABS_PATH)) {
73
+ if (!m[0].startsWith(root))
74
+ found.add(m[0]);
75
+ }
76
+ }
77
+ return [...found].sort();
78
+ }
79
+ export function sha256(text) {
80
+ return createHash("sha256").update(text, "utf8").digest("hex");
81
+ }
82
+ /**
83
+ * Replaces every occurrence of `from`, with NO path-boundary anchoring.
84
+ *
85
+ * rewritePrefix() anchors on a path boundary so that /Code/app cannot rewrite
86
+ * /Code/app2. That rule is wrong for a UUID: a session id is self-delimiting and
87
+ * cannot prefix a longer identifier, and the anchor actively breaks the common case
88
+ * of "<id>.jsonl", where the trailing "." reads as path continuation.
89
+ */
90
+ export function replaceAllText(lines, from, to) {
91
+ if (/["\\]/.test(from)) {
92
+ throw new Error(`replaceAllText: from contains a quote or backslash: ${from}`);
93
+ }
94
+ if (/["\\]/.test(to)) {
95
+ throw new Error(`replaceAllText: to contains a quote or backslash: ${to}`);
96
+ }
97
+ let replaced = 0;
98
+ const out = lines.map((line) => {
99
+ const parts = line.split(from);
100
+ replaced += parts.length - 1;
101
+ return parts.join(to);
102
+ });
103
+ return { lines: out, replaced };
104
+ }
105
+ //# sourceMappingURL=transcript.js.map
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "csession",
3
+ "version": "0.1.0",
4
+ "description": "Move a Claude Code session between machines, or hand one to a peer.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "session",
9
+ "transcript",
10
+ "cli",
11
+ "transfer"
12
+ ],
13
+ "homepage": "https://github.com/jvsteiner/csession#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/jvsteiner/csession/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/jvsteiner/csession.git"
20
+ },
21
+ "author": "Jamie Steiner <jvsteiner@gmail.com>",
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "bin": {
25
+ "csession": "./dist/cli.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "files": [
31
+ "dist/**/*.js",
32
+ "!dist/**/*.test.js",
33
+ "!dist/testutil.js",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "tsc && node --test 'dist/**/*.test.js'"
41
+ },
42
+ "devDependencies": {
43
+ "typescript": "^5.6.0",
44
+ "@types/node": "^22.0.0"
45
+ }
46
+ }