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.
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ claude-memory = claude_memory_sync.cli:main
3
+ claude-memory-hook-pull = claude_memory_sync.hooks:hook_auto_pull
4
+ claude-memory-hook-push = claude_memory_sync.hooks:hook_auto_push