nuramem 0.1.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.
@@ -0,0 +1,95 @@
1
+ # Python
2
+ .venv/
3
+ venv/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .uv-cache/
12
+ dist/
13
+ build/
14
+ *.egg
15
+
16
+ # Node / TypeScript
17
+ node_modules/
18
+ .pnpm-store/
19
+ *.tsbuildinfo
20
+ .next/
21
+ .svelte-kit/
22
+ out/
23
+
24
+ # Tauri / Rust
25
+ target/
26
+ **/target/
27
+ *.rs.bk
28
+ Cargo.lock
29
+
30
+ # IDE / editor
31
+ .vscode/
32
+ !.vscode/extensions.json
33
+ !.vscode/settings.json.example
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+ *~
38
+
39
+ # macOS
40
+ .DS_Store
41
+ .AppleDouble
42
+ .LSOverride
43
+
44
+ # Secrets / local config (NEVER commit these)
45
+ .env
46
+ .env.*
47
+ !.env.example
48
+ *.pem
49
+ *.key
50
+ *-credentials.json
51
+ service-account*.json
52
+ auth0-*.json
53
+ gcp-key*.json
54
+
55
+ # Terraform
56
+ infra/terraform/.terraform/
57
+ infra/terraform/*.tfstate
58
+ infra/terraform/*.tfstate.*
59
+ infra/terraform/*.tfvars
60
+ !infra/terraform/*.tfvars.example
61
+ infra/terraform/.terraform.lock.hcl
62
+ infra/terraform/crash.log
63
+
64
+ # Logs
65
+ *.log
66
+ logs/
67
+
68
+ # Claude Code agent worktrees + local agent state (never commit)
69
+ .claude/
70
+
71
+ # Operational state (local dev only)
72
+ .local-state/
73
+ local-data/
74
+ .firestore-emulator/
75
+ .memorystore-emulator/
76
+
77
+ # Build artifacts
78
+ *.pyc
79
+ *.so
80
+ *.dylib
81
+ *.dll
82
+
83
+ # Documentation builds
84
+ docs/_build/
85
+ docs/site/
86
+
87
+ # Coverage
88
+ .coverage
89
+ .coverage.*
90
+ htmlcov/
91
+ coverage.xml
92
+
93
+ # Local-only dev MCP wiring (machine-specific; never committed —
94
+ # a localhost dev server must not ship to clones). See docs/integrations.md.
95
+ .mcp.json
nuramem-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: nuramem
3
+ Version: 0.1.0
4
+ Summary: Nuramem CLI — cross-model memory from the terminal. Capture (nura save/this → server-side §15 extraction) and recall (nura search/briefing/schema) from the shell, CI, or any tool loop, with --json. Wire Nuramem's MCP server into your AI clients (nura connect) and install the memory skill (nura skill install).
5
+ Project-URL: Homepage, https://nuramem.ai
6
+ Project-URL: Documentation, https://nuramem.ai
7
+ Author: LeadHash LLC
8
+ License: Proprietary
9
+ Keywords: cli,cross-model,llm,mcp,memory,nuramem
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: httpx>=0.27
16
+ Provides-Extra: dev
17
+ Requires-Dist: nuramem-api-server; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Nuramem CLI (`nura`)
22
+
23
+ Cross-model memory from the terminal. The same memory every connected AI reads —
24
+ save, recall, and connect your assistants, without leaving the shell.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ curl -fsSL https://get.nuramem.ai | sh # the universal installer
30
+ # or, with a Python toolchain:
31
+ uv tool install nuramem # · pipx install nuramem · uvx nuramem
32
+ ```
33
+
34
+ ## Use
35
+
36
+ ```sh
37
+ nura login # sign in via the browser (loopback + PKCE)
38
+ nura this "we picked Postgres over Drive for the data plane" # capture
39
+ nura search "data plane" # recall; nura briefing · nura schema
40
+ nura --json search "stripe" # machine-readable, for CI / tool loops
41
+
42
+ nura connect # wire Nuramem's MCP server into your AI clients
43
+ nura skill install # teach a coding assistant the memory gesture
44
+ ```
45
+
46
+ - **Capture** (`save`/`this`) runs Nuramem's server-side §15 extraction.
47
+ - **Recall** (`search`/`briefing`/`schema`) is always free.
48
+ - **`nura connect`** safely merges a `nuramem` MCP entry into Claude Desktop,
49
+ Cursor, Windsurf, Gemini CLI and VS Code configs (idempotent; `--dry-run`).
50
+ - **`nura skill install`** drops the bundled Nuramem skill into Claude Code /
51
+ Cursor / Codex / Windsurf.
52
+
53
+ Auth precedence: `--api-url`/explicit token → `NURAMEM_TOKEN` → `NURAMEM_DEV_AUTH_TOKEN`
54
+ → the credential stored by `nura login`. CI/headless: set `NURAMEM_TOKEN`.
55
+
56
+ Docs and connection guides: <https://nuramem.ai>.
@@ -0,0 +1,36 @@
1
+ # Nuramem CLI (`nura`)
2
+
3
+ Cross-model memory from the terminal. The same memory every connected AI reads —
4
+ save, recall, and connect your assistants, without leaving the shell.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ curl -fsSL https://get.nuramem.ai | sh # the universal installer
10
+ # or, with a Python toolchain:
11
+ uv tool install nuramem # · pipx install nuramem · uvx nuramem
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```sh
17
+ nura login # sign in via the browser (loopback + PKCE)
18
+ nura this "we picked Postgres over Drive for the data plane" # capture
19
+ nura search "data plane" # recall; nura briefing · nura schema
20
+ nura --json search "stripe" # machine-readable, for CI / tool loops
21
+
22
+ nura connect # wire Nuramem's MCP server into your AI clients
23
+ nura skill install # teach a coding assistant the memory gesture
24
+ ```
25
+
26
+ - **Capture** (`save`/`this`) runs Nuramem's server-side §15 extraction.
27
+ - **Recall** (`search`/`briefing`/`schema`) is always free.
28
+ - **`nura connect`** safely merges a `nuramem` MCP entry into Claude Desktop,
29
+ Cursor, Windsurf, Gemini CLI and VS Code configs (idempotent; `--dry-run`).
30
+ - **`nura skill install`** drops the bundled Nuramem skill into Claude Code /
31
+ Cursor / Codex / Windsurf.
32
+
33
+ Auth precedence: `--api-url`/explicit token → `NURAMEM_TOKEN` → `NURAMEM_DEV_AUTH_TOKEN`
34
+ → the credential stored by `nura login`. CI/headless: set `NURAMEM_TOKEN`.
35
+
36
+ Docs and connection guides: <https://nuramem.ai>.
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "nuramem"
3
+ version = "0.1.0"
4
+ description = "Nuramem CLI — cross-model memory from the terminal. Capture (nura save/this → server-side §15 extraction) and recall (nura search/briefing/schema) from the shell, CI, or any tool loop, with --json. Wire Nuramem's MCP server into your AI clients (nura connect) and install the memory skill (nura skill install)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "Proprietary" }
8
+ authors = [{ name = "LeadHash LLC" }]
9
+ keywords = ["nuramem", "memory", "mcp", "llm", "cross-model", "cli"]
10
+ classifiers = [
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Topic :: Utilities",
15
+ ]
16
+ dependencies = [
17
+ "httpx>=0.27",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://nuramem.ai"
22
+ Documentation = "https://nuramem.ai"
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "nuramem-api-server",
28
+ ]
29
+
30
+ [project.scripts]
31
+ nura = "nuramem_cli.__main__:main"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/nuramem_cli"]
@@ -0,0 +1,5 @@
1
+ """Nuramem CLI — scriptable capture + recall over the REST api-server."""
2
+
3
+ from nuramem_cli.client import NuramemClient
4
+
5
+ __all__ = ["NuramemClient"]
@@ -0,0 +1,218 @@
1
+ """`nura` — the Nuramem CLI entrypoint.
2
+
3
+ nura save "text to remember" # capture (server extracts §15 records)
4
+ nura search "stripe" --bucket decisions
5
+ nura briefing # session-start teaser
6
+ nura schema # the consolidated self-model
7
+ nura ... --json # machine-readable, for CI / tool loops
8
+
9
+ Auth: NURAMEM_TOKEN (bearer). Endpoint: NURAMEM_API_URL (default beta).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from typing import Any
18
+
19
+ from nuramem_cli.client import NuramemClient, resolve_base_url, resolve_token
20
+
21
+
22
+ def _print_json(obj: Any) -> None:
23
+ print(json.dumps(obj, indent=2, ensure_ascii=False))
24
+
25
+
26
+ def _err(status: int, data: dict) -> int:
27
+ """Render a non-2xx (or status-dict) response and return a nonzero code."""
28
+ detail = data.get("detail") or data.get("error") or data
29
+ print(f"error ({status}): {detail}", file=sys.stderr)
30
+ return 1
31
+
32
+
33
+ def _is_status(data: dict, *names: str) -> bool:
34
+ return data.get("status") in names
35
+
36
+
37
+ def cmd_save(client: NuramemClient, args) -> int:
38
+ status, data = client.ingest(args.text, source="cli", project_id=args.project)
39
+ if args.json:
40
+ _print_json(data)
41
+ return 0 if status < 300 else 1
42
+ if status >= 300:
43
+ return _err(status, data)
44
+ if _is_status(data, "upgrade_required"):
45
+ print(f"⤴ {data.get('detail', 'This is a paid capability — upgrade to use it.')}")
46
+ return 2
47
+ if _is_status(data, "needs_scope"):
48
+ print(f"? Couldn't tell which project — candidates: {data.get('candidates')}")
49
+ return 3
50
+ if _is_status(data, "nothing_durable"):
51
+ print("nothing durable to save from that text.")
52
+ return 0
53
+ by = data.get("by_bucket") or {}
54
+ summary = ", ".join(f"{n} {b}" for b, n in by.items()) if by else f"{data.get('count', '?')} records"
55
+ print(f"✓ saved to Nuramem: {summary}")
56
+ return 0
57
+
58
+
59
+ def cmd_search(client: NuramemClient, args) -> int:
60
+ status, data = client.search(args.bucket, args.query, limit=args.limit, project_id=args.project)
61
+ if args.json:
62
+ _print_json(data)
63
+ return 0 if status < 300 else 1
64
+ if status >= 300:
65
+ return _err(status, data)
66
+ results = data.get("results") or data.get("matched") or []
67
+ if not results:
68
+ print("no matches.")
69
+ return 0
70
+ for r in results:
71
+ line = r.get("summary") or r.get("title") or r.get("name") or json.dumps(r)
72
+ print(f"• {line}")
73
+ print(f"\n{data.get('total_matched', len(results))} match(es).")
74
+ return 0
75
+
76
+
77
+ def cmd_briefing(client: NuramemClient, args) -> int:
78
+ status, data = client.briefing(project_id=args.project)
79
+ if args.json:
80
+ _print_json(data)
81
+ return 0 if status < 300 else 1
82
+ if status >= 300:
83
+ return _err(status, data)
84
+ print(data.get("briefing") or data.get("teaser") or json.dumps(data))
85
+ return 0
86
+
87
+
88
+ def cmd_login(args) -> int:
89
+ from nuramem_cli.auth import login, resolve_mcp_url
90
+ try:
91
+ login(resolve_mcp_url(getattr(args, "mcp_url", None)),
92
+ open_browser=not args.no_browser)
93
+ except Exception as e: # noqa: BLE001 — surface any flow failure cleanly
94
+ print(f"login failed: {e}", file=sys.stderr)
95
+ return 1
96
+ print("✓ signed in to Nuramem. The token is stored for the CLI.")
97
+ return 0
98
+
99
+
100
+ def cmd_logout(args) -> int:
101
+ from nuramem_cli.auth import logout
102
+ print("signed out." if logout() else "no stored credentials.")
103
+ return 0
104
+
105
+
106
+ def cmd_skill(args) -> int:
107
+ from nuramem_cli.skill import DEFAULT_TARGET, TARGETS, install, render_report
108
+ from pathlib import Path
109
+ if args.skill_cmd != "install":
110
+ print("usage: nura skill install [--client K] [--dir PATH] [--dry-run]", file=sys.stderr)
111
+ return 1
112
+ dest = Path(args.dir).expanduser() if args.dir else None
113
+ target = args.client or DEFAULT_TARGET
114
+ result = install(target, dest=dest, dry_run=args.dry_run)
115
+ if args.json:
116
+ _print_json(result)
117
+ else:
118
+ print(render_report(result, dry_run=args.dry_run))
119
+ return 0 if result["action"] != "unknown" else 1
120
+
121
+
122
+ def cmd_connect(args) -> int:
123
+ from nuramem_cli.installer import CLIENTS, COMMAND_CLIENTS, DEFAULT_MCP_URL, connect, detected, render_report
124
+ url = args.mcp_url or DEFAULT_MCP_URL
125
+ if args.all:
126
+ keys = list(CLIENTS) + list(COMMAND_CLIENTS)
127
+ elif args.client:
128
+ keys = args.client
129
+ else:
130
+ keys = detected()
131
+ if not keys:
132
+ print("No MCP clients detected. Name one explicitly (--client claude-desktop) "
133
+ f"or --all. Known: {', '.join(list(CLIENTS) + list(COMMAND_CLIENTS))}.",
134
+ file=sys.stderr)
135
+ return 1
136
+ results = connect(keys, url, dry_run=args.dry_run)
137
+ if args.json:
138
+ _print_json({"url": url, "dry_run": args.dry_run, "results": results})
139
+ else:
140
+ print(render_report(results, url, dry_run=args.dry_run))
141
+ return 0 if all(r["action"] not in ("error", "unknown") for r in results) else 1
142
+
143
+
144
+ def cmd_schema(client: NuramemClient, args) -> int:
145
+ status, data = client.schema(project_id=args.project)
146
+ if args.json:
147
+ _print_json(data)
148
+ return 0 if status < 300 else 1
149
+ if status >= 300:
150
+ return _err(status, data)
151
+ _print_json(data.get("self_model") or data)
152
+ return 0
153
+
154
+
155
+ def build_parser() -> argparse.ArgumentParser:
156
+ p = argparse.ArgumentParser(prog="nura", description="Nuramem — your cross-model memory, from the terminal.")
157
+ p.add_argument("--json", action="store_true", help="Machine-readable output (for CI / tool loops).")
158
+ p.add_argument("--project", default=None, help="Project id to scope to (default: personal).")
159
+ p.add_argument("--api-url", default=None, help="Override NURAMEM_API_URL.")
160
+ sub = p.add_subparsers(dest="command", required=True)
161
+
162
+ s = sub.add_parser("save", help="Capture text into memory (server extracts records).")
163
+ s.add_argument("text")
164
+ s.set_defaults(func=cmd_save)
165
+ # 'this' is an alias for 'save' (matches the nura-this gesture)
166
+ t = sub.add_parser("this", help="Alias for save.")
167
+ t.add_argument("text")
168
+ t.set_defaults(func=cmd_save)
169
+
170
+ q = sub.add_parser("search", help="Search a bucket of your memory.")
171
+ q.add_argument("query", nargs="?", default=None)
172
+ q.add_argument("--bucket", default="decisions",
173
+ help="decisions|plans|people|references|learnings|state (default: decisions)")
174
+ q.add_argument("--limit", type=int, default=20)
175
+ q.set_defaults(func=cmd_search)
176
+
177
+ sub.add_parser("briefing", help="Session-start teaser of who you are.").set_defaults(func=cmd_briefing)
178
+ sub.add_parser("schema", help="The consolidated self-model.").set_defaults(func=cmd_schema)
179
+
180
+ li = sub.add_parser("login", help="Sign in via the browser (loopback + PKCE).")
181
+ li.add_argument("--no-browser", action="store_true", help="Print the URL instead of opening a browser.")
182
+ li.add_argument("--mcp-url", default=None, help="Override NURAMEM_MCP_URL.")
183
+ li.set_defaults(func=cmd_login, no_client=True)
184
+ sub.add_parser("logout", help="Remove the stored credential.").set_defaults(func=cmd_logout, no_client=True)
185
+
186
+ co = sub.add_parser("connect", help="Wire Nuramem's MCP server into your AI clients.")
187
+ co.add_argument("--client", action="append", default=None,
188
+ help="Client key (repeatable); default: auto-detect installed clients.")
189
+ co.add_argument("--all", action="store_true", help="Configure every known client.")
190
+ co.add_argument("--dry-run", action="store_true", help="Show what would be written; change nothing.")
191
+ co.add_argument("--mcp-url", default=None, help="Override the MCP server URL.")
192
+ co.set_defaults(func=cmd_connect, no_client=True)
193
+
194
+ sk = sub.add_parser("skill", help="Install the Nuramem skill into a coding assistant.")
195
+ sksub = sk.add_subparsers(dest="skill_cmd", required=True)
196
+ ski = sksub.add_parser("install", help="Copy the skill into the assistant's skills dir.")
197
+ ski.add_argument("--client", default=None,
198
+ help="claude-code (default) | cursor | codex | windsurf.")
199
+ ski.add_argument("--dir", default=None, help="Write to an explicit directory instead.")
200
+ ski.add_argument("--dry-run", action="store_true", help="Show what would be written; change nothing.")
201
+ sk.set_defaults(func=cmd_skill, no_client=True)
202
+ return p
203
+
204
+
205
+ def main(argv: list[str] | None = None) -> int:
206
+ args = build_parser().parse_args(argv)
207
+ if getattr(args, "no_client", False): # login / logout manage the token themselves
208
+ return args.func(args)
209
+ if not resolve_token():
210
+ print("Not signed in. Run `nura login`, or set NURAMEM_TOKEN "
211
+ "(NURAMEM_DEV_AUTH_TOKEN for local).", file=sys.stderr)
212
+ return 1
213
+ with NuramemClient(base_url=resolve_base_url(args.api_url)) as client:
214
+ return args.func(client, args)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ sys.exit(main())
@@ -0,0 +1,164 @@
1
+ """`nura login` — browser auth via the existing MCP OAuth proxy (loopback + PKCE).
2
+
3
+ Same RFC 8252 Authorization-Code + PKCE flow the MCP clients use (the proxy
4
+ validates the localhost loopback redirect at OUR layer — what Auth0 won't do):
5
+ 1. POST {mcp}/register → the shared public client_id (RFC 7591)
6
+ 2. open the browser to /authorize (PKCE challenge + loopback redirect)
7
+ 3. a one-shot localhost server catches the redirect's ?code
8
+ 4. POST {mcp}/oauth/token → the Auth0 access token (byte-identical to
9
+ what the REST api-server verifies)
10
+ 5. store it (0600 file) for the CLI to use; NURAMEM_TOKEN still wins for CI.
11
+
12
+ The OAuth endpoints live on the MCP server (NURAMEM_MCP_URL); the resulting token
13
+ works for the REST api-server too (same Auth0 audience). Pieces are factored so
14
+ the flow is testable without a real browser/Auth0.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import secrets
24
+ import threading
25
+ import urllib.parse
26
+ import webbrowser
27
+ from http.server import BaseHTTPRequestHandler, HTTPServer
28
+ from pathlib import Path
29
+ from typing import Any, Callable
30
+
31
+ import httpx
32
+
33
+ DEFAULT_MCP_URL = "https://mcp.nuramem.ai"
34
+ CRED_PATH = Path.home() / ".nuramem" / "credentials"
35
+
36
+
37
+ def resolve_mcp_url(explicit: str | None = None) -> str:
38
+ return (explicit or os.environ.get("NURAMEM_MCP_URL") or DEFAULT_MCP_URL).rstrip("/")
39
+
40
+
41
+ def pkce_pair() -> tuple[str, str]:
42
+ """(verifier, challenge) — challenge = base64url(sha256(verifier)), no pad."""
43
+ verifier = secrets.token_urlsafe(64)
44
+ digest = hashlib.sha256(verifier.encode()).digest()
45
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
46
+ return verifier, challenge
47
+
48
+
49
+ def build_authorize_url(base: str, client_id: str, redirect_uri: str,
50
+ challenge: str, state: str) -> str:
51
+ q = urllib.parse.urlencode({
52
+ "response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
53
+ "code_challenge": challenge, "code_challenge_method": "S256", "state": state})
54
+ return f"{base}/authorize?{q}"
55
+
56
+
57
+ def parse_callback(query: str) -> tuple[str | None, str | None, str | None]:
58
+ """(code, state, error) from the loopback redirect's query string."""
59
+ p = urllib.parse.parse_qs(query)
60
+ err = (p.get("error") or [None])[0]
61
+ if err:
62
+ desc = (p.get("error_description") or [""])[0]
63
+ return None, (p.get("state") or [None])[0], f"{err}: {desc}".strip(": ")
64
+ return (p.get("code") or [None])[0], (p.get("state") or [None])[0], None
65
+
66
+
67
+ def register_client(http: httpx.Client, base: str) -> str:
68
+ r = http.post(f"{base}/register", json={
69
+ "client_name": "Nuramem CLI",
70
+ "grant_types": ["authorization_code", "refresh_token"],
71
+ "response_types": ["code"], "token_endpoint_auth_method": "none"})
72
+ r.raise_for_status()
73
+ cid = r.json().get("client_id")
74
+ if not cid:
75
+ raise RuntimeError("registration returned no client_id")
76
+ return cid
77
+
78
+
79
+ def exchange_code(http: httpx.Client, base: str, code: str, verifier: str,
80
+ redirect_uri: str, client_id: str) -> dict[str, Any]:
81
+ r = http.post(f"{base}/oauth/token", data={
82
+ "grant_type": "authorization_code", "code": code, "code_verifier": verifier,
83
+ "redirect_uri": redirect_uri, "client_id": client_id})
84
+ r.raise_for_status()
85
+ tok = r.json()
86
+ if not tok.get("access_token"):
87
+ raise RuntimeError(f"token exchange returned no access_token: {tok}")
88
+ return tok
89
+
90
+
91
+ # ── token storage (0600 file; NURAMEM_TOKEN env still takes precedence) ──
92
+ def save_token(token: dict[str, Any]) -> None:
93
+ CRED_PATH.parent.mkdir(parents=True, exist_ok=True)
94
+ CRED_PATH.write_text(json.dumps(token), encoding="utf-8")
95
+ os.chmod(CRED_PATH, 0o600)
96
+
97
+
98
+ def load_token() -> str | None:
99
+ try:
100
+ return json.loads(CRED_PATH.read_text(encoding="utf-8")).get("access_token")
101
+ except Exception:
102
+ return None
103
+
104
+
105
+ def logout() -> bool:
106
+ if CRED_PATH.exists():
107
+ CRED_PATH.unlink()
108
+ return True
109
+ return False
110
+
111
+
112
+ def _make_handler(holder: dict, done: threading.Event):
113
+ class _Catcher(BaseHTTPRequestHandler):
114
+ def do_GET(self) -> None: # noqa: N802
115
+ code, state, err = parse_callback(urllib.parse.urlparse(self.path).query)
116
+ holder.update(code=code, state=state, error=err)
117
+ self.send_response(200)
118
+ self.send_header("Content-Type", "text/html")
119
+ self.end_headers()
120
+ msg = "Signed in to Nuramem — you can close this tab." if not err else f"Sign-in failed: {err}"
121
+ self.wfile.write(f"<html><body><h2>{msg}</h2></body></html>".encode())
122
+ done.set()
123
+
124
+ def log_message(self, *a: object) -> None: # silence the default stderr log
125
+ pass
126
+ return _Catcher
127
+
128
+
129
+ def login(mcp_base: str | None = None, *, open_browser: bool = True, timeout: float = 300.0,
130
+ http: httpx.Client | None = None,
131
+ on_authorize_url: Callable[[str], None] | None = None) -> dict[str, Any]:
132
+ """Run the full loopback+PKCE login and persist the token. Returns the token
133
+ dict. `on_authorize_url` overrides the browser-open (used by tests)."""
134
+ base = resolve_mcp_url(mcp_base)
135
+ own = http is None
136
+ http = http or httpx.Client(timeout=30.0)
137
+ try:
138
+ client_id = register_client(http, base)
139
+ verifier, challenge = pkce_pair()
140
+ state = secrets.token_urlsafe(24)
141
+ holder: dict[str, Any] = {}
142
+ done = threading.Event()
143
+ server = HTTPServer(("127.0.0.1", 0), _make_handler(holder, done))
144
+ redirect_uri = f"http://127.0.0.1:{server.server_address[1]}/callback"
145
+ url = build_authorize_url(base, client_id, redirect_uri, challenge, state)
146
+ threading.Thread(target=server.handle_request, daemon=True).start()
147
+ if on_authorize_url is not None:
148
+ on_authorize_url(url)
149
+ elif open_browser:
150
+ webbrowser.open(url)
151
+ else:
152
+ print(f"Open this URL to sign in:\n {url}")
153
+ if not done.wait(timeout):
154
+ raise TimeoutError("login timed out waiting for the browser redirect")
155
+ if holder.get("error"):
156
+ raise RuntimeError(holder["error"])
157
+ if holder.get("state") != state:
158
+ raise RuntimeError("state mismatch — possible CSRF; aborting")
159
+ token = exchange_code(http, base, holder["code"], verifier, redirect_uri, client_id)
160
+ save_token(token)
161
+ return token
162
+ finally:
163
+ if own:
164
+ http.close()