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.
@@ -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 &mdash; 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}.")
@@ -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,,