studylife-cli 1.3.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.
- studylife_cli/__init__.py +0 -0
- studylife_cli/_version.py +24 -0
- studylife_cli/cli.py +546 -0
- studylife_cli/client.py +169 -0
- studylife_cli/credentials.py +54 -0
- studylife_cli/login.py +246 -0
- studylife_cli/models.py +109 -0
- studylife_cli-1.3.0.dist-info/METADATA +98 -0
- studylife_cli-1.3.0.dist-info/RECORD +12 -0
- studylife_cli-1.3.0.dist-info/WHEEL +4 -0
- studylife_cli-1.3.0.dist-info/entry_points.txt +2 -0
- studylife_cli-1.3.0.dist-info/licenses/LICENSE +661 -0
studylife_cli/client.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Typed HTTP client for the StudyLife REST API, scoped to what
|
|
2
|
+
ApiKeyScopes.PubliclyGrantable actually allows a third-party client to do.
|
|
3
|
+
|
|
4
|
+
No internal-CA-trust handling here (unlike studylife-mcp's client.py) - this talks to the
|
|
5
|
+
instance's public-facing HTTPS endpoint, the same one a browser would use, so plain httpx
|
|
6
|
+
default TLS verification is enough.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from studylife_cli.models import (
|
|
14
|
+
Course,
|
|
15
|
+
CourseGoal,
|
|
16
|
+
Note,
|
|
17
|
+
Session,
|
|
18
|
+
StudyProgramDetail,
|
|
19
|
+
StudyProgramSummary,
|
|
20
|
+
TimerState,
|
|
21
|
+
Webhook,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiError(Exception):
|
|
26
|
+
"""Raised for any non-2xx response, carrying the status code and response body."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status_code: int, body: str) -> None:
|
|
29
|
+
super().__init__(f"StudyLife API returned {status_code}: {body.strip()}")
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StudyLifeClient:
|
|
35
|
+
def __init__(self, instance_url: str, api_key: str, timeout: float = 15.0) -> None:
|
|
36
|
+
self._http = httpx.Client(
|
|
37
|
+
base_url=instance_url.rstrip("/"),
|
|
38
|
+
headers={"X-Api-Key": api_key},
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
self._http.close()
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> StudyLifeClient:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
def _request(self, method: str, path: str, **kwargs: object) -> httpx.Response:
|
|
52
|
+
response = self._http.request(method, path, **kwargs)
|
|
53
|
+
if response.status_code >= 400:
|
|
54
|
+
raise ApiError(response.status_code, response.text)
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
# -- Notes ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def list_notes(self) -> list[Note]:
|
|
60
|
+
return [Note.model_validate(item) for item in self._request("GET", "/api/notes").json()]
|
|
61
|
+
|
|
62
|
+
def search_notes(self, query: str) -> list[Note]:
|
|
63
|
+
response = self._request("GET", "/api/notes/search", params={"q": query})
|
|
64
|
+
return [Note.model_validate(item) for item in response.json()]
|
|
65
|
+
|
|
66
|
+
def create_note(self, note: Note) -> Note:
|
|
67
|
+
response = self._request(
|
|
68
|
+
"POST",
|
|
69
|
+
"/api/notes",
|
|
70
|
+
json=note.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
71
|
+
)
|
|
72
|
+
return Note.model_validate(response.json())
|
|
73
|
+
|
|
74
|
+
def update_note(self, note_id: int, note: Note) -> Note:
|
|
75
|
+
response = self._request(
|
|
76
|
+
"PUT",
|
|
77
|
+
f"/api/notes/{note_id}",
|
|
78
|
+
json=note.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
79
|
+
)
|
|
80
|
+
return Note.model_validate(response.json())
|
|
81
|
+
|
|
82
|
+
def delete_note(self, note_id: int) -> None:
|
|
83
|
+
self._request("DELETE", f"/api/notes/{note_id}")
|
|
84
|
+
|
|
85
|
+
# -- Sessions -----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def list_sessions(self) -> list[Session]:
|
|
88
|
+
return [
|
|
89
|
+
Session.model_validate(item) for item in self._request("GET", "/api/sessions").json()
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def session_history(
|
|
93
|
+
self, days: int | None = None, only_completed: bool | None = None
|
|
94
|
+
) -> list[Session]:
|
|
95
|
+
params: dict[str, object] = {}
|
|
96
|
+
if days is not None:
|
|
97
|
+
params["days"] = days
|
|
98
|
+
if only_completed is not None:
|
|
99
|
+
params["onlyCompleted"] = only_completed
|
|
100
|
+
response = self._request("GET", "/api/sessions/history", params=params)
|
|
101
|
+
return [Session.model_validate(item) for item in response.json()]
|
|
102
|
+
|
|
103
|
+
def create_session(self, session: Session) -> Session:
|
|
104
|
+
response = self._request(
|
|
105
|
+
"POST",
|
|
106
|
+
"/api/sessions",
|
|
107
|
+
json=session.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
108
|
+
)
|
|
109
|
+
return Session.model_validate(response.json())
|
|
110
|
+
|
|
111
|
+
def update_session(self, session_id: int, session: Session) -> Session:
|
|
112
|
+
response = self._request(
|
|
113
|
+
"PUT",
|
|
114
|
+
f"/api/sessions/{session_id}",
|
|
115
|
+
json=session.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
116
|
+
)
|
|
117
|
+
return Session.model_validate(response.json())
|
|
118
|
+
|
|
119
|
+
def delete_session(self, session_id: int) -> None:
|
|
120
|
+
self._request("DELETE", f"/api/sessions/{session_id}")
|
|
121
|
+
|
|
122
|
+
# -- Course goals ---------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def list_course_goals(self) -> list[CourseGoal]:
|
|
125
|
+
response = self._request("GET", "/api/coursegoals")
|
|
126
|
+
return [CourseGoal.model_validate(item) for item in response.json()]
|
|
127
|
+
|
|
128
|
+
def save_course_goal(self, course_id: int, goal: CourseGoal) -> CourseGoal:
|
|
129
|
+
response = self._request(
|
|
130
|
+
"PUT",
|
|
131
|
+
f"/api/coursegoals/{course_id}",
|
|
132
|
+
json=goal.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
133
|
+
)
|
|
134
|
+
return CourseGoal.model_validate(response.json())
|
|
135
|
+
|
|
136
|
+
def delete_course_goal(self, course_id: int) -> None:
|
|
137
|
+
self._request("DELETE", f"/api/coursegoals/{course_id}")
|
|
138
|
+
|
|
139
|
+
# -- Timer / courses / study programs (read-only) -------------------------------
|
|
140
|
+
|
|
141
|
+
def get_timer_state(self) -> TimerState:
|
|
142
|
+
return TimerState.model_validate(self._request("GET", "/api/timerstate").json())
|
|
143
|
+
|
|
144
|
+
def list_courses(self) -> list[Course]:
|
|
145
|
+
return [Course.model_validate(item) for item in self._request("GET", "/api/courses").json()]
|
|
146
|
+
|
|
147
|
+
def list_study_programs(self) -> list[StudyProgramSummary]:
|
|
148
|
+
response = self._request("GET", "/api/studyprograms")
|
|
149
|
+
return [StudyProgramSummary.model_validate(item) for item in response.json()]
|
|
150
|
+
|
|
151
|
+
def get_study_program(self, program_id: int) -> StudyProgramDetail:
|
|
152
|
+
response = self._request("GET", f"/api/studyprograms/{program_id}")
|
|
153
|
+
return StudyProgramDetail.model_validate(response.json())
|
|
154
|
+
|
|
155
|
+
# -- Webhooks -------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def list_webhooks(self) -> list[Webhook]:
|
|
158
|
+
return [
|
|
159
|
+
Webhook.model_validate(item) for item in self._request("GET", "/api/webhooks").json()
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
def create_webhook(self, target_url: str, events: list[str]) -> Webhook:
|
|
163
|
+
response = self._request(
|
|
164
|
+
"POST", "/api/webhooks", json={"targetUrl": target_url, "events": events}
|
|
165
|
+
)
|
|
166
|
+
return Webhook.model_validate(response.json())
|
|
167
|
+
|
|
168
|
+
def delete_webhook(self, webhook_id: str) -> None:
|
|
169
|
+
self._request("DELETE", f"/api/webhooks/{webhook_id}")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Local credential storage for `studylife login`.
|
|
2
|
+
|
|
3
|
+
Deliberately plaintext, like every other satellite's own key store in this ecosystem
|
|
4
|
+
(studylife-developers' KeyStore, studylife-mcp's .env) - the key this file holds is already
|
|
5
|
+
narrowly scoped by ApiKeyScopes.PubliclyGrantable server-side (no settings writes, no admin
|
|
6
|
+
actions possible even if this file leaked), not a master credential.
|
|
7
|
+
|
|
8
|
+
Uses a global config directory rather than a per-directory .env (unlike studylife-mcp, which
|
|
9
|
+
always runs from one project checkout) - a CLI is invoked from anywhere, so `studylife notes list`
|
|
10
|
+
needs to find the same credential regardless of the current working directory.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_credentials_path() -> Path:
|
|
24
|
+
config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
25
|
+
base = Path(config_home) if config_home else Path.home() / ".config"
|
|
26
|
+
return base / "studylife-cli" / "credentials.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Credentials(BaseModel):
|
|
30
|
+
instance_url: str
|
|
31
|
+
client_id: str
|
|
32
|
+
api_key: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_credentials(path: Path | None = None) -> Credentials | None:
|
|
36
|
+
path = path or default_credentials_path()
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return None
|
|
39
|
+
return Credentials.model_validate_json(path.read_text(encoding="utf-8"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_credentials(credentials: Credentials, path: Path | None = None) -> None:
|
|
43
|
+
path = path or default_credentials_path()
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
path.write_text(credentials.model_dump_json(indent=2), encoding="utf-8")
|
|
46
|
+
# Best-effort on POSIX (no-op on Windows, which has no chmod bit semantics here) -
|
|
47
|
+
# restrict to the owner only, same intent as ssh's own ~/.ssh/id_* permissions.
|
|
48
|
+
with contextlib.suppress(OSError):
|
|
49
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clear_credentials(path: Path | None = None) -> None:
|
|
53
|
+
path = path or default_credentials_path()
|
|
54
|
+
path.unlink(missing_ok=True)
|
studylife_cli/login.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Browser-based login (`studylife login`).
|
|
2
|
+
|
|
3
|
+
Ported from studylife-mcp's own login.py (same repo family, see that file for the original
|
|
4
|
+
identity-contract-v1 §2 round trip this generalizes), with two real differences:
|
|
5
|
+
|
|
6
|
+
1. Talks to the GENERIC dynamic-client flow (`/connect/client/{client_id}`, `POST
|
|
7
|
+
/api/auth/connect`/`/api/auth/assertion-exchange`) instead of the hardcoded "mcp" audience -
|
|
8
|
+
piggybacking on "mcp" would rotate studylife-mcp's own shared key slot out from under it.
|
|
9
|
+
2. Binds one of a small FIXED set of candidate loopback ports instead of an OS-assigned random
|
|
10
|
+
one. The generic flow validates redirect_uri by EXACT match against the client's own
|
|
11
|
+
registered AllowedRedirectUris (stricter than the old audiences' blanket https-or-loopback
|
|
12
|
+
check) - a random port could never match a single pre-registered URI, so this client is
|
|
13
|
+
registered (once, via studylife-developers) with several fixed candidate ports instead, and
|
|
14
|
+
tries each in turn until one is free.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hmac
|
|
20
|
+
import secrets
|
|
21
|
+
import sys
|
|
22
|
+
import webbrowser
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
26
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from studylife_cli.credentials import Credentials, save_credentials
|
|
31
|
+
|
|
32
|
+
# Fixed candidate ports, tried in order - must match (a subset of) the AllowedRedirectUris
|
|
33
|
+
# registered for this ClientId on the target instance. Kept small and low-numbered enough to
|
|
34
|
+
# rarely collide with something else already running locally.
|
|
35
|
+
CANDIDATE_PORTS = (8765, 8766, 8767, 8768)
|
|
36
|
+
|
|
37
|
+
CALLBACK_TIMEOUT_SECONDS = 300.0
|
|
38
|
+
|
|
39
|
+
DEFAULT_CLIENT_ID = "studylife-cli"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LoginError(Exception):
|
|
43
|
+
"""Raised on any failure of the login round trip, carrying a human-readable reason."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CallbackResult:
|
|
48
|
+
"""What the loopback callback received, before any validation - state/assertion default to
|
|
49
|
+
"" (not missing) so callers can treat "" uniformly as "not provided"."""
|
|
50
|
+
|
|
51
|
+
state: str
|
|
52
|
+
assertion: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_callback_query(query_string: str) -> CallbackResult:
|
|
56
|
+
query = parse_qs(query_string)
|
|
57
|
+
return CallbackResult(
|
|
58
|
+
state=query.get("state", [""])[0],
|
|
59
|
+
assertion=query.get("assertion", [""])[0],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _states_match(received: str, expected: str) -> bool:
|
|
64
|
+
"""Constant-time comparison - the state isn't secret, but there's no reason to prefer a
|
|
65
|
+
timing-observable comparison over a safe one that's just as easy to write."""
|
|
66
|
+
return hmac.compare_digest(received, expected)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_CALLBACK_PAGE = """<!doctype html>
|
|
70
|
+
<html lang="en">
|
|
71
|
+
<head><meta charset="utf-8"><title>studylife-cli login</title></head>
|
|
72
|
+
<body style="font-family: sans-serif; text-align: center; padding-top: 3rem;">
|
|
73
|
+
<p>Login complete — you can close this tab and return to the terminal.</p>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _CallbackState:
|
|
80
|
+
"""Shared between the HTTP handler (invoked synchronously inside
|
|
81
|
+
HTTPServer.handle_request()) and the caller waiting on it - a single request is ever served
|
|
82
|
+
per listener instance, so a plain attribute is enough."""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self.result: CallbackResult | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _make_handler(state: _CallbackState) -> type[BaseHTTPRequestHandler]:
|
|
89
|
+
class Handler(BaseHTTPRequestHandler):
|
|
90
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
91
|
+
pass # Silence the default stderr access log - nothing useful, just noise.
|
|
92
|
+
|
|
93
|
+
def do_GET(self) -> None:
|
|
94
|
+
parsed = urlparse(self.path)
|
|
95
|
+
if parsed.path != "/callback":
|
|
96
|
+
self.send_response(404)
|
|
97
|
+
self.end_headers()
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
state.result = _parse_callback_query(parsed.query)
|
|
101
|
+
|
|
102
|
+
body = _CALLBACK_PAGE.encode("utf-8")
|
|
103
|
+
self.send_response(200)
|
|
104
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
105
|
+
self.send_header("Content-Length", str(len(body)))
|
|
106
|
+
self.end_headers()
|
|
107
|
+
self.wfile.write(body)
|
|
108
|
+
|
|
109
|
+
return Handler
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _LoopbackHTTPServer(HTTPServer):
|
|
113
|
+
"""HTTPServer normally sets allow_reuse_address=1, which on Windows lets a second listener
|
|
114
|
+
silently bind the exact same port instead of raising - the opposite of what
|
|
115
|
+
`_bind_first_free_port`'s try-the-next-candidate fallback needs. Disabling it makes "is this
|
|
116
|
+
port already taken" behave the same (a real OSError) on every platform."""
|
|
117
|
+
|
|
118
|
+
allow_reuse_address = False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _CallbackHTTPServer:
|
|
122
|
+
"""Localhost-only (127.0.0.1) HTTP listener on one of CANDIDATE_PORTS for the single
|
|
123
|
+
`/callback` request the generic consent page redirects the browser to. Serves exactly one
|
|
124
|
+
request: `wait_for_callback()` blocks until it arrives (or the given timeout elapses)."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, port: int) -> None:
|
|
127
|
+
self._state = _CallbackState()
|
|
128
|
+
self._server = _LoopbackHTTPServer(("127.0.0.1", port), _make_handler(self._state))
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def port(self) -> int:
|
|
132
|
+
return int(self._server.server_address[1])
|
|
133
|
+
|
|
134
|
+
def wait_for_callback(self, timeout_seconds: float) -> CallbackResult | None:
|
|
135
|
+
self._server.timeout = timeout_seconds
|
|
136
|
+
self._server.handle_request() # returns on request OR timeout, whichever first
|
|
137
|
+
self._server.server_close()
|
|
138
|
+
return self._state.result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _bind_first_free_port(candidates: tuple[int, ...]) -> _CallbackHTTPServer:
|
|
142
|
+
last_error: OSError | None = None
|
|
143
|
+
for port in candidates:
|
|
144
|
+
try:
|
|
145
|
+
return _CallbackHTTPServer(port)
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
last_error = exc
|
|
148
|
+
continue
|
|
149
|
+
raise LoginError(
|
|
150
|
+
f"None of the candidate ports {list(candidates)} are free on 127.0.0.1. "
|
|
151
|
+
"Close whatever else is using them and try again."
|
|
152
|
+
) from last_error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_login(
|
|
156
|
+
*,
|
|
157
|
+
instance_url: str,
|
|
158
|
+
client_id: str = DEFAULT_CLIENT_ID,
|
|
159
|
+
candidate_ports: tuple[int, ...] = CANDIDATE_PORTS,
|
|
160
|
+
timeout_seconds: float = CALLBACK_TIMEOUT_SECONDS,
|
|
161
|
+
open_browser: Callable[[str], bool] = webbrowser.open,
|
|
162
|
+
) -> Credentials:
|
|
163
|
+
"""Drives one full browser login round trip and returns the resulting Credentials. Raises
|
|
164
|
+
LoginError on any failure - callers decide how to present that (CLI prints and exits 1)."""
|
|
165
|
+
base_url = instance_url.rstrip("/")
|
|
166
|
+
state_token = secrets.token_urlsafe(32)
|
|
167
|
+
|
|
168
|
+
callback_server = _bind_first_free_port(candidate_ports)
|
|
169
|
+
redirect_uri = f"http://127.0.0.1:{callback_server.port}/callback"
|
|
170
|
+
connect_url = (
|
|
171
|
+
f"{base_url}/connect/client/{client_id}?"
|
|
172
|
+
f"{urlencode({'redirect_uri': redirect_uri, 'state': state_token})}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
print(f"Opening your browser to log in to StudyLife:\n {connect_url}")
|
|
176
|
+
print("Waiting for you to finish logging in and approving the connection...")
|
|
177
|
+
open_browser(connect_url)
|
|
178
|
+
|
|
179
|
+
result = callback_server.wait_for_callback(timeout_seconds)
|
|
180
|
+
if result is None:
|
|
181
|
+
raise LoginError(
|
|
182
|
+
f"Timed out after {int(timeout_seconds)}s waiting for StudyLife to redirect back. "
|
|
183
|
+
"Either the login/approval wasn't completed in the browser tab, or this client "
|
|
184
|
+
f"isn't registered on that instance yet - see the README for how to register "
|
|
185
|
+
f"'{client_id}' via studylife-developers first."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not _states_match(result.state, state_token):
|
|
189
|
+
raise LoginError(
|
|
190
|
+
"Rejected the login callback: its state didn't match what this command sent "
|
|
191
|
+
"(possible cross-request mix-up). Please run this command again."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if not result.assertion:
|
|
195
|
+
raise LoginError(
|
|
196
|
+
"StudyLife's callback didn't include a login assertion - the connection may have "
|
|
197
|
+
"been denied."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
user_id, api_key = _exchange_assertion(base_url, client_id, result.assertion)
|
|
201
|
+
del user_id # not needed locally - kept for symmetry with the server's response shape
|
|
202
|
+
return Credentials(instance_url=base_url, client_id=client_id, api_key=api_key)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _exchange_assertion(base_url: str, client_id: str, assertion: str) -> tuple[int, str]:
|
|
206
|
+
"""Server-to-server exchange of the single-use assertion for the user id and a freshly
|
|
207
|
+
issued, per-installation API key (generic flow - AuthController.10.OAuthClients.cs). No
|
|
208
|
+
X-Api-Key is sent: this endpoint is [AllowAnonymous] by design, the assertion itself is the
|
|
209
|
+
one-time credential."""
|
|
210
|
+
try:
|
|
211
|
+
response = httpx.post(
|
|
212
|
+
f"{base_url}/api/auth/assertion-exchange",
|
|
213
|
+
json={"clientId": client_id, "assertion": assertion},
|
|
214
|
+
timeout=10.0,
|
|
215
|
+
)
|
|
216
|
+
except httpx.HTTPError as exc:
|
|
217
|
+
raise LoginError(f"Could not reach StudyLife: {exc}") from exc
|
|
218
|
+
|
|
219
|
+
if response.status_code != 200:
|
|
220
|
+
raise LoginError(
|
|
221
|
+
f"StudyLife rejected the login ({response.status_code}): {response.text.strip()}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
data = response.json()
|
|
226
|
+
return int(data["userId"]), str(data["apiKey"])
|
|
227
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
228
|
+
raise LoginError("StudyLife returned an unexpected response shape.") from exc
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def login_and_save(instance_url: str, client_id: str = DEFAULT_CLIENT_ID) -> Credentials:
|
|
232
|
+
credentials = run_login(instance_url=instance_url, client_id=client_id)
|
|
233
|
+
save_credentials(credentials)
|
|
234
|
+
return credentials
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
if len(sys.argv) < 2:
|
|
239
|
+
print("usage: python -m studylife_cli.login <instance-url>", file=sys.stderr)
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
try:
|
|
242
|
+
creds = login_and_save(sys.argv[1])
|
|
243
|
+
except LoginError as exc:
|
|
244
|
+
print(f"Login failed: {exc}", file=sys.stderr)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
print(f"Login successful - credentials saved for {creds.instance_url}.")
|
studylife_cli/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Pydantic models mirroring StudyLife's actual OpenAPI schemas (fetched live from
|
|
2
|
+
`/openapi/v1.json` and cross-checked against StudyLife.Shared/Dtos.cs - not reconstructed from
|
|
3
|
+
memory, which previously produced wrong field names for StudySessionDto/CourseGoalDto).
|
|
4
|
+
|
|
5
|
+
Field names use snake_case here and are translated to/from the server's camelCase JSON via
|
|
6
|
+
StudyLifeModel's alias generator.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
from pydantic.alias_generators import to_camel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StudyLifeModel(BaseModel):
|
|
18
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Course(StudyLifeModel):
|
|
22
|
+
id: int
|
|
23
|
+
semester: int = 1
|
|
24
|
+
name: str
|
|
25
|
+
code: str = ""
|
|
26
|
+
color: str = "#6C5CE7"
|
|
27
|
+
icon: str = ""
|
|
28
|
+
topics: list[str] = []
|
|
29
|
+
ects: int = 5
|
|
30
|
+
group: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Note(StudyLifeModel):
|
|
34
|
+
id: int | None = None
|
|
35
|
+
title: str
|
|
36
|
+
content: str
|
|
37
|
+
created_at: datetime | None = None
|
|
38
|
+
updated_at: datetime | None = None
|
|
39
|
+
course_id: int | None = None
|
|
40
|
+
session_id: int | None = None
|
|
41
|
+
is_markdown: bool = False
|
|
42
|
+
source_url: str | None = None
|
|
43
|
+
tags: str | None = None
|
|
44
|
+
summary: str | None = None
|
|
45
|
+
related_note_ids: list[int] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Session(StudyLifeModel):
|
|
49
|
+
id: int | None = None
|
|
50
|
+
course_id: int
|
|
51
|
+
# Ignored by the server for a brand-new session (re-derived from the resolved course) and
|
|
52
|
+
# frozen/untouched for an existing one - only required to be non-empty by validation, its
|
|
53
|
+
# actual content never matters once course_id is valid. See SessionsController.Validate.
|
|
54
|
+
course_name: str = "-"
|
|
55
|
+
course_color: str | None = None
|
|
56
|
+
start_time: datetime
|
|
57
|
+
end_time: datetime
|
|
58
|
+
topic: str | None = None
|
|
59
|
+
notes: str | None = None
|
|
60
|
+
is_completed: bool = False
|
|
61
|
+
timer_mode_id: int = 0
|
|
62
|
+
recurrence_group_id: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CourseGoal(StudyLifeModel):
|
|
66
|
+
course_id: int
|
|
67
|
+
# Same "required non-empty, content ignored/frozen server-side" situation as
|
|
68
|
+
# Session.course_name above - see CourseGoalsController.Save.
|
|
69
|
+
course_name: str = "-"
|
|
70
|
+
target_date: datetime | None = None
|
|
71
|
+
completion_note: str | None = None
|
|
72
|
+
completed_at: datetime | None = None
|
|
73
|
+
grade: float | None = None
|
|
74
|
+
completed_topics: str = ""
|
|
75
|
+
tag: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TimerState(StudyLifeModel):
|
|
79
|
+
session_id: int | None = None
|
|
80
|
+
is_running: bool
|
|
81
|
+
is_break: bool
|
|
82
|
+
current_round: int
|
|
83
|
+
timer_mode_id: int
|
|
84
|
+
phase_ends_at: datetime | None = None
|
|
85
|
+
updated_at: datetime | None = None
|
|
86
|
+
server_now: datetime | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class StudyProgramSummary(StudyLifeModel):
|
|
90
|
+
id: int | None = None
|
|
91
|
+
name: str
|
|
92
|
+
is_built_in: bool
|
|
93
|
+
is_completed: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class StudyProgramDetail(StudyLifeModel):
|
|
97
|
+
id: int
|
|
98
|
+
name: str
|
|
99
|
+
group_ects_quotas: dict[str, int] = {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Webhook(StudyLifeModel):
|
|
103
|
+
"""studylife-webhooks' own WebhookOut shape (id is a string there, not an int) - the server's
|
|
104
|
+
WebhooksProxyController is a pure, un-typed reverse proxy for this, so it never appears in
|
|
105
|
+
StudyLife's own OpenAPI document."""
|
|
106
|
+
|
|
107
|
+
id: str | None = None
|
|
108
|
+
target_url: str
|
|
109
|
+
events: list[str]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: studylife-cli
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Command-line client for StudyLife (self-hosted study organizer) - notes, sessions, course goals, and webhooks from your terminal.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.6
|
|
9
|
+
Requires-Dist: pydantic>=2.9
|
|
10
|
+
Requires-Dist: rich>=13.9
|
|
11
|
+
Requires-Dist: typer>=0.15
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# studylife-cli
|
|
15
|
+
|
|
16
|
+
A command-line client for [StudyLife](https://github.com/lukislp/studylife), the self-hosted
|
|
17
|
+
study organizer. Manage notes, sessions, course goals, and webhooks from your terminal - scripts
|
|
18
|
+
and shells welcome.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install git+https://github.com/lukislp/studylife-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
(PyPI publishing is planned; until then, install straight from GitHub.)
|
|
27
|
+
|
|
28
|
+
## Register the CLI on your instance
|
|
29
|
+
|
|
30
|
+
`studylife-cli` connects to your StudyLife instance through the same add-on mechanism any
|
|
31
|
+
third-party integration uses - there is no special-cased server support to set up. Register it
|
|
32
|
+
once via your own [studylife-developers](https://github.com/lukislp/studylife-developers) portal:
|
|
33
|
+
|
|
34
|
+
1. Open your `studylife-developers` instance and go to **Register new add-on**.
|
|
35
|
+
2. Fill in:
|
|
36
|
+
- **Client ID**: `studylife-cli`
|
|
37
|
+
- **Allowed redirect URIs** (one per line):
|
|
38
|
+
```
|
|
39
|
+
http://127.0.0.1:8765/callback
|
|
40
|
+
http://127.0.0.1:8766/callback
|
|
41
|
+
http://127.0.0.1:8767/callback
|
|
42
|
+
http://127.0.0.1:8768/callback
|
|
43
|
+
```
|
|
44
|
+
- **Scopes**: whichever of notes/sessions/course-goals/timer/courses/study-programs/webhooks
|
|
45
|
+
you want the CLI to be able to use.
|
|
46
|
+
3. Save.
|
|
47
|
+
|
|
48
|
+
## Log in
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
studylife login https://studylife.example.com
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This opens your browser to approve the connection, then stores a scoped API key locally at
|
|
55
|
+
`~/.config/studylife-cli/credentials.json` (owner-readable only, on POSIX).
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
studylife notes list
|
|
61
|
+
studylife notes search "exam"
|
|
62
|
+
studylife notes create "Title" "Content" --course-id 1
|
|
63
|
+
|
|
64
|
+
studylife sessions list
|
|
65
|
+
studylife sessions history --days 7
|
|
66
|
+
studylife sessions create 2026-08-30T14:00:00 --end 2026-08-30T15:00:00 --title "Focus block"
|
|
67
|
+
|
|
68
|
+
studylife goals list
|
|
69
|
+
studylife goals set 1 --target-ects 5
|
|
70
|
+
|
|
71
|
+
studylife timer
|
|
72
|
+
studylife courses list
|
|
73
|
+
studylife programs list
|
|
74
|
+
studylife programs get 1
|
|
75
|
+
|
|
76
|
+
studylife webhooks list
|
|
77
|
+
studylife webhooks create https://example.com/hook session.completed
|
|
78
|
+
studylife webhooks delete 3
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Add `--json` to any command for machine-readable output:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
studylife notes list --json | jq '.[] | .title'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv sync
|
|
91
|
+
uv run pytest
|
|
92
|
+
uv run ruff check .
|
|
93
|
+
uv run ruff format --check .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
AGPL-3.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
studylife_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
studylife_cli/_version.py,sha256=kjMXs_RelAfH3nsfXcggy4eqwR8yDdd160wJ9EHB63g,520
|
|
3
|
+
studylife_cli/cli.py,sha256=3eu5Kv4gTD5tOlaAxSXIdyFBoKwrYikjBw_kmniUpck,20601
|
|
4
|
+
studylife_cli/client.py,sha256=XFlWY4vmY8aDSv6zNMxpVB4WcqSrk7OD060qRavBVfw,6380
|
|
5
|
+
studylife_cli/credentials.py,sha256=QsCfdwtc2opCYRsIQahlJghbD2gUNfygy715grj2emE,2007
|
|
6
|
+
studylife_cli/login.py,sha256=sFUpgyoRdCqUBOWnnHiTeRpX3NYQy6IwWUtwNLnfpbc,9777
|
|
7
|
+
studylife_cli/models.py,sha256=wn9TFDaJirQXtSQavT-I5ji8Sps0c18YyTq800zYp1c,3202
|
|
8
|
+
studylife_cli-1.3.0.dist-info/METADATA,sha256=2vMH60uDvP7_tfZ1BPDUUfOEbqERTQN4vzjdaZ6MYj0,2652
|
|
9
|
+
studylife_cli-1.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
studylife_cli-1.3.0.dist-info/entry_points.txt,sha256=ZUsMOnXmjztQVEWcuodc2AV7ZL-h7bzeLcrdylSLsU8,52
|
|
11
|
+
studylife_cli-1.3.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
12
|
+
studylife_cli-1.3.0.dist-info/RECORD,,
|