granola-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- granola/__init__.py +61 -0
- granola/_http.py +49 -0
- granola/auth.py +103 -0
- granola/cli.py +237 -0
- granola/client.py +46 -0
- granola/config.py +48 -0
- granola/crypto.py +117 -0
- granola/editing.py +37 -0
- granola/granola-api-routes.json +394 -0
- granola/notes.py +81 -0
- granola/routes.py +71 -0
- granola/sharing.py +179 -0
- granola/sources.py +327 -0
- granola/store.py +116 -0
- granola_cli-0.1.0.dist-info/METADATA +158 -0
- granola_cli-0.1.0.dist-info/RECORD +19 -0
- granola_cli-0.1.0.dist-info/WHEEL +4 -0
- granola_cli-0.1.0.dist-info/entry_points.txt +2 -0
- granola_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
granola/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Granola — decrypt on-disk credentials, auto-refresh, and drive the documented internal API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from granola import GranolaClient, notes, sharing
|
|
6
|
+
client = GranolaClient()
|
|
7
|
+
me = client.invoke("get-user-info")
|
|
8
|
+
recent = notes.list_notes(client, limit=10)
|
|
9
|
+
sharing.add_collaborator(client, "<doc-id>", "person@example.com", name="Person")
|
|
10
|
+
|
|
11
|
+
Headless / portable auth::
|
|
12
|
+
|
|
13
|
+
from granola import GranolaClient, SessionFileSource, Config
|
|
14
|
+
cfg = Config()
|
|
15
|
+
client = GranolaClient(cfg, source=SessionFileSource(cfg, "session.json"))
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from . import editing, notes, sharing
|
|
20
|
+
from .auth import (
|
|
21
|
+
RefreshRevoked,
|
|
22
|
+
format_token_status,
|
|
23
|
+
refresh_exchange,
|
|
24
|
+
token_is_expiring,
|
|
25
|
+
)
|
|
26
|
+
from .client import GranolaClient
|
|
27
|
+
from .config import Config
|
|
28
|
+
from .routes import load_routes, resolve_endpoint
|
|
29
|
+
from .sources import (
|
|
30
|
+
DesktopStoreSource,
|
|
31
|
+
SessionFileSource,
|
|
32
|
+
StaticTokenSource,
|
|
33
|
+
TokenSource,
|
|
34
|
+
create_session_file,
|
|
35
|
+
resolve_source,
|
|
36
|
+
)
|
|
37
|
+
from .store import get_dek, read_store, save_store
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Config",
|
|
42
|
+
"GranolaClient",
|
|
43
|
+
"TokenSource",
|
|
44
|
+
"DesktopStoreSource",
|
|
45
|
+
"SessionFileSource",
|
|
46
|
+
"StaticTokenSource",
|
|
47
|
+
"resolve_source",
|
|
48
|
+
"create_session_file",
|
|
49
|
+
"refresh_exchange",
|
|
50
|
+
"format_token_status",
|
|
51
|
+
"token_is_expiring",
|
|
52
|
+
"RefreshRevoked",
|
|
53
|
+
"load_routes",
|
|
54
|
+
"resolve_endpoint",
|
|
55
|
+
"read_store",
|
|
56
|
+
"save_store",
|
|
57
|
+
"get_dek",
|
|
58
|
+
"notes",
|
|
59
|
+
"sharing",
|
|
60
|
+
"editing",
|
|
61
|
+
]
|
granola/_http.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""HTTP helpers: base headers + redirect-safe request via httpx."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def base_headers(cfg, access_token: str | None = None, additional: dict | None = None) -> dict:
|
|
11
|
+
headers = {
|
|
12
|
+
"X-Client-Version": cfg.client_version,
|
|
13
|
+
"X-Granola-Platform": cfg.platform,
|
|
14
|
+
"Accept": "application/json",
|
|
15
|
+
"User-Agent": f"Granola/{cfg.client_version}",
|
|
16
|
+
}
|
|
17
|
+
if access_token:
|
|
18
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
19
|
+
if additional:
|
|
20
|
+
headers.update(additional)
|
|
21
|
+
return headers
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def request(method: str, url: str, *, json_body=None, headers: dict | None = None,
|
|
25
|
+
timeout: float = 60.0) -> httpx.Response:
|
|
26
|
+
# follow_redirects=True keeps the POST body across 307/308 (httpx, unlike the
|
|
27
|
+
# old PowerShell Invoke-RestMethod, does this correctly).
|
|
28
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
29
|
+
return client.request(method.upper(), url, json=json_body, headers=headers)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def granola_running() -> bool:
|
|
33
|
+
"""Best-effort check whether the desktop app is running (Windows/macOS)."""
|
|
34
|
+
try:
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
out = subprocess.run(
|
|
37
|
+
["tasklist", "/FI", "IMAGENAME eq Granola.exe", "/NH"],
|
|
38
|
+
capture_output=True, text=True, timeout=5,
|
|
39
|
+
)
|
|
40
|
+
return "Granola.exe" in out.stdout
|
|
41
|
+
if sys.platform == "darwin":
|
|
42
|
+
out = subprocess.run(
|
|
43
|
+
["/usr/bin/pgrep", "-x", "Granola"],
|
|
44
|
+
capture_output=True, text=True, timeout=5,
|
|
45
|
+
)
|
|
46
|
+
return out.returncode == 0 and bool(out.stdout.strip())
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
return False
|
granola/auth.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Token primitives: the refresh HTTP exchange, expiry math, account selection,
|
|
2
|
+
and status formatting.
|
|
3
|
+
|
|
4
|
+
These are deliberately persistence-free. *Where* a refreshed token gets written
|
|
5
|
+
back (the encrypted desktop store vs. a portable session file) lives in
|
|
6
|
+
``sources.py`` — this module only knows how to talk to the refresh endpoint and
|
|
7
|
+
how to reason about a token dict.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from ._http import base_headers, request
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RefreshRevoked(RuntimeError):
|
|
19
|
+
"""The refresh token was rejected (revoked or already rotated away).
|
|
20
|
+
|
|
21
|
+
Carries a source-specific, human-readable recovery message — the desktop and
|
|
22
|
+
session sources re-raise it with the right re-auth instructions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_ms() -> int:
|
|
27
|
+
return int(time.time() * 1000)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def token_is_expiring(token: dict, skew_ms: int) -> bool:
|
|
31
|
+
expiry_ms = int(token["obtained_at"]) + int(token["expires_in"]) * 1000
|
|
32
|
+
return (expiry_ms - _now_ms()) < skew_ms
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def select_account(store, email: str | None = None):
|
|
36
|
+
if email:
|
|
37
|
+
for acct in store.accounts:
|
|
38
|
+
if acct.get("email") == email:
|
|
39
|
+
return acct
|
|
40
|
+
raise ValueError(f"No stored account with email '{email}'.")
|
|
41
|
+
return store.accounts[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh_exchange(cfg: Config, tok: dict) -> dict:
|
|
45
|
+
"""POST the refresh token and return a *new* token dict. Pure — no write-back.
|
|
46
|
+
|
|
47
|
+
Raises ``RefreshRevoked`` on 401 (revoked/rotated) and ``RuntimeError`` on any
|
|
48
|
+
other non-2xx. The returned dict is a copy of ``tok`` with the rotated fields
|
|
49
|
+
applied, so callers decide where to persist it.
|
|
50
|
+
"""
|
|
51
|
+
if not tok.get("refresh_token"):
|
|
52
|
+
raise ValueError("No refresh_token available to refresh.")
|
|
53
|
+
|
|
54
|
+
headers = base_headers(cfg, tok["access_token"])
|
|
55
|
+
resp = request("POST", cfg.refresh_url,
|
|
56
|
+
json_body={"refresh_token": tok["refresh_token"]},
|
|
57
|
+
headers=headers, timeout=cfg.timeout)
|
|
58
|
+
|
|
59
|
+
if resp.status_code == 401:
|
|
60
|
+
kind = None
|
|
61
|
+
try:
|
|
62
|
+
kind = resp.json().get("error")
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
raise RefreshRevoked(f"Refresh rejected (401{': ' + kind if kind else ''}).")
|
|
66
|
+
if resp.status_code >= 400:
|
|
67
|
+
raise RuntimeError(f"Refresh failed: HTTP {resp.status_code}. {resp.text[:300]}")
|
|
68
|
+
|
|
69
|
+
data = resp.json()
|
|
70
|
+
if not data.get("access_token"):
|
|
71
|
+
raise RuntimeError("Refresh OK but no access_token in response.")
|
|
72
|
+
|
|
73
|
+
new = dict(tok)
|
|
74
|
+
new["access_token"] = data["access_token"]
|
|
75
|
+
new["expires_in"] = data.get("expires_in", tok.get("expires_in"))
|
|
76
|
+
for key in ("refresh_token", "token_type", "session_id", "sign_in_method"):
|
|
77
|
+
if data.get(key):
|
|
78
|
+
new[key] = data[key]
|
|
79
|
+
new["obtained_at"] = _now_ms()
|
|
80
|
+
return new
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_token_status(tok: dict, skew_ms: int, include_secrets: bool = False) -> dict:
|
|
84
|
+
"""A no-secrets status view of one token dict (secrets gated behind the flag)."""
|
|
85
|
+
obt_ms = int(tok["obtained_at"])
|
|
86
|
+
obtained = datetime.fromtimestamp(obt_ms / 1000, tz=timezone.utc)
|
|
87
|
+
expiry = datetime.fromtimestamp(
|
|
88
|
+
(obt_ms + int(tok["expires_in"]) * 1000) / 1000, tz=timezone.utc
|
|
89
|
+
)
|
|
90
|
+
now = datetime.now(timezone.utc)
|
|
91
|
+
info = {
|
|
92
|
+
"token_type": tok.get("token_type"),
|
|
93
|
+
"sign_in_method": tok.get("sign_in_method"),
|
|
94
|
+
"obtained_at_utc": obtained.isoformat(),
|
|
95
|
+
"expiry_utc": expiry.isoformat(),
|
|
96
|
+
"expired": now > expiry,
|
|
97
|
+
"expiring_soon": token_is_expiring(tok, skew_ms),
|
|
98
|
+
"minutes_left": round((expiry - now).total_seconds() / 60, 1),
|
|
99
|
+
}
|
|
100
|
+
if include_secrets:
|
|
101
|
+
info["access_token"] = tok.get("access_token")
|
|
102
|
+
info["refresh_token"] = tok.get("refresh_token")
|
|
103
|
+
return info
|
granola/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""`granola` — one CLI for the documented Granola API: credentials + notes + sharing + editing.
|
|
2
|
+
|
|
3
|
+
Auth: auth status | auth token | auth refresh | auth export
|
|
4
|
+
Engine: routes | call
|
|
5
|
+
Read: notes | get | meta | transcript | panels
|
|
6
|
+
Share: who | share | unshare | role | share-folder | folder-who | unshare-folder
|
|
7
|
+
Edit: update | delete
|
|
8
|
+
|
|
9
|
+
Global auth options (before the command) select the token source:
|
|
10
|
+
--email EMAIL pick an account from the local desktop credentials
|
|
11
|
+
--session PATH use a refreshable session file
|
|
12
|
+
--access-token TOK use this bearer token directly (no refresh)
|
|
13
|
+
--no-refresh never auto-refresh
|
|
14
|
+
Environment: GRANOLA_SESSION, GRANOLA_ACCESS_TOKEN.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import editing, notes, sharing
|
|
24
|
+
from .client import GranolaClient
|
|
25
|
+
from .config import Config
|
|
26
|
+
from .routes import load_routes
|
|
27
|
+
from .sources import create_session_file, resolve_source
|
|
28
|
+
|
|
29
|
+
DEFAULT_SESSION_PATH = Path.home() / ".config" / "granola" / "session.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print(obj) -> None:
|
|
33
|
+
print(obj if isinstance(obj, str) else json.dumps(obj, indent=2, ensure_ascii=False))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
37
|
+
p = argparse.ArgumentParser(prog="granola", description=__doc__,
|
|
38
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
39
|
+
p.add_argument("--email", help="Select a specific stored desktop account.")
|
|
40
|
+
p.add_argument("--session", help="Use a refreshable session JSON file.")
|
|
41
|
+
p.add_argument("--access-token", help="Use this bearer token directly (no refresh).")
|
|
42
|
+
p.add_argument("--no-refresh", action="store_true", help="Never auto-refresh the token.")
|
|
43
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
44
|
+
|
|
45
|
+
# --- auth ---
|
|
46
|
+
pa = sub.add_parser("auth", help="Token / session management.")
|
|
47
|
+
asub = pa.add_subparsers(dest="auth_cmd", required=True)
|
|
48
|
+
pas = asub.add_parser("status", help="Token/account status (no secrets by default).")
|
|
49
|
+
pas.add_argument("--include-secrets", action="store_true")
|
|
50
|
+
asub.add_parser("token", help="Print the current valid bearer token.")
|
|
51
|
+
asub.add_parser("refresh", help="Force-refresh the selected source.")
|
|
52
|
+
pae = asub.add_parser("export", help="Write a refreshable session file from desktop creds.")
|
|
53
|
+
pae.add_argument("path", nargs="?", default=None,
|
|
54
|
+
help=f"Destination (default: {DEFAULT_SESSION_PATH}).")
|
|
55
|
+
pae.add_argument("--no-refresh-token", action="store_true",
|
|
56
|
+
help="Bearer-only session (cannot refresh; for short-lived/CI use).")
|
|
57
|
+
|
|
58
|
+
# --- engine ---
|
|
59
|
+
pr = sub.add_parser("routes", help="List endpoint routes (optional filter).")
|
|
60
|
+
pr.add_argument("filter", nargs="?", default="")
|
|
61
|
+
pc = sub.add_parser("call", help="Call any endpoint by name or URL.")
|
|
62
|
+
pc.add_argument("endpoint")
|
|
63
|
+
pc.add_argument("--body", help='JSON body, e.g. \'{"limit":5}\'.')
|
|
64
|
+
pc.add_argument("--method", default="POST")
|
|
65
|
+
pc.add_argument("--raw", action="store_true")
|
|
66
|
+
|
|
67
|
+
# --- read ---
|
|
68
|
+
pn = sub.add_parser("notes", help="List recent notes.")
|
|
69
|
+
pn.add_argument("--limit", type=int, default=20)
|
|
70
|
+
pn.add_argument("--json", action="store_true")
|
|
71
|
+
pg = sub.add_parser("get", help="Full record for one note.")
|
|
72
|
+
pg.add_argument("id")
|
|
73
|
+
pg.add_argument("--json", action="store_true")
|
|
74
|
+
pm = sub.add_parser("meta", help="Creator/attendees/conferencing for a note.")
|
|
75
|
+
pm.add_argument("id")
|
|
76
|
+
pt = sub.add_parser("transcript", help="Transcript as markdown.")
|
|
77
|
+
pt.add_argument("id")
|
|
78
|
+
pp = sub.add_parser("panels", help="AI summary panels for a note.")
|
|
79
|
+
pp.add_argument("id")
|
|
80
|
+
pp.add_argument("--json", action="store_true")
|
|
81
|
+
|
|
82
|
+
# --- share ---
|
|
83
|
+
pw = sub.add_parser("who", help="Who has access to a note.")
|
|
84
|
+
pw.add_argument("id")
|
|
85
|
+
ps = sub.add_parser("share", help="Add a collaborator to a note.")
|
|
86
|
+
ps.add_argument("id")
|
|
87
|
+
ps.add_argument("--email", required=True)
|
|
88
|
+
ps.add_argument("--name")
|
|
89
|
+
ps.add_argument("--role", default="collaborator")
|
|
90
|
+
pu = sub.add_parser("unshare", help="Remove a collaborator from a note.")
|
|
91
|
+
pu.add_argument("id")
|
|
92
|
+
pu.add_argument("--email", required=True)
|
|
93
|
+
pu.add_argument("--cleanup-list", action="append", default=[],
|
|
94
|
+
help="Also strip inherited access from this folder id (repeatable).")
|
|
95
|
+
prole = sub.add_parser("role", help="Change a collaborator's role.")
|
|
96
|
+
prole.add_argument("id")
|
|
97
|
+
prole.add_argument("--user", required=True, help="user_id (from `who`).")
|
|
98
|
+
prole.add_argument("--role", required=True)
|
|
99
|
+
pf = sub.add_parser("share-folder", help="Share a folder with someone (folder-level access).")
|
|
100
|
+
pf.add_argument("folder", help='Folder id or name (e.g. "Team Notes").')
|
|
101
|
+
pf.add_argument("--email", required=True)
|
|
102
|
+
pf.add_argument("--name")
|
|
103
|
+
pf.add_argument("--role", default="collaborator")
|
|
104
|
+
pf.add_argument("--per-note", action="store_true",
|
|
105
|
+
help="Add to each note directly (invites non-Granola emails; vs one-call folder ACL).")
|
|
106
|
+
pf.add_argument("--include-existing", action="store_true",
|
|
107
|
+
help="(per-note) Re-add even where the person already has access.")
|
|
108
|
+
pfw = sub.add_parser("folder-who", help="Who has access to a folder.")
|
|
109
|
+
pfw.add_argument("folder")
|
|
110
|
+
puf = sub.add_parser("unshare-folder", help="Revoke a person's folder-level access.")
|
|
111
|
+
puf.add_argument("folder")
|
|
112
|
+
puf.add_argument("--email", required=True)
|
|
113
|
+
|
|
114
|
+
# --- edit ---
|
|
115
|
+
pup = sub.add_parser("update", help="Partial-edit a note (title/markdown).")
|
|
116
|
+
pup.add_argument("id")
|
|
117
|
+
pup.add_argument("--title")
|
|
118
|
+
pup.add_argument("--markdown")
|
|
119
|
+
pd = sub.add_parser("delete", help="PERMANENTLY hard-delete a note.")
|
|
120
|
+
pd.add_argument("id")
|
|
121
|
+
pd.add_argument("--yes", action="store_true", help="Required: confirm.")
|
|
122
|
+
return p
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None) -> int: # noqa: C901 - flat dispatch is clearer than abstraction here
|
|
126
|
+
try: # Windows console defaults to cp1252; note content is UTF-8 (emoji, smart quotes)
|
|
127
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
args = build_parser().parse_args(argv)
|
|
131
|
+
cfg = Config()
|
|
132
|
+
c = args.cmd
|
|
133
|
+
|
|
134
|
+
# auth export reads the desktop store directly; it doesn't use the resolved source.
|
|
135
|
+
if c == "auth" and args.auth_cmd == "export":
|
|
136
|
+
if args.session or args.access_token:
|
|
137
|
+
print("auth export reads the desktop credential store; "
|
|
138
|
+
"don't pass --session/--access-token.", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
dest = args.path or str(DEFAULT_SESSION_PATH)
|
|
141
|
+
out = create_session_file(cfg, dest, email=args.email,
|
|
142
|
+
include_refresh_token=not args.no_refresh_token)
|
|
143
|
+
mode = "owner-only" if sys.platform == "win32" else "0600"
|
|
144
|
+
kind = "bearer-only" if args.no_refresh_token else "refreshable"
|
|
145
|
+
print(f"Wrote {kind} session file: {out} (mode {mode})", file=sys.stderr)
|
|
146
|
+
print(out)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
source = resolve_source(cfg, email=args.email, session=args.session,
|
|
150
|
+
access_token=args.access_token, no_refresh=args.no_refresh)
|
|
151
|
+
client = GranolaClient(cfg, source=source)
|
|
152
|
+
|
|
153
|
+
# auth
|
|
154
|
+
if c == "auth":
|
|
155
|
+
if args.auth_cmd == "status":
|
|
156
|
+
_print(source.status(include_secrets=args.include_secrets))
|
|
157
|
+
elif args.auth_cmd == "token":
|
|
158
|
+
print(source.access_token())
|
|
159
|
+
elif args.auth_cmd == "refresh":
|
|
160
|
+
source.access_token(force=True)
|
|
161
|
+
_print(source.status(include_secrets=False))
|
|
162
|
+
|
|
163
|
+
# engine
|
|
164
|
+
elif c == "routes":
|
|
165
|
+
for name in sorted(load_routes(cfg)):
|
|
166
|
+
if args.filter in name:
|
|
167
|
+
print(f"{name:40} {load_routes(cfg)[name]}")
|
|
168
|
+
elif c == "call":
|
|
169
|
+
body = json.loads(args.body) if args.body else None
|
|
170
|
+
_print(client.invoke(args.endpoint, body=body, method=args.method, raw=args.raw))
|
|
171
|
+
|
|
172
|
+
# read
|
|
173
|
+
elif c == "notes":
|
|
174
|
+
docs = notes.list_notes(client, limit=args.limit)
|
|
175
|
+
if args.json:
|
|
176
|
+
_print(docs)
|
|
177
|
+
else:
|
|
178
|
+
for d in docs:
|
|
179
|
+
print(f"{d.get('id')} {d.get('updated_at','')[:19]:19} {d.get('title') or '(untitled)'}")
|
|
180
|
+
print(f"{len(docs)} notes", file=sys.stderr)
|
|
181
|
+
elif c == "get":
|
|
182
|
+
rec = notes.get_note(client, args.id)
|
|
183
|
+
if not rec:
|
|
184
|
+
print("note not found", file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
if args.json:
|
|
187
|
+
_print(rec)
|
|
188
|
+
else:
|
|
189
|
+
for k in ("id", "title", "created_at", "updated_at", "workspace_id",
|
|
190
|
+
"public", "visibility", "document_user_role", "is_shared_direct"):
|
|
191
|
+
print(f"{k:20} {rec.get(k)}")
|
|
192
|
+
elif c == "meta":
|
|
193
|
+
_print(notes.get_metadata(client, args.id))
|
|
194
|
+
elif c == "transcript":
|
|
195
|
+
print(notes.transcript_to_markdown(notes.get_transcript(client, args.id)))
|
|
196
|
+
elif c == "panels":
|
|
197
|
+
_print(notes.get_panels(client, args.id))
|
|
198
|
+
|
|
199
|
+
# share
|
|
200
|
+
elif c == "who":
|
|
201
|
+
for u in sharing.list_collaborators(client, args.id):
|
|
202
|
+
print(f"{u.get('role',''):13} {u.get('email',''):35} {u.get('user_id','')}")
|
|
203
|
+
elif c == "share":
|
|
204
|
+
_print(sharing.add_collaborator(client, args.id, args.email, name=args.name, role=args.role))
|
|
205
|
+
elif c == "unshare":
|
|
206
|
+
_print(sharing.remove_collaborator(client, args.id, args.email,
|
|
207
|
+
cleanup_list_ids=args.cleanup_list or None))
|
|
208
|
+
elif c == "role":
|
|
209
|
+
_print(sharing.set_role(client, args.id, args.user, args.role))
|
|
210
|
+
elif c == "share-folder":
|
|
211
|
+
_print(sharing.share_folder(client, args.folder, args.email, name=args.name,
|
|
212
|
+
role=args.role, per_note=args.per_note,
|
|
213
|
+
skip_existing=not args.include_existing))
|
|
214
|
+
elif c == "folder-who":
|
|
215
|
+
for u in sharing.list_folder_collaborators(client, args.folder):
|
|
216
|
+
print(f"{u.get('role',''):13} {u.get('email',''):35} {u.get('access_source','')}")
|
|
217
|
+
elif c == "unshare-folder":
|
|
218
|
+
_print(sharing.unshare_folder(client, args.folder, args.email))
|
|
219
|
+
|
|
220
|
+
# edit
|
|
221
|
+
elif c == "update":
|
|
222
|
+
fields = {k: v for k, v in (("title", args.title), ("notes_markdown", args.markdown)) if v is not None}
|
|
223
|
+
if not fields:
|
|
224
|
+
print("nothing to update (pass --title and/or --markdown)", file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
_print(editing.update_note(client, args.id, **fields))
|
|
227
|
+
elif c == "delete":
|
|
228
|
+
if not args.yes:
|
|
229
|
+
print("refusing to hard-delete without --yes (this is permanent)", file=sys.stderr)
|
|
230
|
+
return 2
|
|
231
|
+
_print(editing.delete_note(client, args.id))
|
|
232
|
+
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|
granola/client.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""High-level Granola API client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ._http import base_headers, request
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .routes import resolve_endpoint
|
|
7
|
+
from .sources import DesktopStoreSource, TokenSource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GranolaClient:
|
|
11
|
+
"""Call Granola API endpoints with a bearer token from a ``TokenSource``.
|
|
12
|
+
|
|
13
|
+
The source (desktop store, session file, or static token) is resolved once and
|
|
14
|
+
handles its own refresh + write-back, so call sites stay auth-agnostic.
|
|
15
|
+
|
|
16
|
+
>>> c = GranolaClient() # defaults to the desktop store
|
|
17
|
+
>>> c.invoke("get-user-info")
|
|
18
|
+
>>> c.invoke("get-documents", body={"limit": 10})
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, cfg: Config | None = None, source: TokenSource | None = None):
|
|
22
|
+
self.cfg = cfg or Config()
|
|
23
|
+
self.source = source or DesktopStoreSource(self.cfg)
|
|
24
|
+
|
|
25
|
+
def access_token(self, force: bool = False) -> str:
|
|
26
|
+
return self.source.access_token(force=force)
|
|
27
|
+
|
|
28
|
+
def resolve(self, endpoint: str) -> str:
|
|
29
|
+
return resolve_endpoint(endpoint, self.cfg)
|
|
30
|
+
|
|
31
|
+
def invoke(self, endpoint: str, body=None, method: str = "POST",
|
|
32
|
+
additional_headers: dict | None = None, raw: bool = False):
|
|
33
|
+
token = self.source.access_token()
|
|
34
|
+
url = self.resolve(endpoint)
|
|
35
|
+
headers = base_headers(self.cfg, token, additional_headers)
|
|
36
|
+
resp = request(method, url, json_body=body, headers=headers, timeout=self.cfg.timeout)
|
|
37
|
+
if resp.status_code >= 400:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
f"Granola API '{endpoint}' returned HTTP {resp.status_code}. {resp.text[:500]}"
|
|
40
|
+
)
|
|
41
|
+
if raw:
|
|
42
|
+
return resp.text
|
|
43
|
+
try:
|
|
44
|
+
return resp.json()
|
|
45
|
+
except Exception:
|
|
46
|
+
return resp.text
|
granola/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Configuration for the Granola API engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _default_granola_dir() -> Path:
|
|
11
|
+
if sys.platform == "darwin":
|
|
12
|
+
return Path.home() / "Library" / "Application Support" / "Granola"
|
|
13
|
+
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
14
|
+
return Path(appdata) / "Granola"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_app_root() -> Path:
|
|
18
|
+
if sys.platform == "darwin":
|
|
19
|
+
return Path("/Applications/Granola.app/Contents")
|
|
20
|
+
local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
21
|
+
return Path(local) / "Programs" / "@granolaelectron"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
"""Resolves paths and constants for one Granola profile.
|
|
27
|
+
|
|
28
|
+
For a *headless second session* (Option B), point ``granola_dir`` at the
|
|
29
|
+
second OS user's ``%APPDATA%\\Granola`` (the process must run as that user so
|
|
30
|
+
DPAPI CurrentUser can unseal the key).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
granola_dir: Path = field(default_factory=_default_granola_dir)
|
|
34
|
+
app_root: Path = field(default_factory=_default_app_root)
|
|
35
|
+
client_version: str = "7.303.0" # app.asar package.json version
|
|
36
|
+
# X-Granola-Platform (darwin -> macOS, win32 -> Windows)
|
|
37
|
+
platform: str = field(
|
|
38
|
+
default_factory=lambda: "macOS" if sys.platform == "darwin" else "Windows")
|
|
39
|
+
refresh_url: str = "https://api.granola.ai/v1/refresh-access-token"
|
|
40
|
+
refresh_skew_ms: int = 120_000 # app refreshes when <2 min to expiry
|
|
41
|
+
timeout: float = 60.0
|
|
42
|
+
routes_path: Path | None = None
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
self.granola_dir = Path(self.granola_dir)
|
|
46
|
+
self.app_root = Path(self.app_root)
|
|
47
|
+
if self.routes_path is not None:
|
|
48
|
+
self.routes_path = Path(self.routes_path)
|
granola/crypto.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Windows DPAPI unseal + AES-256-GCM (clean, via the `cryptography` lib).
|
|
2
|
+
|
|
3
|
+
DPAPI uses a tiny ctypes shim against crypt32.dll so the only third-party
|
|
4
|
+
dependency is `cryptography` (no pywin32). DPAPI is inherently Windows + same
|
|
5
|
+
Windows user (CurrentUser scope); everything above the access token is portable.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ctypes
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from ctypes import wintypes
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dpapi_unprotect(blob: bytes) -> bytes:
|
|
21
|
+
"""Decrypt a Windows DPAPI blob under the CurrentUser scope."""
|
|
22
|
+
if sys.platform != "win32":
|
|
23
|
+
raise RuntimeError("DPAPI decryption is only available on Windows.")
|
|
24
|
+
|
|
25
|
+
class DATA_BLOB(ctypes.Structure):
|
|
26
|
+
_fields_ = [("cbData", wintypes.DWORD),
|
|
27
|
+
("pbData", ctypes.POINTER(ctypes.c_byte))]
|
|
28
|
+
|
|
29
|
+
buf_in = ctypes.create_string_buffer(bytes(blob), len(blob))
|
|
30
|
+
blob_in = DATA_BLOB(len(blob), ctypes.cast(buf_in, ctypes.POINTER(ctypes.c_byte)))
|
|
31
|
+
blob_out = DATA_BLOB()
|
|
32
|
+
|
|
33
|
+
crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
|
34
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
35
|
+
ok = crypt32.CryptUnprotectData(
|
|
36
|
+
ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
|
|
37
|
+
)
|
|
38
|
+
if not ok:
|
|
39
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
40
|
+
try:
|
|
41
|
+
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
|
|
42
|
+
finally:
|
|
43
|
+
kernel32.LocalFree(blob_out.pbData)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def aes_gcm_decrypt(blob: bytes, key: bytes, prefix_len: int = 0, iv_len: int = 12) -> bytes:
|
|
47
|
+
"""Decrypt a `[prefix?] + IV(12) + ciphertext + tag(16)` blob.
|
|
48
|
+
|
|
49
|
+
`cryptography` expects the GCM tag appended to the ciphertext, which is
|
|
50
|
+
exactly Granola's on-disk layout after the IV.
|
|
51
|
+
"""
|
|
52
|
+
nonce = blob[prefix_len:prefix_len + iv_len]
|
|
53
|
+
ct_and_tag = blob[prefix_len + iv_len:]
|
|
54
|
+
return AESGCM(key).decrypt(nonce, ct_and_tag, None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def aes_gcm_encrypt(plaintext: bytes, key: bytes, iv_len: int = 12) -> bytes:
|
|
58
|
+
"""Produce `IV(12) + ciphertext + tag(16)`, matching Granola's `.enc` layout."""
|
|
59
|
+
nonce = os.urandom(iv_len)
|
|
60
|
+
return nonce + AESGCM(key).encrypt(nonce, plaintext, None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- macOS safeStorage (Keychain-backed) ---------------------------------------
|
|
64
|
+
# On macOS, Granola's `storage.dek` is wrapped with Electron/Chromium safeStorage
|
|
65
|
+
# (AES-128-CBC, key derived from a Keychain password) instead of Windows DPAPI.
|
|
66
|
+
# Scheme + behavior verified against harperreed/muesli's session_decrypt.rs.
|
|
67
|
+
|
|
68
|
+
KEYCHAIN_SERVICE = "Granola Safe Storage"
|
|
69
|
+
KEYCHAIN_ACCOUNT = "Granola Key"
|
|
70
|
+
_SAFE_STORAGE_SALT = b"saltysalt"
|
|
71
|
+
_SAFE_STORAGE_ITERATIONS = 1003
|
|
72
|
+
_SAFE_STORAGE_KEY_LEN = 16
|
|
73
|
+
_SAFE_STORAGE_IV = b" " * 16
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def keychain_password(service: str = KEYCHAIN_SERVICE, account: str = KEYCHAIN_ACCOUNT) -> str:
|
|
77
|
+
"""Read Granola's safeStorage key from the macOS login Keychain.
|
|
78
|
+
|
|
79
|
+
Shells out to ``/usr/bin/security`` (no extra deps). The first call may trigger
|
|
80
|
+
a Keychain access prompt unless this binary is already trusted for the item.
|
|
81
|
+
"""
|
|
82
|
+
if sys.platform != "darwin":
|
|
83
|
+
raise RuntimeError("Keychain access is only available on macOS.")
|
|
84
|
+
try:
|
|
85
|
+
out = subprocess.run(
|
|
86
|
+
["/usr/bin/security", "find-generic-password", "-s", service, "-a", account, "-w"],
|
|
87
|
+
capture_output=True, text=True, timeout=20,
|
|
88
|
+
)
|
|
89
|
+
except FileNotFoundError as exc:
|
|
90
|
+
raise RuntimeError("`security` tool not found (macOS only).") from exc
|
|
91
|
+
if out.returncode != 0:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"Keychain item '{service}'/'{account}' not found or access denied: "
|
|
94
|
+
f"{out.stderr.strip() or out.returncode}"
|
|
95
|
+
)
|
|
96
|
+
return out.stdout.strip("\n")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def aes_128_cbc_safestorage_decrypt(blob: bytes, password: str, prefix: bytes = b"v10") -> bytes:
|
|
100
|
+
"""Decrypt an Electron/Chromium safeStorage ``v10`` blob (macOS/Linux scheme).
|
|
101
|
+
|
|
102
|
+
Key = PBKDF2-HMAC-SHA1(password, "saltysalt", 1003, 16 bytes); AES-128-CBC with a
|
|
103
|
+
16-space IV and PKCS#7 padding. Used for ``storage.dek`` on macOS.
|
|
104
|
+
"""
|
|
105
|
+
if blob[: len(prefix)] != prefix:
|
|
106
|
+
raise ValueError(f"safeStorage blob missing {prefix!r} prefix.")
|
|
107
|
+
ciphertext = blob[len(prefix):]
|
|
108
|
+
key = hashlib.pbkdf2_hmac(
|
|
109
|
+
"sha1", password.encode("utf-8"), _SAFE_STORAGE_SALT,
|
|
110
|
+
_SAFE_STORAGE_ITERATIONS, dklen=_SAFE_STORAGE_KEY_LEN,
|
|
111
|
+
)
|
|
112
|
+
dec = Cipher(algorithms.AES(key), modes.CBC(_SAFE_STORAGE_IV)).decryptor()
|
|
113
|
+
padded = dec.update(ciphertext) + dec.finalize()
|
|
114
|
+
pad = padded[-1] if padded else 0
|
|
115
|
+
if not 1 <= pad <= 16 or pad > len(padded):
|
|
116
|
+
raise ValueError("Bad PKCS#7 padding (wrong Keychain password?).")
|
|
117
|
+
return padded[:-pad]
|