claude-git-sessions 0.1.2 → 0.3.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 +47 -12
- package/dist/commands/memory.js +185 -0
- package/dist/commands/pull.js +7 -4
- package/dist/constants.js +7 -0
- package/dist/git.js +7 -3
- package/dist/index.js +64 -6
- package/dist/memory.js +134 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,8 @@ history with `main` and never contains your source files — only session data:
|
|
|
45
45
|
```
|
|
46
46
|
sessions/<session-id>.jsonl # the transcript, verbatim
|
|
47
47
|
sessions/<session-id>.meta.json # sidecar metadata (name, author, cwd, …)
|
|
48
|
+
memory/<fact>.md # shared memory facts (see `ccgs memory`)
|
|
49
|
+
memory/<fact>.md.meta.json # sidecar metadata (type, author, …)
|
|
48
50
|
```
|
|
49
51
|
|
|
50
52
|
Files are keyed by the globally-unique Claude Code session UUID, so transcripts
|
|
@@ -72,26 +74,32 @@ Branch names are validated with `git check-ref-format` before use.
|
|
|
72
74
|
|
|
73
75
|
## Commands
|
|
74
76
|
|
|
75
|
-
### `ccgs pull`
|
|
77
|
+
### `ccgs pull [--force] [--exclude-memory]`
|
|
76
78
|
|
|
77
|
-
Fetches `@ccgs/<name>` and writes each session
|
|
78
|
-
`~/.claude/projects/<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
Fetches `@ccgs/<name>` and writes each session into the **repo root's** project
|
|
80
|
+
slug — `~/.claude/projects/<root-slug>/<id>.jsonl` — so that `claude --resume`
|
|
81
|
+
run at the repo root lists every pulled session. The structural `cwd` field in
|
|
82
|
+
each transcript line is rewritten from the author's path to your repo root so
|
|
83
|
+
resume works. (Absolute paths inside tool *output* are left as-is — cosmetic.)
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
> Sessions are always placed under the repo-root slug, even if the author
|
|
86
|
+
> launched Claude in a subdirectory. Honoring the author's subdir would put the
|
|
87
|
+
> session in a separate (and possibly non-existent) slug dir where `--resume`
|
|
88
|
+
> at the root can't see it. The original subdir is still recorded in `meta.json`.
|
|
85
89
|
|
|
86
|
-
|
|
90
|
+
By default this **also pulls shared memory** (see `ccgs memory`); pass
|
|
91
|
+
`--exclude-memory` for sessions only. If a local session is **newer** than the
|
|
92
|
+
shared copy it is skipped with a warning; pass `--force` to overwrite. If the
|
|
93
|
+
branch doesn't exist, it prints `nothing to pull` and exits 0.
|
|
87
94
|
|
|
88
|
-
### `ccgs push [targets...]`
|
|
95
|
+
### `ccgs push [targets...] [--exclude-memory]`
|
|
89
96
|
|
|
90
97
|
Finds local sessions whose working directory is this repo (or a subdirectory),
|
|
91
98
|
copies each transcript verbatim onto the orphan branch, and (re)generates its
|
|
92
99
|
`meta.json`. With no arguments it pushes all of them; pass session ids/names to
|
|
93
|
-
push only those.
|
|
94
|
-
|
|
100
|
+
push only those. By default this **also pushes shared memory** (project /
|
|
101
|
+
reference facts); pass `--exclude-memory` for sessions only. Creates the orphan
|
|
102
|
+
branch on first push. Reports added vs updated.
|
|
95
103
|
|
|
96
104
|
### `ccgs delete <id|name> [--yes] [--local]`
|
|
97
105
|
|
|
@@ -101,6 +109,33 @@ Shows what will be deleted and asks for `y/N` confirmation (`--yes`/`-y` to skip
|
|
|
101
109
|
for scripting). Removes both files from the branch and pushes. By default only
|
|
102
110
|
the shared branch is touched; add `--local` to also remove the local copy.
|
|
103
111
|
|
|
112
|
+
### `ccgs memory push [--all]` / `ccgs memory pull [--all] [--force]`
|
|
113
|
+
|
|
114
|
+
Claude Code keeps per-project memory in `~/.claude/projects/<slug>/memory/` —
|
|
115
|
+
one Markdown file per fact, plus a `MEMORY.md` index. `ccgs memory` shares those
|
|
116
|
+
facts on the same orphan branch (under a `memory/` prefix).
|
|
117
|
+
|
|
118
|
+
Plain `ccgs push` / `ccgs pull` already include memory by default (with the
|
|
119
|
+
type filter below, i.e. no personal facts). Use these `memory` subcommands when
|
|
120
|
+
you want memory **only**, or need `--all` to include personal facts.
|
|
121
|
+
|
|
122
|
+
Memory is *mixed-sensitivity*, so it's filtered by the fact's frontmatter
|
|
123
|
+
`type`:
|
|
124
|
+
|
|
125
|
+
- **`project` / `reference`** facts ("how we deploy", "where the API docs live")
|
|
126
|
+
are team knowledge and are shared **by default**.
|
|
127
|
+
- **`user` / `feedback`** facts (personal preferences) are held back unless you
|
|
128
|
+
pass **`--all`**.
|
|
129
|
+
|
|
130
|
+
`pull` writes the shared facts into your local memory dir and maintains a
|
|
131
|
+
clearly-marked block inside your `MEMORY.md` pointing at them — **your own index
|
|
132
|
+
lines are left untouched**. `MEMORY.md` itself is never pushed as a blob (two
|
|
133
|
+
people's indexes would clobber each other). Facts are keyed by filename with
|
|
134
|
+
newer-wins conflict handling (`--force` to overwrite a newer local copy).
|
|
135
|
+
|
|
136
|
+
> Scope: only the repo **root** slug's memory dir is synced (the common case);
|
|
137
|
+
> memory from sessions started in subdirectories is not.
|
|
138
|
+
|
|
104
139
|
### Global options
|
|
105
140
|
|
|
106
141
|
| Option | Default | Meaning |
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { MEMORY_DIR, MEMORY_INDEX } from "../constants.js";
|
|
5
|
+
import { applyToOrphanBranch, assertRemote, fetchBranch, localTrackingRef, lsTreeUnder, showFile, validateBranchName, } from "../git.js";
|
|
6
|
+
import { gitAuthor } from "../meta.js";
|
|
7
|
+
import { indexLine, isShareable, memoryDir, parseFrontmatter, readLocalMemory, titleFor, updateIndexBlock, } from "../memory.js";
|
|
8
|
+
function metaPath(filename) {
|
|
9
|
+
return `${MEMORY_DIR}/${filename}.meta.json`;
|
|
10
|
+
}
|
|
11
|
+
function blobPath(filename) {
|
|
12
|
+
return `${MEMORY_DIR}/${filename}`;
|
|
13
|
+
}
|
|
14
|
+
/** Filenames of `.md` memory facts on the branch (excludes sidecars). */
|
|
15
|
+
async function listBranchMemory(repo, ref) {
|
|
16
|
+
const paths = await lsTreeUnder(repo, ref, MEMORY_DIR);
|
|
17
|
+
const names = new Set();
|
|
18
|
+
for (const p of paths) {
|
|
19
|
+
const m = p.match(new RegExp(`^${MEMORY_DIR}/(.+\\.md)$`));
|
|
20
|
+
if (m && m[1] !== MEMORY_INDEX)
|
|
21
|
+
names.add(m[1]);
|
|
22
|
+
}
|
|
23
|
+
return [...names];
|
|
24
|
+
}
|
|
25
|
+
export async function memoryPush(opts) {
|
|
26
|
+
await validateBranchName(opts.repoRoot, opts.branch);
|
|
27
|
+
await assertRemote(opts.repoRoot, opts.remote);
|
|
28
|
+
const all = readLocalMemory(opts.repoRoot);
|
|
29
|
+
const shareable = all.filter((f) => isShareable(f.type, opts.all));
|
|
30
|
+
const heldBack = all.length - shareable.length;
|
|
31
|
+
if (shareable.length === 0) {
|
|
32
|
+
const extra = heldBack
|
|
33
|
+
? ` (${heldBack} personal user/feedback memo(ies) held back; use --all to include)`
|
|
34
|
+
: "";
|
|
35
|
+
console.log(`nothing to push (no shareable memory found for this repo)${extra}`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
|
|
39
|
+
const ref = localTrackingRef(opts.branch);
|
|
40
|
+
const existing = new Set(await listBranchMemory(opts.repoRoot, ref).catch(() => []));
|
|
41
|
+
const author = await gitAuthor(opts.repoRoot);
|
|
42
|
+
const added = [];
|
|
43
|
+
const updated = [];
|
|
44
|
+
let unchanged = 0;
|
|
45
|
+
for (const f of shareable) {
|
|
46
|
+
if (!existing.has(f.filename)) {
|
|
47
|
+
added.push(f.filename);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const prev = await showFile(opts.repoRoot, ref, blobPath(f.filename));
|
|
51
|
+
if (prev === f.content)
|
|
52
|
+
unchanged++;
|
|
53
|
+
else
|
|
54
|
+
updated.push(f.filename);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const result = await applyToOrphanBranch(opts.repoRoot, opts.remote, opts.branch, `ccgs: push ${added.length + updated.length} memory file(s)`, async (b) => {
|
|
58
|
+
for (const f of shareable) {
|
|
59
|
+
const sidecar = {
|
|
60
|
+
filename: f.filename,
|
|
61
|
+
type: f.type,
|
|
62
|
+
name: f.name,
|
|
63
|
+
description: f.description,
|
|
64
|
+
author,
|
|
65
|
+
machine: os.hostname(),
|
|
66
|
+
updatedAt: f.updatedAt,
|
|
67
|
+
};
|
|
68
|
+
await b.addBlob(blobPath(f.filename), f.content);
|
|
69
|
+
await b.addBlob(metaPath(f.filename), JSON.stringify(sidecar, null, 2) + "\n");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
if (!result.changed) {
|
|
73
|
+
console.log(`Memory already up to date — ${unchanged} file(s) unchanged.`);
|
|
74
|
+
if (heldBack)
|
|
75
|
+
console.log(`(${heldBack} personal memo(ies) held back; --all to include)`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
if (added.length) {
|
|
79
|
+
console.log(`Added ${added.length}:`);
|
|
80
|
+
for (const a of added)
|
|
81
|
+
console.log(` + ${a}`);
|
|
82
|
+
}
|
|
83
|
+
if (updated.length) {
|
|
84
|
+
console.log(`Updated ${updated.length}:`);
|
|
85
|
+
for (const u of updated)
|
|
86
|
+
console.log(` ~ ${u}`);
|
|
87
|
+
}
|
|
88
|
+
if (unchanged)
|
|
89
|
+
console.log(`(${unchanged} unchanged)`);
|
|
90
|
+
if (heldBack)
|
|
91
|
+
console.log(`(${heldBack} personal user/feedback memo(ies) held back; --all to include)`);
|
|
92
|
+
console.log(`Pushed memory to ${opts.remote} @ccgs/${opts.branch}.`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
export async function memoryPull(opts) {
|
|
96
|
+
await validateBranchName(opts.repoRoot, opts.branch);
|
|
97
|
+
await assertRemote(opts.repoRoot, opts.remote);
|
|
98
|
+
const exists = await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
|
|
99
|
+
if (!exists) {
|
|
100
|
+
console.log("nothing to pull (branch does not exist on remote)");
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
const ref = localTrackingRef(opts.branch);
|
|
104
|
+
const filenames = await listBranchMemory(opts.repoRoot, ref);
|
|
105
|
+
if (filenames.length === 0) {
|
|
106
|
+
console.log("nothing to pull (no shared memory on branch)");
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const dir = memoryDir(opts.repoRoot);
|
|
110
|
+
const pulled = [];
|
|
111
|
+
const skipped = [];
|
|
112
|
+
// Files present locally after this pull, for index regeneration.
|
|
113
|
+
const indexEntries = [];
|
|
114
|
+
for (const filename of filenames) {
|
|
115
|
+
const content = await showFile(opts.repoRoot, ref, blobPath(filename));
|
|
116
|
+
if (content === null) {
|
|
117
|
+
skipped.push(`${filename}: missing blob`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const rawMeta = await showFile(opts.repoRoot, ref, metaPath(filename));
|
|
121
|
+
let sidecar = null;
|
|
122
|
+
if (rawMeta) {
|
|
123
|
+
try {
|
|
124
|
+
sidecar = JSON.parse(rawMeta);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
sidecar = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const type = sidecar?.type ?? parseFrontmatter(content).type;
|
|
131
|
+
// Apply the same type gate on pull: don't litter local memory with other
|
|
132
|
+
// people's personal facts unless explicitly asked.
|
|
133
|
+
if (!isShareable(type, opts.all)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const description = sidecar?.description ?? parseFrontmatter(content).description;
|
|
137
|
+
const localFile = path.join(dir, filename);
|
|
138
|
+
// Conflict policy: keep a newer local copy unless --force.
|
|
139
|
+
if (!opts.force && fs.existsSync(localFile)) {
|
|
140
|
+
const branchUpdated = sidecar?.updatedAt ?? "";
|
|
141
|
+
const localUpdated = fs.statSync(localFile).mtime.toISOString();
|
|
142
|
+
if (branchUpdated && localUpdated > branchUpdated) {
|
|
143
|
+
skipped.push(`${filename}: local copy is newer (use --force)`);
|
|
144
|
+
// Still index it — the file exists locally and is part of the shared set.
|
|
145
|
+
indexEntries.push({ filename, description, title: titleFor({ filename, name: sidecar?.name ?? null }) });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
fs.writeFileSync(localFile, content);
|
|
151
|
+
pulled.push(filename);
|
|
152
|
+
indexEntries.push({ filename, description, title: titleFor({ filename, name: sidecar?.name ?? null }) });
|
|
153
|
+
}
|
|
154
|
+
// Rebuild the ccgs-managed block in MEMORY.md from the shared set.
|
|
155
|
+
if (indexEntries.length) {
|
|
156
|
+
const indexPath = path.join(dir, MEMORY_INDEX);
|
|
157
|
+
let existing = "";
|
|
158
|
+
try {
|
|
159
|
+
existing = fs.readFileSync(indexPath, "utf8");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
existing = "";
|
|
163
|
+
}
|
|
164
|
+
const lines = indexEntries
|
|
165
|
+
.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
166
|
+
.map((e) => indexLine(e));
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(indexPath, updateIndexBlock(existing, lines));
|
|
169
|
+
}
|
|
170
|
+
if (pulled.length) {
|
|
171
|
+
console.log(`Pulled ${pulled.length} memory file(s):`);
|
|
172
|
+
for (const p of pulled)
|
|
173
|
+
console.log(` + ${p}`);
|
|
174
|
+
console.log(`Index updated: ${path.join(dir, MEMORY_INDEX)}`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
console.log("Pulled 0 memory files.");
|
|
178
|
+
}
|
|
179
|
+
if (skipped.length) {
|
|
180
|
+
console.log(`Skipped ${skipped.length}:`);
|
|
181
|
+
for (const s of skipped)
|
|
182
|
+
console.log(` - ${s}`);
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
2
|
import { SESSIONS_DIR } from "../constants.js";
|
|
4
3
|
import { assertRemote, fetchBranch, localTrackingRef, showFile, validateBranchName, } from "../git.js";
|
|
5
4
|
import { listBranchSessions } from "../branch-sessions.js";
|
|
@@ -28,9 +27,13 @@ export async function pull(opts) {
|
|
|
28
27
|
skipped.push(`${shortId(id)}: missing transcript blob`);
|
|
29
28
|
continue;
|
|
30
29
|
}
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
// Always land the session under the repo ROOT slug, so `claude --resume`
|
|
31
|
+
// run at the repo root lists it. (We deliberately do NOT honor the author's
|
|
32
|
+
// `cwdRelativeToRepoRoot` here: that placed subdir-authored sessions in a
|
|
33
|
+
// separate — and often non-existent — slug dir, making them invisible to
|
|
34
|
+
// resume at the root. The author's subdir is still recorded in meta for
|
|
35
|
+
// provenance.)
|
|
36
|
+
const localCwd = opts.repoRoot;
|
|
34
37
|
const name = meta?.name ?? id;
|
|
35
38
|
// Conflict policy: newer local copy is kept unless --force.
|
|
36
39
|
const localFile = sessionFilePath(localCwd, id);
|
package/dist/constants.js
CHANGED
|
@@ -15,5 +15,12 @@ export const DEFAULT_BRANCH_NAME = "default";
|
|
|
15
15
|
export const DEFAULT_REMOTE = "origin";
|
|
16
16
|
/** Path prefix used for session blobs on the orphan branch. */
|
|
17
17
|
export const SESSIONS_DIR = "sessions";
|
|
18
|
+
/**
|
|
19
|
+
* Path prefix used for shared memory blobs on the orphan branch, and the name
|
|
20
|
+
* of the local memory subdirectory inside a project's slug dir.
|
|
21
|
+
*/
|
|
22
|
+
export const MEMORY_DIR = "memory";
|
|
23
|
+
/** The hand-maintained memory index file (lives inside the memory dir). */
|
|
24
|
+
export const MEMORY_INDEX = "MEMORY.md";
|
|
18
25
|
/** Max length of an auto-derived display name. */
|
|
19
26
|
export const DISPLAY_NAME_MAX = 80;
|
package/dist/git.js
CHANGED
|
@@ -115,15 +115,19 @@ export async function trackingTip(repo, name) {
|
|
|
115
115
|
const sha = r.stdout.trim();
|
|
116
116
|
return sha || null;
|
|
117
117
|
}
|
|
118
|
-
/** List `
|
|
119
|
-
export async function
|
|
118
|
+
/** List paths under `prefix/` present on a tree-ish ref. */
|
|
119
|
+
export async function lsTreeUnder(repo, ref, prefix) {
|
|
120
120
|
const r = await git(["ls-tree", "-r", "--name-only", ref], { cwd: repo, allowFail: true });
|
|
121
121
|
if (r.code !== 0)
|
|
122
122
|
return [];
|
|
123
123
|
return r.stdout
|
|
124
124
|
.split("\n")
|
|
125
125
|
.map((s) => s.trim())
|
|
126
|
-
.filter((p) => p.startsWith(`${
|
|
126
|
+
.filter((p) => p.startsWith(`${prefix}/`));
|
|
127
|
+
}
|
|
128
|
+
/** List `sessions/*` paths present on a tree-ish ref. */
|
|
129
|
+
export async function lsSessions(repo, ref) {
|
|
130
|
+
return lsTreeUnder(repo, ref, SESSIONS_DIR);
|
|
127
131
|
}
|
|
128
132
|
/** Read a blob at `ref:path` as utf-8 text, or null if it does not exist. */
|
|
129
133
|
export async function showFile(repo, ref, filePath) {
|
package/dist/index.js
CHANGED
|
@@ -5,9 +5,10 @@ import { repoRoot } from "./git.js";
|
|
|
5
5
|
import { pull } from "./commands/pull.js";
|
|
6
6
|
import { push } from "./commands/push.js";
|
|
7
7
|
import { deleteSession } from "./commands/delete.js";
|
|
8
|
+
import { memoryPull, memoryPush } from "./commands/memory.js";
|
|
8
9
|
// Kept in sync with package.json at publish time; hardcoded to avoid a JSON
|
|
9
10
|
// import assertion just for --version.
|
|
10
|
-
const VERSION = "0.
|
|
11
|
+
const VERSION = "0.3.0";
|
|
11
12
|
function globals(cmd) {
|
|
12
13
|
const opts = cmd.optsWithGlobals();
|
|
13
14
|
return {
|
|
@@ -33,24 +34,44 @@ program
|
|
|
33
34
|
.option("--remote <remote>", "git remote to use", DEFAULT_REMOTE);
|
|
34
35
|
program
|
|
35
36
|
.command("pull")
|
|
36
|
-
.description("fetch shared sessions and place them where Claude Code can resume them")
|
|
37
|
+
.description("fetch shared sessions (and memory) and place them where Claude Code can resume them")
|
|
37
38
|
.option("--force", "overwrite local copies even if they are newer", false)
|
|
39
|
+
.option("--exclude-memory", "pull sessions only; do not also pull memory", false)
|
|
38
40
|
.action(async (localOpts, cmd) => {
|
|
39
41
|
const g = globals(cmd);
|
|
40
42
|
await run(async () => {
|
|
41
43
|
const root = await repoRoot();
|
|
42
|
-
|
|
44
|
+
let code = await pull({ repoRoot: root, remote: g.remote, branch: g.branch, force: localOpts.force });
|
|
45
|
+
if (!localOpts.excludeMemory) {
|
|
46
|
+
console.log("\nMemory:");
|
|
47
|
+
const m = await memoryPull({
|
|
48
|
+
repoRoot: root,
|
|
49
|
+
remote: g.remote,
|
|
50
|
+
branch: g.branch,
|
|
51
|
+
all: false,
|
|
52
|
+
force: localOpts.force,
|
|
53
|
+
});
|
|
54
|
+
code = code || m;
|
|
55
|
+
}
|
|
56
|
+
return code;
|
|
43
57
|
});
|
|
44
58
|
});
|
|
45
59
|
program
|
|
46
60
|
.command("push")
|
|
47
|
-
.description("publish this repo's local sessions to the orphan branch")
|
|
61
|
+
.description("publish this repo's local sessions (and shared memory) to the orphan branch")
|
|
48
62
|
.argument("[targets...]", "optional session ids/names to push (default: all)")
|
|
49
|
-
.
|
|
63
|
+
.option("--exclude-memory", "push sessions only; do not also push memory", false)
|
|
64
|
+
.action(async (targets, localOpts, cmd) => {
|
|
50
65
|
const g = globals(cmd);
|
|
51
66
|
await run(async () => {
|
|
52
67
|
const root = await repoRoot();
|
|
53
|
-
|
|
68
|
+
let code = await push({ repoRoot: root, remote: g.remote, branch: g.branch, filters: targets });
|
|
69
|
+
if (!localOpts.excludeMemory) {
|
|
70
|
+
console.log("\nMemory:");
|
|
71
|
+
const m = await memoryPush({ repoRoot: root, remote: g.remote, branch: g.branch, all: false });
|
|
72
|
+
code = code || m;
|
|
73
|
+
}
|
|
74
|
+
return code;
|
|
54
75
|
});
|
|
55
76
|
});
|
|
56
77
|
program
|
|
@@ -73,6 +94,43 @@ program
|
|
|
73
94
|
});
|
|
74
95
|
});
|
|
75
96
|
});
|
|
97
|
+
const memory = program
|
|
98
|
+
.command("memory")
|
|
99
|
+
.description("share Claude Code memory files (project/reference facts) for this repo");
|
|
100
|
+
memory
|
|
101
|
+
.command("push")
|
|
102
|
+
.description("publish this repo's shareable memory to the orphan branch")
|
|
103
|
+
.option("--all", "include personal user/feedback memories too", false)
|
|
104
|
+
.action(async (localOpts, cmd) => {
|
|
105
|
+
const g = globals(cmd);
|
|
106
|
+
await run(async () => {
|
|
107
|
+
const root = await repoRoot();
|
|
108
|
+
return memoryPush({ repoRoot: root, remote: g.remote, branch: g.branch, all: localOpts.all });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
memory
|
|
112
|
+
.command("pull")
|
|
113
|
+
.description("fetch shared memory and update this repo's local MEMORY.md index")
|
|
114
|
+
.option("--all", "include personal user/feedback memories too", false)
|
|
115
|
+
.option("--force", "overwrite local copies even if they are newer", false)
|
|
116
|
+
.action(async (localOpts, cmd) => {
|
|
117
|
+
const g = globals(cmd);
|
|
118
|
+
await run(async () => {
|
|
119
|
+
const root = await repoRoot();
|
|
120
|
+
return memoryPull({
|
|
121
|
+
repoRoot: root,
|
|
122
|
+
remote: g.remote,
|
|
123
|
+
branch: g.branch,
|
|
124
|
+
all: localOpts.all,
|
|
125
|
+
force: localOpts.force,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
// `ccgs memory` with no subcommand: show its help on stdout and exit 0.
|
|
130
|
+
memory.action(() => {
|
|
131
|
+
memory.outputHelp();
|
|
132
|
+
process.exit(0);
|
|
133
|
+
});
|
|
76
134
|
// With no subcommand, print help to stdout and exit 0 — Commander's default is
|
|
77
135
|
// to print to stderr and exit 1, which looks like an error to anyone running
|
|
78
136
|
// the bare command (e.g. `npx claude-git-sessions`).
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MEMORY_DIR, MEMORY_INDEX } from "./constants.js";
|
|
4
|
+
import { projectDirForCwd } from "./paths.js";
|
|
5
|
+
const KNOWN_TYPES = ["user", "feedback", "project", "reference"];
|
|
6
|
+
/** Local memory directory for the repo's root slug. */
|
|
7
|
+
export function memoryDir(repoRoot) {
|
|
8
|
+
return path.join(projectDirForCwd(repoRoot), MEMORY_DIR);
|
|
9
|
+
}
|
|
10
|
+
function stripQuotes(s) {
|
|
11
|
+
const t = s.trim();
|
|
12
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
13
|
+
return t.slice(1, -1);
|
|
14
|
+
}
|
|
15
|
+
return t;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Minimal frontmatter reader for the known memory format. Deliberately not a
|
|
19
|
+
* full YAML parser — it only pulls out `name`, `description`, and the nested
|
|
20
|
+
* `type`, and degrades gracefully (type "unknown") on anything unexpected.
|
|
21
|
+
*/
|
|
22
|
+
export function parseFrontmatter(content) {
|
|
23
|
+
let name = null;
|
|
24
|
+
let description = null;
|
|
25
|
+
let type = "unknown";
|
|
26
|
+
if (content.startsWith("---")) {
|
|
27
|
+
// Find the closing delimiter line.
|
|
28
|
+
const rest = content.slice(3);
|
|
29
|
+
const endIdx = rest.search(/\n---\s*(\n|$)/);
|
|
30
|
+
if (endIdx !== -1) {
|
|
31
|
+
const block = rest.slice(0, endIdx);
|
|
32
|
+
for (const line of block.split("\n")) {
|
|
33
|
+
const n = line.match(/^\s*name:\s*(.+?)\s*$/);
|
|
34
|
+
if (n && name === null)
|
|
35
|
+
name = stripQuotes(n[1]);
|
|
36
|
+
const d = line.match(/^\s*description:\s*(.+?)\s*$/);
|
|
37
|
+
if (d && description === null)
|
|
38
|
+
description = stripQuotes(d[1]);
|
|
39
|
+
const t = line.match(/^\s*type:\s*([A-Za-z]+)/);
|
|
40
|
+
if (t) {
|
|
41
|
+
const v = t[1].toLowerCase();
|
|
42
|
+
if (KNOWN_TYPES.includes(v))
|
|
43
|
+
type = v;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { name, description, type };
|
|
49
|
+
}
|
|
50
|
+
/** Should a fact of `type` be shared? `all` overrides the type filter. */
|
|
51
|
+
export function isShareable(type, all) {
|
|
52
|
+
if (all)
|
|
53
|
+
return true;
|
|
54
|
+
return type === "project" || type === "reference";
|
|
55
|
+
}
|
|
56
|
+
/** kebab-or-filename -> a human "Title Case" string for the index link text. */
|
|
57
|
+
export function titleFor(file) {
|
|
58
|
+
const base = file.name ?? file.filename.replace(/\.md$/i, "");
|
|
59
|
+
return base
|
|
60
|
+
.replace(/[-_]+/g, " ")
|
|
61
|
+
.replace(/\s+/g, " ")
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
64
|
+
}
|
|
65
|
+
/** Read all shareable-candidate memory files from the repo's memory dir. */
|
|
66
|
+
export function readLocalMemory(repoRoot) {
|
|
67
|
+
const dir = memoryDir(repoRoot);
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(dir);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const filename of entries) {
|
|
77
|
+
if (!filename.endsWith(".md"))
|
|
78
|
+
continue;
|
|
79
|
+
if (filename === MEMORY_INDEX)
|
|
80
|
+
continue; // index handled separately
|
|
81
|
+
const p = path.join(dir, filename);
|
|
82
|
+
let content;
|
|
83
|
+
let mtime;
|
|
84
|
+
try {
|
|
85
|
+
const st = fs.statSync(p);
|
|
86
|
+
if (!st.isFile())
|
|
87
|
+
continue;
|
|
88
|
+
mtime = st.mtime;
|
|
89
|
+
content = fs.readFileSync(p, "utf8");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const fm = parseFrontmatter(content);
|
|
95
|
+
out.push({
|
|
96
|
+
filename,
|
|
97
|
+
path: p,
|
|
98
|
+
content,
|
|
99
|
+
name: fm.name,
|
|
100
|
+
description: fm.description,
|
|
101
|
+
type: fm.type,
|
|
102
|
+
updatedAt: mtime.toISOString(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
const BLOCK_START = "<!-- ccgs:shared-memory (managed by ccgs; do not edit by hand) -->";
|
|
108
|
+
const BLOCK_END = "<!-- /ccgs:shared-memory -->";
|
|
109
|
+
/** One index pointer line: `- [Title](file.md) — description`. */
|
|
110
|
+
export function indexLine(file) {
|
|
111
|
+
const tail = file.description ? ` — ${file.description}` : "";
|
|
112
|
+
return `- [${file.title}](${file.filename})${tail}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Replace (or append) the ccgs-managed block in a MEMORY.md body with the given
|
|
116
|
+
* lines, leaving everything outside the markers untouched. Returns the new body.
|
|
117
|
+
*/
|
|
118
|
+
export function updateIndexBlock(existing, lines) {
|
|
119
|
+
const block = lines.length
|
|
120
|
+
? `${BLOCK_START}\n${lines.join("\n")}\n${BLOCK_END}`
|
|
121
|
+
: `${BLOCK_START}\n${BLOCK_END}`;
|
|
122
|
+
const startIdx = existing.indexOf(BLOCK_START);
|
|
123
|
+
const endIdx = existing.indexOf(BLOCK_END);
|
|
124
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
125
|
+
const before = existing.slice(0, startIdx);
|
|
126
|
+
const after = existing.slice(endIdx + BLOCK_END.length);
|
|
127
|
+
return before + block + after;
|
|
128
|
+
}
|
|
129
|
+
// Append, ensuring a blank line separates it from existing content.
|
|
130
|
+
const base = existing.trimEnd();
|
|
131
|
+
if (base === "")
|
|
132
|
+
return block + "\n";
|
|
133
|
+
return `${base}\n\n${block}\n`;
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-git-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Share Claude Code sessions across a team through an orphan git branch in your existing repo.",
|
|
5
5
|
"keywords": ["claude", "claude-code", "git", "sessions", "cli"],
|
|
6
6
|
"license": "MIT",
|