claude-memory-sync 0.2.0__py3-none-any.whl
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.
- claude_memory_sync/__init__.py +3 -0
- claude_memory_sync/__main__.py +5 -0
- claude_memory_sync/alias.py +122 -0
- claude_memory_sync/cli.py +454 -0
- claude_memory_sync/config.py +245 -0
- claude_memory_sync/discovery.py +54 -0
- claude_memory_sync/github_api.py +134 -0
- claude_memory_sync/hooks.py +214 -0
- claude_memory_sync/merge.py +119 -0
- claude_memory_sync/paths.py +63 -0
- claude_memory_sync/sync_engine.py +350 -0
- claude_memory_sync-0.2.0.dist-info/METADATA +125 -0
- claude_memory_sync-0.2.0.dist-info/RECORD +17 -0
- claude_memory_sync-0.2.0.dist-info/WHEEL +5 -0
- claude_memory_sync-0.2.0.dist-info/entry_points.txt +4 -0
- claude_memory_sync-0.2.0.dist-info/licenses/LICENSE +21 -0
- claude_memory_sync-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Section-aware markdown merge for memory files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Section:
|
|
11
|
+
"""A markdown section with a heading and body text."""
|
|
12
|
+
heading: str # Empty string for content before first heading
|
|
13
|
+
body: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_sections(content: str) -> list[Section]:
|
|
17
|
+
"""Parse markdown into sections based on headings (##, ###, etc).
|
|
18
|
+
|
|
19
|
+
Content before the first heading becomes a section with heading="".
|
|
20
|
+
"""
|
|
21
|
+
sections: list[Section] = []
|
|
22
|
+
current_heading = ""
|
|
23
|
+
current_lines: list[str] = []
|
|
24
|
+
|
|
25
|
+
for line in content.split("\n"):
|
|
26
|
+
if re.match(r"^#{1,6}\s+", line):
|
|
27
|
+
# Save previous section
|
|
28
|
+
sections.append(Section(
|
|
29
|
+
heading=current_heading,
|
|
30
|
+
body="\n".join(current_lines),
|
|
31
|
+
))
|
|
32
|
+
current_heading = line
|
|
33
|
+
current_lines = []
|
|
34
|
+
else:
|
|
35
|
+
current_lines.append(line)
|
|
36
|
+
|
|
37
|
+
# Save final section
|
|
38
|
+
sections.append(Section(
|
|
39
|
+
heading=current_heading,
|
|
40
|
+
body="\n".join(current_lines),
|
|
41
|
+
))
|
|
42
|
+
|
|
43
|
+
return sections
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def deduplicate_bullets(text: str) -> str:
|
|
47
|
+
"""Remove duplicate bullet points, preserving order of first occurrence.
|
|
48
|
+
|
|
49
|
+
Only deduplicates lines starting with - or *.
|
|
50
|
+
Non-bullet lines are always preserved.
|
|
51
|
+
"""
|
|
52
|
+
seen: set[str] = set()
|
|
53
|
+
result: list[str] = []
|
|
54
|
+
|
|
55
|
+
for line in text.split("\n"):
|
|
56
|
+
stripped = line.strip()
|
|
57
|
+
if stripped and (stripped.startswith("- ") or stripped.startswith("* ")):
|
|
58
|
+
if stripped not in seen:
|
|
59
|
+
seen.add(stripped)
|
|
60
|
+
result.append(line)
|
|
61
|
+
else:
|
|
62
|
+
result.append(line)
|
|
63
|
+
|
|
64
|
+
return "\n".join(result)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def merge_sections(
|
|
68
|
+
local_sections: list[Section], remote_sections: list[Section]
|
|
69
|
+
) -> list[Section]:
|
|
70
|
+
"""Merge two section lists.
|
|
71
|
+
|
|
72
|
+
- Sections unique to either side are kept.
|
|
73
|
+
- Sections with the same heading have their bullets deduplicated and merged.
|
|
74
|
+
- Order follows local sections first, then any remote-only sections appended.
|
|
75
|
+
"""
|
|
76
|
+
remote_by_heading = {s.heading: s for s in remote_sections}
|
|
77
|
+
merged: list[Section] = []
|
|
78
|
+
seen_headings: set[str] = set()
|
|
79
|
+
|
|
80
|
+
for local_sec in local_sections:
|
|
81
|
+
seen_headings.add(local_sec.heading)
|
|
82
|
+
remote_sec = remote_by_heading.get(local_sec.heading)
|
|
83
|
+
if remote_sec is not None:
|
|
84
|
+
# Merge bodies
|
|
85
|
+
combined_body = local_sec.body.rstrip("\n") + "\n" + remote_sec.body.lstrip("\n")
|
|
86
|
+
deduped_body = deduplicate_bullets(combined_body)
|
|
87
|
+
merged.append(Section(heading=local_sec.heading, body=deduped_body))
|
|
88
|
+
else:
|
|
89
|
+
merged.append(local_sec)
|
|
90
|
+
|
|
91
|
+
# Append remote-only sections
|
|
92
|
+
for remote_sec in remote_sections:
|
|
93
|
+
if remote_sec.heading not in seen_headings:
|
|
94
|
+
merged.append(remote_sec)
|
|
95
|
+
|
|
96
|
+
return merged
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render_sections(sections: list[Section]) -> str:
|
|
100
|
+
"""Render a list of sections back to markdown."""
|
|
101
|
+
parts: list[str] = []
|
|
102
|
+
for sec in sections:
|
|
103
|
+
if sec.heading:
|
|
104
|
+
parts.append(sec.heading)
|
|
105
|
+
if sec.body:
|
|
106
|
+
parts.append(sec.body)
|
|
107
|
+
|
|
108
|
+
result = "\n".join(parts)
|
|
109
|
+
# Clean up excessive blank lines (3+ newlines -> 2)
|
|
110
|
+
result = re.sub(r"\n{3,}", "\n\n", result)
|
|
111
|
+
return result.strip() + "\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def merge_markdown(local_content: str, remote_content: str) -> str:
|
|
115
|
+
"""Full merge pipeline: parse both, merge sections, render back to markdown."""
|
|
116
|
+
local_sections = parse_sections(local_content)
|
|
117
|
+
remote_sections = parse_sections(remote_content)
|
|
118
|
+
merged = merge_sections(local_sections, remote_sections)
|
|
119
|
+
return _render_sections(merged)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Cross-platform path utilities for Claude memory files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_claude_dir() -> Path:
|
|
10
|
+
"""Return path to ~/.claude/."""
|
|
11
|
+
return Path.home() / ".claude"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_projects_dir() -> Path:
|
|
15
|
+
"""Return path to ~/.claude/projects/."""
|
|
16
|
+
return get_claude_dir() / "projects"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_config_dir() -> Path:
|
|
20
|
+
"""Return path to ~/.claude-memory-sync/."""
|
|
21
|
+
return Path.home() / ".claude-memory-sync"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_state_file() -> Path:
|
|
25
|
+
"""Return path to ~/.claude-memory-sync/state.json."""
|
|
26
|
+
return get_config_dir() / "state.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def path_to_project_dirname(path: str) -> str:
|
|
30
|
+
"""Convert a filesystem path to Claude's project directory naming convention.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
C:\\Users\\ktanm -> "C--Users-ktanm"
|
|
34
|
+
/Users/ktanm -> "-Users-ktanm"
|
|
35
|
+
"""
|
|
36
|
+
# Normalize to forward slashes
|
|
37
|
+
normalized = path.replace("\\", "/")
|
|
38
|
+
# Remove trailing slash
|
|
39
|
+
normalized = normalized.rstrip("/")
|
|
40
|
+
# Replace colon with dash (Windows drive letter: C: -> C-)
|
|
41
|
+
normalized = normalized.replace(":", "-")
|
|
42
|
+
# Replace slashes with dashes
|
|
43
|
+
return normalized.replace("/", "-")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def project_dirname_to_display(dirname: str) -> str:
|
|
47
|
+
"""Convert a Claude project dirname back to a human-readable path.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
"C--Users-ktanm" -> "C:/Users/ktanm"
|
|
51
|
+
"-Users-ktanm" -> "/Users/ktanm"
|
|
52
|
+
"""
|
|
53
|
+
# Check for Windows-style: starts with a letter followed by --
|
|
54
|
+
match = re.match(r"^([A-Za-z])--(.*)$", dirname)
|
|
55
|
+
if match:
|
|
56
|
+
drive = match.group(1)
|
|
57
|
+
rest = match.group(2).replace("-", "/")
|
|
58
|
+
return f"{drive}:/{rest}"
|
|
59
|
+
# Unix-style: starts with -
|
|
60
|
+
if dirname.startswith("-"):
|
|
61
|
+
return dirname.replace("-", "/")
|
|
62
|
+
# Fallback
|
|
63
|
+
return dirname.replace("-", "/")
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Push/pull/sync orchestration for Claude memory files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .alias import resolve_alias, resolve_dirname
|
|
12
|
+
from .discovery import discover_projects, get_project_memory_dir
|
|
13
|
+
from .github_api import GitHubAPI, GitHubAPIError
|
|
14
|
+
from .merge import merge_markdown
|
|
15
|
+
from .paths import get_state_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SyncResult:
|
|
20
|
+
"""Result of a sync operation."""
|
|
21
|
+
pushed: list[str] = field(default_factory=list)
|
|
22
|
+
pulled: list[str] = field(default_factory=list)
|
|
23
|
+
conflicts: list[str] = field(default_factory=list)
|
|
24
|
+
skipped: list[str] = field(default_factory=list)
|
|
25
|
+
errors: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SyncEngine:
|
|
29
|
+
"""Orchestrates push/pull/sync of memory files to/from GitHub."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: dict, api: GitHubAPI):
|
|
32
|
+
self.config = config
|
|
33
|
+
self.api = api
|
|
34
|
+
self.state = self.load_state()
|
|
35
|
+
|
|
36
|
+
def load_state(self) -> dict:
|
|
37
|
+
"""Load sync state from state.json."""
|
|
38
|
+
path = get_state_file()
|
|
39
|
+
if path.exists():
|
|
40
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
41
|
+
return json.load(f)
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
def save_state(self) -> None:
|
|
45
|
+
"""Save sync state to state.json."""
|
|
46
|
+
path = get_state_file()
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(self.state, f, indent=2)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def compute_hash(content: str) -> str:
|
|
53
|
+
"""Compute SHA-256 hex digest of content."""
|
|
54
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
55
|
+
|
|
56
|
+
def _resolve_alias_for_dirname(self, dirname: str) -> str:
|
|
57
|
+
"""Resolve a project dirname to its alias, or return dirname as-is."""
|
|
58
|
+
return resolve_alias(dirname)
|
|
59
|
+
|
|
60
|
+
def _get_local_dirnames(self, alias: str) -> list[str]:
|
|
61
|
+
"""Get local dirnames for a given alias."""
|
|
62
|
+
dirnames = resolve_dirname(alias)
|
|
63
|
+
if dirnames:
|
|
64
|
+
return dirnames
|
|
65
|
+
# No alias mapping; treat alias as a raw dirname
|
|
66
|
+
return [alias]
|
|
67
|
+
|
|
68
|
+
def get_local_files(self, alias: str) -> dict[str, str]:
|
|
69
|
+
"""Read all .md files from local memory dirs for the given alias.
|
|
70
|
+
|
|
71
|
+
Returns dict of filename -> content.
|
|
72
|
+
"""
|
|
73
|
+
files: dict[str, str] = {}
|
|
74
|
+
for dirname in self._get_local_dirnames(alias):
|
|
75
|
+
memory_dir = get_project_memory_dir(dirname)
|
|
76
|
+
if not memory_dir.is_dir():
|
|
77
|
+
continue
|
|
78
|
+
for md_file in sorted(memory_dir.glob("*.md")):
|
|
79
|
+
content = md_file.read_text(encoding="utf-8")
|
|
80
|
+
files[md_file.name] = content
|
|
81
|
+
return files
|
|
82
|
+
|
|
83
|
+
def get_remote_files(self, alias: str) -> dict[str, tuple[str, str]]:
|
|
84
|
+
"""Read all files from GitHub for the given alias.
|
|
85
|
+
|
|
86
|
+
Returns dict of filename -> (content, sha).
|
|
87
|
+
"""
|
|
88
|
+
remote_path = f"projects/{alias}"
|
|
89
|
+
items = self.api.list_dir(remote_path)
|
|
90
|
+
files: dict[str, tuple[str, str]] = {}
|
|
91
|
+
for item in items:
|
|
92
|
+
if item["type"] == "file" and item["name"].endswith(".md"):
|
|
93
|
+
data = self.api.get_file(item["path"])
|
|
94
|
+
if data:
|
|
95
|
+
files[item["name"]] = (data["content"], data["sha"])
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
def _state_key(self, alias: str, filename: str) -> str:
|
|
99
|
+
return f"{alias}/{filename}"
|
|
100
|
+
|
|
101
|
+
def _now_iso(self) -> str:
|
|
102
|
+
return datetime.now(timezone.utc).isoformat()
|
|
103
|
+
|
|
104
|
+
def push(self, alias: str | None = None, dry_run: bool = False) -> SyncResult:
|
|
105
|
+
"""Push local changes to GitHub.
|
|
106
|
+
|
|
107
|
+
For each local file, compare hash to state. If changed, upload to GitHub.
|
|
108
|
+
"""
|
|
109
|
+
result = SyncResult()
|
|
110
|
+
if alias is None:
|
|
111
|
+
alias = self._detect_current_alias()
|
|
112
|
+
if alias is None:
|
|
113
|
+
result.errors.append("Cannot detect current project. Specify an alias.")
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
local_files = self.get_local_files(alias)
|
|
117
|
+
remote_files = self.get_remote_files(alias)
|
|
118
|
+
|
|
119
|
+
for filename, content in local_files.items():
|
|
120
|
+
key = self._state_key(alias, filename)
|
|
121
|
+
local_hash = self.compute_hash(content)
|
|
122
|
+
prev_state = self.state.get(key, {})
|
|
123
|
+
prev_local_hash = prev_state.get("local_hash", "")
|
|
124
|
+
|
|
125
|
+
if local_hash == prev_local_hash:
|
|
126
|
+
result.skipped.append(filename)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Check for conflict: remote also changed
|
|
130
|
+
remote_entry = remote_files.get(filename)
|
|
131
|
+
if remote_entry is not None:
|
|
132
|
+
remote_content, remote_sha = remote_entry
|
|
133
|
+
remote_hash = self.compute_hash(remote_content)
|
|
134
|
+
prev_remote_hash = prev_state.get("remote_hash", "")
|
|
135
|
+
|
|
136
|
+
if remote_hash != prev_remote_hash and local_hash != remote_hash:
|
|
137
|
+
# Conflict
|
|
138
|
+
strategy = self.config.get("conflict_strategy", "merge")
|
|
139
|
+
resolved = self.handle_conflict(
|
|
140
|
+
filename, content, remote_content, strategy
|
|
141
|
+
)
|
|
142
|
+
if resolved is None:
|
|
143
|
+
result.conflicts.append(filename)
|
|
144
|
+
continue
|
|
145
|
+
content = resolved
|
|
146
|
+
local_hash = self.compute_hash(content)
|
|
147
|
+
result.conflicts.append(filename)
|
|
148
|
+
|
|
149
|
+
if dry_run:
|
|
150
|
+
result.pushed.append(filename)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
remote_path = f"projects/{alias}/{filename}"
|
|
155
|
+
sha = remote_files[filename][1] if filename in remote_files else None
|
|
156
|
+
device = self.config.get("device_name", "unknown")
|
|
157
|
+
self.api.put_file(
|
|
158
|
+
remote_path,
|
|
159
|
+
content,
|
|
160
|
+
f"Sync {filename} from {device}",
|
|
161
|
+
sha=sha,
|
|
162
|
+
)
|
|
163
|
+
self.state[key] = {
|
|
164
|
+
"local_hash": local_hash,
|
|
165
|
+
"remote_hash": local_hash,
|
|
166
|
+
"last_sync": self._now_iso(),
|
|
167
|
+
}
|
|
168
|
+
self.save_state()
|
|
169
|
+
result.pushed.append(filename)
|
|
170
|
+
except GitHubAPIError as e:
|
|
171
|
+
result.errors.append(f"{filename}: {e}")
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
def pull(self, alias: str | None = None, dry_run: bool = False) -> SyncResult:
|
|
176
|
+
"""Pull remote changes to local.
|
|
177
|
+
|
|
178
|
+
For each remote file, compare hash to state. If changed, download to local.
|
|
179
|
+
"""
|
|
180
|
+
result = SyncResult()
|
|
181
|
+
if alias is None:
|
|
182
|
+
alias = self._detect_current_alias()
|
|
183
|
+
if alias is None:
|
|
184
|
+
result.errors.append("Cannot detect current project. Specify an alias.")
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
remote_files = self.get_remote_files(alias)
|
|
188
|
+
local_files = self.get_local_files(alias)
|
|
189
|
+
|
|
190
|
+
for filename, (remote_content, remote_sha) in remote_files.items():
|
|
191
|
+
key = self._state_key(alias, filename)
|
|
192
|
+
remote_hash = self.compute_hash(remote_content)
|
|
193
|
+
prev_state = self.state.get(key, {})
|
|
194
|
+
prev_remote_hash = prev_state.get("remote_hash", "")
|
|
195
|
+
|
|
196
|
+
if remote_hash == prev_remote_hash:
|
|
197
|
+
result.skipped.append(filename)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Check for conflict: local also changed
|
|
201
|
+
local_content = local_files.get(filename)
|
|
202
|
+
if local_content is not None:
|
|
203
|
+
local_hash = self.compute_hash(local_content)
|
|
204
|
+
prev_local_hash = prev_state.get("local_hash", "")
|
|
205
|
+
|
|
206
|
+
if local_hash != prev_local_hash and local_hash != remote_hash:
|
|
207
|
+
strategy = self.config.get("conflict_strategy", "merge")
|
|
208
|
+
resolved = self.handle_conflict(
|
|
209
|
+
filename, local_content, remote_content, strategy
|
|
210
|
+
)
|
|
211
|
+
if resolved is None:
|
|
212
|
+
result.conflicts.append(filename)
|
|
213
|
+
continue
|
|
214
|
+
remote_content = resolved
|
|
215
|
+
remote_hash = self.compute_hash(remote_content)
|
|
216
|
+
result.conflicts.append(filename)
|
|
217
|
+
|
|
218
|
+
if dry_run:
|
|
219
|
+
result.pulled.append(filename)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
# Write to all local dirnames for this alias
|
|
224
|
+
for dirname in self._get_local_dirnames(alias):
|
|
225
|
+
memory_dir = get_project_memory_dir(dirname)
|
|
226
|
+
memory_dir.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
target = memory_dir / filename
|
|
228
|
+
target.write_text(remote_content, encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
self.state[key] = {
|
|
231
|
+
"local_hash": remote_hash,
|
|
232
|
+
"remote_hash": remote_hash,
|
|
233
|
+
"last_sync": self._now_iso(),
|
|
234
|
+
}
|
|
235
|
+
self.save_state()
|
|
236
|
+
result.pulled.append(filename)
|
|
237
|
+
except OSError as e:
|
|
238
|
+
result.errors.append(f"{filename}: {e}")
|
|
239
|
+
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
def sync(self, alias: str | None = None, dry_run: bool = False) -> SyncResult:
|
|
243
|
+
"""Pull then push. Handle conflicts using config's conflict_strategy."""
|
|
244
|
+
pull_result = self.pull(alias=alias, dry_run=dry_run)
|
|
245
|
+
push_result = self.push(alias=alias, dry_run=dry_run)
|
|
246
|
+
|
|
247
|
+
return SyncResult(
|
|
248
|
+
pushed=push_result.pushed,
|
|
249
|
+
pulled=pull_result.pulled,
|
|
250
|
+
conflicts=list(set(pull_result.conflicts + push_result.conflicts)),
|
|
251
|
+
skipped=list(set(pull_result.skipped + push_result.skipped)),
|
|
252
|
+
errors=pull_result.errors + push_result.errors,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def handle_conflict(
|
|
256
|
+
self,
|
|
257
|
+
filename: str,
|
|
258
|
+
local_content: str,
|
|
259
|
+
remote_content: str,
|
|
260
|
+
strategy: str,
|
|
261
|
+
) -> str | None:
|
|
262
|
+
"""Resolve a conflict between local and remote content.
|
|
263
|
+
|
|
264
|
+
Returns resolved content, or None if unresolved (e.g. user chose to skip).
|
|
265
|
+
"""
|
|
266
|
+
if strategy == "latest-wins":
|
|
267
|
+
# On push, local wins; caller context determines which
|
|
268
|
+
return local_content
|
|
269
|
+
elif strategy == "merge":
|
|
270
|
+
return merge_markdown(local_content, remote_content)
|
|
271
|
+
elif strategy == "ask":
|
|
272
|
+
print(f"\nConflict in {filename}:")
|
|
273
|
+
print(" [l] Keep local version")
|
|
274
|
+
print(" [r] Keep remote version")
|
|
275
|
+
print(" [m] Merge both versions")
|
|
276
|
+
print(" [s] Skip this file")
|
|
277
|
+
choice = input("Choice [l/r/m/s]: ").strip().lower()
|
|
278
|
+
if choice == "l":
|
|
279
|
+
return local_content
|
|
280
|
+
elif choice == "r":
|
|
281
|
+
return remote_content
|
|
282
|
+
elif choice == "m":
|
|
283
|
+
return merge_markdown(local_content, remote_content)
|
|
284
|
+
else:
|
|
285
|
+
return None
|
|
286
|
+
else:
|
|
287
|
+
return merge_markdown(local_content, remote_content)
|
|
288
|
+
|
|
289
|
+
def migrate_to_alias(self, old_dirname: str, new_alias: str) -> dict:
|
|
290
|
+
"""Migrate remote data from a raw dirname path to an alias path.
|
|
291
|
+
|
|
292
|
+
Copies files from projects/{old_dirname}/ to projects/{new_alias}/,
|
|
293
|
+
deletes the originals, and updates state keys.
|
|
294
|
+
|
|
295
|
+
Returns dict with 'migrated' (list of filenames) and 'errors' (list of str).
|
|
296
|
+
"""
|
|
297
|
+
result = {"migrated": [], "errors": []}
|
|
298
|
+
old_path = f"projects/{old_dirname}"
|
|
299
|
+
items = self.api.list_dir(old_path)
|
|
300
|
+
|
|
301
|
+
for item in items:
|
|
302
|
+
if item["type"] != "file":
|
|
303
|
+
continue
|
|
304
|
+
filename = item["name"]
|
|
305
|
+
try:
|
|
306
|
+
data = self.api.get_file(item["path"])
|
|
307
|
+
if data is None:
|
|
308
|
+
result["errors"].append(f"{filename}: could not read")
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Copy to new location
|
|
312
|
+
new_path = f"projects/{new_alias}/{filename}"
|
|
313
|
+
existing = self.api.get_file(new_path)
|
|
314
|
+
sha = existing["sha"] if existing else None
|
|
315
|
+
self.api.put_file(
|
|
316
|
+
new_path,
|
|
317
|
+
data["content"],
|
|
318
|
+
f"Migrate {filename} from {old_dirname} to {new_alias}",
|
|
319
|
+
sha=sha,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Delete from old location
|
|
323
|
+
self.api.delete_file(
|
|
324
|
+
item["path"],
|
|
325
|
+
item["sha"],
|
|
326
|
+
f"Migrate {filename}: moved to {new_alias}",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Update state keys
|
|
330
|
+
old_key = self._state_key(old_dirname, filename)
|
|
331
|
+
new_key = self._state_key(new_alias, filename)
|
|
332
|
+
if old_key in self.state:
|
|
333
|
+
self.state[new_key] = self.state.pop(old_key)
|
|
334
|
+
|
|
335
|
+
result["migrated"].append(filename)
|
|
336
|
+
except GitHubAPIError as e:
|
|
337
|
+
result["errors"].append(f"{filename}: {e}")
|
|
338
|
+
|
|
339
|
+
if result["migrated"]:
|
|
340
|
+
self.save_state()
|
|
341
|
+
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
def _detect_current_alias(self) -> str | None:
|
|
345
|
+
"""Detect alias for the current working directory's project."""
|
|
346
|
+
from .discovery import get_current_project
|
|
347
|
+
dirname = get_current_project()
|
|
348
|
+
if dirname is None:
|
|
349
|
+
return None
|
|
350
|
+
return self._resolve_alias_for_dirname(dirname)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-memory-sync
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Sync Claude Code memory files across machines via GitHub
|
|
5
|
+
Author-email: Tanmay Kallakuri <tanmay@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/TanmayKallakuri/claude-memory-sync
|
|
8
|
+
Project-URL: Repository, https://github.com/TanmayKallakuri/claude-memory-sync
|
|
9
|
+
Project-URL: Issues, https://github.com/TanmayKallakuri/claude-memory-sync/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: requests>=2.28.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# claude-memory-sync
|
|
28
|
+
|
|
29
|
+
Sync Claude Code memory files across machines via GitHub.
|
|
30
|
+
|
|
31
|
+
Claude Code stores per-project memory in `~/.claude/projects/<dirname>/memory/`. This tool syncs those files to a private GitHub repo so your memory follows you across devices.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install claude-memory-sync
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 1. Run setup (needs gh CLI authenticated or a GitHub token)
|
|
43
|
+
claude-memory setup
|
|
44
|
+
|
|
45
|
+
# 2. Push your local memory to GitHub
|
|
46
|
+
claude-memory push
|
|
47
|
+
|
|
48
|
+
# 3. Pull memory on another machine
|
|
49
|
+
claude-memory pull
|
|
50
|
+
|
|
51
|
+
# 4. Bidirectional sync (pull then push)
|
|
52
|
+
claude-memory sync
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Cross-Device Setup
|
|
56
|
+
|
|
57
|
+
Different machines encode project paths differently (`C--Users-ktanm` on Windows vs `-Users-ktanm` on Mac). Aliases map these to a shared name so syncing works seamlessly.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Auto-create aliases from matching project names
|
|
61
|
+
claude-memory alias auto
|
|
62
|
+
|
|
63
|
+
# Or manually add an alias
|
|
64
|
+
claude-memory alias add home --dirname C--Users-ktanm
|
|
65
|
+
|
|
66
|
+
# See what aliases exist
|
|
67
|
+
claude-memory alias list
|
|
68
|
+
|
|
69
|
+
# Sync aliases to/from remote
|
|
70
|
+
claude-memory alias sync
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
When you run `claude-memory setup` on a new device, the setup wizard will automatically discover remote projects and let you map them to local ones.
|
|
74
|
+
|
|
75
|
+
### Migrating Existing Data
|
|
76
|
+
|
|
77
|
+
If you already pushed data under a raw dirname and want to consolidate under an alias:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
claude-memory alias migrate --dirname C--Users-ktanm --alias home
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Auto-Sync with Claude Code Hooks
|
|
84
|
+
|
|
85
|
+
Install hooks so memory syncs automatically when Claude Code sessions start and stop:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Install hooks into ~/.claude/settings.json
|
|
89
|
+
claude-memory hooks install
|
|
90
|
+
|
|
91
|
+
# Remove hooks
|
|
92
|
+
claude-memory hooks remove
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Commands
|
|
96
|
+
|
|
97
|
+
| Command | Description |
|
|
98
|
+
|---------|-------------|
|
|
99
|
+
| `setup` | Interactive setup wizard |
|
|
100
|
+
| `push` | Upload local memory to GitHub |
|
|
101
|
+
| `pull` | Download memory from GitHub |
|
|
102
|
+
| `sync` | Bidirectional sync (pull then push) |
|
|
103
|
+
| `alias` | Manage project aliases (add, remove, list, suggest, auto, sync, migrate) |
|
|
104
|
+
| `hooks` | Install/remove Claude Code auto-sync hooks |
|
|
105
|
+
| `status` | Show configuration and sync state |
|
|
106
|
+
| `doctor` | Diagnose configuration issues |
|
|
107
|
+
|
|
108
|
+
All sync commands support `--dry-run` and `--project <alias>` flags.
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
Config is stored at `~/.claude-memory-sync/config.json`:
|
|
113
|
+
|
|
114
|
+
- **device_name**: Identifier for this machine
|
|
115
|
+
- **github_repo**: GitHub repo in `owner/repo` format
|
|
116
|
+
- **conflict_strategy**: How to handle conflicts (`merge`, `latest-wins`, or `ask`)
|
|
117
|
+
|
|
118
|
+
GitHub token is resolved from (in order):
|
|
119
|
+
1. `CLAUDE_MEMORY_SYNC_TOKEN` env var
|
|
120
|
+
2. `GH_TOKEN` env var
|
|
121
|
+
3. `gh auth token` CLI
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
claude_memory_sync/__init__.py,sha256=DmZ3Ae9kQVFmYHuZvtCxse4uksTIlvj4k_SF-K5zChw,111
|
|
2
|
+
claude_memory_sync/__main__.py,sha256=MeDq0gFkTbSEU8bnTZLoM5jiNjXqFqoiMCbB-jnJCWM,91
|
|
3
|
+
claude_memory_sync/alias.py,sha256=0WVwm2OPAn2wIjnGvUT6q2jWTXPV55ZbKDfgrUflZxY,3950
|
|
4
|
+
claude_memory_sync/cli.py,sha256=T2nEPp5ehSuYxDCyyvvQkWQCQ_VSTScVNpjpE1KXNew,16662
|
|
5
|
+
claude_memory_sync/config.py,sha256=fWsvtirOhTek3odwbuKepSQoj40kNQw2sbJZZOadu4o,7744
|
|
6
|
+
claude_memory_sync/discovery.py,sha256=w4yg0FWzvoLAvQGLBlxPFD72EnYfngcRdzqWDzcbE-M,1781
|
|
7
|
+
claude_memory_sync/github_api.py,sha256=pgfhwmY1NDikjJ1DCl9zkmBo06WQlIIGt49wcwZFt9c,4801
|
|
8
|
+
claude_memory_sync/hooks.py,sha256=y7pT1H7vKDKg5NtjKgjLcGcJCDK8IgFIJt5TmgztTFY,6485
|
|
9
|
+
claude_memory_sync/merge.py,sha256=_DGaaSyQyUmpGVdB_CllL_-kc_Xo7thohtlqYSMd8gw,3826
|
|
10
|
+
claude_memory_sync/paths.py,sha256=xvLxnw9m8CZBFXALBUS265y3WrepGZH-fbLz08jj49E,1891
|
|
11
|
+
claude_memory_sync/sync_engine.py,sha256=81EY3ee4efQLRyS4oJGT-3JuHapWKfsHhY6cu7aGRyo,13796
|
|
12
|
+
claude_memory_sync-0.2.0.dist-info/licenses/LICENSE,sha256=_L5S7aSvaX2O38w3pWYfGL_EamTm_G41yXrsREH9Dms,1073
|
|
13
|
+
claude_memory_sync-0.2.0.dist-info/METADATA,sha256=vWvuVUkGAKqzNDMafUHt2dBWpjuXPINJNoXg3pAObjw,3879
|
|
14
|
+
claude_memory_sync-0.2.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
15
|
+
claude_memory_sync-0.2.0.dist-info/entry_points.txt,sha256=69fA-2Ui742r-ESdZ4XXWKFwTdpDW_Ul3rQNADTjI7k,194
|
|
16
|
+
claude_memory_sync-0.2.0.dist-info/top_level.txt,sha256=p4NXBnT6stW89-zuTdaPiKz_GG22vi3tw_JqDkWp_5c,19
|
|
17
|
+
claude_memory_sync-0.2.0.dist-info/RECORD,,
|