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,3 @@
1
+ """Claude Memory Sync - Sync Claude Code memory files across machines via GitHub."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m claude_memory_sync`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,122 @@
1
+ """Cross-platform project aliasing for matching projects across machines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import defaultdict
7
+
8
+ from .config import load_config, save_config
9
+ from .github_api import GitHubAPI
10
+ from .paths import project_dirname_to_display
11
+
12
+
13
+ REMOTE_ALIASES_PATH = "_meta/aliases.json"
14
+
15
+
16
+ def _get_aliases(config: dict | None = None) -> dict[str, list[str]]:
17
+ if config is None:
18
+ config = load_config()
19
+ return config.get("aliases", {})
20
+
21
+
22
+ def add_alias(alias: str, dirname: str | None = None) -> None:
23
+ """Add a project dirname to an alias. Creates alias if new."""
24
+ if dirname is None:
25
+ from .discovery import get_current_project
26
+ dirname = get_current_project()
27
+ if dirname is None:
28
+ raise RuntimeError("Cannot detect current project. Specify a dirname explicitly.")
29
+
30
+ config = load_config()
31
+ aliases = config.setdefault("aliases", {})
32
+ dirnames = aliases.setdefault(alias, [])
33
+ if dirname not in dirnames:
34
+ dirnames.append(dirname)
35
+ save_config(config)
36
+
37
+
38
+ def remove_alias(alias: str) -> None:
39
+ """Remove an alias entirely."""
40
+ config = load_config()
41
+ aliases = config.get("aliases", {})
42
+ if alias in aliases:
43
+ del aliases[alias]
44
+ save_config(config)
45
+
46
+
47
+ def list_aliases() -> dict[str, list[str]]:
48
+ """Return all aliases as alias -> list of dirnames."""
49
+ return _get_aliases()
50
+
51
+
52
+ def resolve_alias(dirname: str) -> str:
53
+ """Given a project dirname, return its alias (or dirname if no alias)."""
54
+ aliases = _get_aliases()
55
+ for alias, dirnames in aliases.items():
56
+ if dirname in dirnames:
57
+ return alias
58
+ return dirname
59
+
60
+
61
+ def resolve_dirname(alias: str) -> list[str]:
62
+ """Given an alias, return all associated dirnames."""
63
+ aliases = _get_aliases()
64
+ return aliases.get(alias, [])
65
+
66
+
67
+ def suggest_aliases(dirnames: list[str]) -> dict[str, list[str]]:
68
+ """Auto-match heuristic: group dirnames that share the same leaf directory name.
69
+
70
+ For example, if two machines both have projects ending in "my-project",
71
+ they likely refer to the same project.
72
+ """
73
+ by_leaf: dict[str, list[str]] = defaultdict(list)
74
+ for dirname in dirnames:
75
+ # Get the last path component from the display path
76
+ display = project_dirname_to_display(dirname)
77
+ leaf = display.rstrip("/").rsplit("/", 1)[-1]
78
+ by_leaf[leaf].append(dirname)
79
+
80
+ # Only suggest aliases for groups with more than one dirname
81
+ return {leaf: dns for leaf, dns in by_leaf.items() if len(dns) > 1}
82
+
83
+
84
+ def sync_aliases_to_remote(api: GitHubAPI) -> None:
85
+ """Push local aliases to _meta/aliases.json in the repo."""
86
+ aliases = _get_aliases()
87
+ content = json.dumps(aliases, indent=2)
88
+
89
+ existing = api.get_file(REMOTE_ALIASES_PATH)
90
+ sha = existing["sha"] if existing else None
91
+ api.put_file(
92
+ REMOTE_ALIASES_PATH,
93
+ content,
94
+ "Update aliases",
95
+ sha=sha,
96
+ )
97
+
98
+
99
+ def get_remote_project_dirnames(api: GitHubAPI) -> list[str]:
100
+ """List all project directory names on the remote repo."""
101
+ items = api.list_dir("projects")
102
+ return [item["name"] for item in items if item["type"] == "dir"]
103
+
104
+
105
+ def sync_aliases_from_remote(api: GitHubAPI) -> None:
106
+ """Pull remote aliases and merge into local config."""
107
+ existing = api.get_file(REMOTE_ALIASES_PATH)
108
+ if existing is None:
109
+ return
110
+
111
+ remote_aliases = json.loads(existing["content"])
112
+ config = load_config()
113
+ local_aliases = config.setdefault("aliases", {})
114
+
115
+ # Merge: union of dirnames for each alias
116
+ for alias, dirnames in remote_aliases.items():
117
+ local_list = local_aliases.setdefault(alias, [])
118
+ for d in dirnames:
119
+ if d not in local_list:
120
+ local_list.append(d)
121
+
122
+ save_config(config)
@@ -0,0 +1,454 @@
1
+ """CLI entry point for Claude Memory Sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+
10
+
11
+ def _is_tty() -> bool:
12
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
13
+
14
+
15
+ def _bold(text: str) -> str:
16
+ return f"\033[1m{text}\033[0m" if _is_tty() else text
17
+
18
+
19
+ def _green(text: str) -> str:
20
+ return f"\033[32m{text}\033[0m" if _is_tty() else text
21
+
22
+
23
+ def _red(text: str) -> str:
24
+ return f"\033[31m{text}\033[0m" if _is_tty() else text
25
+
26
+
27
+ def _yellow(text: str) -> str:
28
+ return f"\033[33m{text}\033[0m" if _is_tty() else text
29
+
30
+
31
+ def _require_config() -> bool:
32
+ """Check if the tool is configured. Print error and return False if not."""
33
+ from .config import is_configured
34
+ if not is_configured():
35
+ print(_red("Not configured. Run `claude-memory setup` first."))
36
+ return False
37
+ return True
38
+
39
+
40
+ def _make_engine():
41
+ """Create a SyncEngine from current config."""
42
+ from .config import get_token, load_config
43
+ from .github_api import GitHubAPI
44
+ from .sync_engine import SyncEngine
45
+
46
+ config = load_config()
47
+ token = get_token()
48
+ api = GitHubAPI(config["github_repo"], token)
49
+ return SyncEngine(config, api)
50
+
51
+
52
+ def _print_sync_result(result) -> None:
53
+ """Print a SyncResult in a readable format."""
54
+ if result.pushed:
55
+ print(_green(f" Pushed: {', '.join(result.pushed)}"))
56
+ if result.pulled:
57
+ print(_green(f" Pulled: {', '.join(result.pulled)}"))
58
+ if result.conflicts:
59
+ print(_yellow(f" Conflicts: {', '.join(result.conflicts)}"))
60
+ if result.skipped:
61
+ print(f" Skipped (no changes): {', '.join(result.skipped)}")
62
+ if result.errors:
63
+ for err in result.errors:
64
+ print(_red(f" Error: {err}"))
65
+ if not any([result.pushed, result.pulled, result.conflicts, result.errors]):
66
+ print(" Everything up to date.")
67
+
68
+
69
+ def cmd_setup(args: argparse.Namespace) -> None:
70
+ """Run setup wizard."""
71
+ from .config import setup_wizard
72
+ setup_wizard()
73
+ # Auto-install shims on Windows
74
+ import sys as _sys
75
+ if _sys.platform == "win32":
76
+ from .hooks import install_shims
77
+ created = install_shims()
78
+ if created:
79
+ from pathlib import Path
80
+ print(f"\nCommand shims installed in {Path(created[0]).parent}")
81
+ print("You can now run `claude-memory` from any terminal.")
82
+
83
+
84
+ def cmd_setup_path(args: argparse.Namespace) -> None:
85
+ """Fix PATH issues on Windows by creating command shims."""
86
+ import sys as _sys
87
+ if _sys.platform != "win32":
88
+ print("This command is only needed on Windows.")
89
+ return
90
+ from .hooks import install_shims
91
+ created = install_shims()
92
+ if created:
93
+ from pathlib import Path
94
+ print(_green(f"Created command shims in {Path(created[0]).parent}:"))
95
+ for path in created:
96
+ print(f" {Path(path).name}")
97
+ print("\nYou can now run `claude-memory` from any terminal.")
98
+ else:
99
+ print("No shims needed.")
100
+
101
+
102
+ def cmd_push(args: argparse.Namespace) -> None:
103
+ """Push local memory to GitHub."""
104
+ if not _require_config():
105
+ sys.exit(1)
106
+ engine = _make_engine()
107
+ if args.dry_run:
108
+ print(_bold("Dry run — no changes will be made."))
109
+ result = engine.push(alias=args.project, dry_run=args.dry_run)
110
+ _print_sync_result(result)
111
+ if result.errors:
112
+ sys.exit(1)
113
+
114
+
115
+ def cmd_pull(args: argparse.Namespace) -> None:
116
+ """Pull memory from GitHub."""
117
+ if not _require_config():
118
+ sys.exit(1)
119
+ engine = _make_engine()
120
+ if args.dry_run:
121
+ print(_bold("Dry run — no changes will be made."))
122
+ result = engine.pull(alias=args.project, dry_run=args.dry_run)
123
+ _print_sync_result(result)
124
+ if result.errors:
125
+ sys.exit(1)
126
+
127
+
128
+ def cmd_sync(args: argparse.Namespace) -> None:
129
+ """Bidirectional sync."""
130
+ if not _require_config():
131
+ sys.exit(1)
132
+ engine = _make_engine()
133
+ if args.dry_run:
134
+ print(_bold("Dry run — no changes will be made."))
135
+ result = engine.sync(alias=args.project, dry_run=args.dry_run)
136
+ _print_sync_result(result)
137
+ if result.errors:
138
+ sys.exit(1)
139
+
140
+
141
+ def cmd_alias(args: argparse.Namespace) -> None:
142
+ """Manage project aliases."""
143
+ from .alias import (
144
+ add_alias,
145
+ get_remote_project_dirnames,
146
+ list_aliases,
147
+ remove_alias,
148
+ suggest_aliases,
149
+ sync_aliases_from_remote,
150
+ sync_aliases_to_remote,
151
+ )
152
+ from .discovery import discover_projects
153
+ from .paths import project_dirname_to_display
154
+
155
+ if args.alias_command == "add":
156
+ add_alias(args.name, dirname=args.dirname)
157
+ print(_green(f"Added alias '{args.name}'."))
158
+ elif args.alias_command == "remove":
159
+ remove_alias(args.name)
160
+ print(f"Removed alias '{args.name}'.")
161
+ elif args.alias_command == "list":
162
+ aliases = list_aliases()
163
+ if not aliases:
164
+ print("No aliases configured.")
165
+ return
166
+ for name, dirnames in aliases.items():
167
+ print(_bold(name))
168
+ for d in dirnames:
169
+ display = project_dirname_to_display(d)
170
+ print(f" {d} ({display})")
171
+ elif args.alias_command == "suggest":
172
+ # Gather dirnames from local projects and remote
173
+ local_dirnames = [p["dirname"] for p in discover_projects() if p["has_memory"]]
174
+ remote_dirnames = []
175
+ if _require_config():
176
+ from .config import get_token, load_config
177
+ from .github_api import GitHubAPI
178
+ config = load_config()
179
+ try:
180
+ token = get_token()
181
+ api = GitHubAPI(config["github_repo"], token)
182
+ remote_dirnames = get_remote_project_dirnames(api)
183
+ except RuntimeError:
184
+ pass
185
+ all_dirnames = list(set(local_dirnames + remote_dirnames))
186
+ suggestions = suggest_aliases(all_dirnames)
187
+ if not suggestions:
188
+ print("No alias suggestions found.")
189
+ return
190
+ print(_bold("Suggested aliases:"))
191
+ for alias, dirnames in suggestions.items():
192
+ print(f"\n {_bold(alias)}:")
193
+ for d in dirnames:
194
+ display = project_dirname_to_display(d)
195
+ print(f" {d} ({display})")
196
+ elif args.alias_command == "auto":
197
+ local_dirnames = [p["dirname"] for p in discover_projects() if p["has_memory"]]
198
+ remote_dirnames = []
199
+ if not _require_config():
200
+ sys.exit(1)
201
+ from .config import get_token, load_config
202
+ from .github_api import GitHubAPI
203
+ config = load_config()
204
+ token = get_token()
205
+ api = GitHubAPI(config["github_repo"], token)
206
+ remote_dirnames = get_remote_project_dirnames(api)
207
+ all_dirnames = list(set(local_dirnames + remote_dirnames))
208
+ suggestions = suggest_aliases(all_dirnames)
209
+ if not suggestions:
210
+ print("No alias suggestions found.")
211
+ return
212
+ if args.dry_run:
213
+ print(_bold("Dry run — would create these aliases:"))
214
+ for alias, dirnames in suggestions.items():
215
+ print(f" {alias}: {dirnames}")
216
+ return
217
+ for alias, dirnames in suggestions.items():
218
+ for d in dirnames:
219
+ add_alias(alias, d)
220
+ print(_green(f"Created alias '{alias}' with {len(dirnames)} dirnames."))
221
+ sync_aliases_to_remote(api)
222
+ print(_green("Aliases pushed to remote."))
223
+ elif args.alias_command == "sync":
224
+ if not _require_config():
225
+ sys.exit(1)
226
+ from .config import get_token, load_config
227
+ from .github_api import GitHubAPI
228
+ config = load_config()
229
+ token = get_token()
230
+ api = GitHubAPI(config["github_repo"], token)
231
+ if args.push:
232
+ sync_aliases_to_remote(api)
233
+ print(_green("Aliases pushed to remote."))
234
+ elif args.pull:
235
+ sync_aliases_from_remote(api)
236
+ print(_green("Aliases pulled from remote."))
237
+ else:
238
+ sync_aliases_from_remote(api)
239
+ sync_aliases_to_remote(api)
240
+ print(_green("Aliases synced (pull then push)."))
241
+ elif args.alias_command == "migrate":
242
+ if not _require_config():
243
+ sys.exit(1)
244
+ engine = _make_engine()
245
+ result = engine.migrate_to_alias(args.dirname, args.alias)
246
+ if result["migrated"]:
247
+ print(_green(f"Migrated: {', '.join(result['migrated'])}"))
248
+ if result["errors"]:
249
+ for err in result["errors"]:
250
+ print(_red(f"Error: {err}"))
251
+ sys.exit(1)
252
+ if not result["migrated"] and not result["errors"]:
253
+ print("Nothing to migrate.")
254
+ else:
255
+ print("Use: alias {add,remove,list,suggest,auto,sync,migrate}")
256
+
257
+
258
+ def cmd_hooks(args: argparse.Namespace) -> None:
259
+ """Manage auto-sync hooks."""
260
+ from .hooks import install_hooks, remove_hooks
261
+
262
+ if args.hooks_command == "install":
263
+ print(_bold("Installing Claude Code hooks..."))
264
+ install_hooks()
265
+ print(_green("Done. Memory will auto-sync on session start/stop."))
266
+ elif args.hooks_command == "remove":
267
+ print(_bold("Removing Claude Code hooks..."))
268
+ remove_hooks()
269
+ print("Done.")
270
+ else:
271
+ print("Use: hooks {install,remove}")
272
+
273
+
274
+ def cmd_status(args: argparse.Namespace) -> None:
275
+ """Show sync status."""
276
+ if not _require_config():
277
+ sys.exit(1)
278
+
279
+ from .config import load_config
280
+ from .discovery import discover_projects
281
+ from .paths import get_state_file
282
+
283
+ config = load_config()
284
+ print(_bold("Configuration:"))
285
+ print(f" Device: {config.get('device_name', 'not set')}")
286
+ print(f" Repo: {config.get('github_repo', 'not set')}")
287
+ print(f" Strategy: {config.get('conflict_strategy', 'merge')}")
288
+ print()
289
+
290
+ projects = discover_projects()
291
+ memory_projects = [p for p in projects if p["has_memory"]]
292
+ print(_bold(f"Local projects with memory: {len(memory_projects)}"))
293
+ for p in memory_projects:
294
+ files = [f.name for f in p["memory_files"]]
295
+ print(f" {p['dirname']}: {', '.join(files)}")
296
+ print()
297
+
298
+ engine = _make_engine()
299
+ if engine.state:
300
+ print(_bold("Sync state:"))
301
+ for key, info in sorted(engine.state.items()):
302
+ last_sync = info.get("last_sync", "never")
303
+ print(f" {key}: last synced {last_sync}")
304
+ else:
305
+ print("No sync history yet.")
306
+
307
+
308
+ def cmd_doctor(args: argparse.Namespace) -> None:
309
+ """Diagnose issues."""
310
+ from .config import get_token, is_configured, load_config
311
+ from .discovery import discover_projects
312
+ from .github_api import GitHubAPI
313
+
314
+ checks_passed = 0
315
+ checks_total = 0
316
+
317
+ # Check 1: Config exists
318
+ checks_total += 1
319
+ if is_configured():
320
+ print(_green("[OK] Configuration found."))
321
+ checks_passed += 1
322
+ else:
323
+ print(_red("[FAIL] Not configured. Run `claude-memory setup`."))
324
+
325
+ # Check 2: Token available
326
+ checks_total += 1
327
+ try:
328
+ token = get_token()
329
+ print(_green(f"[OK] GitHub token found ({token[:4]}...)."))
330
+ checks_passed += 1
331
+ except RuntimeError as e:
332
+ print(_red(f"[FAIL] {e}"))
333
+ token = None
334
+
335
+ # Check 3: Repo accessible
336
+ checks_total += 1
337
+ config = load_config()
338
+ repo = config.get("github_repo", "")
339
+ if token and repo:
340
+ api = GitHubAPI(repo, token)
341
+ if api.test_connection():
342
+ print(_green(f"[OK] Repository '{repo}' is accessible."))
343
+ checks_passed += 1
344
+ else:
345
+ print(_red(f"[FAIL] Cannot access repository '{repo}'."))
346
+ else:
347
+ print(_yellow("[SKIP] Cannot test repo access (missing token or repo)."))
348
+
349
+ # Check 4: Local memory dirs
350
+ checks_total += 1
351
+ projects = discover_projects()
352
+ memory_projects = [p for p in projects if p["has_memory"]]
353
+ if memory_projects:
354
+ print(_green(f"[OK] Found {len(memory_projects)} project(s) with memory files."))
355
+ checks_passed += 1
356
+ else:
357
+ print(_yellow("[WARN] No local projects with memory files found."))
358
+
359
+ print(f"\n{checks_passed}/{checks_total} checks passed.")
360
+
361
+
362
+ def build_parser() -> argparse.ArgumentParser:
363
+ """Build the argument parser."""
364
+ parser = argparse.ArgumentParser(
365
+ prog="claude-memory",
366
+ description="Sync Claude Code memory files across machines via GitHub.",
367
+ )
368
+ parser.add_argument(
369
+ "--version", action="version", version=f"%(prog)s {__version__}"
370
+ )
371
+
372
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
373
+
374
+ # setup
375
+ subparsers.add_parser("setup", help="Run interactive setup wizard")
376
+
377
+ # setup-path
378
+ subparsers.add_parser("setup-path", help="Fix PATH issues on Windows (create command shims)")
379
+
380
+ # push
381
+ push_parser = subparsers.add_parser("push", help="Upload local memory to GitHub")
382
+ push_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
383
+ push_parser.add_argument("--project", type=str, default=None, help="Project alias or dirname")
384
+
385
+ # pull
386
+ pull_parser = subparsers.add_parser("pull", help="Download memory from GitHub")
387
+ pull_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
388
+ pull_parser.add_argument("--project", type=str, default=None, help="Project alias or dirname")
389
+
390
+ # sync
391
+ sync_parser = subparsers.add_parser("sync", help="Bidirectional sync")
392
+ sync_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
393
+ sync_parser.add_argument("--project", type=str, default=None, help="Project alias or dirname")
394
+
395
+ # alias
396
+ alias_parser = subparsers.add_parser("alias", help="Manage project aliases")
397
+ alias_sub = alias_parser.add_subparsers(dest="alias_command")
398
+ add_parser = alias_sub.add_parser("add", help="Add an alias")
399
+ add_parser.add_argument("name", help="Alias name")
400
+ add_parser.add_argument("--dirname", type=str, default=None, help="Project dirname (default: current)")
401
+ remove_parser = alias_sub.add_parser("remove", help="Remove an alias")
402
+ remove_parser.add_argument("name", help="Alias name to remove")
403
+ alias_sub.add_parser("list", help="List all aliases")
404
+ alias_sub.add_parser("suggest", help="Show suggested alias mappings")
405
+ auto_parser = alias_sub.add_parser("auto", help="Auto-create aliases from suggestions")
406
+ auto_parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
407
+ sync_alias_parser = alias_sub.add_parser("sync", help="Sync aliases to/from remote")
408
+ sync_alias_parser.add_argument("--push", action="store_true", help="Push aliases to remote")
409
+ sync_alias_parser.add_argument("--pull", action="store_true", help="Pull aliases from remote")
410
+ migrate_parser = alias_sub.add_parser("migrate", help="Migrate remote data to alias path")
411
+ migrate_parser.add_argument("--dirname", required=True, help="Source dirname on remote")
412
+ migrate_parser.add_argument("--alias", required=True, help="Target alias name")
413
+
414
+ # hooks
415
+ hooks_parser = subparsers.add_parser("hooks", help="Manage auto-sync hooks")
416
+ hooks_sub = hooks_parser.add_subparsers(dest="hooks_command")
417
+ hooks_sub.add_parser("install", help="Install Claude Code auto-sync hooks")
418
+ hooks_sub.add_parser("remove", help="Remove Claude Code auto-sync hooks")
419
+
420
+ # status
421
+ subparsers.add_parser("status", help="Show sync status")
422
+
423
+ # doctor
424
+ subparsers.add_parser("doctor", help="Diagnose configuration issues")
425
+
426
+ return parser
427
+
428
+
429
+ def main() -> None:
430
+ """Main CLI entry point."""
431
+ parser = build_parser()
432
+ args = parser.parse_args()
433
+
434
+ if args.command is None:
435
+ parser.print_help()
436
+ sys.exit(0)
437
+
438
+ commands = {
439
+ "setup": cmd_setup,
440
+ "setup-path": cmd_setup_path,
441
+ "push": cmd_push,
442
+ "pull": cmd_pull,
443
+ "sync": cmd_sync,
444
+ "alias": cmd_alias,
445
+ "hooks": cmd_hooks,
446
+ "status": cmd_status,
447
+ "doctor": cmd_doctor,
448
+ }
449
+
450
+ handler = commands.get(args.command)
451
+ if handler:
452
+ handler(args)
453
+ else:
454
+ parser.print_help()