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,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)
|