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.
- {memgit-0.9.1 → memgit-0.10.0}/PKG-INFO +33 -2
- {memgit-0.9.1 → memgit-0.10.0}/README.md +31 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/__init__.py +1 -1
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cli.py +219 -0
- memgit-0.10.0/memgit/license.py +270 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/mcp_server.py +113 -3
- {memgit-0.9.1 → memgit-0.10.0}/memgit/repo.py +75 -1
- memgit-0.10.0/memgit/savings.py +269 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/store.py +50 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/PKG-INFO +33 -2
- {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/SOURCES.txt +5 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/requires.txt +1 -1
- {memgit-0.9.1 → memgit-0.10.0}/pyproject.toml +9 -2
- memgit-0.10.0/tests/test_license.py +208 -0
- memgit-0.10.0/tests/test_savings.py +118 -0
- memgit-0.10.0/tests/test_server_lifecycle.py +103 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_store_repo.py +184 -0
- {memgit-0.9.1 → memgit-0.10.0}/LICENSE +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/backup.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/__init__.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/client.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/commands.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/crypto.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/state.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/cloud/sync.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/delivery.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/evaluate.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/gitdigest.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/graph.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/hooks.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/http_server.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/importer.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/links.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/metrics.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/models.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/project.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/sanitize.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/scorer.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/tokens.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/toon.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit/usage.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/dependency_links.txt +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/entry_points.txt +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/memgit.egg-info/top_level.txt +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/setup.cfg +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_accrue.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_advanced.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_aliases.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_backup.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_core.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_delivery.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_setup.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_toon.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_v020.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_v030.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_v040.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_v060.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_v070.py +0 -0
- {memgit-0.9.1 → memgit-0.10.0}/tests/test_v080.py +0 -0
- {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.
|
|
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
|
|
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)
|
|
@@ -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"
|