Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.9 KiB
Markdown
207 lines
8.9 KiB
Markdown
# The `schulcloud` CLI
|
|
|
|
Browses and mirrors your Schulcloud files from a laptop, by talking to the
|
|
schulcloud-mcp server on the Pi.
|
|
|
|
## Why it goes through the Pi
|
|
|
|
The CLI never talks to Schulcloud. It holds no `jwt` cookie, no Schulcloud
|
|
credential of any kind — only this server's bearer token.
|
|
|
|
That is not an accident of layering; it solves a real problem. A Schulcloud
|
|
session dies after two hours of inactivity, and a CLI process lives for seconds,
|
|
so a CLI with its own token would be dead most times you reached for it. The Pi
|
|
already keeps one session alive around the clock. Routing through it means one
|
|
session, one keepalive, and one place to paste a fresh cookie once a month.
|
|
|
|
It also means the laptop cannot accidentally end the server's session: nothing
|
|
here can call logout.
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN> --dir ~/Schulcloud
|
|
```
|
|
|
|
The token is the same `MCP_AUTH_TOKEN` the Claude connector uses — one token
|
|
guards both surfaces. `login` verifies it before saving, so a typo fails
|
|
immediately rather than on first real use. Config is written to
|
|
`~/.config/schulcloud/config.json` with mode `0600`.
|
|
|
|
`SCHULCLOUD_SERVER`, `SCHULCLOUD_TOKEN` and `SCHULCLOUD_SYNC_DIR` override the
|
|
file, for CI or one-off invocations.
|
|
|
|
## Commands
|
|
|
|
```
|
|
schulcloud status how fresh the server's index is
|
|
schulcloud ls [--course <id>] [--long]
|
|
schulcloud get <fileId> [--out <path>]
|
|
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
|
schulcloud refresh [--course <id>] [--force]
|
|
schulcloud note ... the notes you take in class — see below
|
|
```
|
|
|
|
`ls --long` prints file ids, which is what `get` takes.
|
|
|
|
`--course` accepts a **course or a room id** — rooms ("Räume") are mirrored
|
|
alongside courses, with their files under the room's name rather than a course's.
|
|
|
|
`refresh` asks the server to re-read Schulcloud. Pass `--course` when you know
|
|
what changed: that is a handful of requests, where a full re-crawl reads every
|
|
course. The server refuses a repeat within a minute unless you pass `--force`.
|
|
A full re-crawl can take many minutes — the first one downloads every file,
|
|
file-manager folders included — so `refresh` starts it and then polls the
|
|
server's status, printing a note every half minute, rather than holding one
|
|
request open (which Node's fetch abandons after five minutes).
|
|
|
|
### The server's Schulcloud token (`token`)
|
|
|
|
```
|
|
schulcloud token when it expires, and whether the session is alive
|
|
schulcloud token set hand the server a fresh one
|
|
```
|
|
|
|
The monthly chore, with no restart and no `.env` edit:
|
|
|
|
1. Open a **private window** and log in to Schulcloud.
|
|
2. DevTools → Application (Firefox: Storage) → Cookies → `jwt`: copy the value.
|
|
3. `schulcloud token set` and paste it at the prompt. The input is hidden.
|
|
4. **Close the private window.** Left open, it logs the token out about two
|
|
hours after login (docs/AUTH.md).
|
|
|
|
Piping works too — `wl-paste | schulcloud token set` — and a pasted cookie
|
|
line such as `jwt=…; Path=/` is cleaned up. The token is never a command-line
|
|
argument, so it cannot end up in shell history.
|
|
|
|
The server checks the token with Schulcloud before swapping it in, so a bad
|
|
paste changes nothing. It refuses a token that is malformed, expired, already
|
|
logged out, or for a different account. A replacement is saved on the server
|
|
(`STATE_DIR`), so a restart keeps it, and the keepalive picks it up at once.
|
|
The same form lives at `https://<server>/token` for when no terminal is at hand.
|
|
The cookie is HttpOnly, so no bookmarklet can read it for you; the DevTools
|
|
copy is the step that remains.
|
|
|
|
### The file manager (`fs`)
|
|
|
|
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and
|
|
Geteilte Dateien — browsed like a filesystem, live:
|
|
|
|
```
|
|
schulcloud fs ls [path] [--long]
|
|
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
|
|
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
|
|
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
|
|
```
|
|
|
|
```console
|
|
$ schulcloud fs ls /courses
|
|
$ schulcloud fs tree "/courses/FIA24B - SK (Rh)"
|
|
$ schulcloud fs find "*Erben*" --path /courses
|
|
$ schulcloud fs get "/courses/FIA24B - SK (Rh)/02_Erbrecht" --out ~/Erbrecht
|
|
```
|
|
|
|
The tree is `/my`, `/courses/<course>`, `/teams/<team>` and `/shared`; the
|
|
German names ("/Kurs-Dateien") work too. Names may contain `/` — course names
|
|
often do — and still resolve; any segment may also be an id from `--long`.
|
|
|
|
`fs find` matches any part of a name, or, given `*` or `?`, the whole name as
|
|
`find -name` does. `fs get` on a folder downloads everything below it, keeps
|
|
the structure, and skips files already present at the same size — so re-running
|
|
it resumes. Each folder is one page load on the server, so large trees take a
|
|
while.
|
|
|
|
`sync` mirrors these files too, under `<course>/Kurs-Dateien/…`,
|
|
`Persönliche Dateien/…`, `Team-Dateien/<team>/…` and `Geteilte Dateien/`, once
|
|
the server's index includes them (`INDEX_FILE_MANAGER`, on by default).
|
|
|
|
### Your own lesson notes (`note`)
|
|
|
|
The notes you take in class, which Claude reads as context. The full story is in
|
|
[NOTES.md](NOTES.md); these are the commands.
|
|
|
|
```
|
|
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
|
|
schulcloud note show <path>
|
|
schulcloud note add --title <title> [--subject <name>] [--date <date>]
|
|
[--tags a,b] [--append] text on stdin, or --text
|
|
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
|
|
```
|
|
|
|
```console
|
|
$ pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
|
Saved LF07/2026-09-16 Subnetting.md
|
|
|
|
$ schulcloud note ls --subject Deutsch --since 2026-09-01
|
|
2026-09-22 [Deutsch] Sprachanalyse
|
|
2026-09-15 [Deutsch] Erörterung — Aufbau
|
|
|
|
$ schulcloud note show "Deutsch/2026-09-15 Erörterung.md"
|
|
```
|
|
|
|
`note add` reads the note from stdin, so it comes just as easily from a
|
|
clipboard, an editor or another command; `--append` adds to the note already
|
|
written for that subject and day rather than starting a second one.
|
|
|
|
`note import` takes the file `scripts/export-apple-notes.js` writes on a Mac.
|
|
`--dry-run` shows what it would do and `--out <dir>` writes the Markdown
|
|
locally instead of sending it to the server — the only note command that needs
|
|
no server at all.
|
|
|
|
## How sync works
|
|
|
|
It is a **one-way mirror, not a two-way sync**, and that follows from the data
|
|
rather than from laziness: Schulcloud file records are immutable — editing a
|
|
file upstream produces a *new* record — so there is no content versioning, no
|
|
conflict resolution and no merge. "Download what I do not have" is the whole
|
|
algorithm.
|
|
|
|
Local state lives in `.schulcloud-sync.json` at the root of the sync directory,
|
|
**keyed by file record id with the path as derived output**. That is what makes
|
|
renames cheap: when a teacher renames a board column, the file moves on disk
|
|
instead of being downloaded again under a new name and left duplicated under the
|
|
old one.
|
|
|
|
What it checks, and why only that:
|
|
|
|
- **Size**, not a checksum. The download endpoint exposes no `ETag` and
|
|
Schulcloud publishes no hash, so verifying content would mean re-downloading
|
|
every file to learn what it already told us. Size reliably catches the failure
|
|
that actually happens — a truncated or interrupted download — and costs a
|
|
`stat`.
|
|
- Downloads land on a `.part` neighbour and are renamed into place, so an
|
|
interrupted run never leaves a half-file that a later run mistakes for
|
|
complete.
|
|
|
|
**Deletions are not propagated by default.** A teacher removing a worksheet is
|
|
not a reason to destroy your copy of it; `sync` reports those as "gone upstream,
|
|
kept". Pass `--prune` to actually delete them.
|
|
|
|
`--dry-run` prints exactly what would happen, writes nothing, and does not
|
|
advance the cursor.
|
|
|
|
## Cursors
|
|
|
|
The server's sync cursor is a **crawl generation id**, not a timestamp. This is
|
|
deliberate and measured: `GET /course-rooms/{id}/board` returns the *request
|
|
time* as `updatedAt` for most elements, so a timestamp cursor would report every
|
|
board as changed on every crawl. Comparing generations by identity also detects
|
|
deletions, which no timestamp scheme can.
|
|
|
|
`--since` on the server API accepts an ISO date for convenience, resolved to the
|
|
nearest generation — but correctness never depends on it.
|
|
|
|
If the server no longer recognises your stored cursor it returns `409` rather
|
|
than silently treating everything as new, so you are never tricked into
|
|
re-downloading the world. Run `sync --full` deliberately in that case.
|
|
|
|
## Paths
|
|
|
|
Mirror paths are `Course/Board/Card/filename`, built by `core/paths.ts`.
|
|
|
|
Every component of that path originates in Schulcloud — course titles, card
|
|
titles and filenames are all user-supplied upstream — so each is reduced to a
|
|
single safe path component, and the result is re-checked against the sync root
|
|
before anything is written. A file named `../../.ssh/authorized_keys` cannot
|
|
escape, and `sync` refuses such an entry rather than writing it.
|