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.
- nuramem-0.1.0/.gitignore +95 -0
- nuramem-0.1.0/PKG-INFO +56 -0
- nuramem-0.1.0/README.md +36 -0
- nuramem-0.1.0/pyproject.toml +38 -0
- nuramem-0.1.0/src/nuramem_cli/__init__.py +5 -0
- nuramem-0.1.0/src/nuramem_cli/__main__.py +218 -0
- nuramem-0.1.0/src/nuramem_cli/auth.py +164 -0
- nuramem-0.1.0/src/nuramem_cli/client.py +88 -0
- nuramem-0.1.0/src/nuramem_cli/installer.py +145 -0
- nuramem-0.1.0/src/nuramem_cli/skill.py +69 -0
- nuramem-0.1.0/src/nuramem_cli/skilldata/README.md +47 -0
- nuramem-0.1.0/src/nuramem_cli/skilldata/SKILL.md +70 -0
- nuramem-0.1.0/tests/test_auth.py +101 -0
- nuramem-0.1.0/tests/test_cli.py +106 -0
- nuramem-0.1.0/tests/test_installer.py +123 -0
- nuramem-0.1.0/tests/test_skill_install.py +67 -0
nuramem-0.1.0/.gitignore
ADDED
|
@@ -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>.
|
nuramem-0.1.0/README.md
ADDED
|
@@ -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,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()
|