memgit 0.9.1__tar.gz → 0.10.0__tar.gz

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.
Files changed (60) hide show
  1. {memgit-0.9.1 → memgit-0.10.0}/PKG-INFO +33 -2
  2. {memgit-0.9.1 → memgit-0.10.0}/README.md +31 -0
  3. {memgit-0.9.1 → memgit-0.10.0}/memgit/__init__.py +1 -1
  4. {memgit-0.9.1 → memgit-0.10.0}/memgit/cli.py +219 -0
  5. memgit-0.10.0/memgit/license.py +270 -0
  6. {memgit-0.9.1 → memgit-0.10.0}/memgit/mcp_server.py +113 -3
  7. {memgit-0.9.1 → memgit-0.10.0}/memgit/repo.py +75 -1
  8. memgit-0.10.0/memgit/savings.py +269 -0
  9. {memgit-0.9.1 → memgit-0.10.0}/memgit/store.py +50 -0
  10. {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/PKG-INFO +33 -2
  11. {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/SOURCES.txt +5 -0
  12. {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/requires.txt +1 -1
  13. {memgit-0.9.1 → memgit-0.10.0}/pyproject.toml +9 -2
  14. memgit-0.10.0/tests/test_license.py +208 -0
  15. memgit-0.10.0/tests/test_savings.py +118 -0
  16. memgit-0.10.0/tests/test_server_lifecycle.py +103 -0
  17. {memgit-0.9.1 → memgit-0.10.0}/tests/test_store_repo.py +184 -0
  18. {memgit-0.9.1 → memgit-0.10.0}/LICENSE +0 -0
  19. {memgit-0.9.1 → memgit-0.10.0}/memgit/backup.py +0 -0
  20. {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/__init__.py +0 -0
  21. {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/client.py +0 -0
  22. {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/commands.py +0 -0
  23. {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/crypto.py +0 -0
  24. {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/state.py +0 -0
  25. {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/sync.py +0 -0
  26. {memgit-0.9.1 → memgit-0.10.0}/memgit/delivery.py +0 -0
  27. {memgit-0.9.1 → memgit-0.10.0}/memgit/evaluate.py +0 -0
  28. {memgit-0.9.1 → memgit-0.10.0}/memgit/gitdigest.py +0 -0
  29. {memgit-0.9.1 → memgit-0.10.0}/memgit/graph.py +0 -0
  30. {memgit-0.9.1 → memgit-0.10.0}/memgit/hooks.py +0 -0
  31. {memgit-0.9.1 → memgit-0.10.0}/memgit/http_server.py +0 -0
  32. {memgit-0.9.1 → memgit-0.10.0}/memgit/importer.py +0 -0
  33. {memgit-0.9.1 → memgit-0.10.0}/memgit/links.py +0 -0
  34. {memgit-0.9.1 → memgit-0.10.0}/memgit/metrics.py +0 -0
  35. {memgit-0.9.1 → memgit-0.10.0}/memgit/models.py +0 -0
  36. {memgit-0.9.1 → memgit-0.10.0}/memgit/project.py +0 -0
  37. {memgit-0.9.1 → memgit-0.10.0}/memgit/sanitize.py +0 -0
  38. {memgit-0.9.1 → memgit-0.10.0}/memgit/scorer.py +0 -0
  39. {memgit-0.9.1 → memgit-0.10.0}/memgit/tokens.py +0 -0
  40. {memgit-0.9.1 → memgit-0.10.0}/memgit/toon.py +0 -0
  41. {memgit-0.9.1 → memgit-0.10.0}/memgit/usage.py +0 -0
  42. {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/dependency_links.txt +0 -0
  43. {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/entry_points.txt +0 -0
  44. {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/top_level.txt +0 -0
  45. {memgit-0.9.1 → memgit-0.10.0}/setup.cfg +0 -0
  46. {memgit-0.9.1 → memgit-0.10.0}/tests/test_accrue.py +0 -0
  47. {memgit-0.9.1 → memgit-0.10.0}/tests/test_advanced.py +0 -0
  48. {memgit-0.9.1 → memgit-0.10.0}/tests/test_aliases.py +0 -0
  49. {memgit-0.9.1 → memgit-0.10.0}/tests/test_backup.py +0 -0
  50. {memgit-0.9.1 → memgit-0.10.0}/tests/test_core.py +0 -0
  51. {memgit-0.9.1 → memgit-0.10.0}/tests/test_delivery.py +0 -0
  52. {memgit-0.9.1 → memgit-0.10.0}/tests/test_setup.py +0 -0
  53. {memgit-0.9.1 → memgit-0.10.0}/tests/test_toon.py +0 -0
  54. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v020.py +0 -0
  55. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v030.py +0 -0
  56. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v040.py +0 -0
  57. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v060.py +0 -0
  58. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v070.py +0 -0
  59. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v080.py +0 -0
  60. {memgit-0.9.1 → memgit-0.10.0}/tests/test_v081.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.9.1
3
+ Version: 0.10.0
4
4
  Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://memgit.dev
@@ -21,7 +21,7 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: click>=8.0
23
23
  Requires-Dist: rich>=13.0
24
- Requires-Dist: mcp>=1.0.0
24
+ Requires-Dist: mcp<2,>=1.0.0
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: pytest>=8.0; extra == "dev"
27
27
  Requires-Dist: pytest-anyio>=0.0.0; extra == "dev"
@@ -298,6 +298,33 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
298
298
 
299
299
  ---
300
300
 
301
+ ## memgit Pro — one memory across every machine
302
+
303
+ The local engine, the store, the MCP server and every command below are MIT and never
304
+ gated. **Pro** is the hosted layer for people who work on more than one machine or with a
305
+ team: end-to-end-encrypted sync where the server stores ciphertext only, unlocked by one
306
+ licence key.
307
+
308
+ ```bash
309
+ memgit pro activate <key> # validate the key with Polar and store it (mode 0600)
310
+ memgit pro status # Free / Pro, last verification, expiry
311
+ memgit pro deactivate # remove the key from this machine
312
+ ```
313
+
314
+ - Buy a key at [memgit.dev/#pricing](https://memgit.dev/#pricing) — $12/month or $99/year,
315
+ billed by Polar (merchant of record, taxes handled). The key arrives by email and in your
316
+ Polar purchases page.
317
+ - The only bytes that leave your machine are the key and memgit's public organisation id,
318
+ sent to Polar's validation endpoint. **No memory content is ever sent.**
319
+ - Headless hosts: set `MEMGIT_LICENSE_KEY=<key>` in the MCP server's environment; it is
320
+ validated with the same cache and never written to disk.
321
+ - Fail-open: a key that verified in the last 14 days keeps working offline; a rejected key
322
+ drops to Free with a clear message. A lapsed plan never locks your data — reads, pulls and
323
+ exports keep working.
324
+ - If this store is logged in to memgit cloud, `pro activate` also upgrades that account.
325
+
326
+ ---
327
+
301
328
  ## Backups that actually happen
302
329
 
303
330
  memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
@@ -414,6 +441,10 @@ memgit setup gemini-cli
414
441
  memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
415
442
  # capture guard + auto-sync at stop (--no-recall / --no-guard)
416
443
 
444
+ # Pro (licence key)
445
+ memgit pro activate <key> # Polar-issued key; unlocks hosted E2E sync
446
+ memgit pro status / deactivate
447
+
417
448
  # Server
418
449
  memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
419
450
  memgit serve --http # HTTP REST (ChatGPT Custom Actions, Gemini)
@@ -264,6 +264,33 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
264
264
 
265
265
  ---
266
266
 
267
+ ## memgit Pro — one memory across every machine
268
+
269
+ The local engine, the store, the MCP server and every command below are MIT and never
270
+ gated. **Pro** is the hosted layer for people who work on more than one machine or with a
271
+ team: end-to-end-encrypted sync where the server stores ciphertext only, unlocked by one
272
+ licence key.
273
+
274
+ ```bash
275
+ memgit pro activate <key> # validate the key with Polar and store it (mode 0600)
276
+ memgit pro status # Free / Pro, last verification, expiry
277
+ memgit pro deactivate # remove the key from this machine
278
+ ```
279
+
280
+ - Buy a key at [memgit.dev/#pricing](https://memgit.dev/#pricing) — $12/month or $99/year,
281
+ billed by Polar (merchant of record, taxes handled). The key arrives by email and in your
282
+ Polar purchases page.
283
+ - The only bytes that leave your machine are the key and memgit's public organisation id,
284
+ sent to Polar's validation endpoint. **No memory content is ever sent.**
285
+ - Headless hosts: set `MEMGIT_LICENSE_KEY=<key>` in the MCP server's environment; it is
286
+ validated with the same cache and never written to disk.
287
+ - Fail-open: a key that verified in the last 14 days keeps working offline; a rejected key
288
+ drops to Free with a clear message. A lapsed plan never locks your data — reads, pulls and
289
+ exports keep working.
290
+ - If this store is logged in to memgit cloud, `pro activate` also upgrades that account.
291
+
292
+ ---
293
+
267
294
  ## Backups that actually happen
268
295
 
269
296
  memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
@@ -380,6 +407,10 @@ memgit setup gemini-cli
380
407
  memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
381
408
  # capture guard + auto-sync at stop (--no-recall / --no-guard)
382
409
 
410
+ # Pro (licence key)
411
+ memgit pro activate <key> # Polar-issued key; unlocks hosted E2E sync
412
+ memgit pro status / deactivate
413
+
383
414
  # Server
384
415
  memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
385
416
  memgit serve --http # HTTP REST (ChatGPT Custom Actions, Gemini)
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.9.1"
3
+ __version__ = "0.10.0"
@@ -1978,6 +1978,105 @@ def metrics(fmt_json, reset):
1978
1978
  console.print()
1979
1979
 
1980
1980
 
1981
+ @cli.command()
1982
+ @click.option('--json', 'fmt_json', is_flag=True, help='Machine-readable output')
1983
+ @click.option('--match', default=None, type=float,
1984
+ help='Share of a memory\'s terms a file must carry to count as '
1985
+ 'containing the fact (default 0.5)')
1986
+ @click.option('--reads', default=None, type=int,
1987
+ help='How many files the counterfactual reader opens (default 3)')
1988
+ @click.option('--all-projects', is_flag=True,
1989
+ help='Every project, not just the one you are standing in')
1990
+ @click.option('--root', type=click.Path(exists=True, file_okay=False),
1991
+ help='Directory the counterfactual reader would search (default: cwd)')
1992
+ def savings(fmt_json, match, reads, all_projects, root):
1993
+ """What memory cost, against what finding the same fact would have cost.
1994
+
1995
+ The counterfactual is stated, not implied: without memgit an agent needing
1996
+ a fact would grep this project and read the best-matching files. That path
1997
+ is mechanical, so it can be measured. Memories no file contains are counted
1998
+ separately and NEVER priced — without memgit those facts are not found at
1999
+ any number of tokens, and turning that into a savings figure would be the
2000
+ fabrication `memgit metrics` was right to refuse.
2001
+
2002
+ The estimate is conservative on purpose: it counts the files only, not the
2003
+ grep output, not the turns spent choosing what to open, and not the reads
2004
+ that miss.
2005
+ """
2006
+ from . import savings as sv
2007
+ from .project import detect_project
2008
+ repo = _require_repo()
2009
+ search_root = Path(root) if root else Path.cwd()
2010
+ label = None if all_projects else detect_project(search_root)
2011
+ r = sv.measure(repo, search_root, project=label,
2012
+ match=match if match is not None else sv.DEFAULT_MATCH,
2013
+ reads=reads if reads is not None else sv.DEFAULT_READS)
2014
+
2015
+ if fmt_json:
2016
+ import json as _j
2017
+ print(_j.dumps(r, indent=2))
2018
+ return
2019
+
2020
+ from rich.rule import Rule
2021
+ from rich import box
2022
+
2023
+ console.print()
2024
+ console.print(Rule('[bold cyan]memgit savings[/bold cyan] '
2025
+ '[dim]measured against a stated counterfactual[/dim]'))
2026
+ console.print()
2027
+ console.print(f'[dim]Project:[/dim] {r["project"] or "all"} '
2028
+ f'[dim]Files a reader could open:[/dim] {r["files_indexed"]:,} '
2029
+ f'[dim]Reads assumed:[/dim] {r["parameters"]["reads"]} '
2030
+ f'[dim]Match:[/dim] {r["parameters"]["match"]:.0%}')
2031
+ console.print()
2032
+
2033
+ if r['memories_considered'] == 0:
2034
+ console.print('[dim]No recalled memories for this project yet — nothing '
2035
+ 'to compare. The usage ledger fills as memories get '
2036
+ 'surfaced.[/dim]')
2037
+ console.print()
2038
+ return
2039
+
2040
+ t = Table(box=box.SIMPLE, header_style='bold')
2041
+ t.add_column('', min_width=34)
2042
+ t.add_column('Tokens', justify='right')
2043
+ t.add_row('With memgit (what was injected)', f'{r["memgit_tokens"]:,}')
2044
+ t.add_row('Without memgit (files that would be read)',
2045
+ f'{r["without_memgit_tokens"]:,}')
2046
+ t.add_row('[bold]Saved[/bold]', f'[bold]{r["saved_tokens"]:,}[/bold]')
2047
+ console.print(t)
2048
+ if r['ratio']:
2049
+ console.print(f' Reading costs [bold]{r["ratio"]:.0f}x[/bold] what recall '
2050
+ f'costs, over {r["recalls_counted"]:,} recorded recalls of '
2051
+ f'{r["recoverable_from_files"]:,} memories.')
2052
+ console.print()
2053
+
2054
+ if r['unrecoverable_from_files']:
2055
+ console.print(
2056
+ f'[bold yellow]{r["unrecoverable_from_files"]:,} of '
2057
+ f'{r["memories_considered"]:,} recalled memories are in NO file in '
2058
+ f'this project.[/bold yellow]')
2059
+ console.print(' [dim]Not priced, deliberately. Without memgit these are '
2060
+ 'not found more expensively, they are not found. That is '
2061
+ 'the claim worth making, and it is a count, not a token '
2062
+ 'figure.[/dim]')
2063
+ console.print()
2064
+
2065
+ if r['top_savers']:
2066
+ t2 = Table(box=box.SIMPLE, header_style='bold',
2067
+ title='Where the saving comes from', title_justify='left')
2068
+ t2.add_column('Memory', min_width=30)
2069
+ t2.add_column('Recalls', justify='right')
2070
+ t2.add_column('Recall tok', justify='right')
2071
+ t2.add_column('Read tok', justify='right')
2072
+ for row in r['top_savers'][:8]:
2073
+ t2.add_row(row['slug'][:48], f'{row["hits"]:,}',
2074
+ f'{row["memgit_tokens_per_recall"]:,}',
2075
+ f'{row["without_tokens_per_recall"]:,}')
2076
+ console.print(t2)
2077
+ console.print()
2078
+
2079
+
1981
2080
  @cli.group(invoke_without_command=True)
1982
2081
  @click.pass_context
1983
2082
  def backup(ctx):
@@ -3354,6 +3453,126 @@ if __name__ == '__main__':
3354
3453
  cli()
3355
3454
 
3356
3455
 
3456
+ # ── pro (licence-key entitlement via Polar; no extra, no telemetry) ──────────
3457
+
3458
+ @cli.group(invoke_without_command=True)
3459
+ @click.pass_context
3460
+ def pro(ctx):
3461
+ """memgit Pro — activate, inspect or remove a licence key.
3462
+
3463
+ Pro unlocks the hosted sync layer (memgit cloud) and future paid features.
3464
+ The free CLI, store and MCP server are MIT and never gated. The only bytes
3465
+ that leave this machine are the key and memgit's public organisation id,
3466
+ sent to Polar's validation endpoint; no memory content is ever sent.
3467
+ """
3468
+ if ctx.invoked_subcommand is None:
3469
+ ctx.invoke(pro_status)
3470
+
3471
+
3472
+ @pro.command('activate')
3473
+ @click.argument('key')
3474
+ @click.option('--no-cloud', is_flag=True, help='Do not also attach the key to a logged-in memgit cloud account')
3475
+ def pro_activate(key, no_cloud):
3476
+ """Store KEY and validate it with Polar. Buy one at memgit.dev/#pricing."""
3477
+ from . import license as lic_mod
3478
+ from datetime import datetime, timezone
3479
+ key = key.strip()
3480
+ if len(key) < 8:
3481
+ console.print('[red]✗[/red] that does not look like a licence key')
3482
+ sys.exit(2)
3483
+ lic = lic_mod.License(key=key)
3484
+ now = datetime.now(timezone.utc)
3485
+ lic = lic_mod.refresh(lic, now=now, force=True)
3486
+ if lic.status == 'granted':
3487
+ lic_mod.write(lic)
3488
+ console.print(f'[green]✓[/green] memgit Pro activated for key {lic.masked}'
3489
+ + (f' ({lic.customer_email})' if lic.customer_email else ''))
3490
+ if lic.expires_at:
3491
+ console.print(f' [dim]expires {lic.expires_at}[/dim]')
3492
+ elif lic.status in ('revoked', 'disabled', 'invalid'):
3493
+ console.print(f'[red]✗[/red] key {lic.masked} was rejected: {lic.error or lic.status}')
3494
+ console.print(' [dim]Keys are issued by Polar on purchase and shown in your purchases page.[/dim]')
3495
+ sys.exit(1)
3496
+ else: # unknown — offline or provider down: keep the key, verify on next run
3497
+ lic.status = 'none'
3498
+ lic_mod.write(lic)
3499
+ console.print(f'[yellow]![/yellow] could not reach the licence service ({lic.error}); '
3500
+ f'key {lic.masked} saved and will be verified on the next run')
3501
+ if not no_cloud and lic.status == 'granted':
3502
+ _pro_attach_cloud(key)
3503
+
3504
+
3505
+ def _pro_attach_cloud(key: str) -> None:
3506
+ """Best-effort: if this store is logged in to memgit cloud, tell the API about
3507
+ the key so the hosted side unlocks too. Silent when not logged in."""
3508
+ try:
3509
+ from .cloud.state import CloudState
3510
+ from .repo import Repository
3511
+ repo = Repository.find()
3512
+ if repo is None:
3513
+ return
3514
+ cstate = CloudState(repo.path)
3515
+ creds = cstate.creds
3516
+ if not creds.get('access'):
3517
+ return
3518
+ from .cloud.client import ApiClient, DEFAULT_API_URL
3519
+ import os as _os
3520
+ api_url = _os.environ.get('MEMGIT_CLOUD_API') or creds.get('api_url') or DEFAULT_API_URL
3521
+ api = ApiClient(api_url, cstate)
3522
+ api._check(api._request('POST', '/v1/billing/polar/activate', json={'license_key': key}))
3523
+ console.print('[green]✓[/green] memgit cloud account upgraded to Pro')
3524
+ except Exception as e: # never let the hosted side break local activation
3525
+ console.print(f'[dim]cloud: not attached ({str(e)[:80]}) — run `memgit cloud login` then re-activate[/dim]')
3526
+
3527
+
3528
+ @pro.command('status')
3529
+ @click.option('--json', 'fmt_json', is_flag=True, help='JSON output')
3530
+ @click.option('--offline', is_flag=True, help='Answer from the cache only; never touch the network')
3531
+ def pro_status(fmt_json, offline):
3532
+ """Show whether this machine is entitled to memgit Pro."""
3533
+ from . import license as lic_mod
3534
+ from datetime import datetime, timezone
3535
+ now = datetime.now(timezone.utc)
3536
+ lic = lic_mod.read()
3537
+ if lic.key and not offline and lic.needs_recheck(now):
3538
+ lic = lic_mod.refresh(lic, now=now)
3539
+ if lic.source == 'file':
3540
+ try:
3541
+ lic_mod.write(lic)
3542
+ except OSError:
3543
+ pass
3544
+ ent = lic.is_entitled(now)
3545
+ if fmt_json:
3546
+ import json as _j
3547
+ print(_j.dumps({'entitled': ent, 'key': lic.masked, 'status': lic.status,
3548
+ 'validated_at': lic.validated_at, 'expires_at': lic.expires_at,
3549
+ 'source': lic.source, 'error': lic.error}))
3550
+ return
3551
+ if not lic.key:
3552
+ console.print('[bold]memgit Free[/bold] — no licence key. '
3553
+ f'Pro (hosted E2E sync across machines and teams): {lic_mod.PRO_URL}')
3554
+ return
3555
+ tier = '[green]memgit Pro[/green]' if ent else '[yellow]memgit Free (key not entitled)[/yellow]'
3556
+ console.print(f'{tier} key {lic.masked} status {lic.status}'
3557
+ f'{" via $MEMGIT_LICENSE_KEY" if lic.source != "file" else ""}')
3558
+ if lic.validated_at:
3559
+ console.print(f' [dim]last verified {lic.validated_at}[/dim]')
3560
+ if lic.expires_at:
3561
+ console.print(f' [dim]expires {lic.expires_at}[/dim]')
3562
+ if lic.error:
3563
+ console.print(f' [yellow]{lic.error}[/yellow]')
3564
+
3565
+
3566
+ @pro.command('deactivate')
3567
+ def pro_deactivate():
3568
+ """Remove the stored licence key from this machine."""
3569
+ from . import license as lic_mod
3570
+ if lic_mod.clear():
3571
+ console.print('[green]✓[/green] licence key removed; memgit is back on the Free tier')
3572
+ else:
3573
+ console.print('[dim]no licence key was stored[/dim]')
3574
+
3575
+
3357
3576
  # ── cloud (E2E-encrypted team sync — optional extra) ──────────────────────────
3358
3577
 
3359
3578
  try:
@@ -0,0 +1,270 @@
1
+ """memgit Pro — licence-key entitlement, validated against Polar, cached locally.
2
+
3
+ Design constraints (all deliberate):
4
+ * NO new dependency. Plain memgit stays `click + rich + mcp`; the check uses urllib.
5
+ * NOTHING about the store ever leaves the machine. The only bytes sent are the
6
+ licence key and our organisation id, to Polar's public validation endpoint.
7
+ * FAIL OPEN, bounded. A network failure never breaks a working install: a key
8
+ that validated within GRACE_DAYS keeps its entitlement; a key that has never
9
+ validated, or whose last good check is older than the grace window, is free.
10
+ * The key is never printed in full. `pro status` shows the last 4 characters.
11
+
12
+ State lives OUTSIDE the store (a licence belongs to a person, not to a repo):
13
+ $MEMGIT_HOME/license.json (default ~/.memgit/license.json, mode 0600)
14
+
15
+ {"key": "...", "activation_id": null, "status": "granted",
16
+ "validated_at": "2026-09-03T10:00:00+00:00", "expires_at": null,
17
+ "customer_email": null, "error": null}
18
+
19
+ Entitlement is also readable from the environment for headless/MCP hosts:
20
+ MEMGIT_LICENSE_KEY=<key> (validated with the same cache; never written to disk
21
+ unless `memgit pro activate` is run explicitly)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ from dataclasses import asdict, dataclass, field
28
+ from datetime import datetime, timedelta, timezone
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ POLAR_API = os.environ.get("MEMGIT_POLAR_API", "https://api.polar.sh")
33
+ # Public identifier of the memgit organisation on Polar. Not a secret — it is the
34
+ # same value every customer's client sends. Overridable so tests and a future
35
+ # org migration never need a code change.
36
+ POLAR_ORGANIZATION_ID = os.environ.get(
37
+ "MEMGIT_POLAR_ORG_ID", "1267be9a-acd0-4cdb-a3e4-0ee41134e87e"
38
+ )
39
+
40
+ GRACE_DAYS = 14 # keep entitlement this long after the last successful check
41
+ RECHECK_HOURS = 24 # do not hit the network more often than this
42
+ TIMEOUT_S = 6
43
+
44
+ _LICENSE_FILE = "license.json"
45
+
46
+
47
+ def home_dir() -> Path:
48
+ return Path(os.environ.get("MEMGIT_HOME") or (Path.home() / ".memgit"))
49
+
50
+
51
+ def _path() -> Path:
52
+ return home_dir() / _LICENSE_FILE
53
+
54
+
55
+ @dataclass
56
+ class License:
57
+ key: str = ""
58
+ activation_id: Optional[str] = None
59
+ status: str = "none" # none | granted | revoked | disabled | invalid | unknown
60
+ validated_at: Optional[str] = None
61
+ expires_at: Optional[str] = None
62
+ customer_email: Optional[str] = None
63
+ error: Optional[str] = None
64
+ source: str = "file" # file | env
65
+ extra: dict = field(default_factory=dict)
66
+
67
+ # ── derived ────────────────────────────────────────────────────────────
68
+ @property
69
+ def masked(self) -> str:
70
+ k = self.key or ""
71
+ return ("…" + k[-4:]) if len(k) >= 4 else ("…" if k else "")
72
+
73
+ def _validated_dt(self) -> Optional[datetime]:
74
+ if not self.validated_at:
75
+ return None
76
+ try:
77
+ return datetime.fromisoformat(self.validated_at)
78
+ except ValueError:
79
+ return None
80
+
81
+ def _expires_dt(self) -> Optional[datetime]:
82
+ if not self.expires_at:
83
+ return None
84
+ try:
85
+ return datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
86
+ except ValueError:
87
+ return None
88
+
89
+ def is_entitled(self, now: Optional[datetime] = None) -> bool:
90
+ """True when the last successful validation is within the grace window
91
+ and the key has not passed its own expiry. Never consults the network."""
92
+ now = now or datetime.now(timezone.utc)
93
+ if self.status != "granted":
94
+ return False
95
+ v = self._validated_dt()
96
+ if v is None or now - v > timedelta(days=GRACE_DAYS):
97
+ return False
98
+ e = self._expires_dt()
99
+ if e is not None and now > e:
100
+ return False
101
+ return True
102
+
103
+ def needs_recheck(self, now: Optional[datetime] = None) -> bool:
104
+ now = now or datetime.now(timezone.utc)
105
+ v = self._validated_dt()
106
+ return v is None or now - v > timedelta(hours=RECHECK_HOURS)
107
+
108
+
109
+ # ── persistence ────────────────────────────────────────────────────────────
110
+
111
+ def read() -> License:
112
+ """The stored licence, or the env-supplied one, or an empty record."""
113
+ p = _path()
114
+ data: dict = {}
115
+ if p.exists():
116
+ try:
117
+ data = json.loads(p.read_text(encoding="utf-8"))
118
+ except (OSError, json.JSONDecodeError):
119
+ data = {}
120
+ lic = License(**{k: v for k, v in data.items() if k in License.__dataclass_fields__})
121
+ env_key = os.environ.get("MEMGIT_LICENSE_KEY", "").strip()
122
+ if env_key and env_key != lic.key:
123
+ # An env key overrides the file but inherits nothing from it.
124
+ lic = License(key=env_key, source="env")
125
+ elif env_key:
126
+ lic.source = "env+file"
127
+ return lic
128
+
129
+
130
+ def write(lic: License) -> Path:
131
+ p = _path()
132
+ p.parent.mkdir(parents=True, exist_ok=True)
133
+ tmp = p.with_suffix(".tmp")
134
+ payload = {k: v for k, v in asdict(lic).items() if k != "source"}
135
+ tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
136
+ try:
137
+ os.chmod(tmp, 0o600)
138
+ except OSError:
139
+ pass
140
+ tmp.replace(p)
141
+ return p
142
+
143
+
144
+ def clear() -> bool:
145
+ p = _path()
146
+ if p.exists():
147
+ p.unlink()
148
+ return True
149
+ return False
150
+
151
+
152
+ # ── network ────────────────────────────────────────────────────────────────
153
+
154
+ class ValidationResult(dict):
155
+ """Raw validate() outcome. Keys: ok (bool), status (str), http (int|None),
156
+ body (dict), error (str|None). `status` is one of granted/revoked/disabled/
157
+ invalid/unknown — 'unknown' means the network did not answer, not that the
158
+ key is bad."""
159
+
160
+
161
+ def _post_json(url: str, payload: dict, timeout: float = TIMEOUT_S) -> tuple[int, dict]:
162
+ import urllib.error
163
+ import urllib.request
164
+ req = urllib.request.Request(
165
+ url, data=json.dumps(payload).encode("utf-8"),
166
+ headers={"Content-Type": "application/json",
167
+ "Accept": "application/json",
168
+ "User-Agent": "memgit-pro/1"},
169
+ method="POST")
170
+ try:
171
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 — fixed https host
172
+ return r.status, _safe_json(r.read())
173
+ except urllib.error.HTTPError as e:
174
+ try:
175
+ body = _safe_json(e.read())
176
+ except Exception:
177
+ body = {}
178
+ return e.code, body
179
+
180
+
181
+ def _safe_json(raw: bytes) -> dict:
182
+ try:
183
+ v = json.loads(raw.decode("utf-8") or "{}")
184
+ return v if isinstance(v, dict) else {"value": v}
185
+ except (ValueError, UnicodeDecodeError):
186
+ return {}
187
+
188
+
189
+ def validate(key: str, activation_id: Optional[str] = None,
190
+ org_id: Optional[str] = None, post=None) -> ValidationResult:
191
+ """Ask Polar whether `key` is granted. Pure function over `post` so tests can
192
+ substitute the transport; never raises."""
193
+ post = post or _post_json # resolved at call time so tests can monkeypatch the module
194
+ org = org_id if org_id is not None else POLAR_ORGANIZATION_ID
195
+ if not key:
196
+ return ValidationResult(ok=False, status="invalid", http=None, body={}, error="no key")
197
+ if not org:
198
+ return ValidationResult(ok=False, status="unknown", http=None, body={},
199
+ error="MEMGIT_POLAR_ORG_ID is not configured in this build")
200
+ payload = {"key": key, "organization_id": org}
201
+ if activation_id:
202
+ payload["activation_id"] = activation_id
203
+ try:
204
+ http, body = post(f"{POLAR_API}/v1/customer-portal/license-keys/validate", payload)
205
+ except Exception as e: # DNS, TLS, timeout, offline
206
+ return ValidationResult(ok=False, status="unknown", http=None, body={}, error=str(e)[:200])
207
+ if http == 200:
208
+ st = str(body.get("status", "")).lower() or "granted"
209
+ return ValidationResult(ok=(st == "granted"), status=st if st in ("granted", "revoked", "disabled") else "invalid",
210
+ http=http, body=body, error=None)
211
+ if http in (400, 403, 404, 422):
212
+ detail = body.get("detail") or body.get("error") or f"HTTP {http}"
213
+ return ValidationResult(ok=False, status="invalid", http=http, body=body,
214
+ error=str(detail)[:200])
215
+ # 5xx / 429 / anything else: the provider did not answer the question
216
+ return ValidationResult(ok=False, status="unknown", http=http, body=body,
217
+ error=f"provider returned HTTP {http}")
218
+
219
+
220
+ def refresh(lic: License, now: Optional[datetime] = None, post=None,
221
+ force: bool = False) -> License:
222
+ """Validate when due and fold the answer into the record. Fail-open: an
223
+ 'unknown' answer leaves the last good validation untouched."""
224
+ now = now or datetime.now(timezone.utc)
225
+ if not lic.key:
226
+ return lic
227
+ if not force and not lic.needs_recheck(now):
228
+ return lic
229
+ res = validate(lic.key, lic.activation_id, post=post)
230
+ if res["status"] == "unknown":
231
+ lic.error = res["error"]
232
+ return lic
233
+ lic.status = res["status"]
234
+ lic.error = None if res["ok"] else res["error"]
235
+ if res["ok"]:
236
+ lic.validated_at = now.isoformat()
237
+ body = res["body"]
238
+ lic.expires_at = body.get("expires_at")
239
+ cust = body.get("customer") or {}
240
+ lic.customer_email = cust.get("email") if isinstance(cust, dict) else None
241
+ lic.extra = {k: body.get(k) for k in ("usage", "limit_usage", "validations", "id") if k in body}
242
+ return lic
243
+
244
+
245
+ # ── the one call everything else uses ─────────────────────────────────────
246
+
247
+ def entitled(now: Optional[datetime] = None, allow_network: bool = True,
248
+ post=None) -> bool:
249
+ """Is this machine entitled to memgit Pro right now?
250
+
251
+ Cheap path: the cached record answers without the network. When a recheck
252
+ is due and the network is allowed, refresh (fail-open) and persist the
253
+ result for file-sourced keys. Never raises."""
254
+ try:
255
+ lic = read()
256
+ if not lic.key:
257
+ return False
258
+ if allow_network and lic.needs_recheck(now):
259
+ lic = refresh(lic, now=now, post=post)
260
+ if lic.source == "file":
261
+ try:
262
+ write(lic)
263
+ except OSError:
264
+ pass
265
+ return lic.is_entitled(now)
266
+ except Exception:
267
+ return False
268
+
269
+
270
+ PRO_URL = "https://memgit.dev/#pricing"