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,245 @@
1
+ """Configuration management for Claude Memory Sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from .paths import get_config_dir
11
+
12
+
13
+ CONFIG_FILE = "config.json"
14
+
15
+ DEFAULT_CONFIG = {
16
+ "device_name": "",
17
+ "github_repo": "",
18
+ "conflict_strategy": "merge",
19
+ }
20
+
21
+
22
+ def _config_path() -> Path:
23
+ return get_config_dir() / CONFIG_FILE
24
+
25
+
26
+ def load_config() -> dict:
27
+ """Load config from disk. Returns empty dict if no config file exists."""
28
+ path = _config_path()
29
+ if not path.exists():
30
+ return {}
31
+ with open(path, "r", encoding="utf-8") as f:
32
+ return json.load(f)
33
+
34
+
35
+ def save_config(config: dict) -> None:
36
+ """Save config dict to disk."""
37
+ path = _config_path()
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ with open(path, "w", encoding="utf-8") as f:
40
+ json.dump(config, f, indent=2)
41
+
42
+
43
+ def get_token() -> str:
44
+ """Resolve GitHub token from environment or gh CLI.
45
+
46
+ Resolution order:
47
+ 1. CLAUDE_MEMORY_SYNC_TOKEN env var
48
+ 2. GH_TOKEN env var
49
+ 3. `gh auth token` subprocess call
50
+
51
+ Raises RuntimeError if no token is found.
52
+ """
53
+ # 1. Dedicated env var
54
+ token = os.environ.get("CLAUDE_MEMORY_SYNC_TOKEN", "").strip()
55
+ if token:
56
+ return token
57
+
58
+ # 2. GH_TOKEN env var
59
+ token = os.environ.get("GH_TOKEN", "").strip()
60
+ if token:
61
+ return token
62
+
63
+ # 3. gh CLI
64
+ try:
65
+ result = subprocess.run(
66
+ ["gh", "auth", "token"],
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=10,
70
+ )
71
+ if result.returncode == 0:
72
+ token = result.stdout.strip()
73
+ if token:
74
+ return token
75
+ except (FileNotFoundError, subprocess.TimeoutExpired):
76
+ pass
77
+
78
+ raise RuntimeError(
79
+ "No GitHub token found. Set CLAUDE_MEMORY_SYNC_TOKEN or GH_TOKEN, "
80
+ "or authenticate with `gh auth login`."
81
+ )
82
+
83
+
84
+ def is_configured() -> bool:
85
+ """Check if the tool has been configured."""
86
+ config = load_config()
87
+ return bool(config.get("device_name") and config.get("github_repo"))
88
+
89
+
90
+ def setup_wizard() -> None:
91
+ """Interactive setup wizard to configure the tool."""
92
+ print("=== Claude Memory Sync Setup ===\n")
93
+
94
+ config = load_config()
95
+
96
+ # Device name
97
+ default_device = config.get("device_name", "")
98
+ prompt = f"Device name [{default_device}]: " if default_device else "Device name: "
99
+ device_name = input(prompt).strip() or default_device
100
+ if not device_name:
101
+ print("Error: Device name is required.")
102
+ return
103
+
104
+ # GitHub repo
105
+ default_repo = config.get("github_repo", "")
106
+ prompt = f"GitHub repo (owner/repo) [{default_repo}]: " if default_repo else "GitHub repo (owner/repo): "
107
+ github_repo = input(prompt).strip() or default_repo
108
+ if not github_repo or "/" not in github_repo:
109
+ print("Error: GitHub repo must be in 'owner/repo' format.")
110
+ return
111
+
112
+ # Validate token
113
+ token = None
114
+ try:
115
+ token = get_token()
116
+ print(f"GitHub token found ({token[:4]}...)")
117
+ except RuntimeError as e:
118
+ print(f"Warning: {e}")
119
+ print("You can set a token later via environment variables.")
120
+
121
+ # Save basic config
122
+ config.update({
123
+ "device_name": device_name,
124
+ "github_repo": github_repo,
125
+ "conflict_strategy": config.get("conflict_strategy", "merge"),
126
+ })
127
+ save_config(config)
128
+ print(f"\nConfig saved to {_config_path()}")
129
+
130
+ # Cross-device alias setup (only if we have a token)
131
+ if token is None:
132
+ return
133
+
134
+ print("\n=== Cross-Device Setup ===\n")
135
+ try:
136
+ _setup_cross_device_aliases(config, token, github_repo)
137
+ except Exception as e:
138
+ print(f"Cross-device setup skipped: {e}")
139
+
140
+
141
+ def _setup_cross_device_aliases(config: dict, token: str, repo: str) -> None:
142
+ """Interactive cross-device alias mapping during setup."""
143
+ from .alias import (
144
+ add_alias,
145
+ get_remote_project_dirnames,
146
+ list_aliases,
147
+ sync_aliases_from_remote,
148
+ sync_aliases_to_remote,
149
+ )
150
+ from .discovery import discover_projects
151
+ from .github_api import GitHubAPI
152
+ from .paths import project_dirname_to_display
153
+
154
+ api = GitHubAPI(repo, token)
155
+
156
+ # Pull existing remote aliases
157
+ sync_aliases_from_remote(api)
158
+ aliases = list_aliases()
159
+
160
+ # Discover local projects with memory files
161
+ local_projects = [p for p in discover_projects() if p["has_memory"]]
162
+ if not local_projects:
163
+ print("No local projects with memory files found.")
164
+ return
165
+
166
+ # Get remote project dirnames
167
+ remote_dirnames = get_remote_project_dirnames(api)
168
+
169
+ # Find local projects not yet mapped to any alias
170
+ unmapped_local = []
171
+ for p in local_projects:
172
+ mapped = False
173
+ for dirnames in aliases.values():
174
+ if p["dirname"] in dirnames:
175
+ mapped = True
176
+ break
177
+ if not mapped:
178
+ unmapped_local.append(p)
179
+
180
+ # Find remote dirnames not yet mapped to any alias
181
+ all_mapped_dirnames = set()
182
+ for dirnames in aliases.values():
183
+ all_mapped_dirnames.update(dirnames)
184
+ unmapped_remote = [d for d in remote_dirnames if d not in all_mapped_dirnames]
185
+
186
+ if not unmapped_local and not unmapped_remote:
187
+ print("All projects are already aliased.")
188
+ return
189
+
190
+ if unmapped_remote:
191
+ print("Remote projects not yet mapped to local:")
192
+ for i, d in enumerate(unmapped_remote, 1):
193
+ display = project_dirname_to_display(d)
194
+ print(f" {i}. {d} ({display})")
195
+ print()
196
+
197
+ if unmapped_local:
198
+ print("Local projects without an alias:")
199
+ for p in unmapped_local:
200
+ display = project_dirname_to_display(p["dirname"])
201
+ print(f" {p['dirname']} ({display})")
202
+ print()
203
+
204
+ # Interactive mapping
205
+ for p in unmapped_local:
206
+ display = project_dirname_to_display(p["dirname"])
207
+ print(f"\nMap '{p['dirname']}' ({display}):")
208
+
209
+ # Show options: existing aliases + remote unmapped dirnames + new alias
210
+ options = []
211
+ if aliases:
212
+ print(" Existing aliases:")
213
+ for i, (name, dirnames) in enumerate(aliases.items(), 1):
214
+ print(f" {i}. {name}")
215
+ options.append(("existing", name))
216
+
217
+ new_idx = len(options) + 1
218
+ print(f" {new_idx}. Create new alias")
219
+ print(f" s. Skip")
220
+
221
+ choice = input(" Choice: ").strip().lower()
222
+ if choice == "s":
223
+ continue
224
+ try:
225
+ idx = int(choice)
226
+ if 1 <= idx <= len(options):
227
+ _, alias_name = options[idx - 1]
228
+ add_alias(alias_name, p["dirname"])
229
+ print(f" Added '{p['dirname']}' to alias '{alias_name}'.")
230
+ elif idx == new_idx:
231
+ alias_name = input(" New alias name: ").strip()
232
+ if alias_name:
233
+ add_alias(alias_name, p["dirname"])
234
+ print(f" Created alias '{alias_name}'.")
235
+ aliases = list_aliases() # refresh
236
+ except (ValueError, IndexError):
237
+ print(" Skipped.")
238
+
239
+ # Push updated aliases to remote
240
+ updated_aliases = list_aliases()
241
+ if updated_aliases:
242
+ push_choice = input("\nPush aliases to remote? [Y/n]: ").strip().lower()
243
+ if push_choice != "n":
244
+ sync_aliases_to_remote(api)
245
+ print("Aliases pushed to remote.")
@@ -0,0 +1,54 @@
1
+ """Discover local Claude memory directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .paths import get_projects_dir, path_to_project_dirname
9
+
10
+
11
+ def discover_projects() -> list[dict]:
12
+ """Scan ~/.claude/projects/ for directories containing memory files.
13
+
14
+ Returns a list of dicts with keys:
15
+ dirname: str — the project directory name
16
+ path: Path — full path to the project directory
17
+ has_memory: bool — whether a memory/ subdirectory exists with .md files
18
+ memory_files: list[Path] — list of .md files in the memory/ subdirectory
19
+ """
20
+ projects_dir = get_projects_dir()
21
+ if not projects_dir.exists():
22
+ return []
23
+
24
+ results = []
25
+ for entry in sorted(projects_dir.iterdir()):
26
+ if not entry.is_dir():
27
+ continue
28
+ memory_dir = entry / "memory"
29
+ memory_files = sorted(memory_dir.glob("*.md")) if memory_dir.is_dir() else []
30
+ results.append({
31
+ "dirname": entry.name,
32
+ "path": entry,
33
+ "has_memory": len(memory_files) > 0,
34
+ "memory_files": memory_files,
35
+ })
36
+ return results
37
+
38
+
39
+ def get_project_memory_dir(project_dirname: str) -> Path:
40
+ """Return the path to the memory directory for a specific project."""
41
+ return get_projects_dir() / project_dirname / "memory"
42
+
43
+
44
+ def get_current_project() -> str | None:
45
+ """Detect the current project dirname based on the current working directory.
46
+
47
+ Returns None if the CWD doesn't map to a known project.
48
+ """
49
+ cwd = os.getcwd()
50
+ dirname = path_to_project_dirname(cwd)
51
+ project_dir = get_projects_dir() / dirname
52
+ if project_dir.is_dir():
53
+ return dirname
54
+ return None
@@ -0,0 +1,134 @@
1
+ """GitHub REST API client for Contents API operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+
7
+ import requests
8
+
9
+
10
+ class GitHubAPIError(Exception):
11
+ """Raised when a GitHub API call fails."""
12
+
13
+ def __init__(self, message: str, status_code: int | None = None):
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+
17
+
18
+ class GitHubAPI:
19
+ """Minimal client for GitHub Contents API."""
20
+
21
+ BASE_URL = "https://api.github.com"
22
+
23
+ def __init__(self, repo: str, token: str):
24
+ self.repo = repo
25
+ self.token = token
26
+ self.session = requests.Session()
27
+ self.session.headers.update({
28
+ "Authorization": f"token {token}",
29
+ "Accept": "application/vnd.github.v3+json",
30
+ "X-GitHub-Api-Version": "2022-11-28",
31
+ })
32
+
33
+ def _contents_url(self, path: str = "") -> str:
34
+ path = path.strip("/")
35
+ return f"{self.BASE_URL}/repos/{self.repo}/contents/{path}"
36
+
37
+ def _check_rate_limit(self, resp: requests.Response) -> None:
38
+ if resp.status_code == 403:
39
+ remaining = resp.headers.get("X-RateLimit-Remaining", "")
40
+ if remaining == "0":
41
+ raise GitHubAPIError(
42
+ "GitHub API rate limit exceeded. Wait a few minutes and retry.",
43
+ status_code=403,
44
+ )
45
+
46
+ def _check_response(self, resp: requests.Response, context: str = "") -> None:
47
+ self._check_rate_limit(resp)
48
+ if resp.status_code >= 400:
49
+ msg = f"GitHub API error ({resp.status_code})"
50
+ if context:
51
+ msg += f" {context}"
52
+ try:
53
+ body = resp.json()
54
+ msg += f": {body.get('message', '')}"
55
+ except ValueError:
56
+ pass
57
+ raise GitHubAPIError(msg, status_code=resp.status_code)
58
+
59
+ def get_file(self, path: str) -> dict | None:
60
+ """Get a file's content and metadata.
61
+
62
+ Returns dict with keys: content (str, decoded), sha (str), encoding.
63
+ Returns None if the file does not exist (404).
64
+ """
65
+ resp = self.session.get(self._contents_url(path))
66
+ if resp.status_code == 404:
67
+ return None
68
+ self._check_response(resp, f"getting {path}")
69
+ data = resp.json()
70
+ content = base64.b64decode(data["content"]).decode("utf-8")
71
+ return {
72
+ "content": content,
73
+ "sha": data["sha"],
74
+ "encoding": data.get("encoding", "base64"),
75
+ }
76
+
77
+ def put_file(
78
+ self, path: str, content: str, message: str, sha: str | None = None
79
+ ) -> dict:
80
+ """Create or update a file. sha is required for updates."""
81
+ encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
82
+ payload: dict = {
83
+ "message": message,
84
+ "content": encoded,
85
+ }
86
+ if sha is not None:
87
+ payload["sha"] = sha
88
+ resp = self.session.put(self._contents_url(path), json=payload)
89
+ self._check_response(resp, f"putting {path}")
90
+ return resp.json()
91
+
92
+ def delete_file(self, path: str, sha: str, message: str) -> dict:
93
+ """Delete a file from the repo."""
94
+ payload = {
95
+ "message": message,
96
+ "sha": sha,
97
+ }
98
+ resp = self.session.delete(self._contents_url(path), json=payload)
99
+ self._check_response(resp, f"deleting {path}")
100
+ return resp.json()
101
+
102
+ def list_dir(self, path: str = "") -> list[dict]:
103
+ """List contents of a directory.
104
+
105
+ Returns list of dicts with keys: name, path, type, sha.
106
+ Returns empty list if path doesn't exist.
107
+ """
108
+ resp = self.session.get(self._contents_url(path))
109
+ if resp.status_code == 404:
110
+ return []
111
+ self._check_response(resp, f"listing {path}")
112
+ data = resp.json()
113
+ if not isinstance(data, list):
114
+ return []
115
+ return [
116
+ {"name": item["name"], "path": item["path"], "type": item["type"], "sha": item["sha"]}
117
+ for item in data
118
+ ]
119
+
120
+ def get_tree(self, path: str = "") -> list[dict]:
121
+ """Recursively list all files under a path."""
122
+ results = []
123
+ items = self.list_dir(path)
124
+ for item in items:
125
+ if item["type"] == "file":
126
+ results.append(item)
127
+ elif item["type"] == "dir":
128
+ results.extend(self.get_tree(item["path"]))
129
+ return results
130
+
131
+ def test_connection(self) -> bool:
132
+ """Verify the repo exists and the token has access."""
133
+ resp = self.session.get(f"{self.BASE_URL}/repos/{self.repo}")
134
+ return resp.status_code == 200
@@ -0,0 +1,214 @@
1
+ """Claude Code hooks for automatic memory sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def _get_settings_path() -> Path:
12
+ """Return path to ~/.claude/settings.json."""
13
+ return Path.home() / ".claude" / "settings.json"
14
+
15
+
16
+ def _load_settings() -> dict:
17
+ """Load Claude Code settings."""
18
+ path = _get_settings_path()
19
+ if path.exists():
20
+ with open(path, "r", encoding="utf-8") as f:
21
+ return json.load(f)
22
+ return {}
23
+
24
+
25
+ def _save_settings(settings: dict) -> None:
26
+ """Save Claude Code settings."""
27
+ path = _get_settings_path()
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ with open(path, "w", encoding="utf-8") as f:
30
+ json.dump(settings, f, indent=2)
31
+
32
+
33
+ HOOK_PULL = {
34
+ "matcher": "SessionStart",
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "claude-memory-hook-pull",
39
+ }
40
+ ],
41
+ }
42
+
43
+ HOOK_PUSH = {
44
+ "matcher": "Stop",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "claude-memory-hook-push",
49
+ }
50
+ ],
51
+ }
52
+
53
+
54
+ def _get_shim_dir() -> Path:
55
+ """Return a directory on PATH where we can place command shims.
56
+
57
+ Tries ~/.local/bin, ~/bin, then falls back to creating ~/.local/bin.
58
+ """
59
+ candidates = [
60
+ Path.home() / ".local" / "bin",
61
+ Path.home() / "bin",
62
+ ]
63
+ # Prefer a directory that already exists AND is on PATH
64
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
65
+ for candidate in candidates:
66
+ if candidate.is_dir() and any(
67
+ Path(p).resolve() == candidate.resolve()
68
+ for p in path_dirs
69
+ if p
70
+ ):
71
+ return candidate
72
+
73
+ # Prefer a directory that exists even if not on PATH
74
+ for candidate in candidates:
75
+ if candidate.is_dir():
76
+ return candidate
77
+
78
+ # Default: create ~/.local/bin
79
+ default = Path.home() / ".local" / "bin"
80
+ default.mkdir(parents=True, exist_ok=True)
81
+ return default
82
+
83
+
84
+ def install_shims() -> list[str]:
85
+ """Create command shims for Windows (Microsoft Store Python PATH workaround).
86
+
87
+ Returns list of created shim paths.
88
+ """
89
+ if sys.platform != "win32":
90
+ return []
91
+
92
+ shim_dir = _get_shim_dir()
93
+ shim_dir.mkdir(parents=True, exist_ok=True)
94
+
95
+ shims = {
96
+ "claude-memory.cmd": '@echo off\npython -m claude_memory_sync %*\n',
97
+ "claude-memory-hook-pull.cmd": '@echo off\npython -c "from claude_memory_sync.hooks import hook_auto_pull; hook_auto_pull()" %*\n',
98
+ "claude-memory-hook-push.cmd": '@echo off\npython -c "from claude_memory_sync.hooks import hook_auto_push; hook_auto_push()" %*\n',
99
+ }
100
+
101
+ created = []
102
+ for name, content in shims.items():
103
+ path = shim_dir / name
104
+ path.write_text(content)
105
+ created.append(str(path))
106
+
107
+ return created
108
+
109
+
110
+ def install_hooks() -> None:
111
+ """Add auto-sync hooks to Claude Code settings."""
112
+ # On Windows, ensure command shims exist for PATH reliability
113
+ if sys.platform == "win32":
114
+ created = install_shims()
115
+ if created:
116
+ print(f" Created command shims in {Path(created[0]).parent}")
117
+
118
+ settings = _load_settings()
119
+ hooks = settings.setdefault("hooks", [])
120
+
121
+ # Check if hooks already installed
122
+ existing_commands = set()
123
+ for hook_entry in hooks:
124
+ for h in hook_entry.get("hooks", []):
125
+ existing_commands.add(h.get("command", ""))
126
+
127
+ added = []
128
+ if "claude-memory-hook-pull" not in existing_commands:
129
+ hooks.append(HOOK_PULL)
130
+ added.append("pull (SessionStart)")
131
+ if "claude-memory-hook-push" not in existing_commands:
132
+ hooks.append(HOOK_PUSH)
133
+ added.append("push (Stop)")
134
+
135
+ if added:
136
+ _save_settings(settings)
137
+ for a in added:
138
+ print(f" Installed hook: {a}")
139
+ else:
140
+ print(" Hooks already installed.")
141
+
142
+
143
+ def remove_hooks() -> None:
144
+ """Remove auto-sync hooks from Claude Code settings."""
145
+ settings = _load_settings()
146
+ hooks = settings.get("hooks", [])
147
+ if not hooks:
148
+ print(" No hooks to remove.")
149
+ return
150
+
151
+ hook_commands = {"claude-memory-hook-pull", "claude-memory-hook-push"}
152
+ new_hooks = [
153
+ entry for entry in hooks
154
+ if not any(
155
+ h.get("command", "") in hook_commands
156
+ for h in entry.get("hooks", [])
157
+ )
158
+ ]
159
+
160
+ removed = len(hooks) - len(new_hooks)
161
+ if removed:
162
+ settings["hooks"] = new_hooks
163
+ _save_settings(settings)
164
+ print(f" Removed {removed} hook(s).")
165
+ else:
166
+ print(" No memory sync hooks found.")
167
+
168
+
169
+ def hook_auto_pull() -> None:
170
+ """Entry point for SessionStart hook — pull memory from remote."""
171
+ from .config import get_token, is_configured, load_config
172
+ from .github_api import GitHubAPI
173
+ from .sync_engine import SyncEngine
174
+
175
+ if not is_configured():
176
+ return
177
+
178
+ try:
179
+ config = load_config()
180
+ token = get_token()
181
+ api = GitHubAPI(config["github_repo"], token)
182
+ engine = SyncEngine(config, api)
183
+ result = engine.pull()
184
+ if result.pulled:
185
+ print(f"[claude-memory] Pulled: {', '.join(result.pulled)}", file=sys.stderr)
186
+ if result.errors:
187
+ for err in result.errors:
188
+ print(f"[claude-memory] Error: {err}", file=sys.stderr)
189
+ except Exception as e:
190
+ print(f"[claude-memory] Pull failed: {e}", file=sys.stderr)
191
+
192
+
193
+ def hook_auto_push() -> None:
194
+ """Entry point for Stop hook — push memory to remote."""
195
+ from .config import get_token, is_configured, load_config
196
+ from .github_api import GitHubAPI
197
+ from .sync_engine import SyncEngine
198
+
199
+ if not is_configured():
200
+ return
201
+
202
+ try:
203
+ config = load_config()
204
+ token = get_token()
205
+ api = GitHubAPI(config["github_repo"], token)
206
+ engine = SyncEngine(config, api)
207
+ result = engine.push()
208
+ if result.pushed:
209
+ print(f"[claude-memory] Pushed: {', '.join(result.pushed)}", file=sys.stderr)
210
+ if result.errors:
211
+ for err in result.errors:
212
+ print(f"[claude-memory] Error: {err}", file=sys.stderr)
213
+ except Exception as e:
214
+ print(f"[claude-memory] Push failed: {e}", file=sys.stderr)