granola-cli 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.
- granola_cli-0.1.0/.github/workflows/ci.yml +45 -0
- granola_cli-0.1.0/.github/workflows/release.yml +55 -0
- granola_cli-0.1.0/.gitignore +20 -0
- granola_cli-0.1.0/LICENSE +21 -0
- granola_cli-0.1.0/PKG-INFO +158 -0
- granola_cli-0.1.0/README.md +137 -0
- granola_cli-0.1.0/docs/api-gotchas.md +14 -0
- granola_cli-0.1.0/granola/__init__.py +61 -0
- granola_cli-0.1.0/granola/_http.py +49 -0
- granola_cli-0.1.0/granola/auth.py +103 -0
- granola_cli-0.1.0/granola/cli.py +237 -0
- granola_cli-0.1.0/granola/client.py +46 -0
- granola_cli-0.1.0/granola/config.py +48 -0
- granola_cli-0.1.0/granola/crypto.py +117 -0
- granola_cli-0.1.0/granola/editing.py +37 -0
- granola_cli-0.1.0/granola/granola-api-routes.json +394 -0
- granola_cli-0.1.0/granola/notes.py +81 -0
- granola_cli-0.1.0/granola/routes.py +71 -0
- granola_cli-0.1.0/granola/sharing.py +179 -0
- granola_cli-0.1.0/granola/sources.py +327 -0
- granola_cli-0.1.0/granola/store.py +116 -0
- granola_cli-0.1.0/pyproject.toml +57 -0
- granola_cli-0.1.0/tests/test_macos_crypto.py +86 -0
- granola_cli-0.1.0/tests/test_sources.py +295 -0
- granola_cli-0.1.0/uv.lock +412 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
lint:
|
|
14
|
+
name: ruff
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v5
|
|
20
|
+
with:
|
|
21
|
+
enable-cache: true
|
|
22
|
+
- name: Sync (with dev group)
|
|
23
|
+
run: uv sync --group dev
|
|
24
|
+
- name: Ruff lint
|
|
25
|
+
run: uv run ruff check --output-format=github .
|
|
26
|
+
|
|
27
|
+
test:
|
|
28
|
+
name: pytest (${{ matrix.os }} / py${{ matrix.python-version }})
|
|
29
|
+
runs-on: ${{ matrix.os }}
|
|
30
|
+
strategy:
|
|
31
|
+
fail-fast: false
|
|
32
|
+
matrix:
|
|
33
|
+
os: [ubuntu-latest, windows-latest]
|
|
34
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@v4
|
|
37
|
+
- name: Install uv
|
|
38
|
+
uses: astral-sh/setup-uv@v5
|
|
39
|
+
with:
|
|
40
|
+
enable-cache: true
|
|
41
|
+
python-version: ${{ matrix.python-version }}
|
|
42
|
+
- name: Sync (with dev group)
|
|
43
|
+
run: uv sync --group dev
|
|
44
|
+
- name: Run tests
|
|
45
|
+
run: uv run pytest
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
workflow_dispatch: # manual run -> TestPyPI (for testing the publish flow)
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: build artifacts
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v5
|
|
16
|
+
- name: Build sdist + wheel
|
|
17
|
+
run: uv build
|
|
18
|
+
- name: Check metadata renders
|
|
19
|
+
run: uvx twine check dist/*
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
testpypi:
|
|
26
|
+
name: publish to TestPyPI
|
|
27
|
+
needs: build
|
|
28
|
+
if: github.event_name == 'workflow_dispatch'
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment: testpypi
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write # OIDC -> trusted publishing, no stored token
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/download-artifact@v4
|
|
35
|
+
with:
|
|
36
|
+
name: dist
|
|
37
|
+
path: dist/
|
|
38
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
39
|
+
with:
|
|
40
|
+
repository-url: https://test.pypi.org/legacy/
|
|
41
|
+
|
|
42
|
+
pypi:
|
|
43
|
+
name: publish to PyPI
|
|
44
|
+
needs: build
|
|
45
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
environment: pypi
|
|
48
|
+
permissions:
|
|
49
|
+
id-token: write
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/download-artifact@v4
|
|
52
|
+
with:
|
|
53
|
+
name: dist
|
|
54
|
+
path: dist/
|
|
55
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Secrets — never commit decrypted creds or tokens
|
|
10
|
+
*.enc
|
|
11
|
+
*.dek
|
|
12
|
+
*.bak-*
|
|
13
|
+
*credentials*.json
|
|
14
|
+
*token*.json
|
|
15
|
+
.env*
|
|
16
|
+
|
|
17
|
+
# Local data / scratch
|
|
18
|
+
*.db
|
|
19
|
+
notes/
|
|
20
|
+
tmp/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Granola CLI contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: granola-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One CLI for Granola: decrypt on-disk creds and drive the documented internal API (read/share/edit notes).
|
|
5
|
+
Project-URL: Homepage, https://github.com/xuelongmu/granola-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/xuelongmu/granola-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/xuelongmu/granola-cli/issues
|
|
8
|
+
Author: Granola CLI contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,granola,meeting-notes,transcripts
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: cryptography>=42
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# granola
|
|
23
|
+
|
|
24
|
+
One CLI for [Granola](https://granola.ai) on **Windows & macOS** — using Granola's own
|
|
25
|
+
on-disk session, no API key, no password prompt. It covers two things:
|
|
26
|
+
|
|
27
|
+
1. **Credentials** — decrypt the on-disk chain (Windows DPAPI / macOS Keychain → DEK →
|
|
28
|
+
cred file), auto-**refresh** the token (single-use rotation, with safe write-back),
|
|
29
|
+
print it, or export a portable **session file** for headless/Linux use.
|
|
30
|
+
2. **The documented internal API** for a note — read, share, edit — plus a generic
|
|
31
|
+
`call` for any of the ~392 internal endpoints.
|
|
32
|
+
|
|
33
|
+
> Unofficial. Uses the internal `api.granola.ai` surface the desktop app uses; it can
|
|
34
|
+
> change without notice. Credential decrypt works on **Windows** (DPAPI) and **macOS**
|
|
35
|
+
> (Keychain); the API layer is portable once you have a token.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
uv tool install . # or: pipx install .
|
|
41
|
+
granola auth status
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python ≥ 3.10. Dependencies: `httpx`, `cryptography`.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
# auth / engine
|
|
50
|
+
granola auth status # token status (no secrets; --include-secrets to show)
|
|
51
|
+
granola auth token # print a valid access token
|
|
52
|
+
granola auth refresh # force-refresh the selected source
|
|
53
|
+
granola auth export session.json # write a portable, refreshable session file (0600)
|
|
54
|
+
granola routes [filter] # endpoint name -> URL map
|
|
55
|
+
granola call <endpoint> --body '{"limit":5}' # any endpoint, raw
|
|
56
|
+
|
|
57
|
+
# read
|
|
58
|
+
granola notes --limit 20 # recent notes
|
|
59
|
+
granola get <id> # full ~50-field record (--json for all)
|
|
60
|
+
granola meta <id> # creator / attendees / conferencing
|
|
61
|
+
granola transcript <id> # transcript as markdown
|
|
62
|
+
granola panels <id> # AI summary panels
|
|
63
|
+
|
|
64
|
+
# share / access
|
|
65
|
+
granola who <id> # who has access (+ user_ids)
|
|
66
|
+
granola share <id> --email teammate@example.com --name "Teammate" # add collaborator
|
|
67
|
+
granola unshare <id> --email teammate@example.com # revoke (no email sent)
|
|
68
|
+
granola role <id> --user <user_id> --role viewer # change role
|
|
69
|
+
granola folder-who "Team Notes" # who has folder-level access
|
|
70
|
+
granola share-folder "Team Notes" --email teammate@example.com # folder ACL (existing users; inherited access)
|
|
71
|
+
granola share-folder "Team Notes" --email teammate@example.com --per-note # invite + direct access on each note
|
|
72
|
+
granola unshare-folder "Team Notes" --email teammate@example.com # revoke folder-level access
|
|
73
|
+
|
|
74
|
+
# edit
|
|
75
|
+
granola update <id> --title "New title"
|
|
76
|
+
granola delete <id> --yes # PERMANENT hard delete
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Roles: `owner` · `collaborator` · `viewer`.
|
|
80
|
+
|
|
81
|
+
See [`docs/api-gotchas.md`](docs/api-gotchas.md) for endpoint quirks baked into the
|
|
82
|
+
typed verbs.
|
|
83
|
+
|
|
84
|
+
## Headless / portable sessions
|
|
85
|
+
|
|
86
|
+
Credential decrypt needs the Keychain (macOS) or DPAPI (Windows), so it only runs on
|
|
87
|
+
the machine you signed in on. To drive the API from a headless Linux box or CI, export
|
|
88
|
+
a **session file** once and carry it over:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# On your macOS/Windows machine (where Granola is signed in):
|
|
92
|
+
granola auth export ~/session.json # minimal, refreshable, written 0600
|
|
93
|
+
|
|
94
|
+
# Sign OUT of the Granola desktop app, so only this session holds the refresh token.
|
|
95
|
+
# (Desktop + session sharing one single-use refresh token will fight and log each
|
|
96
|
+
# other out on rotation.)
|
|
97
|
+
|
|
98
|
+
# On the headless box:
|
|
99
|
+
export GRANOLA_SESSION=~/session.json
|
|
100
|
+
granola notes --limit 20 # refreshes + writes back to the file in place
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every command takes global auth options (before the command) that pick the token source:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
--email EMAIL select an account from local desktop credentials
|
|
107
|
+
--session PATH use a refreshable session file
|
|
108
|
+
--access-token TOKEN use this bearer token directly (no refresh)
|
|
109
|
+
--no-refresh never auto-refresh
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Environment equivalents: `GRANOLA_SESSION`, `GRANOLA_ACCESS_TOKEN`.
|
|
113
|
+
|
|
114
|
+
**Precedence:** a refreshable session (`--session` / `GRANOLA_SESSION`) beats a static
|
|
115
|
+
token (`--access-token` / `GRANOLA_ACCESS_TOKEN`); a flag beats its env var; the desktop
|
|
116
|
+
store is the fallback. If both a static token and a session are set, the session wins and
|
|
117
|
+
the CLI warns — a static token can't refresh and would silently go stale.
|
|
118
|
+
|
|
119
|
+
Use `--access-token` (or `granola auth export --no-refresh-token`) for short-lived CI where
|
|
120
|
+
you don't want a long-lived rotating secret on the box.
|
|
121
|
+
|
|
122
|
+
If a session's refresh token ever dies (the desktop rotated it, or it was revoked), you
|
|
123
|
+
can't re-bootstrap headlessly — re-run `granola auth export` on your macOS/Windows machine
|
|
124
|
+
and copy the file over.
|
|
125
|
+
|
|
126
|
+
## Platforms
|
|
127
|
+
|
|
128
|
+
| | Windows | macOS |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| Data dir | `%APPDATA%\Granola` | `~/Library/Application Support/Granola` |
|
|
131
|
+
| Key source | `Local State` → **DPAPI** (CurrentUser) | login **Keychain** item `Granola Safe Storage` / `Granola Key` |
|
|
132
|
+
| `storage.dek` unwrap | AES-256-GCM (Chromium key) | AES-128-CBC (PBKDF2 `saltysalt`/1003 — Electron safeStorage) |
|
|
133
|
+
| Cred file | `stored-accounts.json.enc` (`accounts[].tokens`) | `supabase.json.enc` (`workos_tokens`) |
|
|
134
|
+
| Final decrypt | AES-256-GCM(DEK) | AES-256-GCM(DEK) |
|
|
135
|
+
|
|
136
|
+
The decrypt must run as the logged-in user (DPAPI / Keychain are user-scoped). On macOS
|
|
137
|
+
the **first** run may show a Keychain access prompt for the `Granola Safe Storage` item —
|
|
138
|
+
allow it. The macOS crypto is verified against
|
|
139
|
+
[harperreed/muesli](https://github.com/harperreed/muesli)'s known-good vectors
|
|
140
|
+
(`tests/test_macos_crypto.py`).
|
|
141
|
+
|
|
142
|
+
## Layout
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
granola/
|
|
146
|
+
config, crypto, _http, store # engine: decrypt the on-disk cred chain
|
|
147
|
+
auth.py token primitives: refresh exchange, expiry, status (persistence-free)
|
|
148
|
+
sources.py token sources (desktop / session-file / static) + precedence
|
|
149
|
+
routes, client # endpoint resolution + API client
|
|
150
|
+
notes.py read ops (list/get/meta/transcript/panels)
|
|
151
|
+
sharing.py collaborators (who/share/unshare/role/share-folder)
|
|
152
|
+
editing.py update-document / hard-delete
|
|
153
|
+
cli.py the `granola` command
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# granola
|
|
2
|
+
|
|
3
|
+
One CLI for [Granola](https://granola.ai) on **Windows & macOS** — using Granola's own
|
|
4
|
+
on-disk session, no API key, no password prompt. It covers two things:
|
|
5
|
+
|
|
6
|
+
1. **Credentials** — decrypt the on-disk chain (Windows DPAPI / macOS Keychain → DEK →
|
|
7
|
+
cred file), auto-**refresh** the token (single-use rotation, with safe write-back),
|
|
8
|
+
print it, or export a portable **session file** for headless/Linux use.
|
|
9
|
+
2. **The documented internal API** for a note — read, share, edit — plus a generic
|
|
10
|
+
`call` for any of the ~392 internal endpoints.
|
|
11
|
+
|
|
12
|
+
> Unofficial. Uses the internal `api.granola.ai` surface the desktop app uses; it can
|
|
13
|
+
> change without notice. Credential decrypt works on **Windows** (DPAPI) and **macOS**
|
|
14
|
+
> (Keychain); the API layer is portable once you have a token.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
uv tool install . # or: pipx install .
|
|
20
|
+
granola auth status
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Python ≥ 3.10. Dependencies: `httpx`, `cryptography`.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
# auth / engine
|
|
29
|
+
granola auth status # token status (no secrets; --include-secrets to show)
|
|
30
|
+
granola auth token # print a valid access token
|
|
31
|
+
granola auth refresh # force-refresh the selected source
|
|
32
|
+
granola auth export session.json # write a portable, refreshable session file (0600)
|
|
33
|
+
granola routes [filter] # endpoint name -> URL map
|
|
34
|
+
granola call <endpoint> --body '{"limit":5}' # any endpoint, raw
|
|
35
|
+
|
|
36
|
+
# read
|
|
37
|
+
granola notes --limit 20 # recent notes
|
|
38
|
+
granola get <id> # full ~50-field record (--json for all)
|
|
39
|
+
granola meta <id> # creator / attendees / conferencing
|
|
40
|
+
granola transcript <id> # transcript as markdown
|
|
41
|
+
granola panels <id> # AI summary panels
|
|
42
|
+
|
|
43
|
+
# share / access
|
|
44
|
+
granola who <id> # who has access (+ user_ids)
|
|
45
|
+
granola share <id> --email teammate@example.com --name "Teammate" # add collaborator
|
|
46
|
+
granola unshare <id> --email teammate@example.com # revoke (no email sent)
|
|
47
|
+
granola role <id> --user <user_id> --role viewer # change role
|
|
48
|
+
granola folder-who "Team Notes" # who has folder-level access
|
|
49
|
+
granola share-folder "Team Notes" --email teammate@example.com # folder ACL (existing users; inherited access)
|
|
50
|
+
granola share-folder "Team Notes" --email teammate@example.com --per-note # invite + direct access on each note
|
|
51
|
+
granola unshare-folder "Team Notes" --email teammate@example.com # revoke folder-level access
|
|
52
|
+
|
|
53
|
+
# edit
|
|
54
|
+
granola update <id> --title "New title"
|
|
55
|
+
granola delete <id> --yes # PERMANENT hard delete
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Roles: `owner` · `collaborator` · `viewer`.
|
|
59
|
+
|
|
60
|
+
See [`docs/api-gotchas.md`](docs/api-gotchas.md) for endpoint quirks baked into the
|
|
61
|
+
typed verbs.
|
|
62
|
+
|
|
63
|
+
## Headless / portable sessions
|
|
64
|
+
|
|
65
|
+
Credential decrypt needs the Keychain (macOS) or DPAPI (Windows), so it only runs on
|
|
66
|
+
the machine you signed in on. To drive the API from a headless Linux box or CI, export
|
|
67
|
+
a **session file** once and carry it over:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# On your macOS/Windows machine (where Granola is signed in):
|
|
71
|
+
granola auth export ~/session.json # minimal, refreshable, written 0600
|
|
72
|
+
|
|
73
|
+
# Sign OUT of the Granola desktop app, so only this session holds the refresh token.
|
|
74
|
+
# (Desktop + session sharing one single-use refresh token will fight and log each
|
|
75
|
+
# other out on rotation.)
|
|
76
|
+
|
|
77
|
+
# On the headless box:
|
|
78
|
+
export GRANOLA_SESSION=~/session.json
|
|
79
|
+
granola notes --limit 20 # refreshes + writes back to the file in place
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every command takes global auth options (before the command) that pick the token source:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
--email EMAIL select an account from local desktop credentials
|
|
86
|
+
--session PATH use a refreshable session file
|
|
87
|
+
--access-token TOKEN use this bearer token directly (no refresh)
|
|
88
|
+
--no-refresh never auto-refresh
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Environment equivalents: `GRANOLA_SESSION`, `GRANOLA_ACCESS_TOKEN`.
|
|
92
|
+
|
|
93
|
+
**Precedence:** a refreshable session (`--session` / `GRANOLA_SESSION`) beats a static
|
|
94
|
+
token (`--access-token` / `GRANOLA_ACCESS_TOKEN`); a flag beats its env var; the desktop
|
|
95
|
+
store is the fallback. If both a static token and a session are set, the session wins and
|
|
96
|
+
the CLI warns — a static token can't refresh and would silently go stale.
|
|
97
|
+
|
|
98
|
+
Use `--access-token` (or `granola auth export --no-refresh-token`) for short-lived CI where
|
|
99
|
+
you don't want a long-lived rotating secret on the box.
|
|
100
|
+
|
|
101
|
+
If a session's refresh token ever dies (the desktop rotated it, or it was revoked), you
|
|
102
|
+
can't re-bootstrap headlessly — re-run `granola auth export` on your macOS/Windows machine
|
|
103
|
+
and copy the file over.
|
|
104
|
+
|
|
105
|
+
## Platforms
|
|
106
|
+
|
|
107
|
+
| | Windows | macOS |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| Data dir | `%APPDATA%\Granola` | `~/Library/Application Support/Granola` |
|
|
110
|
+
| Key source | `Local State` → **DPAPI** (CurrentUser) | login **Keychain** item `Granola Safe Storage` / `Granola Key` |
|
|
111
|
+
| `storage.dek` unwrap | AES-256-GCM (Chromium key) | AES-128-CBC (PBKDF2 `saltysalt`/1003 — Electron safeStorage) |
|
|
112
|
+
| Cred file | `stored-accounts.json.enc` (`accounts[].tokens`) | `supabase.json.enc` (`workos_tokens`) |
|
|
113
|
+
| Final decrypt | AES-256-GCM(DEK) | AES-256-GCM(DEK) |
|
|
114
|
+
|
|
115
|
+
The decrypt must run as the logged-in user (DPAPI / Keychain are user-scoped). On macOS
|
|
116
|
+
the **first** run may show a Keychain access prompt for the `Granola Safe Storage` item —
|
|
117
|
+
allow it. The macOS crypto is verified against
|
|
118
|
+
[harperreed/muesli](https://github.com/harperreed/muesli)'s known-good vectors
|
|
119
|
+
(`tests/test_macos_crypto.py`).
|
|
120
|
+
|
|
121
|
+
## Layout
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
granola/
|
|
125
|
+
config, crypto, _http, store # engine: decrypt the on-disk cred chain
|
|
126
|
+
auth.py token primitives: refresh exchange, expiry, status (persistence-free)
|
|
127
|
+
sources.py token sources (desktop / session-file / static) + precedence
|
|
128
|
+
routes, client # endpoint resolution + API client
|
|
129
|
+
notes.py read ops (list/get/meta/transcript/panels)
|
|
130
|
+
sharing.py collaborators (who/share/unshare/role/share-folder)
|
|
131
|
+
editing.py update-document / hard-delete
|
|
132
|
+
cli.py the `granola` command
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# API gotchas
|
|
2
|
+
|
|
3
|
+
These quirks are baked into the typed verbs so callers do not have to rediscover
|
|
4
|
+
them by hitting API errors.
|
|
5
|
+
|
|
6
|
+
- **`share`** -> `add-users-to-document` wants `names` as an **`{email: name}` object map**,
|
|
7
|
+
not an array. Sending an array can make the server return `500`.
|
|
8
|
+
- **`update`** -> `update-document` keys the note as **`id`**, not `document_id`.
|
|
9
|
+
Sending `document_id` returns `400 "Missing document ID"`.
|
|
10
|
+
- **`get`** -> the full single-note record comes from `get-documents-batch`
|
|
11
|
+
(`{document_ids: [...]}`), not a singular `get-document`.
|
|
12
|
+
|
|
13
|
+
Full request/response shapes are documented in `docs/granola-api.md` in the companion
|
|
14
|
+
credential-decrypt research repo.
|
|
@@ -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
|
+
]
|
|
@@ -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
|
|
@@ -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
|