lifenote 0.1.1__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.
- lifenote-0.1.1/.gitignore +218 -0
- lifenote-0.1.1/LICENSE +21 -0
- lifenote-0.1.1/PKG-INFO +119 -0
- lifenote-0.1.1/README.md +95 -0
- lifenote-0.1.1/pyproject.toml +38 -0
- lifenote-0.1.1/src/lifenote/__init__.py +1 -0
- lifenote-0.1.1/src/lifenote/api.py +43 -0
- lifenote-0.1.1/src/lifenote/auth.py +272 -0
- lifenote-0.1.1/src/lifenote/cli.py +227 -0
- lifenote-0.1.1/src/lifenote/config.py +12 -0
- lifenote-0.1.1/src/lifenote/mcp_proxy.py +106 -0
- lifenote-0.1.1/src/lifenote/output.py +97 -0
- lifenote-0.1.1/src/lifenote/skill.py +70 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
lifenote-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel W. Chen
|
|
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.
|
lifenote-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lifenote
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Bring your private journal into your AI agents — Claude Code, Cursor, Codex. MCP-compatible.
|
|
5
|
+
Project-URL: Homepage, https://www.mylifenote.ai
|
|
6
|
+
Project-URL: Source, https://github.com/dandanbang/lifenote-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/dandanbang/lifenote-cli/issues
|
|
8
|
+
Author-email: "Daniel W. Chen" <daniel@mylifenote.ai>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,claude-code,cursor,journaling,mcp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: filelock>=3.12
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: keyring>=24.0
|
|
20
|
+
Requires-Dist: mcp>=1.0
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# lifenote
|
|
26
|
+
|
|
27
|
+
Bring your private [Life Note](https://www.mylifenote.ai) journal into your AI agents — Claude Code, Claude Desktop, Cursor, Codex, anything that speaks MCP.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install lifenote # recommended
|
|
33
|
+
# or
|
|
34
|
+
pip install lifenote
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Sign in
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
lifenote auth login
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Opens your browser, you click **Allow**, you're done. Your token is stored in your OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret) — never in plain files.
|
|
44
|
+
|
|
45
|
+
For CI / Docker / headless agents, set `LIFENOTE_API_TOKEN` in the environment instead.
|
|
46
|
+
|
|
47
|
+
## Use it from the terminal
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
lifenote search "anxiety about launch"
|
|
51
|
+
lifenote entries --since 7d
|
|
52
|
+
lifenote today
|
|
53
|
+
lifenote week
|
|
54
|
+
lifenote conversations list
|
|
55
|
+
lifenote conversations show 574
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Add `--json` to any command for piping into agents:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
lifenote search "burnout" --json | claude "summarize the patterns"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Use it from your AI agent (MCP)
|
|
65
|
+
|
|
66
|
+
### Claude Desktop
|
|
67
|
+
|
|
68
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"lifenote": {
|
|
74
|
+
"command": "lifenote",
|
|
75
|
+
"args": ["mcp"]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Restart Claude Desktop. Your journal is now a tool Claude can reach for on its own.
|
|
82
|
+
|
|
83
|
+
### Claude.ai web (Connectors)
|
|
84
|
+
|
|
85
|
+
Settings → Connectors → Add → paste `https://mcp.mylifenote.ai/api/agent/v1/mcp`. Approve the OAuth screen.
|
|
86
|
+
|
|
87
|
+
### Cursor / Codex / other MCP clients
|
|
88
|
+
|
|
89
|
+
Same `command: lifenote, args: [mcp]` pattern. Or point them at the HTTP endpoint directly.
|
|
90
|
+
|
|
91
|
+
## Bundled Claude Code skill
|
|
92
|
+
|
|
93
|
+
Drop a brand-aligned skill into your `~/.claude/skills/` so Claude Code knows when to reach for Life Note vs. when not to:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
lifenote skills install
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## What the agent can read
|
|
100
|
+
|
|
101
|
+
Two scopes today, both read-only:
|
|
102
|
+
|
|
103
|
+
- `journal:read` — your written entries + AI-generated analysis (themes, feelings)
|
|
104
|
+
- `chat:read` — your past conversations with mentors (Marcus Aurelius, etc.)
|
|
105
|
+
|
|
106
|
+
It **cannot** write to your journal, change your settings, see payment info, or access other users' data.
|
|
107
|
+
|
|
108
|
+
You can revoke any agent anytime at <https://www.mylifenote.ai/settings/agents>.
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
Environment variables (all optional):
|
|
113
|
+
|
|
114
|
+
- `LIFENOTE_API_TOKEN` — bypass keychain (essential for CI / Docker)
|
|
115
|
+
- `LIFENOTE_BASE_URL` — point at staging / dev (default: `https://www.mylifenote.ai`)
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT. Built by [Daniel W. Chen](https://github.com/dandanbang).
|
lifenote-0.1.1/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# lifenote
|
|
2
|
+
|
|
3
|
+
Bring your private [Life Note](https://www.mylifenote.ai) journal into your AI agents — Claude Code, Claude Desktop, Cursor, Codex, anything that speaks MCP.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pipx install lifenote # recommended
|
|
9
|
+
# or
|
|
10
|
+
pip install lifenote
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Sign in
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
lifenote auth login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Opens your browser, you click **Allow**, you're done. Your token is stored in your OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret) — never in plain files.
|
|
20
|
+
|
|
21
|
+
For CI / Docker / headless agents, set `LIFENOTE_API_TOKEN` in the environment instead.
|
|
22
|
+
|
|
23
|
+
## Use it from the terminal
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
lifenote search "anxiety about launch"
|
|
27
|
+
lifenote entries --since 7d
|
|
28
|
+
lifenote today
|
|
29
|
+
lifenote week
|
|
30
|
+
lifenote conversations list
|
|
31
|
+
lifenote conversations show 574
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Add `--json` to any command for piping into agents:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
lifenote search "burnout" --json | claude "summarize the patterns"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use it from your AI agent (MCP)
|
|
41
|
+
|
|
42
|
+
### Claude Desktop
|
|
43
|
+
|
|
44
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"lifenote": {
|
|
50
|
+
"command": "lifenote",
|
|
51
|
+
"args": ["mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Restart Claude Desktop. Your journal is now a tool Claude can reach for on its own.
|
|
58
|
+
|
|
59
|
+
### Claude.ai web (Connectors)
|
|
60
|
+
|
|
61
|
+
Settings → Connectors → Add → paste `https://mcp.mylifenote.ai/api/agent/v1/mcp`. Approve the OAuth screen.
|
|
62
|
+
|
|
63
|
+
### Cursor / Codex / other MCP clients
|
|
64
|
+
|
|
65
|
+
Same `command: lifenote, args: [mcp]` pattern. Or point them at the HTTP endpoint directly.
|
|
66
|
+
|
|
67
|
+
## Bundled Claude Code skill
|
|
68
|
+
|
|
69
|
+
Drop a brand-aligned skill into your `~/.claude/skills/` so Claude Code knows when to reach for Life Note vs. when not to:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
lifenote skills install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What the agent can read
|
|
76
|
+
|
|
77
|
+
Two scopes today, both read-only:
|
|
78
|
+
|
|
79
|
+
- `journal:read` — your written entries + AI-generated analysis (themes, feelings)
|
|
80
|
+
- `chat:read` — your past conversations with mentors (Marcus Aurelius, etc.)
|
|
81
|
+
|
|
82
|
+
It **cannot** write to your journal, change your settings, see payment info, or access other users' data.
|
|
83
|
+
|
|
84
|
+
You can revoke any agent anytime at <https://www.mylifenote.ai/settings/agents>.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
Environment variables (all optional):
|
|
89
|
+
|
|
90
|
+
- `LIFENOTE_API_TOKEN` — bypass keychain (essential for CI / Docker)
|
|
91
|
+
- `LIFENOTE_BASE_URL` — point at staging / dev (default: `https://www.mylifenote.ai`)
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT. Built by [Daniel W. Chen](https://github.com/dandanbang).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lifenote"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Bring your private journal into your AI agents — Claude Code, Cursor, Codex. MCP-compatible."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Daniel W. Chen", email = "daniel@mylifenote.ai" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["journaling", "mcp", "ai-agents", "claude-code", "cursor"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"typer>=0.12",
|
|
22
|
+
"httpx>=0.27",
|
|
23
|
+
"keyring>=24.0",
|
|
24
|
+
"rich>=13.0",
|
|
25
|
+
"mcp>=1.0",
|
|
26
|
+
"filelock>=3.12", # cross-platform refresh-token lock (Codex round 4)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://www.mylifenote.ai"
|
|
31
|
+
Source = "https://github.com/dandanbang/lifenote-cli"
|
|
32
|
+
Issues = "https://github.com/dandanbang/lifenote-cli/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
lifenote = "lifenote.cli:app"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/lifenote"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Thin httpx wrapper for /api/agent/v1/* — handles auth header + JSON."""
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
from .auth import get_token
|
|
5
|
+
from .config import base_url
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(Exception):
|
|
9
|
+
def __init__(self, status: int, body):
|
|
10
|
+
self.status = status
|
|
11
|
+
self.body = body
|
|
12
|
+
super().__init__(f"{status}: {body}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _client(timeout: float = 30.0) -> httpx.Client:
|
|
16
|
+
token = get_token()
|
|
17
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
18
|
+
headers["User-Agent"] = "lifenote-cli/0.1.0"
|
|
19
|
+
return httpx.Client(base_url=base_url(), headers=headers, timeout=timeout)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get(path: str, params: dict | None = None) -> dict:
|
|
23
|
+
with _client() as c:
|
|
24
|
+
r = c.get(path, params=params or {})
|
|
25
|
+
if r.status_code != 200:
|
|
26
|
+
try:
|
|
27
|
+
body = r.json()
|
|
28
|
+
except Exception:
|
|
29
|
+
body = r.text
|
|
30
|
+
raise APIError(r.status_code, body)
|
|
31
|
+
return r.json()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def delete(path: str) -> dict:
|
|
35
|
+
with _client() as c:
|
|
36
|
+
r = c.delete(path)
|
|
37
|
+
if r.status_code not in (200, 204):
|
|
38
|
+
try:
|
|
39
|
+
body = r.json()
|
|
40
|
+
except Exception:
|
|
41
|
+
body = r.text
|
|
42
|
+
raise APIError(r.status_code, body)
|
|
43
|
+
return {} if r.status_code == 204 else r.json()
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token storage + device-flow login + automatic refresh.
|
|
3
|
+
|
|
4
|
+
Storage precedence (read order):
|
|
5
|
+
1. LIFENOTE_API_TOKEN env var (for CI / Docker / headless agents)
|
|
6
|
+
2. OS keychain (macOS Keychain, Windows Cred Manager, Linux libsecret)
|
|
7
|
+
stores a JSON blob with {access, refresh, expires_at} so the CLI can
|
|
8
|
+
transparently refresh access tokens before they expire (Codex P2 #5).
|
|
9
|
+
|
|
10
|
+
Env-var path is essential: keyring fails or is unavailable in many headless
|
|
11
|
+
environments (CI runners, Docker without secrets manager, sandboxed agents).
|
|
12
|
+
Env-var mode skips refresh — the operator is expected to rotate it.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
import time
|
|
18
|
+
import webbrowser
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .config import (
|
|
24
|
+
ENV_TOKEN_VAR, KEYRING_SERVICE, KEYRING_USERNAME, base_url,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Refresh access token when it has fewer than this many seconds left
|
|
28
|
+
REFRESH_LEEWAY_SECONDS = 120
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Storage
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _now_iso() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).isoformat()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_keyring_blob() -> dict | None:
|
|
40
|
+
try:
|
|
41
|
+
import keyring
|
|
42
|
+
raw = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
if not raw:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
blob = json.loads(raw)
|
|
49
|
+
if isinstance(blob, dict) and 'access' in blob:
|
|
50
|
+
return blob
|
|
51
|
+
# Legacy: pre-refresh-rotation versions stored just the access token
|
|
52
|
+
return {'access': raw, 'refresh': None, 'expires_at': None}
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return {'access': raw, 'refresh': None, 'expires_at': None}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_keyring_blob(blob: dict) -> None:
|
|
58
|
+
import keyring
|
|
59
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, json.dumps(blob))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def store_credentials(access: str, refresh: str | None, expires_in: int | None) -> None:
|
|
63
|
+
"""Save access + refresh + computed absolute expiry."""
|
|
64
|
+
expires_at = None
|
|
65
|
+
if expires_in:
|
|
66
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).isoformat()
|
|
67
|
+
_write_keyring_blob({'access': access, 'refresh': refresh, 'expires_at': expires_at})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_token() -> str | None:
|
|
71
|
+
"""
|
|
72
|
+
Env var first, keyring second. If the keyring blob has a refresh token
|
|
73
|
+
and the access is near expiry, transparently refreshes (with a
|
|
74
|
+
cross-process file lock so two CLIs/MCP-proxies running concurrently
|
|
75
|
+
don't both rotate the same refresh token and trigger family-burn —
|
|
76
|
+
Codex round 3 P2 #4).
|
|
77
|
+
|
|
78
|
+
Returns the access token or None.
|
|
79
|
+
"""
|
|
80
|
+
env = os.environ.get(ENV_TOKEN_VAR)
|
|
81
|
+
if env:
|
|
82
|
+
return env.strip()
|
|
83
|
+
blob = _read_keyring_blob()
|
|
84
|
+
if not blob:
|
|
85
|
+
return None
|
|
86
|
+
access = blob.get('access')
|
|
87
|
+
refresh = blob.get('refresh')
|
|
88
|
+
expires_at = blob.get('expires_at')
|
|
89
|
+
if access and refresh and expires_at and _is_near_expiry(expires_at):
|
|
90
|
+
rotated = _refresh_with_lock(refresh)
|
|
91
|
+
if rotated:
|
|
92
|
+
return rotated
|
|
93
|
+
return access
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_near_expiry(expires_at_iso: str) -> bool:
|
|
97
|
+
try:
|
|
98
|
+
exp = datetime.fromisoformat(expires_at_iso)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return True
|
|
101
|
+
return (exp - datetime.now(timezone.utc)).total_seconds() < REFRESH_LEEWAY_SECONDS
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _lock_path() -> str:
|
|
105
|
+
"""Per-user file lock for refresh — under XDG runtime dir if possible."""
|
|
106
|
+
import tempfile
|
|
107
|
+
base = os.environ.get('XDG_RUNTIME_DIR') or tempfile.gettempdir()
|
|
108
|
+
return os.path.join(base, 'lifenote-cli-refresh.lock')
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _refresh_with_lock(refresh_plain: str) -> str | None:
|
|
112
|
+
"""
|
|
113
|
+
Acquire a cross-platform file lock around the refresh exchange (filelock
|
|
114
|
+
handles Windows, macOS, Linux). While holding the lock we re-read the
|
|
115
|
+
keyring; if another process already rotated, return its newly-stored
|
|
116
|
+
access without burning the family.
|
|
117
|
+
|
|
118
|
+
Codex round 5 P3: fail closed if filelock is missing — racing refresh
|
|
119
|
+
would burn the family on concurrent processes. filelock is a declared
|
|
120
|
+
dependency, so missing it means a broken install; tell the user to
|
|
121
|
+
reinstall rather than silently downgrading the guarantee.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
from filelock import FileLock, Timeout
|
|
125
|
+
except ImportError:
|
|
126
|
+
import sys as _sys
|
|
127
|
+
_sys.stderr.write(
|
|
128
|
+
'lifenote: filelock dependency is missing — refresh would race '
|
|
129
|
+
'across processes and burn your session. Reinstall the CLI '
|
|
130
|
+
'(pipx reinstall lifenote) and try again.\n'
|
|
131
|
+
)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
lock = FileLock(_lock_path() + '.flock', timeout=30)
|
|
135
|
+
try:
|
|
136
|
+
with lock:
|
|
137
|
+
latest = _read_keyring_blob() or {}
|
|
138
|
+
latest_refresh = latest.get('refresh')
|
|
139
|
+
latest_access = latest.get('access')
|
|
140
|
+
latest_expires = latest.get('expires_at')
|
|
141
|
+
if latest_refresh != refresh_plain and latest_access and latest_expires:
|
|
142
|
+
if not _is_near_expiry(latest_expires):
|
|
143
|
+
return latest_access # someone else just refreshed — use it
|
|
144
|
+
return _try_refresh(latest_refresh or refresh_plain)
|
|
145
|
+
except Timeout:
|
|
146
|
+
# Another process is taking too long; don't burn the family
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _try_refresh(refresh_plain: str) -> str | None:
|
|
151
|
+
"""
|
|
152
|
+
Exchange refresh token for a new pair via /api/agent/v1/oauth/token.
|
|
153
|
+
On reuse-detected (invalid_grant), clear stored creds — the user must
|
|
154
|
+
re-login. Returns new access token on success, None on failure.
|
|
155
|
+
|
|
156
|
+
Caller is responsible for serialization (use _refresh_with_lock above
|
|
157
|
+
when running from a long-lived process like mcp_proxy).
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
with httpx.Client(timeout=15.0) as c:
|
|
161
|
+
r = c.post(
|
|
162
|
+
f'{base_url()}/api/agent/v1/oauth/token',
|
|
163
|
+
data={'grant_type': 'refresh_token', 'refresh_token': refresh_plain},
|
|
164
|
+
)
|
|
165
|
+
if r.status_code != 200:
|
|
166
|
+
# invalid_grant could mean: expired, revoked, OR reuse detected
|
|
167
|
+
# (family burned). In any case, the stored creds are dead.
|
|
168
|
+
clear_token()
|
|
169
|
+
return None
|
|
170
|
+
body = r.json()
|
|
171
|
+
store_credentials(
|
|
172
|
+
access=body['access_token'],
|
|
173
|
+
refresh=body.get('refresh_token'),
|
|
174
|
+
expires_in=body.get('expires_in'),
|
|
175
|
+
)
|
|
176
|
+
return body['access_token']
|
|
177
|
+
except Exception:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def set_token(token: str) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Legacy API — store just an access token (no refresh, no expiry). Kept
|
|
184
|
+
for backward-compat with old-CLI users; new logins go through
|
|
185
|
+
store_credentials() instead.
|
|
186
|
+
"""
|
|
187
|
+
_write_keyring_blob({'access': token, 'refresh': None, 'expires_at': None})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def clear_token() -> bool:
|
|
191
|
+
"""Remove from keyring. Env var (if set) is left alone."""
|
|
192
|
+
try:
|
|
193
|
+
import keyring
|
|
194
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
195
|
+
return True
|
|
196
|
+
except Exception:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Device flow
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _agent_name() -> str:
|
|
205
|
+
"""Friendly default name shown in the approval UI. User-overridable."""
|
|
206
|
+
host = socket.gethostname().split(".")[0]
|
|
207
|
+
return f"lifenote CLI on {host}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def device_login(*, scopes: list[str] | None = None,
|
|
211
|
+
name: str | None = None,
|
|
212
|
+
open_browser: bool = True) -> dict:
|
|
213
|
+
"""
|
|
214
|
+
Run the device-authorization flow. Blocks until the user approves (or
|
|
215
|
+
until the device code expires). Returns the token response dict on
|
|
216
|
+
success; raises RuntimeError otherwise.
|
|
217
|
+
"""
|
|
218
|
+
scopes = scopes or ["journal:read", "chat:read"]
|
|
219
|
+
name = name or _agent_name()
|
|
220
|
+
|
|
221
|
+
# 1. Request device code
|
|
222
|
+
with httpx.Client(timeout=30.0) as client:
|
|
223
|
+
r = client.post(
|
|
224
|
+
f"{base_url()}/api/agent/v1/device/code",
|
|
225
|
+
json={"name": name, "scopes": scopes, "client_kind": "cli"},
|
|
226
|
+
)
|
|
227
|
+
if r.status_code != 200:
|
|
228
|
+
raise RuntimeError(f"device/code failed: {r.status_code} {r.text}")
|
|
229
|
+
body = r.json()
|
|
230
|
+
|
|
231
|
+
user_code = body["user_code"]
|
|
232
|
+
verify = body.get("verification_uri_complete") or body["verification_uri"]
|
|
233
|
+
interval = body.get("interval", 5)
|
|
234
|
+
expires_in = body.get("expires_in", 600)
|
|
235
|
+
device_code = body["device_code"]
|
|
236
|
+
|
|
237
|
+
print()
|
|
238
|
+
print(f" Open this in your browser: {verify}")
|
|
239
|
+
print(f" Or visit {body['verification_uri']} and enter: {user_code}")
|
|
240
|
+
print()
|
|
241
|
+
|
|
242
|
+
if open_browser:
|
|
243
|
+
try:
|
|
244
|
+
webbrowser.open(verify)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# 2. Poll for token
|
|
249
|
+
deadline = time.time() + expires_in
|
|
250
|
+
with httpx.Client(timeout=30.0) as client:
|
|
251
|
+
while time.time() < deadline:
|
|
252
|
+
time.sleep(interval)
|
|
253
|
+
r = client.post(
|
|
254
|
+
f"{base_url()}/api/agent/v1/device/token",
|
|
255
|
+
json={"device_code": device_code},
|
|
256
|
+
)
|
|
257
|
+
if r.status_code == 200:
|
|
258
|
+
return r.json()
|
|
259
|
+
try:
|
|
260
|
+
err = r.json().get("error")
|
|
261
|
+
except Exception:
|
|
262
|
+
err = None
|
|
263
|
+
if err == "authorization_pending":
|
|
264
|
+
continue
|
|
265
|
+
if err == "slow_down":
|
|
266
|
+
interval += 2
|
|
267
|
+
continue
|
|
268
|
+
if err in ("access_denied", "expired_token"):
|
|
269
|
+
raise RuntimeError(f"login {err}")
|
|
270
|
+
# Other errors (invalid_grant etc.) — bail
|
|
271
|
+
raise RuntimeError(f"login failed: {r.status_code} {r.text}")
|
|
272
|
+
raise RuntimeError("login timed out — please run `lifenote auth login` again")
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lifenote CLI — typer entry point.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
auth login | logout | whoami
|
|
6
|
+
search <query>
|
|
7
|
+
entries [--since] [--limit]
|
|
8
|
+
today | week
|
|
9
|
+
conversations list | show <id>
|
|
10
|
+
mcp # stdio MCP server
|
|
11
|
+
skills install # bundled Claude Code skill
|
|
12
|
+
"""
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .api import APIError, get
|
|
19
|
+
from .auth import device_login, store_credentials, clear_token, get_token
|
|
20
|
+
from .output import console, emit
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
add_completion=False,
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
help="Bring your private journal into your AI agents.",
|
|
26
|
+
rich_markup_mode="rich",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
auth_app = typer.Typer(no_args_is_help=True, help="Sign in, sign out, who am I.")
|
|
30
|
+
conv_app = typer.Typer(no_args_is_help=True, help="Past mentor conversations.")
|
|
31
|
+
skills_app = typer.Typer(no_args_is_help=True, help="Bundled agent skills.")
|
|
32
|
+
app.add_typer(auth_app, name="auth")
|
|
33
|
+
app.add_typer(conv_app, name="conversations")
|
|
34
|
+
app.add_typer(skills_app, name="skills")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _handle_api_error(e: APIError) -> None:
|
|
38
|
+
if e.status == 401:
|
|
39
|
+
console.print("[red]Not signed in.[/] Run [bold]lifenote auth login[/].")
|
|
40
|
+
elif e.status == 403:
|
|
41
|
+
console.print(f"[red]Permission denied:[/] {e.body}")
|
|
42
|
+
elif e.status == 429:
|
|
43
|
+
console.print(f"[yellow]Rate limited:[/] {e.body}")
|
|
44
|
+
else:
|
|
45
|
+
console.print(f"[red]API error {e.status}:[/] {e.body}")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# auth
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@auth_app.command("login")
|
|
54
|
+
def auth_login(
|
|
55
|
+
name: str | None = typer.Option(None, help="Name shown in the approval UI."),
|
|
56
|
+
no_browser: bool = typer.Option(False, help="Don't open the browser automatically."),
|
|
57
|
+
):
|
|
58
|
+
"""Sign in via the device flow."""
|
|
59
|
+
try:
|
|
60
|
+
result = device_login(name=name, open_browser=not no_browser)
|
|
61
|
+
except RuntimeError as e:
|
|
62
|
+
console.print(f"[red]Login failed:[/] {e}")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
# Store full credentials so the CLI can transparently refresh when the
|
|
65
|
+
# 1-hour access token nears expiry. (Codex P2 #5)
|
|
66
|
+
store_credentials(
|
|
67
|
+
access=result["access_token"],
|
|
68
|
+
refresh=result.get("refresh_token"),
|
|
69
|
+
expires_in=result.get("expires_in"),
|
|
70
|
+
)
|
|
71
|
+
console.print(f"[green]Signed in.[/] Scopes: {result.get('scope', '?')}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@auth_app.command("logout")
|
|
75
|
+
def auth_logout():
|
|
76
|
+
"""Forget the stored token (does not revoke it server-side)."""
|
|
77
|
+
if clear_token():
|
|
78
|
+
console.print("[green]Signed out.[/] To revoke server-side, visit Settings → Connected Agents.")
|
|
79
|
+
else:
|
|
80
|
+
console.print("[dim]No stored token.[/]")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@auth_app.command("whoami")
|
|
84
|
+
def auth_whoami(json: bool = typer.Option(False, "--json")):
|
|
85
|
+
"""Show who you're signed in as."""
|
|
86
|
+
if not get_token():
|
|
87
|
+
console.print("[red]Not signed in.[/] Run [bold]lifenote auth login[/].")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
try:
|
|
90
|
+
emit(get("/api/agent/v1/whoami"), json)
|
|
91
|
+
except APIError as e:
|
|
92
|
+
_handle_api_error(e)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# search / entries
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def search(
|
|
101
|
+
query: str = typer.Argument(..., help="Search term (≥2 chars)."),
|
|
102
|
+
since: str | None = typer.Option(None, help="Time bound: 7d / 30d / YYYY-MM-DD"),
|
|
103
|
+
limit: int = typer.Option(20, help="Max results (1–50)."),
|
|
104
|
+
json: bool = typer.Option(False, "--json"),
|
|
105
|
+
):
|
|
106
|
+
"""Search journal entries, mentor responses, and conversations."""
|
|
107
|
+
params = {"q": query, "limit": limit}
|
|
108
|
+
if since:
|
|
109
|
+
params["since"] = since
|
|
110
|
+
try:
|
|
111
|
+
emit(get("/api/agent/v1/search", params=params), json)
|
|
112
|
+
except APIError as e:
|
|
113
|
+
_handle_api_error(e)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def entries(
|
|
118
|
+
since: str | None = typer.Option(None, help="Time bound: 7d / 30d / YYYY-MM-DD"),
|
|
119
|
+
limit: int = typer.Option(20, help="Max entries (1–100)."),
|
|
120
|
+
json: bool = typer.Option(False, "--json"),
|
|
121
|
+
):
|
|
122
|
+
"""List recent journal entries."""
|
|
123
|
+
params: dict = {"limit": limit}
|
|
124
|
+
if since:
|
|
125
|
+
params["since"] = since
|
|
126
|
+
try:
|
|
127
|
+
emit(get("/api/agent/v1/entries", params=params), json)
|
|
128
|
+
except APIError as e:
|
|
129
|
+
_handle_api_error(e)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def today(json: bool = typer.Option(False, "--json")):
|
|
134
|
+
"""Today's journal entries (shortcut for entries --since 1d)."""
|
|
135
|
+
try:
|
|
136
|
+
emit(get("/api/agent/v1/entries", params={"since": "1d", "limit": 50}), json)
|
|
137
|
+
except APIError as e:
|
|
138
|
+
_handle_api_error(e)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def week(json: bool = typer.Option(False, "--json")):
|
|
143
|
+
"""The last 7 days of entries."""
|
|
144
|
+
try:
|
|
145
|
+
emit(get("/api/agent/v1/entries", params={"since": "7d", "limit": 100}), json)
|
|
146
|
+
except APIError as e:
|
|
147
|
+
_handle_api_error(e)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# conversations
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
@conv_app.command("list")
|
|
155
|
+
def conv_list(
|
|
156
|
+
since: str | None = typer.Option(None),
|
|
157
|
+
limit: int = typer.Option(20),
|
|
158
|
+
json: bool = typer.Option(False, "--json"),
|
|
159
|
+
):
|
|
160
|
+
"""List past mentor conversations."""
|
|
161
|
+
params: dict = {"limit": limit}
|
|
162
|
+
if since:
|
|
163
|
+
params["since"] = since
|
|
164
|
+
try:
|
|
165
|
+
emit(get("/api/agent/v1/conversations", params=params), json)
|
|
166
|
+
except APIError as e:
|
|
167
|
+
_handle_api_error(e)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@conv_app.command("show")
|
|
171
|
+
def conv_show(
|
|
172
|
+
conversation_id: int = typer.Argument(...),
|
|
173
|
+
json: bool = typer.Option(False, "--json"),
|
|
174
|
+
):
|
|
175
|
+
"""Show one conversation's full message thread."""
|
|
176
|
+
try:
|
|
177
|
+
emit(get(f"/api/agent/v1/conversations/{conversation_id}"), json)
|
|
178
|
+
except APIError as e:
|
|
179
|
+
_handle_api_error(e)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# mcp — stdio MCP server proxying to the HTTP MCP endpoint
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def mcp():
|
|
188
|
+
"""
|
|
189
|
+
Run as a stdio MCP server. Reads JSON-RPC from stdin, writes to stdout.
|
|
190
|
+
Proxies every request to /api/agent/v1/mcp using the stored token.
|
|
191
|
+
|
|
192
|
+
Configure in Claude Desktop's claude_desktop_config.json:
|
|
193
|
+
{ "mcpServers": { "lifenote": { "command": "lifenote", "args": ["mcp"] } } }
|
|
194
|
+
"""
|
|
195
|
+
from .mcp_proxy import run as run_mcp
|
|
196
|
+
if not get_token():
|
|
197
|
+
# MCP clients see stderr in their logs
|
|
198
|
+
sys.stderr.write("lifenote: not signed in. Run `lifenote auth login` first.\n")
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
run_mcp()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# skills install
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
@skills_app.command("install")
|
|
208
|
+
def skills_install(
|
|
209
|
+
target: str = typer.Option(
|
|
210
|
+
"~/.claude/skills",
|
|
211
|
+
help="Where to drop the lifenote.md skill.",
|
|
212
|
+
),
|
|
213
|
+
):
|
|
214
|
+
"""Install the bundled Claude Code skill into ~/.claude/skills/."""
|
|
215
|
+
from .skill import install as install_skill
|
|
216
|
+
path = install_skill(target)
|
|
217
|
+
console.print(f"[green]Installed[/] {path}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def version():
|
|
222
|
+
"""Print the CLI version."""
|
|
223
|
+
console.print(f"lifenote {__version__}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
app()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Runtime config — base URL, env-var token fallback, keyring service name."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
DEFAULT_BASE_URL = "https://www.mylifenote.ai"
|
|
5
|
+
KEYRING_SERVICE = "mylifenote"
|
|
6
|
+
KEYRING_USERNAME = "agent-token"
|
|
7
|
+
ENV_TOKEN_VAR = "LIFENOTE_API_TOKEN"
|
|
8
|
+
ENV_BASE_URL_VAR = "LIFENOTE_BASE_URL"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def base_url() -> str:
|
|
12
|
+
return os.environ.get(ENV_BASE_URL_VAR, DEFAULT_BASE_URL).rstrip("/")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
stdio MCP server that proxies to the HTTP MCP endpoint.
|
|
3
|
+
|
|
4
|
+
Reads JSON-RPC requests from stdin (one per line), forwards each to
|
|
5
|
+
POST /api/agent/v1/mcp with the user's Bearer token, writes the response
|
|
6
|
+
back to stdout. Lives as `lifenote mcp` so MCP clients (Claude Desktop,
|
|
7
|
+
Cursor) can spawn the binary without dealing with HTTP+auth themselves.
|
|
8
|
+
|
|
9
|
+
Long-running correctness (Codex round 2 P2 #6 + #7):
|
|
10
|
+
- 202 No Content → notification; emit nothing per JSON-RPC spec
|
|
11
|
+
- 401 → refresh access token from keyring (refresh path) and retry once;
|
|
12
|
+
if refresh fails, emit JSON-RPC error and ask user to re-login
|
|
13
|
+
- The proxy holds a single session id across requests
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .auth import get_token, _refresh_with_lock, _read_keyring_blob
|
|
21
|
+
from .config import base_url
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run() -> None:
|
|
25
|
+
if not get_token():
|
|
26
|
+
sys.stderr.write("lifenote mcp: no token. Run `lifenote auth login` first.\n")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
session_id: str | None = None
|
|
30
|
+
url = f"{base_url()}/api/agent/v1/mcp"
|
|
31
|
+
|
|
32
|
+
def headers_with_token() -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"Authorization": f"Bearer {get_token() or ''}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"User-Agent": "lifenote-cli/0.1.1 (mcp_proxy)",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def post_once(line: str, sid: str | None) -> httpx.Response:
|
|
40
|
+
h = headers_with_token()
|
|
41
|
+
if sid:
|
|
42
|
+
h["Mcp-Session-Id"] = sid
|
|
43
|
+
return _http_client.post(url, headers=h, content=line)
|
|
44
|
+
|
|
45
|
+
def refresh_or_die() -> bool:
|
|
46
|
+
# Cross-process lock + keyring re-read so concurrent proxies don't
|
|
47
|
+
# both rotate and burn the family (Codex round 3 P2 #4)
|
|
48
|
+
blob = _read_keyring_blob()
|
|
49
|
+
if not blob or not blob.get('refresh'):
|
|
50
|
+
return False
|
|
51
|
+
return _refresh_with_lock(blob['refresh']) is not None
|
|
52
|
+
|
|
53
|
+
with httpx.Client(timeout=120.0) as _http_client:
|
|
54
|
+
for line in sys.stdin:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
req = json.loads(line)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
_emit({"jsonrpc": "2.0", "id": None,
|
|
62
|
+
"error": {"code": -32700, "message": "Parse error"}})
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
r = post_once(line, session_id)
|
|
67
|
+
# 401 → token expired; refresh once and retry
|
|
68
|
+
if r.status_code == 401:
|
|
69
|
+
if refresh_or_die():
|
|
70
|
+
# Session may have been bound to the old token; drop it
|
|
71
|
+
# so we re-initialize cleanly.
|
|
72
|
+
session_id = None
|
|
73
|
+
r = post_once(line, None)
|
|
74
|
+
else:
|
|
75
|
+
_emit({"jsonrpc": "2.0", "id": req.get("id"),
|
|
76
|
+
"error": {"code": -32001,
|
|
77
|
+
"message": "lifenote: token expired and refresh failed; run `lifenote auth login`"}})
|
|
78
|
+
continue
|
|
79
|
+
except httpx.HTTPError as e:
|
|
80
|
+
_emit({"jsonrpc": "2.0", "id": req.get("id"),
|
|
81
|
+
"error": {"code": -32603, "message": f"transport error: {e}"}})
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Capture the session id on initialize so subsequent calls echo it
|
|
85
|
+
if req.get("method") == "initialize":
|
|
86
|
+
sid = r.headers.get("Mcp-Session-Id")
|
|
87
|
+
if sid:
|
|
88
|
+
session_id = sid
|
|
89
|
+
|
|
90
|
+
# Notifications: server returns 202 No Content; spec says emit
|
|
91
|
+
# nothing back to the MCP client (Codex round 2 P2 #6).
|
|
92
|
+
if r.status_code == 202 or not r.content:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
_emit(r.json())
|
|
97
|
+
except ValueError:
|
|
98
|
+
_emit({"jsonrpc": "2.0", "id": req.get("id"),
|
|
99
|
+
"error": {"code": -32603,
|
|
100
|
+
"message": f"server returned non-JSON: {r.text[:200]}"}})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _emit(obj) -> None:
|
|
104
|
+
sys.stdout.write(json.dumps(obj, ensure_ascii=False))
|
|
105
|
+
sys.stdout.write("\n")
|
|
106
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Rendering: human-readable for the terminal, --json for piping into agents."""
|
|
2
|
+
import json as _json
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def emit(payload, json_mode: bool):
|
|
11
|
+
if json_mode:
|
|
12
|
+
# Stable, agent-pipe-friendly JSON (no rich formatting)
|
|
13
|
+
print(_json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False))
|
|
14
|
+
return
|
|
15
|
+
# Otherwise dispatch by shape
|
|
16
|
+
if isinstance(payload, dict) and "entries" in payload:
|
|
17
|
+
_print_entries(payload)
|
|
18
|
+
elif isinstance(payload, dict) and "results" in payload:
|
|
19
|
+
_print_search(payload)
|
|
20
|
+
elif isinstance(payload, dict) and "conversations" in payload:
|
|
21
|
+
_print_conversations(payload)
|
|
22
|
+
elif isinstance(payload, dict) and "messages" in payload:
|
|
23
|
+
_print_conversation(payload)
|
|
24
|
+
elif isinstance(payload, dict) and "tokens" in payload:
|
|
25
|
+
_print_tokens(payload)
|
|
26
|
+
else:
|
|
27
|
+
console.print(payload)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_entries(p):
|
|
31
|
+
if p.get("count", 0) == 0:
|
|
32
|
+
console.print("[dim]No entries.[/]")
|
|
33
|
+
return
|
|
34
|
+
for e in p["entries"]:
|
|
35
|
+
head = f"[bold]{e['date']}[/]"
|
|
36
|
+
if e.get("emoji"):
|
|
37
|
+
head += f" {e['emoji']}"
|
|
38
|
+
console.print(head)
|
|
39
|
+
if e.get("preview"):
|
|
40
|
+
console.print(f" [dim]{e['preview']}[/]")
|
|
41
|
+
console.print()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_search(p):
|
|
45
|
+
console.print(f"[dim]{p['count']} hits for[/] [bold]{p['query']}[/] "
|
|
46
|
+
f"[dim](scanned {p['scanned']})[/]\n")
|
|
47
|
+
if not p["results"]:
|
|
48
|
+
console.print("[dim]No matches.[/]")
|
|
49
|
+
return
|
|
50
|
+
for r in p["results"]:
|
|
51
|
+
tag = r["source"].replace("_", " ")
|
|
52
|
+
date = r.get("date") or ""
|
|
53
|
+
console.print(f"[bold cyan]{tag}[/] [dim]{date}[/]")
|
|
54
|
+
console.print(f" {r['snippet']}")
|
|
55
|
+
console.print()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_conversations(p):
|
|
59
|
+
if p.get("count", 0) == 0:
|
|
60
|
+
console.print("[dim]No conversations.[/]")
|
|
61
|
+
return
|
|
62
|
+
t = Table(show_header=True, header_style="bold", box=None)
|
|
63
|
+
t.add_column("ID")
|
|
64
|
+
t.add_column("Mentor")
|
|
65
|
+
t.add_column("Last")
|
|
66
|
+
t.add_column("Title")
|
|
67
|
+
for c in p["conversations"]:
|
|
68
|
+
t.add_row(str(c["id"]), c.get("mentor") or "-",
|
|
69
|
+
(c.get("last_message_at") or "")[:10],
|
|
70
|
+
(c.get("title") or "(no title)")[:60])
|
|
71
|
+
console.print(t)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _print_conversation(p):
|
|
75
|
+
console.print(f"[bold]{p.get('mentor') or 'Conversation'}[/] — {p.get('title') or ''}")
|
|
76
|
+
console.print()
|
|
77
|
+
for m in p["messages"]:
|
|
78
|
+
who = "[cyan]you[/]" if m["sender"] == "user" else f"[magenta]{p.get('mentor','mentor')}[/]"
|
|
79
|
+
console.print(f"{who} [dim]{(m.get('timestamp') or '')[:16]}[/]")
|
|
80
|
+
console.print(f" {m.get('content') or '[dim](no content)[/]'}")
|
|
81
|
+
console.print()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _print_tokens(p):
|
|
85
|
+
t = Table(show_header=True, header_style="bold", box=None)
|
|
86
|
+
t.add_column("ID")
|
|
87
|
+
t.add_column("Name")
|
|
88
|
+
t.add_column("Kind")
|
|
89
|
+
t.add_column("Last used")
|
|
90
|
+
t.add_column("Status")
|
|
91
|
+
for tok in p["tokens"]:
|
|
92
|
+
t.add_row(
|
|
93
|
+
str(tok["id"]), tok["name"], tok["client_kind"],
|
|
94
|
+
(tok.get("last_used_at") or "never")[:16],
|
|
95
|
+
"[green]active[/]" if tok["active"] else "[red]revoked[/]",
|
|
96
|
+
)
|
|
97
|
+
console.print(t)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Bundled Claude Code skill — drops a markdown file into ~/.claude/skills/."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
SKILL_NAME = "lifenote.md"
|
|
5
|
+
|
|
6
|
+
SKILL_BODY = """---
|
|
7
|
+
name: lifenote
|
|
8
|
+
description: |
|
|
9
|
+
Use Life Note as the user's reflective memory layer. Search their journal
|
|
10
|
+
entries, past mentor conversations, and emotional themes — to ground
|
|
11
|
+
responses in who the user actually is, not what you'd guess.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Life Note
|
|
15
|
+
|
|
16
|
+
The user keeps a private journal at https://www.mylifenote.ai. They've connected
|
|
17
|
+
it to you so you can read from it as part of your responses.
|
|
18
|
+
|
|
19
|
+
## When to reach for Life Note (proactively, without being asked)
|
|
20
|
+
|
|
21
|
+
- The user mentions a feeling ("I'm anxious about X", "I've been frustrated lately")
|
|
22
|
+
→ search the journal for that feeling word + topic
|
|
23
|
+
- The user references a person, project, or recurring theme by name
|
|
24
|
+
→ search for the name; if hits, read at least one entry
|
|
25
|
+
- The user asks "why am I…" or "what's been on my mind lately"
|
|
26
|
+
→ list recent entries (last 7 days)
|
|
27
|
+
- The user references "what [mentor] said" or any past mentor chat
|
|
28
|
+
→ list conversations and read the relevant one
|
|
29
|
+
|
|
30
|
+
## When NOT to reach for Life Note
|
|
31
|
+
|
|
32
|
+
- Coding / debugging questions
|
|
33
|
+
- General factual queries
|
|
34
|
+
- Anything where the user is acting in a professional or task-execution mode,
|
|
35
|
+
not a reflective one
|
|
36
|
+
|
|
37
|
+
## How to use what you find
|
|
38
|
+
|
|
39
|
+
- **Paraphrase, don't quote verbatim.** The user wrote those words for themselves,
|
|
40
|
+
not for you to recite back. Reference what they said in your own voice.
|
|
41
|
+
- **Cite the date casually**: "you wrote on Tuesday that you were…" — not
|
|
42
|
+
"Per journal entry id 39938 dated 2026-05-06…"
|
|
43
|
+
- **If search returns nothing, say so plainly.** Don't fabricate continuity.
|
|
44
|
+
- **Don't summarize the whole journal.** Pull what's relevant to this exact
|
|
45
|
+
moment in the conversation. The user has hundreds of entries.
|
|
46
|
+
|
|
47
|
+
## Boundaries
|
|
48
|
+
|
|
49
|
+
- You can READ. You cannot write to the journal in this version.
|
|
50
|
+
- The journal contains emotionally significant material. If recent entries
|
|
51
|
+
reference self-harm, severe distress, or crisis, prioritize a caring,
|
|
52
|
+
grounded response and the appropriate hotlines — don't analyze.
|
|
53
|
+
|
|
54
|
+
## Tools available (via the lifenote MCP server)
|
|
55
|
+
|
|
56
|
+
- `search_entries(query, since?, limit?)` — keyword search across entries
|
|
57
|
+
- `list_recent_entries(since?, limit?)` — most recent with previews
|
|
58
|
+
- `get_entry(entry_id)` — full text + analysis + mentor responses
|
|
59
|
+
- `list_conversations(since?, limit?)` — past mentor chats
|
|
60
|
+
- `get_conversation(conversation_id)` — full message thread
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def install(target_dir: str) -> str:
|
|
65
|
+
target_dir = os.path.expanduser(target_dir)
|
|
66
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
67
|
+
path = os.path.join(target_dir, SKILL_NAME)
|
|
68
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
69
|
+
f.write(SKILL_BODY)
|
|
70
|
+
return path
|