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 +21 -0
- package/README.md +25 -0
- package/dist/args.js +50 -0
- package/dist/bundle.js +73 -0
- package/dist/cli.js +63 -0
- package/dist/commands/export.js +113 -0
- package/dist/commands/import.js +147 -0
- package/dist/commands/inspect.js +35 -0
- package/dist/commands/list.js +17 -0
- package/dist/errors.js +27 -0
- package/dist/git.js +68 -0
- package/dist/manifest.js +91 -0
- package/dist/paths.js +42 -0
- package/dist/redact.js +54 -0
- package/dist/sanitize.js +24 -0
- package/dist/transcript.js +105 -0
- package/package.json +46 -0
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
|
package/dist/manifest.js
ADDED
|
@@ -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
|
package/dist/sanitize.js
ADDED
|
@@ -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
|
+
}
|