diff --git a/README.md b/README.md index 3b9e446..ad7ee73 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,161 @@ # python-kuleuven -CLI, MCP server, and Python client for KU Leuven. Signs in via the reverse-engineered SAML2 SSO flow against `idp.kuleuven.be` and gives you one session that works against `toledo.kuleuven.be/portal/api/*` (the portal), `ultra.edu.kuleuven.cloud/learn/api/v1/*` (Blackboard Ultra), and `kurt3.ghum.kuleuven.be/api/*` (the KURT study-space reservation system). +CLI, MCP server, and Python client for KU Leuven. Signs in through the SAML2 SSO flow at `idp.kuleuven.be` and gives you one session that works against the Toledo portal, Blackboard Ultra, and the KURT study-space reservation system. -See [`docs/AUTH.md`](docs/AUTH.md) for the SAML flow and [`docs/DATA.md`](docs/DATA.md) for the REST surface. +See [`docs/AUTH.md`](docs/AUTH.md) for the sign-in flow and [`docs/DATA.md`](docs/DATA.md) for the REST surface. ## Installation -Requires Python 3.13 or newer. The project uses [uv](https://docs.astral.sh/uv/) for dependency management. +Requires Python 3.13 or newer and [uv](https://docs.astral.sh/uv/). To install the `kuleuven` command globally: ```sh -uv sync +uv tool install git+https://github.com/breitburg/python-kuleuven ``` -The `kuleuven` command becomes available inside the project environment. Run it with `uv run kuleuven …` or activate the venv first. +To work on the project itself, clone it and run `uv sync` instead. The command is then available inside the project environment as `uv run kuleuven …`. ## Quickstart -Sign in, list your courses, and pull the files for one of them. +Sign in once. Credentials can also come from `$KULEUVEN_USERNAME` / `$KULEUVEN_PASSWORD` or a `.env` file: ```sh -# 1. Authenticate. Credentials can also come from $KULEUVEN_USERNAME / $KULEUVEN_PASSWORD or a .env file. kuleuven session start - -# 2. List the courses on your portal dashboard. -kuleuven toledo courses list --type course - -# 3. Download every file and video in a course, mirroring its tree on disk. -kuleuven toledo courses files sync EX101a --into ./downloads --skip-videos ``` -Every command writes one JSON object to stdout, success or failure, so the output is pipeline-friendly: +Then use any command. The JSON output pipes straight into `jq`: ```sh kuleuven toledo courses list --type course | jq '.items[].course_code' ``` -## Concepts - -### Two backends, one session - -Toledo is a single front door over two backends. The portal at `toledo.kuleuven.be` is a thin Plone-backed dashboard that hosts the curated course grid, the notification feed, and the upcoming-events list. Clicking into any course launches Blackboard Ultra at `ultra.edu.kuleuven.cloud`, which is where the actual content tree, assignments, members, grades, and files live. Each backend has its own session cookie and its own REST surface. The CLI hides this split for everyday use — one `kuleuven session start` establishes both — but exposes both as needed. - -### Identifier zoo - -The two systems share data but speak different vocabularies. The same course is identified by `learning_unit_id` on the portal (a string like `ULTRA-B-KUL-EX101a-2526`, called the batchUid) and by `id` in Blackboard (a primary key like `_100000_1`). The portal additionally has its own UUID per enrollment, and Ultra additionally has a UUID per course. The library treats all five as valid references to the same `Course` object; `kuleuven toledo courses show` accepts any of them. Course codes like `EX101a` are derived from the batchUid and are accepted case-insensitively. The course-code form is convenient but ambiguous when both an active and an archived year exist; the CLI resolves to the portal-visible course first, then falls back to Ultra if no portal match is found. - -People are identified by their KU Leuven q-uid (the `userName` in Blackboard, e.g. `q1234567`). The portal also surfaces the student-facing r-uid (`r0123456`) on the SAML side, and Blackboard adds its own primary key plus a UUID and a foundations identifier. The q-uid is the join key across portal-issued and Blackboard-issued identity records. - -### Content tree +To pull all the files of a course onto disk: -The content tree of a course is recursive. Each item has a `parentId` chain rooted at `ROOT` and a `contentHandler` that classifies it (`resource/x-bb-folder`, `resource/x-bb-document`, `resource/x-bb-file`, `resource/x-bb-blti-link`, `resource/x-bb-lesson`, etc.). A particular wrinkle: a content item with `contentHandler: resource/x-bb-folder` and `contentDetail."resource/x-bb-folder".isBbPage: true` is not really a folder — it is an Ultra Document, a single-child wrapper around the inner payload. The classic shape carries an HTML child literally titled `ultraDocumentBody`; some courses instead wrap a real `resource/x-bb-document` whose title mirrors the page. The tree walker treats Ultra Documents as leaves and `kuleuven toledo courses content show`, `kuleuven toledo courses content embeds`, and `kuleuven toledo courses files list` descend into the inner child in either shape automatically. - -## CLI reference - -### Conventions - -Every CLI command writes exactly one JSON object to stdout. Success is wrapped as `{"status": "ok", ...}`. Failures are wrapped as `{"status": "error", "code": "", ...}` and use these exit codes: - -| Exit code | Meaning | -| --- | --- | -| `0` | Success. | -| `1` | Auth or session failure, downstream HTTP error, or download failure. | -| `2` | Bad input — unknown reference, ambiguous match, missing argument. | +```sh +kuleuven toledo courses files sync EX101a --into ./downloads --skip-videos +``` -Errors go to stdout, not stderr, so a pipeline only needs to parse one stream. The one exception is `kuleuven toledo courses files fetch --out -`, which streams binary bytes to stdout and reports errors on stderr. +### AI agents -Credentials and the TOTP code are accepted via flags, environment variables, or a `.env` file in the current directory. They are never written to disk. The only thing persisted is the session cookie jar, at `platformdirs.user_data_path("kuleuven") / "cookies.json"` with `0600` permissions. +The interface is built for agents. Every command prints exactly one JSON object to stdout, errors included, and `kuleuven docs` emits the full command reference so an agent can learn the whole surface in a single call. -The global environment variables: +To let Claude book your study spaces and read your courses, register the MCP server and restart Claude Desktop: -| Variable | Used by | Purpose | -| --- | --- | --- | -| `KULEUVEN_USERNAME` | `session start` | KU Leuven username (r-uid or q-uid). | -| `KULEUVEN_PASSWORD` | `session start` | KU Leuven password. | -| `KULEUVEN_TOTP` | `session start` | 6-digit TOTP code for two-factor sign-in. | -| `KULEUVEN_AUTH_DEVICE` | `session start` | KU Leuven Authenticator device to push to: a name, 1-based index, or `most-recent`. | +```sh +kuleuven mcp install +``` -### Docs +Claude then gets every command as a tool and reuses your signed-in session. See the [Claude Desktop](#claude-desktop) section for details. -#### `kuleuven docs` +## CLI -Prints Markdown documentation for the whole command tree — every command and subcommand, with usage, arguments, and options — so an agent can read the full surface in one call instead of walking `--help` group by group. The Markdown is generated from the live Typer app, so it never drifts from the real CLI. Needs no session and makes no network calls. +Every command exits `0` on success, `1` on auth or HTTP failure, and `2` on bad input. Errors are printed as JSON on stdout too, so a pipeline only parses one stream. -```text -kuleuven docs -``` +### `kuleuven docs` -Unlike every other command, `docs` prints raw Markdown to stdout rather than a JSON line — it is meant to be read or piped into a file, not parsed. Exits `0`. +Prints Markdown documentation for the whole command tree, generated from the live CLI, with the usage, arguments, and options of every command. Unlike every other command it prints raw Markdown, meant to be read or piped to a file: ```sh kuleuven docs > COMMANDS.md ``` -```text -# `kuleuven` - -KU Leuven CLI -... -``` - ### Session #### `kuleuven session start` -Signs in to the portal, Ultra, and KURT and persists the session cookies. - -```text -kuleuven session start [--username U] [--password P] [--totp CODE] [--device D] -``` - -| Flag | Env var | Default | Description | -| --- | --- | --- | --- | -| `--username` | `KULEUVEN_USERNAME` | prompted on TTY | KU Leuven r-uid or q-uid. | -| `--password` | `KULEUVEN_PASSWORD` | prompted on TTY | Account password. | -| `--totp` | `KULEUVEN_TOTP` | prompted on TTY | 6-digit TOTP, only used when the IdP serves a TOTP form. | -| `--device` | `KULEUVEN_AUTH_DEVICE` | `most-recent` | KU Leuven Authenticator device to push to: a device name (case-insensitive), a 1-based index, or `most-recent`. | - -Sign-in walks three SAML handshakes in sequence: the Toledo portal SP (sets `_shibsession_*` on `toledo.kuleuven.be`), the Blackboard Ultra SP (sets `BbRouter` and friends on `ultra.edu.kuleuven.cloud`), and the KURT SP (sets `_shibsession_*` on `kurt3.ghum.kuleuven.be`). All three sessions are required to reach the full API surface, so they are established together. The Ultra and KURT round-trips reuse the IdP session minted by the portal step and do not prompt for credentials a second time. Every call runs fresh SAML flows — any existing SP cookies are dropped before bootstrap so credentials are actually re-submitted. The IdP's `rememberDevice` cookie is kept across runs, so 2FA only happens once per device. +Signs in to the portal, Ultra, and KURT and saves the cookies. Credentials come from `--username` and `--password`, the matching `KULEUVEN_*` environment variables (`KULEUVEN_USERNAME`, `KULEUVEN_PASSWORD`, `KULEUVEN_TOTP`, `KULEUVEN_AUTH_DEVICE`), or a `.env` file, and are prompted for only on a TTY. -The second factor depends on the account's enrolment. A TOTP-enrolled account is served a code form and consumes `--totp`. An account on the **KU Leuven Authenticator** smartphone app is served a device picker: the command sends a push to the chosen device (`--device`), prints `Waiting for approval on ...` to stderr, and blocks until you approve on the device, then completes. The wait times out after 120 seconds. Progress text goes to stderr; stdout stays a single JSON object. - -Exit codes: `0` on success; `1` with `code: "invalid_credentials"`, `"auth_failed"`, `"totp_required"`, `"approval_timeout"`, `"unparseable_device_timestamp"`, or `"http_error"`; `2` with `code: "missing_credentials"` (stdin is not a TTY and neither flags nor env vars supply credentials) or `"unknown_device"` (`--device` matches no enrolled device — the error JSON lists the devices). +The second factor depends on your account: TOTP accounts pass `--totp` (or get prompted), accounts on the KU Leuven Authenticator app get a push sent to the device chosen with `--device` and the command waits up to 120 seconds for approval. ```sh kuleuven session start --device "My phone" ``` +On success it prints your session attributes and expiry: + ```json { "status": "ok", "session": { - "attributes": { - "KULMoreUnifiedUID": "q1234567", - "affiliation": "member@kuleuven.be;student@kuleuven.be", - "entitlement": "urn:mace:kuleuven.be:entitlement:toledo", - "uid": "r0123456", - "unscoped-affiliation": "member;student" - }, + "attributes": { "uid": "r0123456", "KULMoreUnifiedUID": "q1234567", "...": "..." }, "expires_in_minutes": 2880, - "authenticated_at": "2026-01-01T12:00:00.000Z", - "authentication_context_class": "https://nextauth.com" + "authenticated_at": "2026-01-01T12:00:00.000Z" } } ``` +The IdP remembers your device, so 2FA only happens once per machine. Credentials are never written to disk. The only thing saved is the session cookie jar, at `platformdirs.user_data_path("kuleuven") / "cookies.json"` with `0600` permissions. + #### `kuleuven session status` -Reports whether the persisted session is still valid. +Checks whether the saved session still works: -```text +```sh kuleuven session status ``` -The `authentication_context_class` field is the canonical signal for whether the session was 2FA-elevated; `MobileTwoFactorContract` confirms it was. Exits `1` with `reason: "no_session"` if nothing is saved, or `reason: "expired"` if the saved session no longer works. - -```json -{ - "authenticated": true, - "session": { - "attributes": { "uid": "r0123456", "...": "..." }, - "expires_in_minutes": 2845, - "authenticated_at": "2026-01-01T12:00:00.000Z", - "authentication_context_class": "urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract" - } -} -``` +Exits `1` with `reason: "no_session"` or `reason: "expired"` when it doesn't. An `authentication_context_class` of `MobileTwoFactorContract` in the output confirms the session was 2FA-elevated. #### `kuleuven session end` -Deletes the saved session. Idempotent — `removed` is `false` if there was nothing to delete. +Deletes the saved session: -```text +```sh kuleuven session end ``` -```json -{ "status": "ok", "removed": true } -``` +Idempotent: `removed` is `false` if there was nothing to delete. #### `kuleuven session raw` -Sends a pre-authenticated HTTP request to any URL and prints the response as JSON. The escape hatch for endpoints the higher-level commands do not cover yet. Lives under `session` because it uses the persisted session cookies and works against any host the cookie jar can authenticate against (Toledo, Ultra, KURT, …). - -```text -kuleuven session raw [--body STRING] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--body` | — | Request body, sent as `application/json`. Query parameters belong in the URL. | - -The saved cookie jar is attached to the request; cookies set by the response are persisted back. The response is wrapped as `{"status": "ok", "response": {…}}`, where the inner object carries `status_code`, the final `url`, all response `headers`, and the body. The body is parsed JSON when the response content type is JSON, kept as text for textual responses, and reported as `{"body_kind": "binary", "size": N}` for everything else. Non-2xx HTTP responses are returned the same way — they are not CLI errors. Exits `1` with `code: "http_error"` only for network failures. Authentication state is not checked; whatever the server returns to the cookies you have is what you get. +Sends a pre-authenticated request to any URL, using the saved cookies. This is the escape hatch for endpoints the higher-level commands don't cover: ```sh kuleuven session raw GET 'https://toledo.kuleuven.be/portal/api/upcoming' ``` +The response comes back wrapped, with the status code, final URL, headers, and parsed body: + ```json { "status": "ok", "response": { "status_code": 200, "url": "https://toledo.kuleuven.be/portal/api/upcoming", - "headers": { "content-type": "application/json;charset=UTF-8" }, "body_kind": "json", - "body": [ - { - "start": 1900000000000, - "end": 1900007200000, - "name": "Other Example", - "location": { "url": "...", "mnemonic": "BLD1", "roomNumber": "01.1234" }, - "source": { "learningUnitId": "ULTRA-B-KUL-EX102a-2526", "color": 13 } - } - ] + "body": [ { "name": "Other Example", "start": 1900000000000, "...": "..." } ] } } ``` +Pass `--body` to send a JSON body. Query parameters belong in the URL. Non-2xx responses are returned the same way, not treated as errors. + ### Toledo -The `kuleuven toledo` group covers the Toledo portal and Blackboard Ultra: courses, content, and files. A note on course references: every course command takes a single positional `` that accepts any of the five identifier forms — course code, batchUid, Blackboard PK, portal UUID, or Ultra course UUID. When the same code matches multiple academic years, the command exits `2` with `code: "ambiguous"` and lists the candidates so a more specific reference can be chosen. +Toledo commands live under `kuleuven toledo courses`, with `content`, `files`, and `discussions` nested per course. The `` argument takes any identifier of a course, most conveniently the case-insensitive course code like `EX101a`. The batchUid, Blackboard pk, and UUIDs work too, and when a code matches several academic years the command exits `2` and lists the candidates. #### `kuleuven toledo courses list` -Lists the courses and communities the user is enrolled in. - -```text -kuleuven toledo courses list [--favorite] [--type course|community] [--semester SEMESTER1|SEMESTER2|UNKNOWN] [--year YYYY] [--all] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--favorite` | off | Only courses marked as favourite on the portal. | -| `--type` | — | Filter by `course` or `community`. | -| `--semester` | — | `SEMESTER1`, `SEMESTER2`, or `UNKNOWN`. | -| `--year` | — | Academic year code, e.g. `2526` for 2025/2026. | -| `--all` | off | Use Ultra memberships instead of the portal set; includes archived and hidden entries. | - -By default this hits the portal's curated set of around 29 entries — the same set the dashboard grid shows. `--all` falls back to Ultra memberships and returns every enrollment Blackboard knows about (around 47 in a typical account). Role values come through raw: the portal calls it `Learner`, Ultra returns the short code `S`. +Lists your courses and communities. Filter with `--favorite`, `--type course|community`, `--semester`, or `--year 2526`. Add `--all` to include archived and hidden enrollments. ```sh kuleuven toledo courses list --type course ``` +Each item carries the course's identifiers, name, year, semester, and role: + ```json { "status": "ok", "count": 15, "items": [ { - "batch_uid": "ULTRA-B-KUL-EX101a-2526", "course_code": "EX101a", - "pk": null, - "portal_uuid": "00000000-0000-0000-0000-000000000001", - "course_uuid": null, + "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]", - "type": "course", - "is_available": true, "academic_year": 2526, "semester": "SEMESTER2", "role": "Learner", - "favorite": false, - "color": 10 + "favorite": false } ] } @@ -269,393 +163,126 @@ kuleuven toledo courses list --type course #### `kuleuven toledo courses show` -Returns everything that can be known about one course as a single merged record. - -```text -kuleuven toledo courses show -``` - -The portal list, the portal detail endpoint, the Ultra membership row, and the Ultra course detail endpoint are all merged into one `Course` payload. The response also includes the instructor list (deduplicated by q-uid, with Ultra-side records preferred so emails are populated when available) and the portal's administrative URLs. +Shows everything known about one course as a single merged record, including its instructors: ```sh kuleuven toledo courses show EX101a ``` -```json -{ - "status": "ok", - "course": { - "batch_uid": "ULTRA-B-KUL-EX101a-2526", - "course_code": "EX101a", - "pk": "_100000_1", - "portal_uuid": "00000000-0000-0000-0000-000000000001", - "course_uuid": "00000000000000000000000000000001", - "foundations_id": "00000000-0000-0000-0000-000000000002", - "display_name": "Example Course [EX101a]", - "type": "course", - "is_available": true, - "effective_availability": true, - "academic_year": 2526, - "semester": "SEMESTER2", - "locale": "en_GB", - "ultra_status": "ULTRA", - "role": "Learner", - "favorite": false, - "description": null, - "instructors": [ - { "q_uid": "q0000001", "given_name": "Jane", "family_name": "Doe", "pk": "_30000_1", "email": "jane.doe@example.edu" } - ], - "admin_urls": { - "unenrollUrl": "...", - "manageUrl": "...", - "gradeCenterToolUrl": "...", - "impexUrl": "..." - } - } -} -``` +The output adds the Blackboard pk, UUIDs, availability, locale, and an `instructors` list with names and emails. #### `kuleuven toledo courses favorite` -Pins or unpins a course on the portal dashboard. - -```text -kuleuven toledo courses favorite [--off] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--off` | off | Unpin instead of pin. | - -Sends `PATCH /portal/api/enrollments/`. Only portal-visible courses can be favourited; trying to favourite an Ultra-only course exits `2` with `code: "not_in_portal"`. +Pins a course on the portal dashboard, or unpins it with `--off`: ```sh kuleuven toledo courses favorite EX101a ``` -```json -{ - "status": "ok", - "course": { - "portal_uuid": "00000000-0000-0000-0000-000000000001", - "batch_uid": "ULTRA-B-KUL-EX101a-2526", - "display_name": "Example Course [EX101a]", - "favorite": true - } -} -``` +Only portal-visible courses can be favourited. An Ultra-only course exits `2` with `code: "not_in_portal"`. #### `kuleuven toledo courses members` -Lists the people enrolled in a course, plus per-bucket counts. - -```text -kuleuven toledo courses members [--role-bucket BUCKET] [--all-members] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--role-bucket` | — | Filter by Ultra's role bucket, e.g. `TEACHING`, `LEARNING`, `SUPPORT`. Passed through verbatim. | -| `--all-members` | off | Include disabled memberships; default keeps only available ones. | - -Each item is a Membership: a `pk`, a `role` and `role_bucket` (passed through from Ultra verbatim — typically `S`/`P`/`T` and `LEARNING`/`TEACHING`/`SUPPORT`), an `is_available` flag, and a nested `user`. The response also includes per-bucket counts from `/memberships/counts`, so a roster summary is one call. +Lists who is enrolled, with per-role counts. Filter with `--role-bucket TEACHING|LEARNING|SUPPORT`, or add `--all-members` to include disabled memberships: ```sh kuleuven toledo courses members EX101a --role-bucket TEACHING ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "counts": { "INSTRUCTOR": 3, "TEACHING_ASSISTANT": 1, "STUDENT": 501 }, - "total": 505, - "count": 3, - "items": [ - { - "pk": "_20000_1", - "course_pk": "_100000_1", - "role": "P", - "role_bucket": "TEACHING", - "user": { - "q_uid": "q0000002", - "given_name": "John", - "family_name": "Roe", - "pk": "_30001_1", - "email": "john.roe@example.edu" - } - } - ] -} -``` +Each item has the person's q-uid, name, and email, plus the raw role codes from Ultra. #### `kuleuven toledo courses announcements` -Lists announcements for a course, with bodies rendered as markdown. - -```text -kuleuven toledo courses announcements [--unread] [--limit N] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--unread` | off | Only announcements the user has not read yet. | -| `--limit` | `100` | Maximum number of announcements to fetch. | - -Each item carries the announcement `id`, the `title`, the `body` (markdown, converted via `markitdown` from Blackboard's rich-text dialect), `is_read`, the create/modify dates, the `creator_user_id` (a Blackboard user PK; cross-reference with `kuleuven toledo courses members` to resolve to a person), the start/end visibility dates, and the `permanent`/`push_notify`/`read_tracking` flags. The response also includes a `counts` object from `/announcements/counts` (`postedCount`, `unreadCount`) so a unread badge is one call. +Lists announcements, with bodies rendered to markdown. `--unread` keeps only what you haven't read, and `--limit` caps the fetch (default 100): ```sh kuleuven toledo courses announcements EX101a --unread ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "counts": { "postedCount": 4, "unreadCount": 2 }, - "count": 2, - "items": [ - { - "id": "_500000_1", - "course_pk": "_100000_1", - "title": "Class cancelled", - "body": "Dear students,\n\nDue to illness, today's class is cancelled. …", - "is_read": false, - "is_draft": false, - "created_date": "2026-01-15T09:00:00.000Z", - "modified_date": "2026-01-15T09:00:00.000Z", - "creator_user_id": "_30000_1", - "type": "COURSE", - "start_date_restriction": "2026-01-15T09:00:00.000Z", - "end_date_restriction": null, - "position": 1, - "permanent": true, - "push_notify": true, - "read_tracking": true - } - ] -} -``` +The response includes `counts` with `postedCount` and `unreadCount`, so an unread badge is one call. #### `kuleuven toledo courses schedule` -Returns the course's own calendar items. - -```text -kuleuven toledo courses schedule [--from ISO] [--to ISO] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--from` | — | ISO timestamp; inclusive start of window. | -| `--to` | — | ISO timestamp; exclusive end of window. | - -In practice this endpoint is sparsely populated — for everyday "what's next" use `/portal/api/upcoming` via `kuleuven session raw` for now. +Returns the course's own calendar items, optionally windowed with `--from` and `--to` (ISO timestamps): ```sh kuleuven toledo courses schedule EX101a ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "count": 0, - "items": [] -} -``` +In practice this endpoint is sparsely populated. For "what's next", use `session raw GET …/portal/api/upcoming` instead. -#### `kuleuven toledo courses content tree` +#### `kuleuven toledo courses grades` -Walks the content tree of a course and emits every node in pre-order. +Lists your per-item grades in a course. Instructors can pass `--user` with a Blackboard user pk to query a student: -```text -kuleuven toledo courses content tree [--depth N] +```sh +kuleuven toledo courses grades EX102a ``` -| Flag | Default | Description | -| --- | --- | --- | -| `--depth` | unlimited | Bound the recursion to N levels. | +Each item is a gradebook record with `displayGrade`, `submissionStatus`, attempts, and the column's name, points, and due date. + +#### `kuleuven toledo courses content tree` -The CLI flag is one call; under the hood there is one HTTP call per non-leaf node. Real folders and lessons are descended into; Ultra Documents (BB Pages) are emitted as leaves. +Walks the content tree and emits every node in pre-order, and `--depth N` bounds the recursion: ```sh kuleuven toledo courses content tree EX101a --depth 1 ``` +Each node carries its id, parent, title, and handler: + ```json { "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, "count": 16, "items": [ - { "id": "_10000_1", "parent_id": "_99999_1", "title": "Lecture recordings", "handler": "resource/x-bb-folder", "is_bb_page": false, "depth": 0, "position": 0 }, - { "id": "_10001_1", "parent_id": "_99999_1", "title": "Course overview", "handler": "resource/x-bb-folder", "is_bb_page": true, "depth": 0, "position": 1 }, - { "id": "_10002_1", "parent_id": "_99999_1", "title": "Lecture 1: Introduction", "handler": "resource/x-bb-folder", "is_bb_page": true, "depth": 0, "position": 2 } + { "id": "_10000_1", "title": "Lecture recordings", "handler": "resource/x-bb-folder", "is_bb_page": false, "depth": 0 }, + { "id": "_10002_1", "title": "Lecture 1: Introduction", "handler": "resource/x-bb-folder", "is_bb_page": true, "depth": 0 } ] } ``` -#### `kuleuven toledo courses content show` - -Shows one content item's metadata, plus body markdown and parsed embeds for documents. +Items with `is_bb_page: true` are Ultra Documents, which are pages rather than folders. The tree treats them as leaves, and `content show` reads their body for you. -```text -kuleuven toledo courses content show -``` +#### `kuleuven toledo courses content show` -The second positional is the Blackboard content PK (e.g. `_10003_1`) as it appears in `kuleuven toledo courses content tree` output. The response carries the common fields (`id`, `parent_id`, `title`, `handler`, `is_bb_page`, `visibility`, `modified_date`, `position`, `description`) plus the raw `detail` dict from the API so handler-specific fields come through verbatim. For documents and Ultra Documents the response also carries `body` (markdown, converted via the `markitdown` package) and `embeds` (structured records lifted out of the HTML, see [`kuleuven toledo courses content embeds`](#kuleuven-toledo-courses-content-embeds)). +Shows one content item by the id from `content tree`. For documents you also get the body as markdown and the parsed embeds: ```sh kuleuven toledo courses content show EX101a _10002_1 ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "item": { - "id": "_10002_1", - "parent_id": "_99999_1", - "title": "Lecture 1: Introduction", - "handler": "resource/x-bb-folder", - "is_bb_page": true, - "visibility": "VISIBLE", - "detail": { "resource/x-bb-folder": { "isBbPage": true, "isFolder": true } }, - "body": "Welcome to the first lecture. Required reading is *Example Textbook* by Doe (2024). …", - "embeds": [ - { "kind": "file", "title": "Lecture 1: slides", "mime_type": "application/pdf", "url": "https://ultra.edu.kuleuven.cloud/bbcswebdav/pid-10003-dt-content-rid-55555_1/xid-55555_1" } - ] - } -} -``` +The `embeds` array lists each attachment or LTI launch found in the body, with its title, URL, and mime type. #### `kuleuven toledo courses content embeds` -Lists the structured records lifted out of the HTML body — file attachments and LTI launches. - -```text -kuleuven toledo courses content embeds -``` - -Each item has a `kind` (`file` for `data-bbfile` plain attachments, `lti` for `data-bbtype="embedded-app"` LTI launches), a `title`, a `url` (the resource URL for files, the launch URL for LTI), an optional `mime_type` for files, and the full `raw` payload from the JSON-in-attribute the BB rich-text dialect encodes. +Lists just the embeds of one item, handy when you don't need the body: ```sh kuleuven toledo courses content embeds EX101a _10002_1 ``` -```json -{ - "status": "ok", - "count": 2, - "items": [ - { - "kind": "file", - "title": "Lecture 1: slides", - "url": "https://ultra.edu.kuleuven.cloud/bbcswebdav/pid-10003-dt-content-rid-55555_1/xid-55555_1", - "mime_type": "application/pdf", - "raw": { "linkName": "lecture-1-slides.pdf", "displayName": "Lecture 1: slides", "mimeType": "application/pdf", "render": "inline", "resourceUrl": "...", "viewerUrl": "..." } - } - ] -} -``` - -Files come from three places in a course. Some are content-tree leaves with `contentHandler: resource/x-bb-file` — those carry their own `permanentUrl` and download in one HTTP GET. Most are embedded inside document bodies as `` JSON-in-attribute markers; the library lifts each one out, points its `resourceUrl` at the underlying Xythos blob, and identifies it by `@` so the CLI can address it. Videos live in Kaltura, reached either through a top-level `resource/x-bb-blti-link` content item or an `embedded-app` anchor inside a body; the library starts an anonymous Kaltura widget session, fetches the entry's `downloadUrl`, and streams the resulting MP4. All three paths are unified into one `FileItem` shape so `kuleuven toledo courses files list` returns a single flat list. +Each embed has a `kind` (`file` or `lti`), a `title`, a `url`, and the full raw payload from Blackboard. #### `kuleuven toledo courses files list` -Walks the content tree and emits every downloadable in pre-order. - -```text -kuleuven toledo courses files list -``` - -Each item carries a synthetic `id` (a bare content PK for leaves and BLTI links, `@` for body embeds), a `kind` (`file` or `video`), the original `title`, the on-disk `filename` (empty for videos — Kaltura supplies it on download), `mime_type` and `size` when known, the resolved `url` for Blackboard files (Kaltura resolves on fetch), the `kaltura_entry_id` for videos, and a `source_path` array carrying the chain of folder titles down to the file's container so `sync` can mirror the structure on disk. +Lists every downloadable in the course as one flat list, covering file leaves in the tree, attachments embedded in document bodies, and Kaltura videos: ```sh kuleuven toledo courses files list EX101a ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "count": 34, - "items": [ - { - "id": "_10002_1@0", - "kind": "file", - "title": "Lecture 1: slides", - "filename": "lecture-1-slides.pdf", - "source_content_id": "_10002_1", - "source_path": ["Lecture 1: Introduction"], - "embed_index": 0, - "mime_type": "application/pdf", - "size": null, - "url": "https://ultra.edu.kuleuven.cloud/bbcswebdav/pid-10003-dt-content-rid-55555_1/xid-55555_1", - "kaltura_entry_id": null - }, - { - "id": "_10100_1", - "kind": "video", - "title": "Lecture 1 recording", - "filename": "", - "source_content_id": "_10100_1", - "source_path": ["Lecture recordings"], - "embed_index": null, - "mime_type": "video/mp4", - "size": null, - "url": null, - "kaltura_entry_id": "1_examplekt" - } - ] -} -``` +Each item has a synthetic `id` to pass to `fetch` or `resolve` (`@` for body embeds), a `kind` of `file` or `video`, a filename, and a `source_path` with the folder chain it lives under. #### `kuleuven toledo courses files fetch` -Downloads one file or video. Writes to disk, to a chosen directory, or streams to stdout. - -```text -kuleuven toledo courses files fetch [--out PATH | DIR | -] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--out` | `item.filename` in cwd | File path (writes there), existing directory (writes into it with the default filename), or `-` (streams raw bytes to stdout). | - -When `--out` is omitted the file is written to the cwd using `item.filename`, with Kaltura videos defaulting to `.mp4`. Streaming to stdout writes errors to stderr as JSON and exits non-zero; the disk modes use the normal JSON envelope. +Downloads one file or video by its id. `--out` takes a file path, an existing directory, or `-` to stream raw bytes to stdout. When omitted, the file is written to the current directory under its own name: ```sh kuleuven toledo courses files fetch EX101a '_10002_1@0' --out lecture-1.pdf ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "item": { - "id": "_10002_1@0", - "kind": "file", - "title": "Lecture 1: slides", - "filename": "lecture-1-slides.pdf", - "source_content_id": "_10002_1", - "source_path": ["Lecture 1: Introduction"], - "embed_index": 0, - "mime_type": "application/pdf", - "size": null, - "url": "https://ultra.edu.kuleuven.cloud/bbcswebdav/pid-10003-dt-content-rid-55555_1/xid-55555_1", - "kaltura_entry_id": null - }, - "download": { - "path": "lecture-1.pdf", - "bytes": 802583, - "server_filename": "lecture-1-slides.pdf" - } -} -``` - -Streaming a video to stdout: +Streaming works for videos too: ```sh kuleuven toledo courses files fetch EX101a '_10100_1' --out - > lecture-1.mp4 @@ -663,89 +290,27 @@ kuleuven toledo courses files fetch EX101a '_10100_1' --out - > lecture-1.mp4 #### `kuleuven toledo courses files resolve` -Resolves a short-lived, standalone-fetchable URL for one file or video. For Blackboard files, follows the bbcswebdav redirect chain to the signed S3 URL. For Kaltura videos, returns the CDN download URL. The returned URL can be fetched by any HTTP client without further authentication until it expires (around six hours for files). - -```text -kuleuven toledo courses files resolve <ref> <file_id> -``` +Resolves a short-lived URL that any HTTP client can fetch without authentication, useful for handing a download to another tool: ```sh kuleuven toledo courses files resolve EX101a '_10000_1@0' ``` -```json -{ - "status": "ok", - "course": { "pk": "_1_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course" }, - "item": { "id": "_10000_1@0", "kind": "file", "mime_type": "application/pdf", "...": "..." }, - "url": "https://learn-eu-central-1-prod-fleet01-xythos.content.blackboardcdn.com/..." -} -``` +The returned URL stays valid for a few hours. #### `kuleuven toledo courses files sync` -Bulk-downloads every file and video in a course, mirroring the content tree on disk. - -```text -kuleuven toledo courses files sync <ref> [--into DIR] [--dry-run] [--skip-videos] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--into` | `<course code>` in cwd | Destination directory; falls back to batchUid if no course code can be derived. | -| `--dry-run` | off | Report the plan without writing anything. | -| `--skip-videos` | off | Skip Kaltura videos — the usual setting on bandwidth-limited networks. | - -Already-existing non-empty files are kept; failures are surfaced in a `failures` array with the underlying error rather than aborting the whole run. Overall `status` is `"ok"` when every item downloaded or already existed, `"partial"` when some items failed. +Downloads every file and video in a course, mirroring the folder structure on disk. `--into` picks the destination (default: a directory named after the course code), `--skip-videos` skips Kaltura videos, and `--dry-run` reports the plan without writing: ```sh kuleuven toledo courses files sync EX101a --into ./downloads --skip-videos ``` -```json -{ - "status": "ok", - "course": { "pk": "_100000_1", "batch_uid": "ULTRA-B-KUL-EX101a-2526", "display_name": "Example Course [EX101a]" }, - "destination": "downloads", - "downloaded": 27, - "existing": 0, - "skipped": [{ "id": "_10100_1", "reason": "skip_videos" }], - "failures": [], - "items": [ - { "id": "_10002_1@0", "target": "downloads/Lecture 1: Introduction/lecture-1-slides.pdf", "bytes": 802583, "status": "downloaded" } - ] -} -``` - -#### `kuleuven toledo courses grades` - -Lists per-item grades for one user in a course. Defaults to the signed-in user; most accounts can only query themselves. - -```text -kuleuven toledo courses grades <ref> [--user <userId>] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--user` | signed-in user | Blackboard user pk to look up. Instructors with the right entitlements can query students. | - -The response carries the course identity, the `user_pk` that was queried, and one item per gradebook column. Each item is the raw API record: `columnId`, `displayGrade` (when scored), `lastAttempt`, `submissionStatus.status` (`UNOPENED`, `IN_PROGRESS`, `READY_TO_POST`, …), `hasAttemptOrGradeFeedback`, `attemptsLeft`, and a nested `column` with the column's metadata (name, max `possible` points, due date, category, grading schema). Use `jq '.items[].column'` to extract just the gradebook structure. - -```sh -kuleuven toledo courses grades EX102a -``` - -A course's discussion forums sit as content items under a virtual `INTERACTIVE` parent in the content tree. Each forum has a `forum_id` (the API key) and a `content_id` (what URLs in the Ultra SPA use). The discussion-board path segment in the API is always `default`; Ultra resolves it to the course's discussion board server-side, so callers don't need the `conference_id`. +Already-existing files are kept, and per-item failures land in a `failures` array instead of aborting the run, and `status` becomes `"partial"` when some items failed. #### `kuleuven toledo courses discussions list` -Lists every discussion forum in a course with the ids and dates the other discussion commands need. - -```text -kuleuven toledo courses discussions list <ref> -``` - -Each item carries `content_id`, `forum_id`, `conference_id`, `title`, `due_date`, `modified_date`, `visibility`, `has_grade_column`, `has_participation_requirements`, `is_created_by_student`, `is_group_content`, `web_location`. +Lists the discussion forums of a course, with the `forum_id` the other discussion commands need: ```sh kuleuven toledo courses discussions list EX103c @@ -753,167 +318,97 @@ kuleuven toledo courses discussions list EX103c #### `kuleuven toledo courses discussions show` -Shows one forum's metadata, read/reply counts, and the top-level messages (root threads). For threaded discussions each message's `messageStatus.totalCount` gives the full descendant count — use `discussions replies` to drill in. - -```text -kuleuven toledo courses discussions show <ref> <forum_id> -``` +Shows one forum's metadata, read/reply counts, and its root threads: ```sh kuleuven toledo courses discussions show EX103c _99089_1 ``` -#### `kuleuven toledo courses discussions replies` +Each thread's `messageStatus.totalCount` tells you how many replies to expect underneath. -Lists replies to one message, in Blackboard's default sort (drafts first, then most recent post date). Paginated; pass `--limit`/`--offset` and follow `next_page` in the response. - -```text -kuleuven toledo courses discussions replies <ref> <forum_id> <message_id> [--limit N] [--offset N] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--limit` | 100 | Page size. | -| `--offset` | 0 | Starting index into the reply list. | +#### `kuleuven toledo courses discussions replies` -The response carries `total` (descendant count), the current `offset`/`limit`, a `next_page` URL when more remain, and `items` with each reply's body (`body.rawText` is the BB rich-text dialect; `body.displayText` is sanitised HTML), author fields, `postDate`, `editDate`, and a `messageStatus` block. +Lists the replies to one message, paginated with `--limit` and `--offset`: ```sh kuleuven toledo courses discussions replies EX103c _99089_1 _565094_1 --limit 25 ``` -### KURT +Follow `next_page` in the response while more remain. -The `kuleuven kurt` commands cover KU Leuven's KURT3 reservation system at `kurt3.ghum.kuleuven.be` — study seats, group rooms, audio-visual kits, and the learning-lab equipment. Locations have numeric ids surfaced by `kuleuven kurt locations list`; resource types and zones live one level deeper under each location. Dates are `YYYY-MM-DD`, times are `HH:MM` on the hour (the booking grid is hour-slotted). Reservations are owned by the signed-in user; a new day becomes bookable each evening for the same time one week out. +### KURT -The verbs are split between two subgroups: `locations` for browsing the venue tree, and `resources` for everything you do with individual resources (search, favourites, reservations). +The `kuleuven kurt` commands book study seats, group rooms, and equipment. Dates are `YYYY-MM-DD` and times are `HH:MM` on the hour. A new day becomes bookable each evening one week out. #### `kuleuven kurt locations list` -Lists every bookable location, grouped into tiles (cities and special categories like the Learning Lab). - -```text -kuleuven kurt locations list -``` +Lists every bookable location, grouped into city tiles: ```sh kuleuven kurt locations list | jq '.tiles[].locations[] | {id, unit}' ``` -#### `kuleuven kurt locations show` +The numeric `id` is what the other commands take. -Shows one location with its zones (floor plans), its bookable resource types, and current occupancy. - -```text -kuleuven kurt locations show <ID> [--no-occupancy] -``` +#### `kuleuven kurt locations show` -| Flag | Default | Description | -| --- | --- | --- | -| `--occupancy` / `--no-occupancy` | on | Include the live occupancy snapshot. | +Shows one location with its zones, its bookable resource types, and a live occupancy snapshot (skip it with `--no-occupancy`): ```sh kuleuven kurt locations show 10 ``` -#### `kuleuven kurt resources search` - -Searches bookable resources at a location for a given window. Leave `--start` and `--end` blank to see every resource regardless of availability. - -```text -kuleuven kurt resources search --location ID --type ID --date YYYY-MM-DD [--end-date YYYY-MM-DD] [--start HH:MM] [--end HH:MM] [--zone ID] [--participants N] [--only-favorites] [--name SUBSTRING] -``` +The resource type ids listed here feed into `resources search`. -| Flag | Default | Description | -| --- | --- | --- | -| `--location` | required | Location id from `locations list`. | -| `--type` | required | Resource type id from `locations show`. | -| `--date` | required | Window start date. | -| `--end-date` | same as `--date` | Window end date for multi-day resources. | -| `--start` / `--end` | empty | Window times. Leave blank for any. | -| `--zone` | `-1` | Restrict to one zone id, `-1` for any. | -| `--participants` | `1` | Required capacity. | -| `--only-favorites` | off | Restrict to favourited resources. | -| `--name` | empty | Case-insensitive substring filter on resource name. | +#### `kuleuven kurt resources search` -The response carries the raw availability records, including `slotAllocation` — a 24-character string where `A` means free, `B` means booked, and `C` means closed (one character per hour). +Searches what's bookable at a location for a window. `--location`, `--type`, and `--date` are required. Narrow with `--start`/`--end`, `--zone`, `--participants`, `--name`, or `--only-favorites`. Leave the times blank to see every resource regardless of availability: ```sh kuleuven kurt resources search --location 10 --type 302 --date 2026-05-25 --start 09:00 --end 12:00 ``` +Each result includes `slotAllocation`, a 24-character string with one letter per hour: `A` free, `B` booked, `C` closed. + #### `kuleuven kurt resources quotas` -Reports per-resource-type usage and limits for the signed-in user (day and week). `-1` in a `max*` field means unlimited. +Shows your usage and limits per resource type, per day and week: -```text +```sh kuleuven kurt resources quotas ``` -```json -{ - "status": "ok", - "quotas": [ - { - "resourceType": "Study Seat", - "resourceTypeId": 302, - "usageDay": 0, - "usageWeek": 0, - "maxUsageDay": -1, - "maxUsageWeek": 48 - } - ] -} -``` +A `-1` in a `max*` field means unlimited. #### `kuleuven kurt resources favorites` -Lists the resources the user has favourited in KURT. +Lists the resources you've starred in KURT: -```text +```sh kuleuven kurt resources favorites ``` #### `kuleuven kurt resources reservations` -Lists the signed-in user's current and future KURT reservations. - -```text -kuleuven kurt resources reservations [--today] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--today` | off | Restrict to reservations whose window includes today. | +Lists your current and future reservations, and `--today` keeps only those whose window includes today: ```sh kuleuven kurt resources reservations ``` +The reservation ids here are what `unbook` takes. + #### `kuleuven kurt resources book` -Creates a reservation on a specific resource. +Books a resource for a window. Add `--subject` and `--purpose` where the resource type requires them, and repeat `--participant uid:email` for group rooms (you are always included): -```text -kuleuven kurt resources book --resource ID --date YYYY-MM-DD --start HH:MM --end HH:MM [--end-date YYYY-MM-DD] [--subject TEXT] [--purpose TEXT] [--participant uid:email]... +```sh +kuleuven kurt resources book --resource 301108 --date 2026-05-25 --start 09:00 --end 12:00 ``` -| Flag | Default | Description | -| --- | --- | --- | -| `--resource` | required | Resource id from `resources search`. | -| `--date` | required | Reservation date. | -| `--start` / `--end` | required | Reservation times on the hour. | -| `--end-date` | same as `--date` | Only set for multi-day-reservable resources. | -| `--subject` | `Productivity Session` | Subject string shown on the reservation. | -| `--purpose` | empty | Purpose string; required by some resource types. | -| `--participant` | — | Additional participant as `uid:email`. Repeat for group rooms; the signed-in user is always added. | - -Resource types with `reservationRequestRequired` (rare; staff approval flow) are not yet supported. +A group room with extra participants looks like: ```sh -kuleuven kurt resources book --resource 301108 --date 2026-05-25 --start 09:00 --end 12:00 - -# group work room with extra participants kuleuven kurt resources book --resource 200001 --date 2026-05-26 --start 14:00 --end 16:00 \ --participant r0000001:first.collab@kuleuven.be \ --participant r0000002:second.collab@kuleuven.be @@ -921,11 +416,7 @@ kuleuven kurt resources book --resource 200001 --date 2026-05-26 --start 14:00 - #### `kuleuven kurt resources unbook` -Cancels a reservation by id. - -```text -kuleuven kurt resources unbook <RESERVATION_ID> -``` +Cancels a reservation by id: ```sh kuleuven kurt resources unbook 3963083203 @@ -933,80 +424,46 @@ kuleuven kurt resources unbook 3963083203 ### Claude Desktop -The `kuleuven mcp` commands run an MCP server backed by the same CLI you see here and manage its registration in Claude Desktop's config. The MCP tools mirror the CLI verbs one-for-one — `kuleuven_toledo_courses_list`, `kuleuven_toledo_courses_content_show`, `kuleuven_kurt_resources_search`, and so on — with the same arguments, the same JSON output, and the same cookie jar. Tool names use underscores because Claude Desktop validates tool names against `^[a-zA-Z0-9_-]{1,64}$`. Sign in once with `kuleuven session start` in a terminal; Claude Desktop reuses the persisted session. - -The MCP server exposes every CLI command except `kuleuven toledo courses files fetch` and `kuleuven toledo courses files sync` (which write to local disk — use `kuleuven toledo courses files resolve` to get a fetchable URL instead) and the `mcp` commands themselves. `session start` is included with `username`, `password`, and `totp` as optional fields; if the MCP server's environment has `KULEUVEN_USERNAME` and `KULEUVEN_PASSWORD` set, Claude can refresh expired sessions without prompting. Set those via the `env` block in Claude Desktop's `claude_desktop_config.json` if you want that behaviour. TOTP rotates every 30 seconds so a static `KULEUVEN_TOTP` is rarely useful. - -#### `kuleuven mcp start` - -Runs a stdio MCP server. Invoked by Claude Desktop, not by humans — it speaks JSON-RPC on stdin and stdout and produces no human-readable output. No flags. Exits with `0` on clean shutdown. +The `kuleuven mcp` commands expose the CLI as an MCP server. The tools mirror the CLI verbs one-for-one (`kuleuven_toledo_courses_list`, `kuleuven_kurt_resources_search`, and so on) with the same arguments, JSON output, and cookie jar. Sign in once with `kuleuven session start` in a terminal and Claude Desktop reuses the session. #### `kuleuven mcp install` -Adds a `kuleuven` entry to Claude Desktop's `claude_desktop_config.json` pointing at the `kuleuven` executable on `PATH`. Atomic write; other entries in the config are preserved. - -```text -kuleuven mcp install [--name NAME] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--name` | `kuleuven` | Name of the MCP server entry; use a different name to register multiple instances. | - -The response carries the resolved config path, the absolute command path written, and `restart_required: true` when the config was modified. If the `kuleuven` executable cannot be found on `PATH`, exits `1` with `code: "kuleuven_not_on_path"`. Restart Claude Desktop after installation for the change to take effect. +Registers the server in Claude Desktop's config. Restart Claude Desktop afterwards. Use `--name` to register more than one instance: ```sh kuleuven mcp install ``` -```json -{ - "status": "ok", - "config_path": "/Users/you/Library/Application Support/Claude/claude_desktop_config.json", - "name": "kuleuven", - "command": "/Users/you/.local/bin/kuleuven", - "changed": true, - "restart_required": true -} -``` +If you set `KULEUVEN_USERNAME` and `KULEUVEN_PASSWORD` in the server's `env` block in `claude_desktop_config.json`, Claude can refresh expired sessions on its own. #### `kuleuven mcp uninstall` -Removes the entry from Claude Desktop's config. No-op if the entry was not present (`removed: false`). +Removes the entry again: -```text -kuleuven mcp uninstall [--name NAME] +```sh +kuleuven mcp uninstall ``` -| Flag | Default | Description | -| --- | --- | --- | -| `--name` | `kuleuven` | Name of the MCP server entry to remove. | +#### `kuleuven mcp start` + +The stdio server itself, invoked by Claude Desktop rather than by humans: ```sh -kuleuven mcp uninstall +kuleuven mcp start ``` -```json -{ - "status": "ok", - "config_path": "/Users/you/Library/Application Support/Claude/claude_desktop_config.json", - "name": "kuleuven", - "removed": true -} -``` +It exposes every CLI command except the disk-writing ones (`files fetch`, `files sync`). Claude gets `files resolve` instead. ## Python API -The library is split along the same axis as the CLI: `kuleuven.session.KuleuvenSession` owns the `httpx.Client` and the SAML handshake; `kuleuven.toledo.ToledoClient` exposes Toledo/Ultra endpoints as methods; `kuleuven.kurt.KurtClient` exposes the KURT endpoints. Both per-service clients are constructed from a `KuleuvenSession` and share its cookie jar. Pydantic models in `kuleuven.models` represent the domain. - -The library does not persist cookies — that lives in `kuleuven.cli.storage` and is only wired up by the CLI. To reuse a saved session in your own code, build an `httpx.Client` with cookies you loaded yourself and pass it as `KuleuvenSession(http_client=...)`. +The library is pure: HTTP in, Pydantic models out, with no disk I/O, environment reads, or prompting. `KuleuvenSession` owns the `httpx.Client` and the sign-in. `ToledoClient` and `KurtClient` are built from it and share its cookies. ```python from kuleuven import KuleuvenSession, ToledoClient, KurtClient class Authenticator: # Satisfies the AuthenticationProvider protocol. Each method is called only - # if the IdP demands it: provide_totp for a TOTP form; select_device + + # if the IdP demands it: provide_totp for a TOTP form, select_device and # awaiting_approval for the KU Leuven Authenticator push. def provide_totp(self): return "123456" @@ -1026,176 +483,94 @@ with KuleuvenSession() as session: reservations = kurt.list_reservations() ``` -### `KuleuvenSession` - -Owns the `httpx.Client` and runs the three-leg SAML sign-in (portal + Ultra + KURT). No per-service endpoints live here. - -| Method | Signature | Returns | Description | -| --- | --- | --- | --- | -| `__init__` | `KuleuvenSession(http_client: httpx.Client \| None = None)` | — | Optionally bring your own `httpx.Client` (e.g. with pre-loaded cookies). | -| `sign_in` | `sign_in(username, password, provider: AuthenticationProvider)` | `None` | Walks the portal, Ultra, and KURT SAML flows in sequence. `provider` supplies the second factor on demand (TOTP code, or Authenticator device choice + approval wait). | -| `session_info` | `session_info()` | `dict \| None` | Same shape as `kuleuven session status`; `None` if the portal session is gone. | -| `has_ultra_session` | `has_ultra_session()` | `bool` | Cheap cookie-jar check for whether the Ultra handshake completed. | -| `close` | `close()` | `None` | Closes the underlying `httpx.Client`. Also called by `__exit__`. | - -### `AuthenticationProvider` - -A Protocol (in `kuleuven.shibboleth`, re-exported from `kuleuven`) that `sign_in` calls to satisfy the second factor. Implement the methods the IdP will use for your account; methods are only invoked when their form appears. - -| Method | Signature | Called when | Description | -| --- | --- | --- | --- | -| `provide_totp` | `provide_totp() -> str` | the IdP serves a `tokencode` form | Return a 6-digit TOTP code. | -| `select_device` | `select_device(devices: list[NextauthDevice]) -> NextauthDevice` | the IdP serves the Authenticator device picker | Choose which device to push to. | -| `awaiting_approval` | `awaiting_approval(device_name: str) -> None` | right after the push is sent | Notify the user (e.g. print "Waiting for approval on …"). Return immediately; `sign_in` then blocks on the WebSocket. | +The library does not persist cookies. That part lives in the CLI. To reuse a saved session in your own code, build an `httpx.Client` with the cookies loaded yourself and pass it as `KuleuvenSession(http_client=...)`. Check `session.session_info()` to see whether it still works. -`NextauthDevice` (in `kuleuven.nextauth`) is a frozen dataclass with `account_id: str`, `name: str`, and `last_login: str | None` (the raw "Last login at:" text, or `None`). +### Working with courses -Push-related exceptions, both subclassing `AuthenticationError`: `AuthApprovalTimeoutError` (no approval within 120 s) and `AuthApprovalFailedError` (the push was rejected or the WebSocket errored). +`ToledoClient` covers the portal and Ultra. `list_portal_courses()` returns your dashboard courses, and `list_ultra_courses()` returns everything Blackboard knows about, archived included. `ToledoClient.resolve(courses, ref)` matches any identifier form against a course list, the same way the CLI does: -### Data model - -The Pydantic models in `kuleuven.models`. Two kinds: *curated* models like `Course` / `Person` / `Membership` / `FileItem` (snake_case fields we chose) and *API echo* models like `MemberCounts` / `Reservation` / `Location` (mirror the upstream JSON; `model_dump()` preserves the camelCase keys). All inherit from `kuleuven.models.KuleuvenModel` (or `ApiEchoModel`) — call `.model_dump(mode="json")` to get the same shape the CLI emits. Role values and content handlers come through raw — the library does not maintain a translation table that could fall behind Blackboard's vocabulary. - -#### `Course` - -Collects cross-system identity (`batch_uid`, `pk`, `portal_uuid`, `course_uuid`, `foundations_id`), display fields, type and availability, academic fields, and user-relative fields like `role`, `favorite`, and `last_accessed`. Three constructors — `Course.from_portal`, `Course.from_ultra_membership`, `Course.from_ultra_course` — each populate what their source carries; `.merge(other)` joins two views field-wise so the CLI can present a single record. The `course_code` property returns the `EX101a`-style segment parsed out of the batchUid for real courses, and `None` for communities and anything that does not match the `ULTRA-…` shape. - -#### `Person` - -Carries q-uid, given and family name, and (when sourced from Ultra) the BB pk, email, foundations id, and uuid. `Person.from_portal_instructor` and `Person.from_ultra_user` cover the two source shapes. - -#### `Membership` +```python +courses = toledo.list_portal_courses() +course = ToledoClient.resolve(courses, "EX101a")[0] +``` -Wraps a `pk`, the `course_pk`, a nested `Person`, the raw `role` and `role_bucket` strings, and `is_available`. `Membership.from_ultra` builds it from a `/courses/<pk>/memberships` item. +Everything else hangs off the course's `pk`, with one `fetch_*` method per CLI command: -#### `Announcement` +```python +teachers = toledo.fetch_members(course.pk, role_bucket="TEACHING") +announcements = toledo.fetch_announcements(course.pk) +grades = toledo.fetch_my_grades(course.pk) +``` -Carries the announcement `id`, the `course_pk`, the `title`, the raw HTML body as `body_html` (Blackboard's rich-text dialect; render with `ToledoClient.render_markdown`), `is_read`, `is_draft`, create/modify dates, the `creator_user_id` (Blackboard user PK), the start/end visibility dates, and the `permanent`/`push_notify`/`read_tracking` flags. `Announcement.from_ultra` builds it from a `/courses/<pk>/announcements` item. `model_dump()` excludes `body_html` — render to markdown at the call site and add it back under whatever key you prefer. +### Content and files -#### `ContentNode` +`walk_contents(course_pk)` generates the content tree in pre-order, handling the Ultra Document wrinkle for you. For one document, `fetch_body_and_embeds(course_pk, content_id)` returns the body as markdown plus the parsed embeds in one call: -The per-node record `walk_contents` yields: `id`, `parent_id`, `title`, `handler`, `is_bb_page`, `depth`, `position`. +```python +for node in toledo.walk_contents(course.pk, max_depth=2): + print(" " * node.depth, node.title) +``` -#### `Embed` +Downloads start from `discover_files(course_pk)`, which returns every downloadable in the course as a flat `list[FileItem]`, Kaltura videos included: -A parsed embed lifted out of an HTML body. `Embed.from_anchor(payload, bb_type=...)` classifies the anchor as `file` or `lti` based on the surrounding `data-bbtype` attribute and the payload fields, exposes `title`/`url`/`mime_type`, and keeps the full `raw` payload so nothing is lost. When an embed only carries `viewerUrl` (no `resourceUrl`), the query string is stripped so the resulting URL points at the raw file rather than the inline-render React viewer. +```python +from pathlib import Path -#### `FileItem` +files = toledo.discover_files(course.pk) +slides = next(f for f in files if f.filename.endswith(".pdf")) +toledo.download_file_item(slides, Path(slides.filename)) +``` -The unified record `kuleuven toledo courses files list` emits. The `id` is synthetic (a bare content PK or `<content_pk>@<index>`), `kind` is either `file` or `video`, and `source_path` is a list of folder titles down to the container so `kuleuven toledo courses files sync` can mirror the tree on disk. `kaltura_entry_id` is populated for videos; `kuleuven toledo courses files fetch` resolves it through an anonymous Kaltura widget session at download time and follows the resulting `downloadUrl`. +To write somewhere other than a file, `stream_file_item(item, writable)` streams into any binary writable. `resolve_signed_url(item)` returns the same standalone URL as `files resolve`. -### `ToledoClient` +### KURT -Constructed from a `KuleuvenSession`. Exposes every Toledo portal + Blackboard Ultra endpoint the CLI uses, plus a handful of pure helpers as `@staticmethod`. Module-level constant `KALTURA_PARTNER_ID = 2375821`. +`KurtClient` mirrors the KURT commands. Find a location and resource type with `list_tiles()`, `get_location(id)`, and `list_resource_types(id)`, then search for a free slot and book it: ```python -from kuleuven import KuleuvenSession, ToledoClient +result = kurt.search_availability( + location_id=10, + resource_type_id=302, + start_date="2026-05-25", + end_date="2026-05-25", + start_time="09:00", + end_time="12:00", +) +seat = result.availabilities[0] -with KuleuvenSession() as session: - session.sign_in("r0123456", "...", provider=Authenticator()) - toledo = ToledoClient(session) - for course in toledo.list_portal_courses(): - print(course.course_code, course.display_name) +kurt.create_reservation( + resource_id=seat.resource_id, + resource_name=seat.resource_name, + start_date="2026-05-25", + end_date="2026-05-25", + start_time="09:00", + end_time="12:00", +) ``` -| Method | Description | -| --- | --- | -| **courses** | | -| `fetch_user_pk()` | Calls `/learn/api/v1/users/me` and returns the caller's Blackboard PK. | -| `list_portal_courses()` | Curated ~29 portal entries as `list[Course]`. | -| `list_ultra_courses(user_pk=None)` | Full Ultra-side set. When `user_pk` is omitted, fetches it first. | -| `fetch_portal_detail(batch_uid, language="en")` | `PortalCourseDetail` — description, `list[Person]` instructors, and a nested `PortalAdminUrls`. | -| `fetch_ultra_course(course_pk)` | `UltraCourseDetail` — wraps a `Course` plus `list[Person]` instructor memberships. | -| `fetch_members(course_pk, *, role_bucket=None, available=None, limit=1000)` | `list[Membership]`. | -| `fetch_member_counts(course_pk)` | `MemberCounts` mirroring the `/memberships/counts` summary. | -| `fetch_schedule(course_pk, *, start=None, end=None)` | `list[ScheduleItem]` — per-course calendar items. | -| `fetch_announcements(course_pk, *, limit=100, offset=0)` | `list[Announcement]`. | -| `fetch_announcement_counts(course_pk)` | `AnnouncementCounts` with `postedCount` and `unreadCount`. | -| `set_favorite(portal_uuid, favorite)` | Patches the favourite flag. Returns `None`. | -| `fetch_user_grades(course_pk, user_pk, *, include_no_grade_items=True, limit=200)` | `list[GradeEntry]` — per-column grade records for one user, with `column` / `submissionStatus` / `lastAttempt` / `hasAttemptOrGradeFeedback` / `attemptsLeft` expanded inline. The column expansion means each result is a full student-view record in one round-trip. | -| `fetch_my_grades(course_pk, **kwargs)` | Convenience: resolves the signed-in user's pk and calls `fetch_user_grades`. | -| **discussions** | | -| `list_discussions(course_pk, *, limit=100)` | `list[DiscussionItem]` — discussion-forum content items from the virtual `INTERACTIVE` parent. Each carries `contentDetail.resource/x-bb-forumlink` with `id` (forumId) and `conferenceId`. | -| `fetch_forum(course_pk, forum_id)` | `Forum` — title, dates, `properties` (allowAnonymousPosts, allowAttachment, …), permissions. | -| `fetch_forum_counts(course_pk, forum_id)` | `ForumCounts` — `commentCount`, `totalCount`, `readCount`, `draftCount`, `replyCount`. | -| `list_forum_messages(course_pk, forum_id, *, limit=1000)` | `list[ForumMessage]` — top-level messages (root threads). | -| `list_message_replies(course_pk, forum_id, message_id, *, limit=100, offset=0, sort="draft(desc),postDate(desc)")` | `MessageRepliesPage` — full paging envelope (`paging.count`, `offset`, `limit`, `next_page`, `results`). | -| `@staticmethod resolve(courses, ref)` | Every course whose portal UUID, batchUid, BB pk, course UUID, or course code matches `ref`. Case-insensitive on course codes. May return multiple matches across academic years. | -| **content** | | -| `fetch_content(course_pk, content_id)` | `ContentItem` for one item. | -| `fetch_children(course_pk, parent_id, *, limit=1000)` | `list[ContentItem]` children for one parent. | -| `walk_contents(course_pk, *, parent_id="ROOT", max_depth=None)` | Generator yielding `ContentNode` records in pre-order, with the BB Page short-circuit baked in. | -| `resolve_body_item(course_pk, content_id)` | `BodyResolution` (`.original`, `.body_item`), descending into the BB Page's inner child when the original is a BB Page. | -| `fetch_body_and_embeds(course_pk, content_id)` | `BodyAndEmbeds` (`.original`, `.body_item`, `.markdown`, `.embeds`) — descends the BB Page wrapper, renders the markdown, and parses embeds in one call. | -| `@staticmethod is_bb_page(item)` | Predicate for the Ultra Document gotcha. Takes a `ContentItem`. | -| `@staticmethod pick_bb_page_child(children)` | Picks the inner child of a BB Page from its `fetch_children` results. Prefers the literal `ultraDocumentBody` child, falls back to the sole child when the named marker is absent, returns `None` when the shape is ambiguous. | -| `@staticmethod extract_embeds(html)` | Returns `(rewritten_html, list[Embed])`. Parses the BB rich-text dialect, lifts `<a data-bbfile>` and `<a data-bbtype="embedded-app">` records out, and replaces inert anchors with normal `<a href>` links. | -| `@staticmethod render_markdown(html)` | Runs the embed rewrite, then `markitdown`. | -| **files** | | -| `discover_files(course_pk)` | Walks the content tree and returns `list[FileItem]` covering native file leaves, files embedded in document bodies, and Kaltura videos reached via top-level BLTI links or embedded-app anchors. | -| `resolve_download_url(item)` | URL for one item. Fetches Kaltura entry metadata through an anonymous widget session when the item is a video. | -| `resolve_signed_url(item)` | Resolves a standalone-fetchable URL (HEAD-follows bbcswebdav to the signed S3 URL; Kaltura URLs are already standalone). | -| `kaltura_widget_session()` | Starts an anonymous Kaltura widget session. | -| `kaltura_entry_info(entry_id)` | `KalturaEntry` — `downloadUrl` and friends for one Kaltura entry. | -| `stream_response(url, dest_writable)` | Streams a URL into any binary writable (a file, `sys.stdout.buffer`, an `io.BytesIO`); returns `(bytes_written, server_filename_or_None)`. | -| `stream_download(url, dest_path)` | File-targeted convenience wrapping `stream_response`. | -| `download_file_item(item, dest_path)` | `DownloadResult` (`.path`, `.bytes`, `.server_filename`). `FileItem`-aware wrapper used by `kuleuven toledo courses files fetch`. | -| `stream_file_item(item, dest_writable)` | `FileItem`-aware wrapper for `--out -` and `kuleuven toledo courses files sync`. | -| `@staticmethod find_item(items, file_id)` | Looks one `FileItem` up by its synthetic `id`. | - -### `KurtClient` - -Constructed from a `KuleuvenSession`. Wraps `kurt3.ghum.kuleuven.be/api`; auth is already on the session's cookie jar. +Passing `resource_name` is not optional. KURT rejects the request with a bare 400 unless it echoes the resource's name exactly, which is why the example takes it from the search result. -```python -from kuleuven import KuleuvenSession, KurtClient +Manage what you booked with `list_reservations()`, `update_reservation(...)`, and `cancel_reservation(id)`, and check your booking limits with `get_account()`. -with KuleuvenSession() as session: - session.sign_in("r0123456", "...", provider=Authenticator()) - kurt = KurtClient(session) - reservations = kurt.list_reservations() -``` +### Models -| Method | Returns | Description | -| --- | --- | --- | -| `get_user_info()` | `UserInfo` | uid, email, common name. | -| `get_account()` | `Account` | Superset of `get_user_info` that adds `list[Quota]` (`usageDay`, `usageWeek`, `maxUsageDay`, `maxUsageWeek`; `-1` is unlimited). | -| `list_tiles()` | `list[TileGroup]` | Tile groups (cities and special categories) with the `list[Tile]` under each. | -| `get_location(location_id)` | `Location` | Address, opening hours, and `list[Zone]` for one location. | -| `list_resource_types(location_id)` | `list[ResourceType]` | Bookable resource types (study seat, group room, …) at a location. | -| `get_occupancy(location_id)` | `Occupancy` | Current occupancy snapshot. | -| `list_favorites()` | `list[Favorite]` | Resources the user has starred. | -| `get_resource(resource_id, with_details=True)` | `Resource` | Resource detail with constraints (min/max participants, subject/purpose requirements, legal text). | -| `search_availability(...)` | `AvailabilitySearchResult` | Searches `/resourcetypeavailabilities` with the same flag shape the `kuleuven kurt resources search` CLI exposes; returns `list[AvailabilitySlot]` under `.availabilities`. | -| `list_reservations(only_today=False)` | `list[Reservation]` | The user's reservations. | -| `get_reservation(reservation_id)` | `Reservation` | One reservation with editable-window metadata. | -| `create_reservation(resource_id, resource_name, start_date, end_date, start_time, end_time, subject="", purpose="", participants=None)` | `ReservationOrText` | POSTs to `/reservations/`. `resource_name` must echo the resource's `name` field; KURT rejects the request with a bare 400 otherwise. Returns `.reservation` on JSON success or `.message` when KURT replies with text. | -| `update_reservation(reservation)` | `ReservationOrText` | PUTs a full reservation object (a `Reservation` or its dict form) with edited fields applied. | -| `cancel_reservation(reservation_id)` | `None` | DELETEs the reservation. | - -The module also exposes `has_session(http_client) -> bool` — a cookie-jar check used by `KuleuvenSession.sign_in` to confirm the KURT handshake completed. +Responses are Pydantic models in `kuleuven.models`. Call `.model_dump(mode="json")` to get the same shape the CLI prints. Curated models like `Course`, `Person`, and `FileItem` use snake_case fields, while API-echo models like `Reservation` and `Location` mirror the upstream JSON verbatim. Role values and content handlers come through raw, untranslated. ### Exceptions -The library raises these: - -| Exception | When | -| --- | --- | -| `kuleuven.AuthenticationError` | Any failure in the SAML or per-SP bootstrap flow. Base class for the auth exceptions below. | -| `kuleuven.InvalidCredentialsError` | The IdP rejected the username or password. Inherits from `AuthenticationError`. | -| `kuleuven.AuthApprovalTimeoutError` | The KU Leuven Authenticator push was not approved within 120 s. Inherits from `AuthenticationError`. | -| `kuleuven.AuthApprovalFailedError` | The push was rejected or the WebSocket errored. Inherits from `AuthenticationError`. | -| `kuleuven.SessionExpiredError` | An API call hit a Shibboleth re-auth bounce instead of the real endpoint — the SP session expired. Raised by `KurtClient`. Inherits from `AuthenticationError`. | -| `httpx.HTTPError` | Raised by the per-domain helpers and the underlying HTTP layer. Propagated, not wrapped. | +Auth failures raise `kuleuven.AuthenticationError` or one of its subclasses: -## Cookie storage +- `InvalidCredentialsError`: the IdP rejected the username or password +- `AuthApprovalTimeoutError`: the Authenticator push wasn't approved within 120 s +- `AuthApprovalFailedError`: the push was rejected or the WebSocket errored +- `SessionExpiredError`: an API call hit a re-auth bounce, so sign in again -Cookies live at `platformdirs.user_data_path("kuleuven") / "cookies.json"` with `0600` permissions. On macOS that resolves to `~/Library/Application Support/kuleuven/cookies.json`, on Linux to `~/.local/share/kuleuven/cookies.json`. The file holds portal, Ultra, and KURT cookies in one jar; all three are loaded into the `httpx.Client` automatically by the CLI callback before any subcommand runs. +`httpx.HTTPError` from the HTTP layer is propagated, not wrapped. ## Tests -``` +The suite is offline. HTTP is mocked at the transport layer with [respx](https://lundberg.github.io/respx/), and CLI tests redirect the cookie jar to a temp path, so nothing touches the live hosts or your saved session: + +```sh uv run pytest ``` - -The suite is offline: HTTP is mocked with [respx](https://lundberg.github.io/respx/) at the transport layer, so no real KU Leuven request goes out. CLI tests use Typer's `CliRunner` and monkeypatch `kuleuven.cli.storage.cookies_path` onto `tmp_path` so the on-disk cookie jar is never touched. Tests live in `tests/` with shared fixtures (sample payloads, a session/client pair) in `tests/conftest.py`.