claude-git-sessions 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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # ccgs — Claude Code Git Sessions
2
+
3
+ Share [Claude Code](https://claude.com/claude-code) sessions across a team
4
+ through an **orphan branch in your existing git repo**. No server, no extra
5
+ infrastructure — your transcripts ride along with the code they belong to.
6
+
7
+ > Published on npm as **`claude-git-sessions`** (the bare name `ccgs` is blocked by npm's
8
+ > name-similarity filter). The command it installs is **`ccgs`**.
9
+
10
+ ```bash
11
+ npx claude-git-sessions pull # bring shared sessions onto this machine
12
+ npx claude-git-sessions push # publish your local sessions for this repo
13
+ npx claude-git-sessions delete <id|name>
14
+ ```
15
+
16
+ After `pull`, resume any teammate's session locally with:
17
+
18
+ ```bash
19
+ claude --resume <session-id>
20
+ ```
21
+
22
+ ## Install
23
+
24
+ Run on demand with `npx` (no install):
25
+
26
+ ```bash
27
+ npx claude-git-sessions pull
28
+ ```
29
+
30
+ …or install globally (this installs the `ccgs` command):
31
+
32
+ ```bash
33
+ npm i -g claude-git-sessions
34
+ ccgs --help
35
+ ```
36
+
37
+ Requires **Node 20+** and **git 2.5+**. Run it from inside your git repo.
38
+
39
+ ## How it works
40
+
41
+ Sessions are stored on an **orphan branch** named `@ccgs/<name>` (default
42
+ `@ccgs/default`) on your repo's remote (default `origin`). The branch shares no
43
+ history with `main` and never contains your source files — only session data:
44
+
45
+ ```
46
+ sessions/<session-id>.jsonl # the transcript, verbatim
47
+ sessions/<session-id>.meta.json # sidecar metadata (name, author, cwd, …)
48
+ ```
49
+
50
+ Files are keyed by the globally-unique Claude Code session UUID, so transcripts
51
+ from different authors never collide.
52
+
53
+ Every `ccgs` operation is done with low-level git plumbing
54
+ (`hash-object`/`update-index`/`write-tree`/`commit-tree`/`push`) against a
55
+ private temporary index. **Your working tree, index, and current branch are
56
+ never touched**, and everything works even with a dirty tree. Concurrent pushes
57
+ are handled by re-fetching and replaying on a non-fast-forward rejection.
58
+
59
+ ### Branch namespacing
60
+
61
+ The `@ccgs/<name>` namespace lets you keep separate session sets. Use the
62
+ default for the team's shared sessions, and a private name for your own
63
+ work-in-progress that you don't want to share yet:
64
+
65
+ ```bash
66
+ ccgs push -b my-wip # publish to @ccgs/my-wip
67
+ ccgs pull -b my-wip # only your private set
68
+ ccgs push # the shared @ccgs/default set
69
+ ```
70
+
71
+ Branch names are validated with `git check-ref-format` before use.
72
+
73
+ ## Commands
74
+
75
+ ### `ccgs pull`
76
+
77
+ Fetches `@ccgs/<name>` and writes each session where Claude Code expects it:
78
+ `~/.claude/projects/<local-slug>/<id>.jsonl`. The structural `cwd` field in each
79
+ transcript line is rewritten from the author's path to your local equivalent so
80
+ `claude --resume` works. (Absolute paths inside tool *output* are left as-is —
81
+ cosmetic only.)
82
+
83
+ If a local session is **newer** than the shared copy it is skipped with a
84
+ warning; pass `--force` to overwrite anyway. Prints what was pulled / skipped.
85
+
86
+ If the branch doesn't exist, it prints `nothing to pull` and exits 0.
87
+
88
+ ### `ccgs push [targets...]`
89
+
90
+ Finds local sessions whose working directory is this repo (or a subdirectory),
91
+ copies each transcript verbatim onto the orphan branch, and (re)generates its
92
+ `meta.json`. With no arguments it pushes all of them; pass session ids/names to
93
+ push only those. Creates the orphan branch on first push. Reports added vs
94
+ updated.
95
+
96
+ ### `ccgs delete <id|name> [--yes] [--local]`
97
+
98
+ Resolves the target by full UUID, unambiguous UUID prefix (git-style, ≥4
99
+ chars), or unique display name — listing candidates and aborting if ambiguous.
100
+ Shows what will be deleted and asks for `y/N` confirmation (`--yes`/`-y` to skip
101
+ for scripting). Removes both files from the branch and pushes. By default only
102
+ the shared branch is touched; add `--local` to also remove the local copy.
103
+
104
+ ### Global options
105
+
106
+ | Option | Default | Meaning |
107
+ | --- | --- | --- |
108
+ | `-b, --branch <name>` | `default` | session set → branch `@ccgs/<name>` |
109
+ | `--remote <remote>` | `origin` | git remote to use |
110
+ | `-v, --version` | | print version |
111
+ | `-h, --help` | | help |
112
+
113
+ ## Step 0 findings — how Claude Code stores sessions
114
+
115
+ These assumptions were verified empirically against a real `~/.claude/` before
116
+ writing the tool. They live in one place each so they're easy to fix if the
117
+ convention changes.
118
+
119
+ ### Project slug encoding (`src/slug.ts`)
120
+
121
+ Claude Code stores each project's sessions under
122
+ `~/.claude/projects/<slug>/`, where the slug is derived from the absolute
123
+ working directory. Comparing real directories to the `cwd` recorded inside their
124
+ session files showed the rule is: **replace every character that is not
125
+ `[A-Za-z0-9]` with `-`**.
126
+
127
+ | Working directory | Slug |
128
+ | --- | --- |
129
+ | `/home/user` | `-home-user` |
130
+ | `/home/user/code/api` | `-home-user-code-api` |
131
+ | `/home/user/code/my.app.dev` | `-home-user-code-my-app-dev` |
132
+ | `/home/user/code/web-client` | `-home-user-code-web-client` |
133
+
134
+ Note that both `/` and `.` map to `-`, so the encoding is **lossy and
135
+ irreversible**. ccgs therefore only ever goes path → slug, and stores the real
136
+ `originalCwd` separately in `meta.json`.
137
+
138
+ The config root honors `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Local
139
+ sessions live at `<config>/projects/<slug>/<session-id>.jsonl`.
140
+
141
+ ### Session `.jsonl` schema (`src/session.ts`)
142
+
143
+ Each session is **JSONL** — one JSON object per line, with many object `type`s
144
+ (`user`, `assistant`, `attachment`, `ai-title`, `mode`, `tool_result`, …).
145
+ Verified fields:
146
+
147
+ - **Session UUID** — `sessionId`, present on most lines, equal to the filename.
148
+ - **Title / name** — a `{ "type": "ai-title", "aiTitle": "…" }` line. Older
149
+ versions used `{ "type": "summary", "summary": "…" }`; some objects also carry
150
+ a `title`. The display name resolves: title → summary → first user message
151
+ (truncated) → session id.
152
+ - **Working directory** — the `cwd` field, present on `user`/`assistant`/
153
+ `attachment` lines. This is the **only** field `pull` rewrites.
154
+ - **Timestamps** — per-line `timestamp` (ISO-8601). `updatedAt` is the max.
155
+ - **First user message** — `message.content` on the first `type:"user"` line;
156
+ `content` is a string or an array of content blocks (each may have `.text`).
157
+
158
+ ## Known considerations / future work
159
+
160
+ - **Secret redaction** — transcripts are pushed verbatim. If your sessions may
161
+ contain secrets, scrub them before pushing. A `--redact` flag is a likely
162
+ future addition. **Not implemented today.**
163
+ - **Git LFS** — very large transcripts are committed as ordinary blobs. LFS
164
+ handling may be added later.
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ npm install
170
+ npm run build # tsc -> dist/, chmod +x the bin
171
+ npm test # node:test unit tests (no live Claude install needed)
172
+ npm run typecheck
173
+ ```
174
+
175
+ Tests cover the pure pieces: slug encoding, display-name resolution,
176
+ id/prefix/name matching, and the `cwd` remap transform.
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,33 @@
1
+ import { SESSIONS_DIR } from "./constants.js";
2
+ import { lsSessions, showFile } from "./git.js";
3
+ import { parseMeta } from "./meta.js";
4
+ /** Session ids present on the branch ref (derived from `.jsonl` blobs). */
5
+ export async function listBranchSessionIds(repo, ref) {
6
+ const paths = await lsSessions(repo, ref);
7
+ const ids = new Set();
8
+ for (const p of paths) {
9
+ const m = p.match(new RegExp(`^${SESSIONS_DIR}/([^/]+)\\.jsonl$`));
10
+ if (m)
11
+ ids.add(m[1]);
12
+ }
13
+ return [...ids];
14
+ }
15
+ /** Read every session id + its parsed sidecar meta from the branch ref. */
16
+ export async function listBranchSessions(repo, ref) {
17
+ const ids = await listBranchSessionIds(repo, ref);
18
+ const out = [];
19
+ for (const id of ids) {
20
+ const raw = await showFile(repo, ref, `${SESSIONS_DIR}/${id}.meta.json`);
21
+ let meta = null;
22
+ if (raw) {
23
+ try {
24
+ meta = parseMeta(raw);
25
+ }
26
+ catch {
27
+ meta = null;
28
+ }
29
+ }
30
+ out.push({ id, meta });
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import { SESSIONS_DIR } from "../constants.js";
5
+ import { applyToOrphanBranch, assertRemote, fetchBranch, localTrackingRef, validateBranchName, } from "../git.js";
6
+ import { listBranchSessions } from "../branch-sessions.js";
7
+ import { resolveTarget, shortId } from "../match.js";
8
+ import { sessionFilePath } from "../paths.js";
9
+ function confirm(question) {
10
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ return new Promise((resolve) => {
12
+ rl.question(question, (answer) => {
13
+ rl.close();
14
+ resolve(/^y(es)?$/i.test(answer.trim()));
15
+ });
16
+ });
17
+ }
18
+ export async function deleteSession(opts) {
19
+ await validateBranchName(opts.repoRoot, opts.branch);
20
+ await assertRemote(opts.repoRoot, opts.remote);
21
+ const exists = await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
22
+ if (!exists) {
23
+ console.log("nothing to delete (branch does not exist on remote)");
24
+ return 0;
25
+ }
26
+ const ref = localTrackingRef(opts.branch);
27
+ const sessions = await listBranchSessions(opts.repoRoot, ref);
28
+ if (sessions.length === 0) {
29
+ console.log("nothing to delete (no sessions on branch)");
30
+ return 0;
31
+ }
32
+ const candidates = sessions.map((s) => ({
33
+ id: s.id,
34
+ name: s.meta?.name ?? s.id,
35
+ meta: s.meta,
36
+ }));
37
+ const res = resolveTarget(opts.target, candidates);
38
+ if (res.kind === "none") {
39
+ console.error(`"${opts.target}" matched no session on @ccgs/${opts.branch}`);
40
+ return 1;
41
+ }
42
+ if (res.kind === "ambiguous") {
43
+ console.error(`"${opts.target}" is ambiguous; candidates:`);
44
+ for (const c of res.items)
45
+ console.error(` ${shortId(c.id)} ${c.name}`);
46
+ console.error("Refine your query (use a longer id prefix or exact name).");
47
+ return 1;
48
+ }
49
+ const item = res.item;
50
+ const meta = item.meta;
51
+ console.log("Will delete:");
52
+ console.log(` id: ${item.id}`);
53
+ console.log(` name: ${meta?.name ?? "(unknown)"}`);
54
+ console.log(` author: ${meta?.author ?? "(unknown)"}`);
55
+ console.log(` updatedAt: ${meta?.updatedAt ?? "(unknown)"}`);
56
+ if (!opts.yes) {
57
+ const ok = await confirm(`Delete this session from @ccgs/${opts.branch}? [y/N] `);
58
+ if (!ok) {
59
+ console.log("Aborted.");
60
+ return 0;
61
+ }
62
+ }
63
+ const result = await applyToOrphanBranch(opts.repoRoot, opts.remote, opts.branch, `ccgs: delete session ${shortId(item.id)}`, async (b) => {
64
+ await b.removeBlob(`${SESSIONS_DIR}/${item.id}.jsonl`);
65
+ await b.removeBlob(`${SESSIONS_DIR}/${item.id}.meta.json`);
66
+ });
67
+ if (result.changed) {
68
+ console.log(`Deleted ${item.name} (${shortId(item.id)}) from ${opts.remote} @ccgs/${opts.branch}.`);
69
+ }
70
+ else {
71
+ console.log("Nothing changed on the branch (already absent).");
72
+ }
73
+ if (opts.local) {
74
+ const rel = meta?.cwdRelativeToRepoRoot ?? "";
75
+ const localCwd = rel ? path.join(opts.repoRoot, rel) : opts.repoRoot;
76
+ const localFile = sessionFilePath(localCwd, item.id);
77
+ if (fs.existsSync(localFile)) {
78
+ fs.rmSync(localFile);
79
+ console.log(`Removed local copy: ${localFile}`);
80
+ }
81
+ else {
82
+ console.log("No local copy found to remove.");
83
+ }
84
+ }
85
+ return 0;
86
+ }
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { SESSIONS_DIR } from "../constants.js";
4
+ import { assertRemote, fetchBranch, localTrackingRef, showFile, validateBranchName, } from "../git.js";
5
+ import { listBranchSessions } from "../branch-sessions.js";
6
+ import { parseSession, remapCwd } from "../session.js";
7
+ import { projectDirForCwd, sessionFilePath } from "../paths.js";
8
+ import { shortId } from "../match.js";
9
+ export async function pull(opts) {
10
+ await validateBranchName(opts.repoRoot, opts.branch);
11
+ await assertRemote(opts.repoRoot, opts.remote);
12
+ const exists = await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
13
+ if (!exists) {
14
+ console.log("nothing to pull (branch does not exist on remote)");
15
+ return 0;
16
+ }
17
+ const ref = localTrackingRef(opts.branch);
18
+ const sessions = await listBranchSessions(opts.repoRoot, ref);
19
+ if (sessions.length === 0) {
20
+ console.log("nothing to pull (no sessions on branch)");
21
+ return 0;
22
+ }
23
+ const pulled = [];
24
+ const skipped = [];
25
+ for (const { id, meta } of sessions) {
26
+ const jsonl = await showFile(opts.repoRoot, ref, `${SESSIONS_DIR}/${id}.jsonl`);
27
+ if (jsonl === null) {
28
+ skipped.push(`${shortId(id)}: missing transcript blob`);
29
+ continue;
30
+ }
31
+ // Reconstruct the local cwd for this session: repo root + recorded subdir.
32
+ const rel = meta?.cwdRelativeToRepoRoot ?? "";
33
+ const localCwd = rel ? path.join(opts.repoRoot, rel) : opts.repoRoot;
34
+ const name = meta?.name ?? id;
35
+ // Conflict policy: newer local copy is kept unless --force.
36
+ const localFile = sessionFilePath(localCwd, id);
37
+ if (!opts.force && fs.existsSync(localFile)) {
38
+ const branchUpdated = meta?.updatedAt ?? "";
39
+ const localInfo = parseSession(fs.readFileSync(localFile, "utf8"), id);
40
+ const localUpdated = localInfo.updatedAt ?? "";
41
+ if (localUpdated && branchUpdated && localUpdated > branchUpdated) {
42
+ skipped.push(`${name} (${shortId(id)}): local copy is newer (use --force)`);
43
+ continue;
44
+ }
45
+ }
46
+ // Remap the structural cwd from the author's path to ours.
47
+ const remapped = meta?.originalCwd
48
+ ? remapCwd(jsonl, meta.originalCwd, localCwd)
49
+ : jsonl;
50
+ const dir = projectDirForCwd(localCwd);
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ fs.writeFileSync(localFile, remapped);
53
+ pulled.push(`${name} (${shortId(id)})`);
54
+ }
55
+ if (pulled.length) {
56
+ console.log(`Pulled ${pulled.length} session(s):`);
57
+ for (const p of pulled)
58
+ console.log(` + ${p}`);
59
+ }
60
+ else {
61
+ console.log("Pulled 0 sessions.");
62
+ }
63
+ if (skipped.length) {
64
+ console.log(`Skipped ${skipped.length}:`);
65
+ for (const s of skipped)
66
+ console.log(` - ${s}`);
67
+ }
68
+ return 0;
69
+ }
@@ -0,0 +1,91 @@
1
+ import { SESSIONS_DIR } from "../constants.js";
2
+ import { applyToOrphanBranch, assertRemote, fetchBranch, localTrackingRef, showFile, validateBranchName, } from "../git.js";
3
+ import { listBranchSessionIds } from "../branch-sessions.js";
4
+ import { discoverLocalSessions } from "../discover.js";
5
+ import { buildMeta, gitAuthor, serializeMeta } from "../meta.js";
6
+ import { resolveTarget, shortId } from "../match.js";
7
+ export async function push(opts) {
8
+ await validateBranchName(opts.repoRoot, opts.branch);
9
+ await assertRemote(opts.repoRoot, opts.remote);
10
+ let sessions = discoverLocalSessions(opts.repoRoot);
11
+ if (sessions.length === 0) {
12
+ console.log("nothing to push (no local sessions found for this repo)");
13
+ return 0;
14
+ }
15
+ // Apply positional id/name filters if any were given.
16
+ if (opts.filters.length > 0) {
17
+ const candidates = sessions.map((s) => ({
18
+ id: s.info.id,
19
+ name: s.info.displayName,
20
+ session: s,
21
+ }));
22
+ const selected = new Map();
23
+ for (const f of opts.filters) {
24
+ const res = resolveTarget(f, candidates);
25
+ if (res.kind === "match") {
26
+ selected.set(res.item.id, res.item.session);
27
+ }
28
+ else if (res.kind === "ambiguous") {
29
+ console.error(`"${f}" is ambiguous; matches:`);
30
+ for (const c of res.items)
31
+ console.error(` ${shortId(c.id)} ${c.name}`);
32
+ return 1;
33
+ }
34
+ else {
35
+ console.error(`"${f}" matched no local session for this repo`);
36
+ return 1;
37
+ }
38
+ }
39
+ sessions = [...selected.values()];
40
+ }
41
+ // Pre-compute what already exists on the branch so we can label each session
42
+ // as added / updated / unchanged by comparing the transcript blob.
43
+ await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
44
+ const ref = localTrackingRef(opts.branch);
45
+ const existing = new Set(await listBranchSessionIds(opts.repoRoot, ref).catch(() => []));
46
+ const author = await gitAuthor(opts.repoRoot);
47
+ const added = [];
48
+ const updated = [];
49
+ let unchanged = 0;
50
+ for (const s of sessions) {
51
+ const label = `${s.info.displayName} (${shortId(s.info.id)})`;
52
+ if (!existing.has(s.info.id)) {
53
+ added.push(label);
54
+ }
55
+ else {
56
+ const prev = await showFile(opts.repoRoot, ref, `${SESSIONS_DIR}/${s.info.id}.jsonl`);
57
+ if (prev === s.content)
58
+ unchanged++;
59
+ else
60
+ updated.push(label);
61
+ }
62
+ }
63
+ const result = await applyToOrphanBranch(opts.repoRoot, opts.remote, opts.branch, buildCommitMessage(added.length + updated.length), async (b) => {
64
+ for (const s of sessions) {
65
+ const meta = buildMeta(s.info, opts.repoRoot, author);
66
+ await b.addBlob(`${SESSIONS_DIR}/${s.info.id}.jsonl`, s.content);
67
+ await b.addBlob(`${SESSIONS_DIR}/${s.info.id}.meta.json`, serializeMeta(meta));
68
+ }
69
+ });
70
+ if (!result.changed) {
71
+ console.log(`Already up to date — ${unchanged} session(s) unchanged.`);
72
+ return 0;
73
+ }
74
+ if (added.length) {
75
+ console.log(`Added ${added.length}:`);
76
+ for (const a of added)
77
+ console.log(` + ${a}`);
78
+ }
79
+ if (updated.length) {
80
+ console.log(`Updated ${updated.length}:`);
81
+ for (const u of updated)
82
+ console.log(` ~ ${u}`);
83
+ }
84
+ if (unchanged)
85
+ console.log(`(${unchanged} unchanged)`);
86
+ console.log(`Pushed to ${opts.remote} @ccgs/${opts.branch}.`);
87
+ return 0;
88
+ }
89
+ function buildCommitMessage(n) {
90
+ return `ccgs: push ${n} session(s)`;
91
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Single source of truth for the package + command identity, so renaming is a
3
+ * one-line change.
4
+ */
5
+ /**
6
+ * npm package name. The bare name `ccgs` is blocked by npm's name-similarity
7
+ * filter, so the package ships as `claude-git-sessions`; the installed command is still
8
+ * `ccgs` (see COMMAND_NAME). Both are one-line changes if either is renamed.
9
+ */
10
+ export const PACKAGE_NAME = "claude-git-sessions";
11
+ export const COMMAND_NAME = "ccgs";
12
+ /** Orphan branches are namespaced as `@ccgs/<name>`. */
13
+ export const BRANCH_PREFIX = "@ccgs/";
14
+ export const DEFAULT_BRANCH_NAME = "default";
15
+ export const DEFAULT_REMOTE = "origin";
16
+ /** Path prefix used for session blobs on the orphan branch. */
17
+ export const SESSIONS_DIR = "sessions";
18
+ /** Max length of an auto-derived display name. */
19
+ export const DISPLAY_NAME_MAX = 80;
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { projectsDir } from "./paths.js";
4
+ import { parseSession } from "./session.js";
5
+ /** Is `child` the same as, or nested under, `parent`? */
6
+ function isWithin(parent, child) {
7
+ if (child === parent)
8
+ return true;
9
+ const rel = path.relative(parent, child);
10
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
11
+ }
12
+ /**
13
+ * Find local Claude Code sessions belonging to `repoRoot`.
14
+ *
15
+ * Sessions launched at the repo root live under the repo-root slug, but a
16
+ * session launched in a subdirectory lives under a *different* slug. So rather
17
+ * than rely on the slug alone, we scan every project directory, read each
18
+ * transcript, and keep the ones whose recorded `cwd` is the repo root or nested
19
+ * within it. The recorded cwd also gives us `cwdRelativeToRepoRoot` for free.
20
+ */
21
+ export function discoverLocalSessions(repoRoot) {
22
+ const root = projectsDir();
23
+ let dirs;
24
+ try {
25
+ dirs = fs.readdirSync(root, { withFileTypes: true });
26
+ }
27
+ catch {
28
+ return []; // no ~/.claude/projects yet
29
+ }
30
+ const found = [];
31
+ for (const dir of dirs) {
32
+ if (!dir.isDirectory())
33
+ continue;
34
+ const dirPath = path.join(root, dir.name);
35
+ let files;
36
+ try {
37
+ files = fs.readdirSync(dirPath);
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ for (const f of files) {
43
+ if (!f.endsWith(".jsonl"))
44
+ continue;
45
+ const file = path.join(dirPath, f);
46
+ let content;
47
+ try {
48
+ content = fs.readFileSync(file, "utf8");
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ const fallbackId = f.replace(/\.jsonl$/, "");
54
+ const info = parseSession(content, fallbackId);
55
+ if (info.cwd && isWithin(repoRoot, info.cwd)) {
56
+ found.push({ info, file, content });
57
+ }
58
+ }
59
+ }
60
+ return found;
61
+ }
package/dist/git.js ADDED
@@ -0,0 +1,194 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { BRANCH_PREFIX, SESSIONS_DIR } from "./constants.js";
6
+ export class GitError extends Error {
7
+ code;
8
+ stderr;
9
+ constructor(message, code, stderr) {
10
+ super(message);
11
+ this.code = code;
12
+ this.stderr = stderr;
13
+ this.name = "GitError";
14
+ }
15
+ }
16
+ /**
17
+ * Run git with an explicit argv (never a shell string) so no user input is ever
18
+ * interpolated into a shell. stdout is captured as a Buffer then decoded utf-8.
19
+ */
20
+ export function git(args, opts = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn("git", args, {
23
+ cwd: opts.cwd,
24
+ env: opts.env ?? process.env,
25
+ stdio: ["pipe", "pipe", "pipe"],
26
+ });
27
+ const out = [];
28
+ const err = [];
29
+ child.stdout.on("data", (d) => out.push(d));
30
+ child.stderr.on("data", (d) => err.push(d));
31
+ child.on("error", (e) => reject(e));
32
+ child.on("close", (code) => {
33
+ const result = {
34
+ code: code ?? 0,
35
+ stdout: Buffer.concat(out).toString("utf8"),
36
+ stderr: Buffer.concat(err).toString("utf8"),
37
+ };
38
+ if (result.code !== 0 && !opts.allowFail) {
39
+ reject(new GitError(`git ${args.join(" ")} failed (exit ${result.code}): ${result.stderr.trim()}`, result.code, result.stderr));
40
+ }
41
+ else {
42
+ resolve(result);
43
+ }
44
+ });
45
+ if (opts.input !== undefined)
46
+ child.stdin.end(opts.input);
47
+ else
48
+ child.stdin.end();
49
+ });
50
+ }
51
+ /** Convenience: return trimmed stdout, throwing on failure unless allowFail. */
52
+ export async function gitOut(args, opts = {}) {
53
+ const r = await git(args, opts);
54
+ return r.stdout;
55
+ }
56
+ /** Absolute repo root, or throw a clear error if not inside a git repo. */
57
+ export async function repoRoot() {
58
+ const r = await git(["rev-parse", "--show-toplevel"], { allowFail: true });
59
+ if (r.code !== 0) {
60
+ throw new Error("not inside a git repository (run ccgs from within your repo)");
61
+ }
62
+ return r.stdout.trim();
63
+ }
64
+ /** Full `@ccgs/<name>` branch name. */
65
+ export function branchName(name) {
66
+ return `${BRANCH_PREFIX}${name}`;
67
+ }
68
+ /** Local tracking ref we fetch the orphan branch into (kept out of refs/heads). */
69
+ export function localTrackingRef(name) {
70
+ return `refs/ccgs/${name}`;
71
+ }
72
+ /** Fully-qualified remote head ref we push to / fetch from. */
73
+ export function remoteHeadRef(name) {
74
+ return `refs/heads/${branchName(name)}`;
75
+ }
76
+ /** Validate the branch name with `git check-ref-format`; throw if invalid. */
77
+ export async function validateBranchName(repo, name) {
78
+ const ref = remoteHeadRef(name);
79
+ const r = await git(["check-ref-format", ref], { cwd: repo, allowFail: true });
80
+ if (r.code !== 0) {
81
+ throw new Error(`invalid branch name "${name}": "${ref}" is not a valid git ref`);
82
+ }
83
+ }
84
+ /** Error clearly if the named remote does not exist. */
85
+ export async function assertRemote(repo, remote) {
86
+ const r = await git(["remote"], { cwd: repo });
87
+ const remotes = r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
88
+ if (!remotes.includes(remote)) {
89
+ throw new Error(`remote "${remote}" not found (configured remotes: ${remotes.join(", ") || "none"})`);
90
+ }
91
+ }
92
+ /**
93
+ * Fetch the orphan branch from the remote into our local tracking ref.
94
+ * Returns true if it exists remotely, false if the remote ref is absent.
95
+ */
96
+ export async function fetchBranch(repo, remote, name) {
97
+ const refspec = `+${remoteHeadRef(name)}:${localTrackingRef(name)}`;
98
+ const r = await git(["fetch", "--no-tags", remote, refspec], {
99
+ cwd: repo,
100
+ allowFail: true,
101
+ });
102
+ if (r.code === 0)
103
+ return true;
104
+ // git reports a missing branch as "couldn't find remote ref ...".
105
+ if (/couldn't find remote ref|couldn't find remote/i.test(r.stderr))
106
+ return false;
107
+ throw new GitError(`failed to fetch ${branchName(name)} from ${remote}: ${r.stderr.trim()}`, r.code, r.stderr);
108
+ }
109
+ /** SHA of the local tracking ref, or null if it does not exist. */
110
+ export async function trackingTip(repo, name) {
111
+ const r = await git(["rev-parse", "--verify", "--quiet", localTrackingRef(name)], {
112
+ cwd: repo,
113
+ allowFail: true,
114
+ });
115
+ const sha = r.stdout.trim();
116
+ return sha || null;
117
+ }
118
+ /** List `sessions/*` paths present on a tree-ish ref. */
119
+ export async function lsSessions(repo, ref) {
120
+ const r = await git(["ls-tree", "-r", "--name-only", ref], { cwd: repo, allowFail: true });
121
+ if (r.code !== 0)
122
+ return [];
123
+ return r.stdout
124
+ .split("\n")
125
+ .map((s) => s.trim())
126
+ .filter((p) => p.startsWith(`${SESSIONS_DIR}/`));
127
+ }
128
+ /** Read a blob at `ref:path` as utf-8 text, or null if it does not exist. */
129
+ export async function showFile(repo, ref, filePath) {
130
+ const r = await git(["show", `${ref}:${filePath}`], { cwd: repo, allowFail: true });
131
+ if (r.code !== 0)
132
+ return null;
133
+ return r.stdout;
134
+ }
135
+ const NONFF = /non-fast-forward|fetch first|\[rejected\]|cannot lock ref|failed to update ref|stale info/i;
136
+ /**
137
+ * Build a new commit on the orphan branch and push it, WITHOUT touching the
138
+ * user's working tree, index or current branch, and tolerating a dirty tree.
139
+ *
140
+ * Mechanics (pure plumbing):
141
+ * - work against a private temp index via GIT_INDEX_FILE
142
+ * - seed it from the current branch tip (or empty, for a true orphan root)
143
+ * - let `apply` hash-object/update-index the session blobs
144
+ * - write-tree -> commit-tree (-p parent, omitted => orphan root)
145
+ * - push <commit>:refs/heads/@ccgs/<name>
146
+ * On a non-fast-forward rejection it re-fetches the tip and replays, a few times.
147
+ */
148
+ export async function applyToOrphanBranch(repo, remote, name, message, apply, maxRetries = 5) {
149
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
150
+ const exists = await fetchBranch(repo, remote, name);
151
+ const parent = exists ? await trackingTip(repo, name) : null;
152
+ const indexFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ccgs-idx-")), "index");
153
+ const env = { ...process.env, GIT_INDEX_FILE: indexFile };
154
+ try {
155
+ // Seed the temp index from the parent tree (orphan root starts empty).
156
+ if (parent) {
157
+ await git(["read-tree", parent], { cwd: repo, env });
158
+ }
159
+ const builder = {
160
+ async addBlob(gitPath, content) {
161
+ const sha = (await gitOut(["hash-object", "-w", "--stdin"], { cwd: repo, input: content })).trim();
162
+ await git(["update-index", "--add", "--cacheinfo", `100644,${sha},${gitPath}`], { cwd: repo, env });
163
+ },
164
+ async removeBlob(gitPath) {
165
+ await git(["update-index", "--force-remove", gitPath], { cwd: repo, env, allowFail: true });
166
+ },
167
+ };
168
+ await apply(builder);
169
+ const tree = (await gitOut(["write-tree"], { cwd: repo, env })).trim();
170
+ // No-op short-circuit: identical to parent tree -> nothing to push.
171
+ if (parent) {
172
+ const parentTree = (await gitOut(["rev-parse", `${parent}^{tree}`], { cwd: repo })).trim();
173
+ if (tree === parentTree)
174
+ return { changed: false };
175
+ }
176
+ const commitArgs = ["commit-tree", tree];
177
+ if (parent)
178
+ commitArgs.push("-p", parent);
179
+ const commit = (await gitOut(commitArgs, { cwd: repo, input: message })).trim();
180
+ const push = await git(["push", remote, `${commit}:${remoteHeadRef(name)}`], { cwd: repo, allowFail: true });
181
+ if (push.code === 0)
182
+ return { changed: true, commit };
183
+ if (NONFF.test(push.stderr) && attempt < maxRetries - 1) {
184
+ continue; // re-fetch tip and replay on top
185
+ }
186
+ throw new GitError(`failed to push ${branchName(name)} to ${remote}: ${push.stderr.trim()}`, push.code, push.stderr);
187
+ }
188
+ finally {
189
+ // Clean up the temp index dir regardless of outcome.
190
+ fs.rmSync(path.dirname(indexFile), { recursive: true, force: true });
191
+ }
192
+ }
193
+ throw new Error(`could not push ${branchName(name)} after ${maxRetries} attempts (concurrent updates?)`);
194
+ }
package/dist/index.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { COMMAND_NAME, DEFAULT_BRANCH_NAME, DEFAULT_REMOTE, PACKAGE_NAME, } from "./constants.js";
4
+ import { repoRoot } from "./git.js";
5
+ import { pull } from "./commands/pull.js";
6
+ import { push } from "./commands/push.js";
7
+ import { deleteSession } from "./commands/delete.js";
8
+ // Kept in sync with package.json at publish time; hardcoded to avoid a JSON
9
+ // import assertion just for --version.
10
+ const VERSION = "0.1.0";
11
+ function globals(cmd) {
12
+ const opts = cmd.optsWithGlobals();
13
+ return {
14
+ branch: opts.branch ?? DEFAULT_BRANCH_NAME,
15
+ remote: opts.remote ?? DEFAULT_REMOTE,
16
+ };
17
+ }
18
+ async function run(fn) {
19
+ try {
20
+ process.exitCode = await fn();
21
+ }
22
+ catch (err) {
23
+ console.error(`${COMMAND_NAME}: ${err instanceof Error ? err.message : String(err)}`);
24
+ process.exitCode = 1;
25
+ }
26
+ }
27
+ const program = new Command();
28
+ program
29
+ .name(COMMAND_NAME)
30
+ .description(`${PACKAGE_NAME} — share Claude Code sessions through an orphan git branch`)
31
+ .version(VERSION, "-v, --version")
32
+ .option("-b, --branch <name>", "session set name (branch @ccgs/<name>)", DEFAULT_BRANCH_NAME)
33
+ .option("--remote <remote>", "git remote to use", DEFAULT_REMOTE);
34
+ program
35
+ .command("pull")
36
+ .description("fetch shared sessions and place them where Claude Code can resume them")
37
+ .option("--force", "overwrite local copies even if they are newer", false)
38
+ .action(async (localOpts, cmd) => {
39
+ const g = globals(cmd);
40
+ await run(async () => {
41
+ const root = await repoRoot();
42
+ return pull({ repoRoot: root, remote: g.remote, branch: g.branch, force: localOpts.force });
43
+ });
44
+ });
45
+ program
46
+ .command("push")
47
+ .description("publish this repo's local sessions to the orphan branch")
48
+ .argument("[targets...]", "optional session ids/names to push (default: all)")
49
+ .action(async (targets, _localOpts, cmd) => {
50
+ const g = globals(cmd);
51
+ await run(async () => {
52
+ const root = await repoRoot();
53
+ return push({ repoRoot: root, remote: g.remote, branch: g.branch, filters: targets });
54
+ });
55
+ });
56
+ program
57
+ .command("delete")
58
+ .description("remove a session from the shared orphan branch")
59
+ .argument("<target>", "session id, unambiguous id prefix, or unique name")
60
+ .option("-y, --yes", "skip the interactive confirmation", false)
61
+ .option("--local", "also remove the local copy under ~/.claude/projects", false)
62
+ .action(async (target, localOpts, cmd) => {
63
+ const g = globals(cmd);
64
+ await run(async () => {
65
+ const root = await repoRoot();
66
+ return deleteSession({
67
+ repoRoot: root,
68
+ remote: g.remote,
69
+ branch: g.branch,
70
+ target,
71
+ yes: localOpts.yes,
72
+ local: localOpts.local,
73
+ });
74
+ });
75
+ });
76
+ program.parseAsync(process.argv);
package/dist/match.js ADDED
@@ -0,0 +1,32 @@
1
+ export function resolveTarget(query, candidates) {
2
+ const q = query.trim();
3
+ // 1. Exact id wins outright.
4
+ const exactId = candidates.filter((c) => c.id === q);
5
+ if (exactId.length === 1)
6
+ return { kind: "match", item: exactId[0] };
7
+ // 2. Exact (case-insensitive) display-name match.
8
+ const exactName = candidates.filter((c) => c.name.toLowerCase() === q.toLowerCase());
9
+ if (exactName.length === 1)
10
+ return { kind: "match", item: exactName[0] };
11
+ if (exactName.length > 1)
12
+ return { kind: "ambiguous", items: exactName };
13
+ // 3. Git-style unambiguous id prefix (only for hex-ish queries).
14
+ if (/^[0-9a-fA-F-]+$/.test(q) && q.length >= 4) {
15
+ const byPrefix = candidates.filter((c) => c.id.startsWith(q.toLowerCase()));
16
+ if (byPrefix.length === 1)
17
+ return { kind: "match", item: byPrefix[0] };
18
+ if (byPrefix.length > 1)
19
+ return { kind: "ambiguous", items: byPrefix };
20
+ }
21
+ // 4. Fall back to a case-insensitive name substring.
22
+ const bySubstr = candidates.filter((c) => c.name.toLowerCase().includes(q.toLowerCase()));
23
+ if (bySubstr.length === 1)
24
+ return { kind: "match", item: bySubstr[0] };
25
+ if (bySubstr.length > 1)
26
+ return { kind: "ambiguous", items: bySubstr };
27
+ return { kind: "none" };
28
+ }
29
+ /** Short 8-char id, git-style, for display. */
30
+ export function shortId(id) {
31
+ return id.slice(0, 8);
32
+ }
package/dist/meta.js ADDED
@@ -0,0 +1,44 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { gitOut } from "./git.js";
4
+ /** "Name <email>" from git config, best-effort. */
5
+ export async function gitAuthor(repoRoot) {
6
+ const name = (await gitOut(["config", "user.name"], { cwd: repoRoot, allowFail: true })).trim();
7
+ const email = (await gitOut(["config", "user.email"], { cwd: repoRoot, allowFail: true })).trim();
8
+ if (name && email)
9
+ return `${name} <${email}>`;
10
+ if (name)
11
+ return name;
12
+ if (email)
13
+ return email;
14
+ return "unknown";
15
+ }
16
+ /**
17
+ * Build the sidecar metadata for a local session being pushed.
18
+ * `cwdRelativeToRepoRoot` is derived from the session's recorded cwd relative
19
+ * to the local repo root (POSIX-style, "" when at the root).
20
+ */
21
+ export function buildMeta(info, repoRoot, author) {
22
+ const originalCwd = info.cwd ?? repoRoot;
23
+ let rel = path.relative(repoRoot, originalCwd);
24
+ if (rel === "" || rel === ".")
25
+ rel = "";
26
+ // Normalize to forward slashes so it round-trips across platforms.
27
+ rel = rel.split(path.sep).join("/");
28
+ return {
29
+ id: info.id,
30
+ name: info.displayName,
31
+ originalCwd,
32
+ cwdRelativeToRepoRoot: rel,
33
+ author,
34
+ machine: os.hostname(),
35
+ messageCount: info.messageCount,
36
+ updatedAt: info.updatedAt ?? new Date().toISOString(),
37
+ };
38
+ }
39
+ export function serializeMeta(meta) {
40
+ return JSON.stringify(meta, null, 2) + "\n";
41
+ }
42
+ export function parseMeta(raw) {
43
+ return JSON.parse(raw);
44
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,25 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { pathToProjectSlug } from "./slug.js";
4
+ /**
5
+ * Root of the Claude Code config, honoring `CLAUDE_CONFIG_DIR` and falling back
6
+ * to `~/.claude`.
7
+ */
8
+ export function claudeConfigDir() {
9
+ const override = process.env.CLAUDE_CONFIG_DIR;
10
+ if (override && override.trim() !== "")
11
+ return override;
12
+ return path.join(os.homedir(), ".claude");
13
+ }
14
+ /** `<config>/projects` — the directory holding one slug subdir per project. */
15
+ export function projectsDir() {
16
+ return path.join(claudeConfigDir(), "projects");
17
+ }
18
+ /** Absolute project directory Claude Code uses for sessions launched in `absCwd`. */
19
+ export function projectDirForCwd(absCwd) {
20
+ return path.join(projectsDir(), pathToProjectSlug(absCwd));
21
+ }
22
+ /** Where a session `.jsonl` lives locally for a given cwd + id. */
23
+ export function sessionFilePath(absCwd, sessionId) {
24
+ return path.join(projectDirForCwd(absCwd), `${sessionId}.jsonl`);
25
+ }
@@ -0,0 +1,114 @@
1
+ import { DISPLAY_NAME_MAX } from "./constants.js";
2
+ function safeParse(line) {
3
+ if (!line)
4
+ return null;
5
+ try {
6
+ return JSON.parse(line);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ /** Collapse whitespace and truncate for a tidy one-line display name. */
13
+ export function truncateName(s, max = DISPLAY_NAME_MAX) {
14
+ const clean = s.replace(/\s+/g, " ").trim();
15
+ if (clean.length <= max)
16
+ return clean;
17
+ return clean.slice(0, max - 1).trimEnd() + "…";
18
+ }
19
+ /** Pull plain text out of a user message `content` (string or block array). */
20
+ function userMessageText(content) {
21
+ if (typeof content === "string")
22
+ return content;
23
+ if (Array.isArray(content)) {
24
+ for (const block of content) {
25
+ if (block && typeof block === "object" && typeof block.text === "string") {
26
+ return block.text;
27
+ }
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * Parse a session `.jsonl` body into a {@link SessionInfo}. `fallbackId` (the
34
+ * filename UUID) is used when no line carries a `sessionId`.
35
+ */
36
+ export function parseSession(content, fallbackId) {
37
+ const lines = content.split("\n");
38
+ let id = null;
39
+ let cwd = null;
40
+ let title = null;
41
+ let summary = null;
42
+ let firstUserText = null;
43
+ let messageCount = 0;
44
+ let updatedAt = null;
45
+ for (const raw of lines) {
46
+ const obj = safeParse(raw);
47
+ if (!obj)
48
+ continue;
49
+ if (!id && typeof obj.sessionId === "string")
50
+ id = obj.sessionId;
51
+ // Track the most recent cwd we see (later lines win).
52
+ if (typeof obj.cwd === "string" && obj.cwd)
53
+ cwd = obj.cwd;
54
+ if (typeof obj.timestamp === "string") {
55
+ if (!updatedAt || obj.timestamp > updatedAt)
56
+ updatedAt = obj.timestamp;
57
+ }
58
+ if (!title) {
59
+ if (typeof obj.aiTitle === "string" && obj.aiTitle.trim())
60
+ title = obj.aiTitle;
61
+ else if (obj.type === "summary" && typeof obj.summary === "string" && obj.summary.trim())
62
+ summary = obj.summary;
63
+ else if (typeof obj.title === "string" && obj.title.trim())
64
+ title = obj.title;
65
+ }
66
+ if (obj.type === "user" || obj.type === "assistant")
67
+ messageCount++;
68
+ if (firstUserText === null && obj.type === "user" && obj.message) {
69
+ const t = userMessageText(obj.message.content);
70
+ if (t && t.trim())
71
+ firstUserText = t;
72
+ }
73
+ }
74
+ // Display name resolution order: title -> summary -> first user msg -> id.
75
+ const resolvedId = id ?? fallbackId;
76
+ let displayName;
77
+ if (title)
78
+ displayName = truncateName(title);
79
+ else if (summary)
80
+ displayName = truncateName(summary);
81
+ else if (firstUserText)
82
+ displayName = truncateName(firstUserText);
83
+ else
84
+ displayName = resolvedId;
85
+ return { id: resolvedId, cwd, displayName, messageCount, updatedAt };
86
+ }
87
+ /**
88
+ * Rewrite the structural `cwd` field from `fromCwd` to `toCwd` on every line
89
+ * whose parsed `cwd` exactly equals `fromCwd`.
90
+ *
91
+ * We deliberately do a TARGETED replacement of just the `"cwd":"..."` token
92
+ * (located by JSON-encoding the values) rather than a blind find-and-replace or
93
+ * a full re-serialize: this preserves every other byte of the transcript
94
+ * exactly, avoiding any risk of corrupting message/tool-output content that may
95
+ * happen to contain the same path string.
96
+ */
97
+ export function remapCwd(content, fromCwd, toCwd) {
98
+ if (fromCwd === toCwd)
99
+ return content;
100
+ const fromToken = `"cwd":${JSON.stringify(fromCwd)}`;
101
+ const toToken = `"cwd":${JSON.stringify(toCwd)}`;
102
+ return content
103
+ .split("\n")
104
+ .map((line) => {
105
+ const obj = safeParse(line);
106
+ if (obj && typeof obj.cwd === "string" && obj.cwd === fromCwd) {
107
+ // Replace only the first occurrence (the structural field); any path in
108
+ // free-text content is left untouched.
109
+ return line.replace(fromToken, toToken);
110
+ }
111
+ return line;
112
+ })
113
+ .join("\n");
114
+ }
package/dist/slug.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Convert an absolute filesystem path into the directory slug Claude Code uses
3
+ * under `~/.claude/projects/`.
4
+ *
5
+ * Verified empirically against a real `~/.claude/projects/` by comparing each
6
+ * project directory name to the `cwd` recorded inside its session `.jsonl`
7
+ * files. Illustrative examples:
8
+ *
9
+ * /home/user -> -home-user
10
+ * /home/user/code/api -> -home-user-code-api
11
+ * /home/user/code/my.app.dev -> -home-user-code-my-app-dev (dot -> '-')
12
+ * /home/user/code/web-client -> -home-user-code-web-client (existing '-' kept)
13
+ *
14
+ * Rule: replace every character that is NOT [A-Za-z0-9] with '-'. The leading
15
+ * '/' therefore becomes a leading '-'.
16
+ *
17
+ * IMPORTANT: this encoding is LOSSY — both '/' and '.' (and any other special
18
+ * character) map to '-', so it cannot be reversed back into a unique path.
19
+ * That is why ccgs only ever goes path -> slug, never slug -> path, and why the
20
+ * sidecar `meta.json` carries the real `originalCwd` separately.
21
+ *
22
+ * Keep this as the ONE place that encodes the convention, so it is trivial to
23
+ * fix if Claude Code ever changes it.
24
+ */
25
+ export function pathToProjectSlug(absPath) {
26
+ return absPath.replace(/[^a-zA-Z0-9]/g, "-");
27
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "claude-git-sessions",
3
+ "version": "0.1.0",
4
+ "description": "Share Claude Code sessions across a team through an orphan git branch in your existing repo.",
5
+ "keywords": ["claude", "claude-code", "git", "sessions", "cli"],
6
+ "license": "MIT",
7
+ "author": "Ingram Technologies",
8
+ "homepage": "https://github.com/ingram-technologies/ccgs",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ingram-technologies/ccgs.git"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "ccgs": "dist/index.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc && chmod +x dist/index.js",
26
+ "dev": "tsc --watch",
27
+ "test": "node --test --import tsx test/*.test.ts",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepare": "npm run build",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "commander": "^12.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^20.14.0",
37
+ "tsx": "^4.16.0",
38
+ "typescript": "^5.5.0"
39
+ }
40
+ }