memgit 0.1.1__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.
memgit/cli.py ADDED
@@ -0,0 +1,1267 @@
1
+ """memgit CLI — git for AI memory."""
2
+
3
+ from __future__ import annotations
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .models import Mnemonic
15
+ from .repo import Repository
16
+ from .toon import mnemonic_to_markdown, serialize_mnemonic
17
+
18
+ console = Console()
19
+ err = Console(stderr=True)
20
+
21
+
22
+ def _require_repo() -> Repository:
23
+ repo = Repository.find()
24
+ if repo is None:
25
+ err.print('[red]Not in a memgit repository. Run `memgit init` first.[/red]')
26
+ sys.exit(1)
27
+ return repo
28
+
29
+
30
+ # ── Root group ────────────────────────────────────────────────────────────────
31
+
32
+ @click.group()
33
+ @click.version_option('0.1.0', prog_name='memgit')
34
+ def cli():
35
+ """memgit — git for AI memory.
36
+
37
+ Version-controlled context persistence for Claude Code and other AI tools.
38
+ """
39
+ pass
40
+
41
+
42
+ # ── init ──────────────────────────────────────────────────────────────────────
43
+
44
+ @cli.command()
45
+ @click.argument('directory', default='.', type=click.Path())
46
+ def init(directory):
47
+ """Initialize a memgit repository."""
48
+ path = Path(directory).resolve()
49
+ if (path / '.memgit').exists():
50
+ console.print(f'[yellow]Already initialized:[/yellow] {path / ".memgit"}')
51
+ return
52
+ repo = Repository.init(path)
53
+ console.print(f'[green]Initialized[/green] memgit repository in [cyan]{repo.path}[/cyan]')
54
+
55
+
56
+ # ── add ───────────────────────────────────────────────────────────────────────
57
+
58
+ @cli.command()
59
+ @click.argument('slug')
60
+ @click.argument('rule')
61
+ @click.option('--type', '-t', 'type_code', default='fb',
62
+ type=click.Choice(['fb', 'us', 'pj', 'rf', 'cn', 'lx']),
63
+ help='fb=feedback us=user pj=project rf=reference cn=convention lx=lesson')
64
+ @click.option('--why', '-w', default=None, help='Reasoning / why this rule exists')
65
+ @click.option('--when', '-W', default=None, help='When / where to apply')
66
+ @click.option('--tags', default=None, help='Comma-separated tags')
67
+ @click.option('--priority', '-p', default=2, type=click.IntRange(1, 3),
68
+ help='1=low 2=medium 3=critical (always loaded)')
69
+ def add(slug, rule, type_code, why, when, tags, priority):
70
+ """Add or update a mnemonic.
71
+
72
+ SLUG kebab-case identifier (e.g. ig-pipeline-no-fallback)\n
73
+ RULE the primary fact / rule (quoted if it contains spaces)
74
+ """
75
+ repo = _require_repo()
76
+ tag_list = [t.strip() for t in tags.split(',')] if tags else []
77
+
78
+ m = Mnemonic(
79
+ type_code=type_code,
80
+ slug=slug,
81
+ timestamp=datetime.now(timezone.utc),
82
+ rule=rule,
83
+ why=why,
84
+ when=when,
85
+ tags=tag_list,
86
+ priority=priority,
87
+ )
88
+ sha = repo.add(m)
89
+ console.print(f'[green]staged[/green] {slug} [{sha[:8]}]')
90
+
91
+
92
+ # ── remove ────────────────────────────────────────────────────────────────────
93
+
94
+ @cli.command()
95
+ @click.argument('slug')
96
+ def remove(slug):
97
+ """Remove a mnemonic from the index (does not delete history)."""
98
+ repo = _require_repo()
99
+ if repo.remove(slug):
100
+ console.print(f'[yellow]removed[/yellow] {slug}')
101
+ else:
102
+ console.print(f'[dim]not found: {slug}[/dim]')
103
+
104
+
105
+ # ── commit ────────────────────────────────────────────────────────────────────
106
+
107
+ @cli.command()
108
+ @click.option('--message', '-m', default=None, help='Checkpoint message')
109
+ def commit(message):
110
+ """Create a checkpoint of the current memory state."""
111
+ repo = _require_repo()
112
+ sha = repo.commit(message=message)
113
+ if sha is None:
114
+ console.print('[dim]Nothing to commit — memory state unchanged.[/dim]')
115
+ else:
116
+ console.print(f'[green]checkpoint[/green] {sha[:8]}')
117
+ ck = repo.store.read_checkpoint(sha)
118
+ console.print(f' {ck.message}')
119
+
120
+
121
+ # ── status ────────────────────────────────────────────────────────────────────
122
+
123
+ @cli.command()
124
+ def status():
125
+ """Show current repository status."""
126
+ repo = _require_repo()
127
+ thread = repo.current_thread()
128
+ head = repo.head_sha()
129
+
130
+ console.print(f'Thread: [cyan]{thread}[/cyan]')
131
+ if head:
132
+ console.print(f'HEAD: [yellow]{head[:8]}[/yellow]')
133
+ ck = repo.store.read_checkpoint(head)
134
+ console.print(f' {ck.message}')
135
+ else:
136
+ console.print('HEAD: [dim]none[/dim]')
137
+
138
+ # Staged vs committed
139
+ index = repo.get_index()
140
+ if head:
141
+ ms = repo.store.read_mindstate(
142
+ repo.store.read_checkpoint(head).mindstate_sha
143
+ )
144
+ committed = {e.slug: e.mnem_sha for e in ms.entries}
145
+ else:
146
+ committed = {}
147
+
148
+ new_slugs = [s for s in index if s not in committed]
149
+ updated = [s for s in index if s in committed and index[s] != committed[s]]
150
+ removed = [s for s in committed if s not in index]
151
+
152
+ if new_slugs or updated or removed:
153
+ console.print('\n[bold]Staged changes (not yet committed):[/bold]')
154
+ for s in new_slugs:
155
+ console.print(f' [green]new[/green] {s}')
156
+ for s in updated:
157
+ console.print(f' [yellow]updated[/yellow] {s}')
158
+ for s in removed:
159
+ console.print(f' [red]removed[/red] {s}')
160
+ console.print('\n[dim] (run "memgit commit" to checkpoint)[/dim]')
161
+ else:
162
+ total = len(index)
163
+ console.print(f'\n[dim]Clean — {total} mnemonic{"s" if total != 1 else ""} committed[/dim]')
164
+
165
+
166
+ # ── log ───────────────────────────────────────────────────────────────────────
167
+
168
+ @cli.command()
169
+ @click.option('--limit', '-n', default=10, help='Max checkpoints to show')
170
+ @click.option('--oneline', is_flag=True, help='Compact one-line format')
171
+ def log(limit, oneline):
172
+ """Show checkpoint history."""
173
+ repo = _require_repo()
174
+ checkpoints = repo.log(limit=limit)
175
+ if not checkpoints:
176
+ console.print('[dim]No checkpoints yet.[/dim]')
177
+ return
178
+
179
+ for ck in checkpoints:
180
+ sha_s = ck.sha[:8] if ck.sha else '????????'
181
+ ts = ck.timestamp.strftime('%Y-%m-%d %H:%M')
182
+
183
+ if oneline:
184
+ console.print(f'[yellow]{sha_s}[/yellow] {ts} {ck.message}')
185
+ else:
186
+ console.print(f'\n[yellow]checkpoint {sha_s}[/yellow]')
187
+ console.print(f' Date: {ts}')
188
+ console.print(f' Trigger: {ck.trigger}')
189
+ console.print(f' Author: {ck.author}')
190
+ console.print(f' Message: {ck.message}')
191
+ if ck.diff_summary:
192
+ d = ck.diff_summary
193
+ parts = []
194
+ if d.added:
195
+ parts.append(f'[green]+{len(d.added)}[/green]')
196
+ if d.modified:
197
+ parts.append(f'[yellow]~{len(d.modified)}[/yellow]')
198
+ if d.removed:
199
+ parts.append(f'[red]-{len(d.removed)}[/red]')
200
+ if parts:
201
+ console.print(f' Changes: {" ".join(parts)}')
202
+
203
+
204
+ # ── diff ──────────────────────────────────────────────────────────────────────
205
+
206
+ @cli.command()
207
+ @click.argument('sha1', required=False)
208
+ @click.argument('sha2', required=False)
209
+ @click.option('--full', is_flag=True, help='Show rule text for changed mnemonics')
210
+ def diff(sha1, sha2, full):
211
+ """Show diff between two checkpoints (default: HEAD^ vs HEAD)."""
212
+ repo = _require_repo()
213
+
214
+ if full:
215
+ changes = repo.diff_full(sha1, sha2)
216
+ for slug, status, old_m, new_m in changes:
217
+ if status == 'unchanged':
218
+ continue
219
+ color = {'added': 'green', 'removed': 'red', 'modified': 'yellow'}[status]
220
+ marker = {'added': '+', 'removed': '-', 'modified': '~'}[status]
221
+ console.print(f'[{color}]{marker} {slug}[/{color}]')
222
+ if status in ('added', 'modified') and new_m:
223
+ console.print(f' [dim]RULE:[/dim] {new_m.rule}')
224
+ if status == 'modified' and old_m:
225
+ console.print(f' [dim]WAS:[/dim] {old_m.rule}')
226
+ else:
227
+ d = repo.diff(sha1, sha2)
228
+ for s in d.added:
229
+ console.print(f'[green]+ {s}[/green]')
230
+ for s in d.modified:
231
+ console.print(f'[yellow]~ {s}[/yellow]')
232
+ for s in d.removed:
233
+ console.print(f'[red]- {s}[/red]')
234
+ if not d.added and not d.modified and not d.removed:
235
+ console.print('[dim]No changes[/dim]')
236
+
237
+
238
+ # ── show ──────────────────────────────────────────────────────────────────────
239
+
240
+ @cli.command()
241
+ @click.argument('slug')
242
+ @click.option('--toon', is_flag=True, help='Show raw TOON format')
243
+ @click.option('--markdown', 'fmt_markdown', is_flag=True, help='Show Claude Code markdown format')
244
+ def show(slug, toon, fmt_markdown):
245
+ """Show a mnemonic."""
246
+ repo = _require_repo()
247
+ m = repo.get(slug)
248
+ if m is None:
249
+ err.print(f'[red]No mnemonic: {slug}[/red]')
250
+ sys.exit(1)
251
+
252
+ if fmt_markdown:
253
+ print(mnemonic_to_markdown(m))
254
+ elif toon:
255
+ print(serialize_mnemonic(m))
256
+ else:
257
+ sha_s = m.sha[:8] if m.sha else '?'
258
+ p_label = {1: 'low', 2: 'medium', 3: '[bold red]CRITICAL[/bold red]'}[m.priority]
259
+ console.print(f'[bold cyan]{m.slug}[/bold cyan] [{m.type_code}] priority={p_label} sha={sha_s}')
260
+ console.print(f'')
261
+ console.print(f'[bold]RULE[/bold] {m.rule}')
262
+ if m.why:
263
+ console.print(f'[bold]WHY[/bold] {m.why}')
264
+ if m.when:
265
+ console.print(f'[bold]WHEN[/bold] {m.when}')
266
+ if m.desc:
267
+ console.print(f'[bold]DESC[/bold] {m.desc}')
268
+ if m.who:
269
+ console.print(f'[bold]WHO[/bold] {m.who}')
270
+ if m.where:
271
+ console.print(f'[bold]WHERE[/bold] {m.where}')
272
+ if m.inc:
273
+ console.print(f'[bold]INC[/bold] {m.inc}')
274
+ if m.cost:
275
+ console.print(f'[bold]COST[/bold] {m.cost}')
276
+ if m.tags:
277
+ console.print(f'[dim]Tags: {", ".join(m.tags)}[/dim]')
278
+ if m.related:
279
+ console.print(f'[dim]Related: {", ".join(m.related)}[/dim]')
280
+ if m.supersedes:
281
+ console.print(f'[dim]Supersedes: {", ".join(m.supersedes)}[/dim]')
282
+
283
+
284
+ # ── list ──────────────────────────────────────────────────────────────────────
285
+
286
+ @cli.command(name='list')
287
+ @click.option('--type', '-t', 'type_filter', default=None,
288
+ type=click.Choice(['fb', 'us', 'pj', 'rf', 'cn', 'lx']),
289
+ help='Filter by type')
290
+ @click.option('--priority', '-p', default=None, type=click.IntRange(1, 3), help='Filter by priority')
291
+ @click.option('--toon', is_flag=True, help='Show TOON format')
292
+ def list_cmd(type_filter, priority, toon):
293
+ """List all mnemonics in the current thread."""
294
+ repo = _require_repo()
295
+ mnemonics = repo.list()
296
+ if type_filter:
297
+ mnemonics = [m for m in mnemonics if m.type_code == type_filter]
298
+ if priority:
299
+ mnemonics = [m for m in mnemonics if m.priority == priority]
300
+ mnemonics.sort(key=lambda m: (m.type_code, m.slug))
301
+
302
+ if not mnemonics:
303
+ console.print('[dim]No mnemonics.[/dim]')
304
+ return
305
+
306
+ if toon:
307
+ for m in mnemonics:
308
+ print(serialize_mnemonic(m))
309
+ print()
310
+ return
311
+
312
+ table = Table(show_header=True, header_style='bold', box=None, pad_edge=False)
313
+ table.add_column('Slug', style='cyan', min_width=20)
314
+ table.add_column('T', width=2)
315
+ table.add_column('P', width=1)
316
+ table.add_column('Rule', max_width=70)
317
+
318
+ for m in mnemonics:
319
+ p_str = '!' if m.priority == 3 else str(m.priority)
320
+ rule_preview = m.rule[:68] + '..' if len(m.rule) > 68 else m.rule
321
+ table.add_row(m.slug, m.type_code, p_str, rule_preview)
322
+
323
+ console.print(table)
324
+ console.print(f'\n[dim]{len(mnemonics)} mnemonic{"s" if len(mnemonics) != 1 else ""}[/dim]')
325
+
326
+
327
+ # ── import ────────────────────────────────────────────────────────────────────
328
+
329
+ @cli.group(name='import')
330
+ def import_group():
331
+ """Import memories from other sources."""
332
+ pass
333
+
334
+
335
+ @import_group.command(name='claude-code')
336
+ @click.argument('path', required=False, type=click.Path(exists=True, file_okay=False))
337
+ @click.option('--dry-run', is_flag=True, help='Preview without importing')
338
+ @click.option('--no-commit', is_flag=True, help='Stage but do not checkpoint')
339
+ def import_claude_code(path, dry_run, no_commit):
340
+ """Import Claude Code memory markdown files.
341
+
342
+ PATH optional directory to read from (default: ~/.claude/projects/*/memory/)
343
+ """
344
+ from .importer import from_claude_code
345
+ repo = _require_repo()
346
+
347
+ mem_dir = Path(path) if path else None
348
+ mnemonics = from_claude_code(mem_dir)
349
+
350
+ if not mnemonics:
351
+ console.print('[yellow]No memories found.[/yellow]')
352
+ return
353
+
354
+ console.print(f'Found [bold]{len(mnemonics)}[/bold] memories')
355
+
356
+ if dry_run:
357
+ for m in mnemonics:
358
+ rule_preview = m.rule[:60] + '..' if len(m.rule) > 60 else m.rule
359
+ console.print(f' [cyan]{m.slug}[/cyan] [{m.type_code}] {rule_preview}')
360
+ return
361
+
362
+ count = 0
363
+ for m in mnemonics:
364
+ try:
365
+ repo.add(m)
366
+ count += 1
367
+ except Exception as e:
368
+ err.print(f'[yellow]skip {m.slug}: {e}[/yellow]')
369
+
370
+ console.print(f'[green]Staged {count} memories[/green]')
371
+
372
+ if not no_commit:
373
+ sha = repo.commit(
374
+ message=f'Import {count} memories from Claude Code',
375
+ trigger='import',
376
+ )
377
+ if sha:
378
+ console.print(f'[green]Checkpoint[/green] {sha[:8]}')
379
+ else:
380
+ console.print('[dim]Nothing new to checkpoint[/dim]')
381
+
382
+
383
+ @import_group.command(name='toon-file')
384
+ @click.argument('path', type=click.Path(exists=True, dir_okay=False))
385
+ @click.option('--dry-run', is_flag=True)
386
+ def import_toon_file(path, dry_run):
387
+ """Import mnemonics from a .toon file."""
388
+ from .importer import from_toon_file
389
+ repo = _require_repo()
390
+
391
+ mnemonics = from_toon_file(Path(path))
392
+ if not mnemonics:
393
+ console.print('[yellow]No mnemonics found.[/yellow]')
394
+ return
395
+
396
+ console.print(f'Found [bold]{len(mnemonics)}[/bold] mnemonics')
397
+ if dry_run:
398
+ for m in mnemonics:
399
+ console.print(f' [cyan]{m.slug}[/cyan] [{m.type_code}]')
400
+ return
401
+
402
+ for m in mnemonics:
403
+ repo.add(m)
404
+ sha = repo.commit(trigger='import')
405
+ if sha:
406
+ console.print(f'[green]Imported {len(mnemonics)} mnemonics → checkpoint {sha[:8]}[/green]')
407
+
408
+
409
+ # ── export ────────────────────────────────────────────────────────────────────
410
+
411
+ @cli.command()
412
+ @click.argument('slug')
413
+ @click.option('--toon', 'fmt', flag_value='toon', default=True, help='TOON format (default)')
414
+ @click.option('--markdown', 'fmt', flag_value='markdown', help='Claude Code markdown format')
415
+ def export(slug, fmt):
416
+ """Export a mnemonic to stdout."""
417
+ repo = _require_repo()
418
+ m = repo.get(slug)
419
+ if m is None:
420
+ err.print(f'[red]No mnemonic: {slug}[/red]')
421
+ sys.exit(1)
422
+ if fmt == 'markdown':
423
+ print(mnemonic_to_markdown(m))
424
+ else:
425
+ print(serialize_mnemonic(m))
426
+
427
+
428
+ # ── fsck ──────────────────────────────────────────────────────────────────────
429
+
430
+ @cli.command()
431
+ @click.option('--rebuild-index', is_flag=True, help='Rebuild TOON_INDEX from HEAD')
432
+ def fsck(rebuild_index):
433
+ """Verify repository integrity."""
434
+ repo = _require_repo()
435
+ console.print('Checking…')
436
+ errors = repo.fsck(rebuild_index=rebuild_index)
437
+ index = repo.get_index()
438
+ if errors:
439
+ for e in errors:
440
+ err.print(f'[red]{e}[/red]')
441
+ sys.exit(1)
442
+ else:
443
+ console.print(f'[green]OK[/green] — {len(index)} objects verified'
444
+ + (', index rebuilt' if rebuild_index else ''))
445
+
446
+
447
+ # ── thread ────────────────────────────────────────────────────────────────────
448
+
449
+ @cli.group()
450
+ def thread():
451
+ """Manage memory threads (branches)."""
452
+ pass
453
+
454
+
455
+ @thread.command(name='list')
456
+ def thread_list():
457
+ """List all threads."""
458
+ repo = _require_repo()
459
+ current = repo.current_thread()
460
+ threads = repo.thread_list()
461
+ for t in sorted(threads, key=lambda t: t.name):
462
+ marker = '*' if t.name == current else ' '
463
+ sha_s = t.head_sha[:8] if t.head_sha else '?'
464
+ console.print(f' {marker} [cyan]{t.name}[/cyan] [{sha_s}]')
465
+
466
+
467
+ @thread.command(name='create')
468
+ @click.argument('name')
469
+ @click.option('--description', '-d', default='')
470
+ def thread_create(name, description):
471
+ """Create a new thread from HEAD."""
472
+ repo = _require_repo()
473
+ t = repo.thread_create(name, description)
474
+ console.print(f'[green]Created thread[/green] {name} from {t.head_sha[:8]}')
475
+
476
+
477
+ @thread.command(name='switch')
478
+ @click.argument('name')
479
+ def thread_switch(name):
480
+ """Switch to a different thread."""
481
+ repo = _require_repo()
482
+ try:
483
+ repo.thread_switch(name)
484
+ console.print(f'[green]Switched to[/green] {name}')
485
+ except ValueError as e:
486
+ err.print(f'[red]{e}[/red]')
487
+ sys.exit(1)
488
+
489
+
490
+ # ── lint ──────────────────────────────────────────────────────────────────────
491
+
492
+ @cli.command()
493
+ def lint():
494
+ """Lint all staged mnemonics."""
495
+ repo = _require_repo()
496
+ mnemonics = repo.list()
497
+ issues = 0
498
+ for m in mnemonics:
499
+ if not m.rule:
500
+ console.print(f'[red]{m.slug}[/red]: missing RULE')
501
+ issues += 1
502
+ if len(m.rule) > 400:
503
+ console.print(f'[yellow]{m.slug}[/yellow]: RULE too long ({len(m.rule)} chars, max 400)')
504
+ issues += 1
505
+ if not re.match(r'^[a-z0-9_-]+$', m.slug):
506
+ console.print(f'[yellow]{m.slug}[/yellow]: slug should be kebab-case [a-z0-9_-]')
507
+ issues += 1
508
+ if issues == 0:
509
+ console.print(f'[green]OK[/green] — {len(mnemonics)} mnemonics, no issues')
510
+ else:
511
+ console.print(f'[yellow]{issues} issue{"s" if issues != 1 else ""}[/yellow]')
512
+
513
+
514
+ import re # noqa: E402 — needed for lint command
515
+
516
+
517
+ # ── search ────────────────────────────────────────────────────────────────────
518
+
519
+ @cli.command()
520
+ @click.argument('query')
521
+ @click.option('--top', '-k', default=10, help='Max results to return')
522
+ @click.option('--toon', is_flag=True, help='Output TOON format (token-efficient)')
523
+ @click.option('--json', 'fmt_json', is_flag=True, help='Output JSON')
524
+ @click.option('--type', '-t', 'type_filter', default=None,
525
+ type=click.Choice(['fb', 'us', 'pj', 'rf', 'cn', 'lx']),
526
+ help='Filter by type before scoring')
527
+ def search(query, top, toon, fmt_json, type_filter):
528
+ """Search memories by relevance.
529
+
530
+ Returns the top-k mnemonics scored against QUERY using BM25.
531
+ """
532
+ import json
533
+ from .scorer import score as bm25_score
534
+
535
+ repo = _require_repo()
536
+ mnemonics = repo.list()
537
+ if type_filter:
538
+ mnemonics = [m for m in mnemonics if m.type_code == type_filter]
539
+
540
+ results = bm25_score(query, mnemonics, top_k=top)
541
+
542
+ if not results:
543
+ console.print('[dim]No results.[/dim]')
544
+ return
545
+
546
+ if fmt_json:
547
+ out = []
548
+ for r in results:
549
+ m = r.mnemonic
550
+ out.append({
551
+ 'slug': m.slug,
552
+ 'score': r.score,
553
+ 'type': m.type_code,
554
+ 'priority': m.priority,
555
+ 'rule': m.rule,
556
+ 'why': m.why,
557
+ 'when': m.when,
558
+ 'tags': m.tags,
559
+ 'matched': r.matched_fields,
560
+ })
561
+ print(json.dumps(out, indent=2))
562
+ return
563
+
564
+ if toon:
565
+ for r in results:
566
+ print(serialize_mnemonic(r.mnemonic))
567
+ print()
568
+ return
569
+
570
+ table = Table(show_header=True, header_style='bold', box=None, pad_edge=False)
571
+ table.add_column('Score', width=6, style='dim')
572
+ table.add_column('Slug', style='cyan', min_width=20)
573
+ table.add_column('T', width=2)
574
+ table.add_column('Rule', max_width=65)
575
+
576
+ for r in results:
577
+ m = r.mnemonic
578
+ rule_preview = m.rule[:63] + '..' if len(m.rule) > 63 else m.rule
579
+ table.add_row(f'{r.score:.2f}', m.slug, m.type_code, rule_preview)
580
+
581
+ console.print(table)
582
+ console.print(f'\n[dim]{len(results)} result{"s" if len(results) != 1 else ""} for "{query}"[/dim]')
583
+
584
+
585
+ # ── sync ──────────────────────────────────────────────────────────────────────
586
+
587
+ @cli.command()
588
+ @click.option('--message', '-m', default=None, help='Custom checkpoint message')
589
+ @click.option('--dry-run', is_flag=True, help='Show what would be imported, no writes')
590
+ def sync(message, dry_run):
591
+ """Sync from Claude Code memory files and auto-checkpoint.
592
+
593
+ Imports all Claude Code markdown memory files, stages changes,
594
+ and creates a checkpoint if anything changed. Safe to run repeatedly.
595
+ """
596
+ from .importer import from_claude_code
597
+
598
+ repo = _require_repo()
599
+ mnemonics = from_claude_code()
600
+
601
+ if not mnemonics:
602
+ console.print('[dim]No Claude Code memories found.[/dim]')
603
+ return
604
+
605
+ if dry_run:
606
+ console.print(f'Would import [bold]{len(mnemonics)}[/bold] memories:')
607
+ for m in mnemonics[:10]:
608
+ console.print(f' [cyan]{m.slug}[/cyan] [{m.type_code}]')
609
+ if len(mnemonics) > 10:
610
+ console.print(f' [dim]… and {len(mnemonics) - 10} more[/dim]')
611
+ return
612
+
613
+ count = 0
614
+ skipped = 0
615
+ for m in mnemonics:
616
+ try:
617
+ repo.add(m)
618
+ count += 1
619
+ except Exception:
620
+ skipped += 1
621
+
622
+ msg = message or f'sync: {count} memories from Claude Code'
623
+ sha = repo.commit(message=msg, trigger='session_end')
624
+
625
+ if sha:
626
+ console.print(f'[green]sync[/green] {sha[:8]} {count} staged' +
627
+ (f', {skipped} skipped' if skipped else ''))
628
+ else:
629
+ console.print(f'[dim]sync: no changes ({count} memories already current)[/dim]')
630
+
631
+
632
+ # ── graph ─────────────────────────────────────────────────────────────────────
633
+
634
+ @cli.command()
635
+ @click.option('--output', '-o', default=None,
636
+ help='Output HTML file path (default: memgit-graph.html in repo dir)')
637
+ @click.option('--open', 'auto_open', is_flag=True, default=True,
638
+ help='Open in browser after generating (default: true)')
639
+ @click.option('--no-open', 'auto_open', flag_value=False,
640
+ help='Skip opening browser')
641
+ def graph(output, auto_open):
642
+ """Generate an interactive HTML graph of the memory store.
643
+
644
+ Visualizes all mnemonics as a force-directed graph with:
645
+ - Nodes colored by type (fb/us/pj/rf/cn/lx)
646
+ - Node size by priority
647
+ - Edges from [[wikilink]] references and explicit related/supersedes links
648
+ - Filter by type, search by keyword, click to highlight neighbours
649
+ - Checkpoint timeline in the sidebar
650
+ """
651
+ import webbrowser
652
+ from .graph import build_graph_data, render_html
653
+
654
+ repo = _require_repo()
655
+ data = build_graph_data(repo)
656
+ html = render_html(data)
657
+
658
+ out_path = Path(output) if output else (repo.path.parent / 'memgit-graph.html')
659
+ out_path.write_text(html, encoding='utf-8')
660
+
661
+ n = data['meta']['total']
662
+ e = data['meta']['edge_count']
663
+ console.print(f'[green]graph[/green] {out_path}')
664
+ console.print(f' {n} nodes · {e} edges · {len(data["checkpoints"])} checkpoints')
665
+
666
+ if auto_open:
667
+ webbrowser.open(out_path.as_uri())
668
+ console.print(f'[dim]opened in browser[/dim]')
669
+
670
+
671
+ # ── serve ─────────────────────────────────────────────────────────────────────
672
+
673
+ @cli.command()
674
+ @click.option('--store', default=None,
675
+ help='Path to memgit store dir (default: ~/.claude/memgit-store)')
676
+ @click.option('--http', 'use_http', is_flag=True, default=False,
677
+ help='Run as HTTP REST server instead of MCP stdio (for GPT Actions, Gemini, etc.)')
678
+ @click.option('--port', default=7474, show_default=True,
679
+ help='HTTP server port (only used with --http)')
680
+ def serve(store, use_http, port):
681
+ """Start the memgit server.
682
+
683
+ Default (no flags): MCP stdio server — for Claude Code, Cursor, Windsurf, Cline, Continue.dev.
684
+ With --http: REST server — for GPT Custom Actions, Gemini function calling, any OpenAPI client.
685
+ """
686
+ store_path = Path(store).resolve() if store else None
687
+ if use_http:
688
+ from .http_server import run_http_server
689
+ run_http_server(port=port, store_path=store_path)
690
+ else:
691
+ from .mcp_server import run_server
692
+ run_server(store_path)
693
+
694
+
695
+ # ── stats ─────────────────────────────────────────────────────────────────────
696
+
697
+ @cli.command()
698
+ def stats():
699
+ """Show token-savings proof and store health metrics.
700
+
701
+ Compares loading ALL memories (the claude.md / dump approach) against
702
+ memgit's relevance-filtered search — and shows the real token and dollar
703
+ savings your team gets every session.
704
+ """
705
+ repo = _require_repo()
706
+ s = repo.stats()
707
+
708
+ if s.get('total', 0) == 0:
709
+ console.print('[yellow]No memories yet. Run `memgit sync` or `memgit add` first.[/yellow]')
710
+ return
711
+
712
+ type_labels = {'fb': 'feedback', 'us': 'user', 'pj': 'project',
713
+ 'rf': 'reference', 'cn': 'convention', 'lx': 'lesson'}
714
+
715
+ type_str = ' · '.join(
716
+ f"{s['by_type'].get(tc, 0)} {lbl}"
717
+ for tc, lbl in type_labels.items()
718
+ if s['by_type'].get(tc, 0) > 0
719
+ )
720
+
721
+ prio = s['priority_counts']
722
+ prio_str = f"{prio.get(3, 0)} critical · {prio.get(2, 0)} medium · {prio.get(1, 0)} low"
723
+
724
+ reduction = s['reduction_pct']
725
+ full_tok = s['full_tokens']
726
+ search_tok = s['avg_search_tokens']
727
+ crit_tok = s['critical_tokens']
728
+
729
+ weekly_tokens = s['weekly_savings_tokens']
730
+ weekly_usd = s['weekly_savings_usd']
731
+
732
+ ck_count = s['checkpoint_count']
733
+ first_ts = s['first_checkpoint_ts'].strftime('%Y-%m-%d') if s['first_checkpoint_ts'] else '—'
734
+ last_ts = s['last_checkpoint_ts'].strftime('%Y-%m-%d') if s['last_checkpoint_ts'] else '—'
735
+
736
+ # ── render ────────────────────────────────────────────────────────────────
737
+ from rich.rule import Rule
738
+ from rich.panel import Panel
739
+ from rich import box
740
+
741
+ console.print()
742
+ console.print(Rule('[bold cyan]memgit stats[/bold cyan]'))
743
+ console.print()
744
+
745
+ t = Table(show_header=False, box=None, padding=(0, 2))
746
+ t.add_column(style='dim', width=30)
747
+ t.add_column()
748
+
749
+ t.add_row('Total memories', f'[bold]{s["total"]}[/bold] {type_str}')
750
+ t.add_row('Priority breakdown', prio_str)
751
+ t.add_row('Checkpoints', f'{ck_count} {first_ts} → {last_ts}')
752
+ console.print(t)
753
+ console.print()
754
+
755
+ console.print('[bold]Token cost comparison[/bold]')
756
+ console.print()
757
+
758
+ bench = Table(box=box.SIMPLE, header_style='bold')
759
+ bench.add_column('Approach', style='', min_width=30)
760
+ bench.add_column('Tokens/session', justify='right')
761
+ bench.add_column('vs full load', justify='right')
762
+ bench.add_column('$/session (GPT-4o)', justify='right')
763
+
764
+ from .tokens import token_cost_usd
765
+ bench.add_row(
766
+ '[red]claude.md / dump all memories[/red]',
767
+ f'{full_tok:,}',
768
+ '100% baseline',
769
+ f'${token_cost_usd(full_tok):.4f}',
770
+ )
771
+ bench.add_row(
772
+ '[yellow]mem-search plugin (top-20 obs)[/yellow]',
773
+ f'{min(full_tok, search_tok * 2):,} [dim](est.)[/dim]',
774
+ f'~{min(100, round(100 * min(full_tok, search_tok * 2) / full_tok))}%',
775
+ f'${token_cost_usd(min(full_tok, search_tok * 2)):.4f}',
776
+ )
777
+ bench.add_row(
778
+ '[green]memgit search (BM25 top-8)[/green]',
779
+ f'{search_tok:,}',
780
+ f'[green]{100 - reduction}% ({reduction}% savings)[/green]',
781
+ f'[green]${token_cost_usd(search_tok):.4f}[/green]',
782
+ )
783
+ if crit_tok > 0:
784
+ bench.add_row(
785
+ '[cyan] + critical memories (always)[/cyan]',
786
+ f'+{crit_tok:,} [dim](overhead)[/dim]',
787
+ '',
788
+ '',
789
+ )
790
+ console.print(bench)
791
+
792
+ console.print()
793
+ console.print('[bold]Weekly savings [dim](10 sessions/week)[/dim][/bold]')
794
+ console.print()
795
+
796
+ savings = Table(box=None, show_header=False, padding=(0, 2))
797
+ savings.add_column(style='dim', width=30)
798
+ savings.add_column()
799
+
800
+ savings.add_row('Tokens saved/week', f'[green]{weekly_tokens:,}[/green]')
801
+ savings.add_row('Cost saved/week (GPT-4o)', f'[green]${weekly_usd:.4f}[/green]')
802
+ savings.add_row('', '')
803
+ savings.add_row('Annualised token savings', f'[bold green]{weekly_tokens * 52:,}[/bold green]')
804
+ savings.add_row('Annualised cost savings', f'[bold green]${weekly_usd * 52:.2f}[/bold green]')
805
+ console.print(savings)
806
+
807
+ console.print()
808
+ has_flat = (repo.path.parent / 'memories').exists()
809
+ has_git = (repo.path.parent / '.git').exists()
810
+ git_status = '[green]✓[/green]' if has_git else '[red]✗[/red] (run `memgit git init` to enable team sync)'
811
+ flat_status = '[green]✓[/green]' if has_flat else '[yellow]–[/yellow] (run `memgit git export`)'
812
+ console.print(f'[dim]Git sync:[/dim] {git_status} [dim]Flat memories/:[/dim] {flat_status}')
813
+ console.print()
814
+
815
+
816
+ # ── squash ────────────────────────────────────────────────────────────────────
817
+
818
+ @cli.command()
819
+ @click.option('--keep-last', type=int, default=None,
820
+ help='Keep this many recent checkpoints; squash the rest into one baseline')
821
+ @click.option('--older-than', 'older_than_days', type=int, default=None, metavar='DAYS',
822
+ help='Squash all checkpoints older than N days')
823
+ @click.option('--dry-run', is_flag=True, help='Preview what would be squashed without changing anything')
824
+ def squash(keep_last, older_than_days, dry_run):
825
+ """Squash old checkpoints to keep history manageable at scale.
826
+
827
+ Like `git rebase --autosquash`, but for memory history. Collapses old
828
+ checkpoints into a single baseline so the store stays fast even at
829
+ 10,000+ commits. The current memory state is always preserved — only
830
+ the historical chain is compressed.
831
+
832
+ Examples:
833
+
834
+ memgit squash --keep-last 100 Keep only the last 100 checkpoints
835
+
836
+ memgit squash --older-than 30 Squash everything older than 30 days
837
+
838
+ memgit squash --dry-run Preview without making changes
839
+ """
840
+ repo = _require_repo()
841
+ result = repo.squash(
842
+ keep_last=keep_last,
843
+ older_than_days=older_than_days,
844
+ dry_run=dry_run,
845
+ )
846
+
847
+ kept = result['kept']
848
+ squashed = result['squashed']
849
+
850
+ if squashed == 0:
851
+ console.print('[yellow]Nothing to squash (too few checkpoints).[/yellow]')
852
+ return
853
+
854
+ if dry_run:
855
+ console.print(f'[dim]dry-run:[/dim] would squash [yellow]{squashed}[/yellow] checkpoints '
856
+ f'(baseline: {result["baseline_ts"]}) '
857
+ f'→ keep [green]{kept}[/green] recent ones')
858
+ else:
859
+ console.print(f'[green]squash[/green] {squashed} old checkpoints '
860
+ f'→ baseline at {result["baseline_ts"]} '
861
+ f'[dim]({kept} kept, new HEAD: {result.get("new_head", "?")})[/dim]')
862
+
863
+
864
+ # ── git ───────────────────────────────────────────────────────────────────────
865
+
866
+ @cli.group()
867
+ def git():
868
+ """Git-native sync — push/pull memories across machines and teammates.
869
+
870
+ memgit stores are plain git repos under the hood. Every memory is a
871
+ readable .toon file in memories/. Standard git commands work on them.
872
+
873
+ Quick start:
874
+
875
+ memgit git init Initialize git in your store
876
+
877
+ memgit git push Push memories to remote
878
+
879
+ memgit git pull Pull teammate memories from remote
880
+
881
+ memgit git export Write flat memories/ files (no push)
882
+
883
+ memgit git status Show what's changed since last push
884
+ """
885
+ pass
886
+
887
+
888
+ @git.command('init')
889
+ @click.option('--remote', default=None, help='Git remote URL to add as "origin" (optional)')
890
+ def git_init(remote):
891
+ """Initialize git in the memory store for team sync.
892
+
893
+ After this, your memory store is a regular git repo. You can:
894
+
895
+ cd ~/.claude/memgit-store
896
+
897
+ git remote add origin git@github.com:yourteam/ai-memory.git
898
+
899
+ git push -u origin main
900
+
901
+ Then teammates run `memgit git pull` to get your memories.
902
+ """
903
+ repo = _require_repo()
904
+ ok = repo.git_init()
905
+ if not ok:
906
+ err.print('[red]git init failed — is git installed?[/red]')
907
+ return
908
+
909
+ store_root = repo.path.parent
910
+ if remote:
911
+ try:
912
+ import subprocess
913
+ subprocess.run(['git', 'remote', 'add', 'origin', remote],
914
+ cwd=store_root, check=True, capture_output=True)
915
+ console.print(f'[green]git init[/green] {store_root} remote: {remote}')
916
+ except Exception:
917
+ console.print(f'[green]git init[/green] {store_root} [yellow](remote add failed — add manually)[/yellow]')
918
+ else:
919
+ console.print(f'[green]git init[/green] {store_root}')
920
+ console.print(f'[dim]Add a remote: cd {store_root} && git remote add origin <url>[/dim]')
921
+
922
+ # Write initial flat files
923
+ repo.write_flat()
924
+ mem_count = len(list((store_root / 'memories').glob('*.toon')))
925
+ console.print(f'[dim]memories/: {mem_count} .toon files ready to commit[/dim]')
926
+
927
+
928
+ @git.command('export')
929
+ def git_export():
930
+ """Write all memories as flat .toon files in memories/ without pushing.
931
+
932
+ Creates one file per memory: memories/{slug}.toon
933
+
934
+ Files are human-readable, greppable, and diff-friendly. You can also
935
+ search across all your memories with: grep -r "trading" memories/
936
+ """
937
+ repo = _require_repo()
938
+ repo.write_flat()
939
+ store_root = repo.path.parent
940
+ count = len(list((store_root / 'memories').glob('*.toon')))
941
+ console.print(f'[green]export[/green] {count} memories → {store_root / "memories"}')
942
+ console.print(f'[dim]Grep them: grep -rl "your query" {store_root / "memories"}[/dim]')
943
+
944
+
945
+ @git.command('push')
946
+ @click.argument('remote', default='origin')
947
+ @click.argument('branch', default='main')
948
+ @click.option('--message', '-m', default=None, help='Git commit message')
949
+ def git_push(remote, branch, message):
950
+ """Write flat files, git commit, and push to remote.
951
+
952
+ This is how you share memories with teammates:
953
+
954
+ memgit git push Push to origin/main
955
+
956
+ memgit git push upstream feature Push to upstream/feature
957
+ """
958
+ repo = _require_repo()
959
+ ok, msg = repo.git_push(remote=remote, branch=branch, message=message)
960
+ color = 'green' if ok else 'red'
961
+ console.print(f'[{color}]{"push" if ok else "error"}[/{color}] {msg}')
962
+
963
+
964
+ @git.command('pull')
965
+ @click.argument('remote', default='origin')
966
+ @click.argument('branch', default='main')
967
+ def git_pull_cmd(remote, branch):
968
+ """Pull memories from a git remote and import them.
969
+
970
+ After a `git pull`, memgit imports any new or updated memories from
971
+ the memories/ flat files and creates a new checkpoint.
972
+
973
+ Teammate workflow:
974
+
975
+ git clone git@github.com:yourteam/ai-memory.git ~/.claude/memgit-store
976
+
977
+ memgit git pull # then pull updates anytime
978
+ """
979
+ repo = _require_repo()
980
+ ok, msg, count = repo.git_pull(remote=remote, branch=branch)
981
+ color = 'green' if ok else 'red'
982
+ console.print(f'[{color}]{"pull" if ok else "error"}[/{color}] {msg}')
983
+
984
+
985
+ @git.command('status')
986
+ def git_status_cmd():
987
+ """Show what's changed in the memory store since the last git commit."""
988
+ repo = _require_repo()
989
+ status = repo.git_status()
990
+ if status is None:
991
+ console.print('[yellow]Not a git repo. Run `memgit git init` first.[/yellow]')
992
+ return
993
+ if not status:
994
+ console.print('[green]Nothing to push — memories are in sync.[/green]')
995
+ else:
996
+ console.print('[bold]Changes since last git commit:[/bold]')
997
+ console.print(status)
998
+
999
+
1000
+ # ── setup ─────────────────────────────────────────────────────────────────────
1001
+
1002
+ import shutil as _shutil
1003
+ import json as _json
1004
+
1005
+
1006
+ def _memgit_cmd() -> list[str]:
1007
+ """Return the best command to launch `memgit serve`.
1008
+
1009
+ Priority: running binary path > which > python -m fallback.
1010
+ sys.argv[0] ensures we register the exact binary that ran setup,
1011
+ not whatever 'memgit' is first in PATH.
1012
+ """
1013
+ import sys as _sys, os as _os
1014
+ argv0 = _sys.argv[0]
1015
+ if _os.path.isabs(argv0) and _os.path.isfile(argv0):
1016
+ return [argv0, 'serve']
1017
+ resolved = _os.path.realpath(argv0) if argv0 else None
1018
+ if resolved and _os.path.isfile(resolved):
1019
+ return [resolved, 'serve']
1020
+ binary = _shutil.which('memgit')
1021
+ if binary:
1022
+ return [binary, 'serve']
1023
+ return [_sys.executable, '-m', 'memgit.cli', 'serve']
1024
+
1025
+
1026
+ def _mcp_server_entry() -> dict:
1027
+ cmd = _memgit_cmd()
1028
+ return {'command': cmd[0], 'args': cmd[1:]}
1029
+
1030
+
1031
+ def _write_json_safe(path: Path, data: dict) -> None:
1032
+ path.parent.mkdir(parents=True, exist_ok=True)
1033
+ path.write_text(_json.dumps(data, indent=2) + '\n', encoding='utf-8')
1034
+
1035
+
1036
+ def _patch_mcp_servers(config_path: Path, dry_run: bool = False) -> str:
1037
+ """Upsert mcpServers.memgit in a JSON config file. Returns status string."""
1038
+ if config_path.exists():
1039
+ try:
1040
+ data = _json.loads(config_path.read_text(encoding='utf-8'))
1041
+ except _json.JSONDecodeError:
1042
+ data = {}
1043
+ else:
1044
+ data = {}
1045
+
1046
+ servers = data.setdefault('mcpServers', {})
1047
+ existing = servers.get('memgit')
1048
+ entry = _mcp_server_entry()
1049
+
1050
+ if existing == entry:
1051
+ return 'already registered'
1052
+
1053
+ servers['memgit'] = entry
1054
+ if not dry_run:
1055
+ _write_json_safe(config_path, data)
1056
+ return 'updated' if existing else 'registered'
1057
+
1058
+
1059
+ def _patch_continue(config_path: Path, dry_run: bool = False) -> str:
1060
+ """Patch Continue.dev config.json which uses a list, not a dict."""
1061
+ if config_path.exists():
1062
+ try:
1063
+ data = _json.loads(config_path.read_text(encoding='utf-8'))
1064
+ except _json.JSONDecodeError:
1065
+ data = {}
1066
+ else:
1067
+ data = {}
1068
+
1069
+ entry = _mcp_server_entry()
1070
+ entry['name'] = 'memgit'
1071
+
1072
+ servers: list = data.setdefault('mcpServers', [])
1073
+ for i, s in enumerate(servers):
1074
+ if s.get('name') == 'memgit':
1075
+ if s == entry:
1076
+ return 'already registered'
1077
+ servers[i] = entry
1078
+ if not dry_run:
1079
+ _write_json_safe(config_path, data)
1080
+ return 'updated'
1081
+
1082
+ servers.append(entry)
1083
+ if not dry_run:
1084
+ _write_json_safe(config_path, data)
1085
+ return 'registered'
1086
+
1087
+
1088
+ # Targets: (label, config_path_fn, patch_fn)
1089
+ def _all_targets():
1090
+ home = Path.home()
1091
+ app_support = home / 'Library' / 'Application Support'
1092
+ linux_config = home / '.config'
1093
+ return [
1094
+ (
1095
+ 'Claude Code',
1096
+ home / '.claude' / 'settings.json',
1097
+ _patch_mcp_servers,
1098
+ ),
1099
+ (
1100
+ 'Claude Desktop (macOS)',
1101
+ app_support / 'Claude' / 'claude_desktop_config.json',
1102
+ _patch_mcp_servers,
1103
+ ),
1104
+ (
1105
+ 'Claude Desktop (Linux)',
1106
+ linux_config / 'Claude' / 'claude_desktop_config.json',
1107
+ _patch_mcp_servers,
1108
+ ),
1109
+ (
1110
+ 'Cursor',
1111
+ home / '.cursor' / 'mcp.json',
1112
+ _patch_mcp_servers,
1113
+ ),
1114
+ (
1115
+ 'Windsurf',
1116
+ home / '.windsurf' / 'mcp.json',
1117
+ _patch_mcp_servers,
1118
+ ),
1119
+ (
1120
+ 'Cline (VS Code)',
1121
+ app_support / 'Code' / 'User' / 'globalStorage' / 'saoudrizwan.claude-dev' / 'settings' / 'cline_mcp_settings.json',
1122
+ _patch_mcp_servers,
1123
+ ),
1124
+ (
1125
+ 'Roo-Code (VS Code)',
1126
+ app_support / 'Code' / 'User' / 'globalStorage' / 'rooveterinaryinc.roo-cline' / 'settings' / 'cline_mcp_settings.json',
1127
+ _patch_mcp_servers,
1128
+ ),
1129
+ (
1130
+ 'Continue.dev',
1131
+ home / '.continue' / 'config.json',
1132
+ _patch_continue,
1133
+ ),
1134
+ ]
1135
+
1136
+
1137
+ @cli.group()
1138
+ def setup():
1139
+ """Register memgit with AI coding tools (MCP).
1140
+
1141
+ Writes the memgit MCP server entry into each tool's config file.
1142
+ Safe to run multiple times — only updates what's missing.
1143
+ """
1144
+ pass
1145
+
1146
+
1147
+ def _run_target(label: str, config_path: Path, patch_fn, dry_run: bool) -> None:
1148
+ exists = config_path.exists()
1149
+ if not exists and label.startswith('Claude Desktop (Linux)') and (Path.home() / 'Library').exists():
1150
+ # Skip Linux path on macOS
1151
+ return
1152
+ if not exists and 'Linux' in label and not (Path.home() / '.config').exists():
1153
+ return
1154
+
1155
+ try:
1156
+ status = patch_fn(config_path, dry_run=dry_run)
1157
+ icon = '[green]✓[/green]' if 'registered' in status or 'already' in status else '[yellow]↻[/yellow]'
1158
+ note = ' [dim](dry run)[/dim]' if dry_run else ''
1159
+ console.print(f'{icon} {label}: {status}{note}')
1160
+ console.print(f' [dim]{config_path}[/dim]')
1161
+ except Exception as e:
1162
+ console.print(f'[red]✗[/red] {label}: {e}')
1163
+
1164
+
1165
+ @setup.command('all')
1166
+ @click.option('--dry-run', is_flag=True, help='Show what would be changed without writing files.')
1167
+ def setup_all(dry_run):
1168
+ """Detect all installed AI tools and register memgit with each.
1169
+
1170
+ Safe to re-run. Skips tools that aren't installed (config dir missing).
1171
+ Only registers tools whose config file already exists OR whose parent dir exists.
1172
+ """
1173
+ cmd = _memgit_cmd()
1174
+ console.print(f'[bold]memgit setup all[/bold] command=[cyan]{" ".join(cmd)}[/cyan]\n')
1175
+
1176
+ registered = 0
1177
+ skipped = 0
1178
+ for label, config_path, patch_fn in _all_targets():
1179
+ # Skip if neither the file nor its parent directory exists
1180
+ # (tool not installed on this machine)
1181
+ if not config_path.exists() and not config_path.parent.exists():
1182
+ skipped += 1
1183
+ continue
1184
+ _run_target(label, config_path, patch_fn, dry_run)
1185
+ registered += 1
1186
+
1187
+ console.print(f'\n[dim]{registered} tool(s) processed, {skipped} not installed (skipped)[/dim]')
1188
+ if not dry_run and registered:
1189
+ console.print('[dim]Restart each AI tool for changes to take effect.[/dim]')
1190
+
1191
+
1192
+ @setup.command('claude-code')
1193
+ @click.option('--dry-run', is_flag=True)
1194
+ def setup_claude_code(dry_run):
1195
+ """Register with Claude Code (~/.claude/settings.json)."""
1196
+ path = Path.home() / '.claude' / 'settings.json'
1197
+ _run_target('Claude Code', path, _patch_mcp_servers, dry_run)
1198
+
1199
+
1200
+ @setup.command('claude-desktop')
1201
+ @click.option('--dry-run', is_flag=True)
1202
+ def setup_claude_desktop(dry_run):
1203
+ """Register with Claude Desktop app."""
1204
+ mac = Path.home() / 'Library' / 'Application Support' / 'Claude' / 'claude_desktop_config.json'
1205
+ linux = Path.home() / '.config' / 'Claude' / 'claude_desktop_config.json'
1206
+ path = mac if mac.parent.exists() else linux
1207
+ _run_target('Claude Desktop', path, _patch_mcp_servers, dry_run)
1208
+
1209
+
1210
+ @setup.command('cursor')
1211
+ @click.option('--dry-run', is_flag=True)
1212
+ def setup_cursor(dry_run):
1213
+ """Register with Cursor (~/.cursor/mcp.json)."""
1214
+ path = Path.home() / '.cursor' / 'mcp.json'
1215
+ _run_target('Cursor', path, _patch_mcp_servers, dry_run)
1216
+
1217
+
1218
+ @setup.command('windsurf')
1219
+ @click.option('--dry-run', is_flag=True)
1220
+ def setup_windsurf(dry_run):
1221
+ """Register with Windsurf (~/.windsurf/mcp.json)."""
1222
+ path = Path.home() / '.windsurf' / 'mcp.json'
1223
+ _run_target('Windsurf', path, _patch_mcp_servers, dry_run)
1224
+
1225
+
1226
+ @setup.command('cline')
1227
+ @click.option('--dry-run', is_flag=True)
1228
+ def setup_cline(dry_run):
1229
+ """Register with Cline/Roo-Code (VS Code extension)."""
1230
+ base = Path.home() / 'Library' / 'Application Support' / 'Code' / 'User' / 'globalStorage'
1231
+ for slug, label in [
1232
+ ('saoudrizwan.claude-dev', 'Cline'),
1233
+ ('rooveterinaryinc.roo-cline', 'Roo-Code'),
1234
+ ]:
1235
+ path = base / slug / 'settings' / 'cline_mcp_settings.json'
1236
+ if path.parent.exists() or path.exists():
1237
+ _run_target(label, path, _patch_mcp_servers, dry_run)
1238
+ else:
1239
+ console.print(f'[dim]skip {label} — not installed[/dim]')
1240
+
1241
+
1242
+ @setup.command('continue')
1243
+ @click.option('--dry-run', is_flag=True)
1244
+ def setup_continue(dry_run):
1245
+ """Register with Continue.dev (~/.continue/config.json)."""
1246
+ path = Path.home() / '.continue' / 'config.json'
1247
+ _run_target('Continue.dev', path, _patch_continue, dry_run)
1248
+
1249
+
1250
+ @setup.command('print-config')
1251
+ @click.argument('tool', default='generic',
1252
+ type=click.Choice(['claude-code', 'claude-desktop', 'cursor', 'windsurf', 'continue', 'generic']))
1253
+ def setup_print_config(tool):
1254
+ """Print the config snippet to copy-paste manually.
1255
+
1256
+ Useful when you need to edit the config yourself or when auto-setup fails.
1257
+ """
1258
+ entry = _mcp_server_entry()
1259
+
1260
+ if tool == 'continue':
1261
+ snippet = _json.dumps({'mcpServers': [{'name': 'memgit', **entry}]}, indent=2)
1262
+ else:
1263
+ snippet = _json.dumps({'mcpServers': {'memgit': entry}}, indent=2)
1264
+
1265
+ console.print(f'\n[bold]Config snippet for {tool}:[/bold]\n')
1266
+ console.print(snippet)
1267
+ console.print('\n[dim]Merge this into your tool\'s config file.[/dim]')