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