@wilm-ai/wilma-cli 1.6.1 → 2.0.0

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,578 @@
1
+ import { NetworkError, WilmaClient, addExamTimes, } from "@wilm-ai/wilma-client";
2
+ import { createHash } from "node:crypto";
3
+ import { fileNameFromResponse, readResponseCapped } from "./downloads.js";
4
+ /**
5
+ * An error with a stable code for agents and scripts (the CLI's JSON errors
6
+ * and exit codes use it): e.g. "unknown_student", "invalid_argument".
7
+ */
8
+ export class WilmaAiError extends Error {
9
+ code;
10
+ details;
11
+ constructor(code, message, details) {
12
+ super(message);
13
+ this.name = "WilmaAiError";
14
+ this.code = code;
15
+ this.details = details;
16
+ }
17
+ }
18
+ /* ------------------------------------------------------------------ */
19
+ /* Date helpers shared by the CLI and agent tools */
20
+ /* ------------------------------------------------------------------ */
21
+ // School days follow Finnish time, even when an agent runs on a UTC cloud computer.
22
+ const SCHOOL_TIME_ZONE = "Europe/Helsinki";
23
+ /** YYYY-MM-DD for an instant, in Finnish time. */
24
+ function finnishDate(d = new Date()) {
25
+ const parts = new Intl.DateTimeFormat("en-GB", {
26
+ timeZone: SCHOOL_TIME_ZONE,
27
+ year: "numeric",
28
+ month: "2-digit",
29
+ day: "2-digit",
30
+ }).formatToParts(d);
31
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
32
+ return `${get("year")}-${get("month")}-${get("day")}`;
33
+ }
34
+ function addDays(isoDate, days) {
35
+ const d = new Date(isoDate + "T12:00:00Z");
36
+ d.setUTCDate(d.getUTCDate() + days);
37
+ return d.toISOString().slice(0, 10);
38
+ }
39
+ /** 0 = Sunday ... 6 = Saturday */
40
+ function weekdayOf(isoDate) {
41
+ return new Date(isoDate + "T12:00:00Z").getUTCDay();
42
+ }
43
+ function todayString() {
44
+ return finnishDate();
45
+ }
46
+ function nextSchoolDay(from) {
47
+ let d = addDays(from ?? todayString(), 1);
48
+ // Skip Saturday (6) and Sunday (0)
49
+ while (weekdayOf(d) === 0 || weekdayOf(d) === 6) {
50
+ d = addDays(d, 1);
51
+ }
52
+ return d;
53
+ }
54
+ /** The school day before `from` (default today): Friday for a Monday. */
55
+ function previousSchoolDay(from) {
56
+ let d = addDays(from ?? todayString(), -1);
57
+ while (weekdayOf(d) === 0 || weekdayOf(d) === 6) {
58
+ d = addDays(d, -1);
59
+ }
60
+ return d;
61
+ }
62
+ function currentWeekBounds(weeksAhead = 0) {
63
+ const today = todayString();
64
+ const dayOfWeek = weekdayOf(today); // 0=Sun, 1=Mon, ...
65
+ const monday = addDays(today, (dayOfWeek === 0 ? -6 : 1 - dayOfWeek) + 7 * weeksAhead);
66
+ return [monday, addDays(monday, 4)];
67
+ }
68
+ /** YYYY-MM-DD, or "today", "yesterday" or "tomorrow" (Finnish time). */
69
+ export function parseIsoDate(raw) {
70
+ const value = (raw ?? "").trim();
71
+ const word = value.toLowerCase();
72
+ if (word === "today")
73
+ return todayString();
74
+ if (word === "yesterday")
75
+ return addDays(todayString(), -1);
76
+ if (word === "tomorrow")
77
+ return addDays(todayString(), 1);
78
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
79
+ throw new WilmaAiError("invalid_argument", `Invalid date "${raw}". Use YYYY-MM-DD, today, yesterday or tomorrow.`);
80
+ }
81
+ // Validate date is real.
82
+ const d = new Date(value + "T12:00:00Z");
83
+ if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) {
84
+ throw new WilmaAiError("invalid_argument", `Invalid date "${raw}". Expected a real calendar date.`);
85
+ }
86
+ return value;
87
+ }
88
+ const WEEKDAYS = {
89
+ sun: 0, su: 0, sunday: 0,
90
+ mon: 1, ma: 1, monday: 1,
91
+ tue: 2, ti: 2, tuesday: 2,
92
+ wed: 3, ke: 3, wednesday: 3,
93
+ thu: 4, to: 4, thursday: 4,
94
+ fri: 5, pe: 5, friday: 5,
95
+ sat: 6, la: 6, saturday: 6,
96
+ };
97
+ function nextDateForWeekday(rawWeekday) {
98
+ const target = WEEKDAYS[(rawWeekday ?? "").trim().toLowerCase()];
99
+ if (target === undefined) {
100
+ throw new WilmaAiError("invalid_argument", `Invalid weekday "${rawWeekday}". Use mon|tue|wed|thu|fri|sat|sun (also accepts fi: ma|ti|ke|to|pe|la|su).`);
101
+ }
102
+ // Asking for "thu" on a Saturday gives next Thursday; today's weekday gives today.
103
+ const today = todayString();
104
+ return addDays(today, (target - weekdayOf(today) + 7) % 7);
105
+ }
106
+ export const SCHEDULE_WHEN = ["today", "tomorrow", "week", "next-week"];
107
+ function resolveScheduleDateSelection(opts) {
108
+ const when = opts.when || "week";
109
+ if (!SCHEDULE_WHEN.includes(when)) {
110
+ throw new WilmaAiError("invalid_argument", `Unknown period "${when}". Use ${SCHEDULE_WHEN.join(", ")}, a date or a weekday.`);
111
+ }
112
+ if (opts.date && opts.weekday) {
113
+ throw new WilmaAiError("invalid_argument", "Use either a date or a weekday, not both.");
114
+ }
115
+ if (opts.date) {
116
+ const parsed = parseIsoDate(opts.date);
117
+ return { when, startDate: parsed, endDate: parsed, queryDate: parsed, outputWhen: "date" };
118
+ }
119
+ if (opts.weekday) {
120
+ const parsed = nextDateForWeekday(opts.weekday);
121
+ return { when, startDate: parsed, endDate: parsed, queryDate: parsed, outputWhen: "weekday" };
122
+ }
123
+ if (when === "today") {
124
+ const date = todayString();
125
+ return { when, startDate: date, endDate: date, outputWhen: when };
126
+ }
127
+ if (when === "tomorrow") {
128
+ const date = nextSchoolDay();
129
+ return { when, startDate: date, endDate: date, outputWhen: when };
130
+ }
131
+ const [startDate, endDate] = currentWeekBounds(when === "next-week" ? 1 : 0);
132
+ return { when, startDate, endDate, outputWhen: when };
133
+ }
134
+ /**
135
+ * One child's daily briefing: today's and the next school day's lessons,
136
+ * upcoming exams (with start times), recent homework, lesson notes (teachers'
137
+ * feedback and absences), bulletins and messages. Without `since`: bulletins
138
+ * and messages from the last `days` days, homework from the last 3 days, and
139
+ * lesson notes from the previous school day. With `since` (YYYY-MM-DD): only
140
+ * what is from that day on. Unread messages are always included.
141
+ */
142
+ function buildSummaryData(input, opts = {}) {
143
+ const today = todayString();
144
+ const tomorrow = nextSchoolDay();
145
+ const cutoff = opts.since ?? addDays(today, -(opts.days ?? 7));
146
+ const homeworkCutoff = opts.since ?? addDays(today, -3);
147
+ const { overview } = input;
148
+ const news = input.news
149
+ .filter((n) => n.published && finnishDate(n.published) >= cutoff)
150
+ .slice(0, opts.since ? 30 : 10)
151
+ .map((n) => ({ wilmaId: n.wilmaId, title: n.title, published: n.published, pinned: Boolean(n.pinned) }));
152
+ const messages = input.messages
153
+ .filter((m) => m.unread || finnishDate(m.sentAt) >= cutoff)
154
+ .sort((a, b) => b.sentAt.getTime() - a.sentAt.getTime())
155
+ .slice(0, opts.since ? 30 : 10)
156
+ .map((m) => ({
157
+ wilmaId: m.wilmaId,
158
+ subject: m.subject,
159
+ sentAt: m.sentAt,
160
+ senderName: m.senderName ?? null,
161
+ folder: m.folder,
162
+ unread: Boolean(m.unread),
163
+ replyCount: m.replyCount ?? 0,
164
+ }));
165
+ return {
166
+ today,
167
+ tomorrow,
168
+ todaySchedule: overview.schedule.filter((l) => l.date === today),
169
+ tomorrowSchedule: overview.schedule.filter((l) => l.date === tomorrow),
170
+ upcomingExams: overview.upcomingExams,
171
+ homework: overview.homework.filter((h) => h.date >= homeworkCutoff),
172
+ lessonNotes: input.lessonNotes,
173
+ news,
174
+ messages,
175
+ unreadMessages: input.messages.filter((m) => m.unread).length,
176
+ ...(input.unavailable.length ? { unavailable: input.unavailable } : {}),
177
+ };
178
+ }
179
+ /**
180
+ * Bulletins to show: the newest `limit` dated ones, every pinned one (they
181
+ * stay relevant all year), and the older ones only when asked for.
182
+ */
183
+ function selectNews(news, opts = {}) {
184
+ const dated = news.filter((item) => !item.archived && item.published).slice(0, opts.limit ?? 20);
185
+ const pinnedUndated = news.filter((item) => !item.archived && !item.published);
186
+ const older = opts.includeOlder ? news.filter((item) => item.archived) : [];
187
+ return [...dated, ...pinnedUndated, ...older];
188
+ }
189
+ /**
190
+ * What a daily summary needs, fetched at once. The front page, bulletins and
191
+ * inbox are required; meeting invitations, lesson notes and exam times are
192
+ * extras — if Wilma can't give one, the summary says so in `unavailable`.
193
+ */
194
+ async function fetchSummaryInputs(client, opts) {
195
+ const unavailable = [];
196
+ const optional = (name, promise, fallback) => promise.catch((err) => {
197
+ if (err instanceof NetworkError)
198
+ throw err;
199
+ unavailable.push(name);
200
+ return fallback;
201
+ });
202
+ const [overview, calendar, news, inbox, appointments, lessonNotes] = await Promise.all([
203
+ client.overview.get(),
204
+ client.exams.calendarOrEmpty(),
205
+ client.news.list(),
206
+ client.messages.list("inbox"),
207
+ optional("appointments", client.messages.list("appointments"), []),
208
+ optional("lessonNotes", client.attendance.list({ from: opts.notesFrom, to: todayString() }), []),
209
+ ]);
210
+ return {
211
+ overview: { ...overview, upcomingExams: addExamTimes(overview.upcomingExams, calendar) },
212
+ news,
213
+ messages: [...inbox, ...appointments],
214
+ lessonNotes,
215
+ unavailable,
216
+ };
217
+ }
218
+ /** The first day of a period ending today that is `days` long. */
219
+ function daysBack(days) {
220
+ const today = todayString();
221
+ return addDays(today, -(days - 1));
222
+ }
223
+ /* ------------------------------------------------------------------ */
224
+ /* Session pool */
225
+ /* ------------------------------------------------------------------ */
226
+ // Wilma allows one live session per account: every new login cancels the
227
+ // previous one (and logs the parent out of Wilma in their own browser).
228
+ // Assistants often call several tools at once, so tool calls in one process
229
+ // share a session per account instead of logging in each time. Sessions
230
+ // re-log in by themselves if Wilma cancels them.
231
+ const POOL_IDLE_MS = 10 * 60 * 1000;
232
+ const pool = new Map();
233
+ // Between processes (CLI commands, the local MCP server), sessions are saved
234
+ // in a store when the host sets one.
235
+ let sessionStore;
236
+ /** Continue saved sessions instead of logging in for each process (CLI and local MCP server). */
237
+ export function useSessionStore(store) {
238
+ sessionStore = store;
239
+ }
240
+ function rememberSession(key, client) {
241
+ const store = sessionStore;
242
+ if (!store)
243
+ return;
244
+ const save = () => store.save(key, client.exportSession()).catch(() => { });
245
+ client.onLogin(() => void save());
246
+ void save();
247
+ }
248
+ /** Identifies one Wilma account and password (a changed password starts fresh). */
249
+ function poolKey(profile) {
250
+ const { baseUrl, username, password } = profile;
251
+ // The password is part of the key, so a changed password starts a fresh session.
252
+ return createHash("sha256")
253
+ .update(`${baseUrl.replace(/\/$/, "")}\n${username.toLowerCase()}\n${password}`)
254
+ .digest("hex");
255
+ }
256
+ /**
257
+ * Keep a session that is already logged in — e.g. the one the login page just
258
+ * verified — so the first tool call needs no second login (and, with two-step
259
+ * verification, no second code).
260
+ */
261
+ export function adoptSession(profile, client) {
262
+ const key = poolKey(profile);
263
+ pool.set(key, { client: Promise.resolve(client), lastUsed: Date.now() });
264
+ rememberSession(key, client);
265
+ }
266
+ /** Save any sessions still being written (call before a short-lived process exits). */
267
+ export async function flushSessions() {
268
+ if (!sessionStore)
269
+ return;
270
+ for (const [key, entry] of pool) {
271
+ const client = await entry.client.catch(() => null);
272
+ if (client)
273
+ await sessionStore.save(key, client.exportSession()).catch(() => { });
274
+ }
275
+ }
276
+ function pooledSession(account) {
277
+ const now = Date.now();
278
+ for (const [key, entry] of pool) {
279
+ if (now - entry.lastUsed > POOL_IDLE_MS)
280
+ pool.delete(key);
281
+ }
282
+ const key = poolKey(account.profile);
283
+ let entry = pool.get(key);
284
+ if (!entry) {
285
+ const profile = { ...account.profile, studentNumber: null };
286
+ const client = (async () => {
287
+ // A saved session first; it logs in again by itself if Wilma has ended it.
288
+ const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
289
+ const resumed = saved ? WilmaClient.resume(profile, saved, account.mfa) : null;
290
+ const fresh = resumed ?? (await WilmaClient.login(profile, account.mfa));
291
+ rememberSession(key, fresh);
292
+ return fresh;
293
+ })();
294
+ const created = { client, lastUsed: now };
295
+ entry = created;
296
+ pool.set(key, created);
297
+ client.catch(() => {
298
+ if (pool.get(key) === created)
299
+ pool.delete(key);
300
+ });
301
+ }
302
+ entry.lastUsed = now;
303
+ return entry.client;
304
+ }
305
+ /**
306
+ * Read-only Wilma access for agent tools (MCP). Covers every Wilma login the
307
+ * family has saved; every method defaults to all children, and a student
308
+ * number or name narrows it. Uses one pooled session per Wilma login for
309
+ * listing and for every child.
310
+ */
311
+ /**
312
+ * The students a name or number refers to. Agents and people pass names from
313
+ * free text, so match strictly: a student number, the full name, or the start
314
+ * of a name part ("Kiia" for "Kiia Example"). No fuzzy matching — a child not
315
+ * on the account must not match a sibling. Throws when nothing or several match.
316
+ */
317
+ export function matchStudents(students, student) {
318
+ const needle = student.trim().toLowerCase();
319
+ const names = students.map((s) => s.name).join(", ") || "none";
320
+ const byNumber = students.filter((s) => s.studentNumber === student.trim());
321
+ if (byNumber.length === 1)
322
+ return byNumber;
323
+ const exact = students.filter((s) => s.name.toLowerCase() === needle);
324
+ const matches = exact.length
325
+ ? exact
326
+ : students.filter((s) => {
327
+ const name = s.name.toLowerCase();
328
+ return name.startsWith(needle) || name.split(/[\s-]+/).some((part) => part.startsWith(needle));
329
+ });
330
+ if (matches.length === 1)
331
+ return matches;
332
+ const list = students.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
333
+ if (!matches.length) {
334
+ throw new WilmaAiError("unknown_student", `No student matching "${student}". Students on this account: ${names}`, { students: list });
335
+ }
336
+ throw new WilmaAiError("ambiguous_student", `"${student}" matches several students (${matches.map((s) => s.name).join(", ")}). Use the full name or student number.`, { students: list });
337
+ }
338
+ export class WilmaAccess {
339
+ accounts;
340
+ studentsCache = null;
341
+ /** One logged-in session per Wilma login, shared by every child on it. */
342
+ sessions = new Map();
343
+ /** Logins that failed while other logins worked. */
344
+ problems = [];
345
+ constructor(accounts) {
346
+ this.accounts = Array.isArray(accounts) ? accounts : [accounts];
347
+ if (!this.accounts.length)
348
+ throw new Error("No Wilma login");
349
+ }
350
+ get multi() {
351
+ return this.accounts.length > 1;
352
+ }
353
+ session(index) {
354
+ let session = this.sessions.get(index);
355
+ if (!session) {
356
+ session = pooledSession(this.accounts[index]);
357
+ this.sessions.set(index, session);
358
+ }
359
+ return session;
360
+ }
361
+ labelOf(index) {
362
+ const account = this.accounts[index];
363
+ return account.label ?? account.profile.baseUrl;
364
+ }
365
+ ref(s) {
366
+ return this.multi
367
+ ? { studentNumber: s.studentNumber, name: s.name, wilma: this.labelOf(s.account) }
368
+ : { studentNumber: s.studentNumber, name: s.name };
369
+ }
370
+ async students() {
371
+ if (this.studentsCache)
372
+ return this.studentsCache;
373
+ // Different Wilma logins can't cancel each other, so log in to all at once.
374
+ const fetched = await Promise.all(this.accounts.map(async (_account, index) => {
375
+ try {
376
+ return { index, list: await (await this.session(index)).students() };
377
+ }
378
+ catch (error) {
379
+ return { index, error };
380
+ }
381
+ }));
382
+ const all = [];
383
+ const seen = new Set();
384
+ let firstError = null;
385
+ let worked = 0;
386
+ for (const result of fetched) {
387
+ const account = this.accounts[result.index];
388
+ if (!("list" in result) || !result.list) {
389
+ firstError ??= result.error;
390
+ const message = result.error instanceof Error ? result.error.message : String(result.error);
391
+ this.problems.push({ wilma: this.labelOf(result.index), message });
392
+ continue;
393
+ }
394
+ worked += 1;
395
+ // One at a time: these write the saved config.
396
+ await account.onStudents?.(result.list);
397
+ const list = result.list.length
398
+ ? result.list
399
+ : account.knownStudents?.length
400
+ ? // An empty answer keeps the children saved with the login.
401
+ account.knownStudents.map((s) => ({ ...s, href: `/!${s.studentNumber}/` }))
402
+ : // Accounts that list no students still work against the default page.
403
+ [{ studentNumber: account.profile.studentNumber ?? "", name: "", href: "/" }];
404
+ for (const student of list) {
405
+ // Two guardians' logins on the same Wilma list the same children once.
406
+ const key = `${account.profile.baseUrl}|${student.studentNumber}`;
407
+ if (seen.has(key))
408
+ continue;
409
+ seen.add(key);
410
+ all.push({ ...student, account: result.index });
411
+ }
412
+ }
413
+ if (!worked)
414
+ throw firstError instanceof Error ? firstError : new Error("Could not log in to Wilma");
415
+ this.studentsCache = all;
416
+ return all;
417
+ }
418
+ /** The children as tools and commands show them (with their Wilma when there are several). */
419
+ async studentList() {
420
+ return (await this.students()).map((s) => this.ref(s));
421
+ }
422
+ async selectStudents(student) {
423
+ const students = await this.students();
424
+ return student ? matchStudents(students, student) : students;
425
+ }
426
+ async client(student) {
427
+ return (await this.session(student.account)).forStudent(student.studentNumber || null);
428
+ }
429
+ async perStudent(student, fn) {
430
+ const selected = await this.selectStudents(student);
431
+ // All children at once; children on one Wilma share its session.
432
+ const results = await Promise.all(selected.map(async (s) => ({ student: this.ref(s), ...(await fn(await this.client(s), s)) })));
433
+ return this.problems.length ? { students: results, problems: this.problems } : { students: results };
434
+ }
435
+ /** Try each selected student until one can open the item (messages and news are often shared across siblings). */
436
+ async firstStudentWith(student, fn) {
437
+ if (!student && this.multi) {
438
+ // Message and news ids are only unique within one Wilma.
439
+ throw new WilmaAiError("student_required", "Several Wilmas are connected and ids are only unique within one Wilma. Pass the student the item was listed under.");
440
+ }
441
+ const selected = await this.selectStudents(student);
442
+ let lastError;
443
+ for (const s of selected) {
444
+ try {
445
+ const client = await this.client(s);
446
+ return { student: this.ref(s), item: await fn(client) };
447
+ }
448
+ catch (err) {
449
+ lastError = err;
450
+ }
451
+ }
452
+ throw lastError instanceof Error ? lastError : new Error("Item not found");
453
+ }
454
+ /** The daily briefing per child (see buildSummaryData). `since`: YYYY-MM-DD, "yesterday"… */
455
+ async summary(opts = {}) {
456
+ const since = opts.since ? parseIsoDate(opts.since) : undefined;
457
+ const notesFrom = since ?? previousSchoolDay();
458
+ const result = await this.perStudent(opts.student, async (client) => {
459
+ const inputs = await fetchSummaryInputs(client, { notesFrom });
460
+ return { summary: buildSummaryData(inputs, { days: opts.days, since }) };
461
+ });
462
+ return { generatedAt: new Date(), ...(since ? { since } : {}), ...result };
463
+ }
464
+ async schedule(opts = {}) {
465
+ const selection = resolveScheduleDateSelection(opts);
466
+ const range = selection.startDate === selection.endDate
467
+ ? { date: selection.startDate }
468
+ : { weekStart: selection.startDate, weekEnd: selection.endDate };
469
+ const result = await this.perStudent(opts.student, async (client) => ({
470
+ lessons: await client.schedule.list({ from: selection.startDate, to: selection.endDate }),
471
+ }));
472
+ return { when: selection.outputWhen, ...range, ...result };
473
+ }
474
+ async homework(opts = {}) {
475
+ return this.perStudent(opts.student, async (client) => {
476
+ const overview = await client.overview.get();
477
+ return { homework: overview.homework.slice(0, opts.limit ?? 10) };
478
+ });
479
+ }
480
+ async upcomingExams(opts = {}) {
481
+ return this.perStudent(opts.student, async (client) => {
482
+ return { exams: (await client.exams.upcoming()).slice(0, opts.limit ?? 20) };
483
+ });
484
+ }
485
+ async grades(opts = {}) {
486
+ return this.perStudent(opts.student, async (client) => {
487
+ const overview = await client.overview.get();
488
+ return { grades: overview.grades.slice(0, opts.limit ?? 20) };
489
+ });
490
+ }
491
+ /** Lesson notes for one day (default today), the last `days` days, or `from`–`to` (to defaults to today). */
492
+ async lessonNotes(opts = {}) {
493
+ const ways = [opts.date, opts.days, opts.from].filter((x) => x !== undefined).length;
494
+ if (ways > 1)
495
+ throw new WilmaAiError("invalid_argument", "Use one of a date, a number of days, or from/to.");
496
+ if (opts.to && !opts.from)
497
+ throw new WilmaAiError("invalid_argument", "A period needs a start date (from) as well.");
498
+ if (opts.days || opts.from) {
499
+ const from = opts.days ? daysBack(opts.days) : parseIsoDate(opts.from);
500
+ const to = opts.to ? parseIsoDate(opts.to) : todayString();
501
+ return {
502
+ from,
503
+ to,
504
+ ...(await this.perStudent(opts.student, async (client) => ({ notes: await client.attendance.list({ from, to }) }))),
505
+ };
506
+ }
507
+ const date = opts.date ? parseIsoDate(opts.date) : todayString();
508
+ return { date, ...(await this.perStudent(opts.student, async (client) => ({ notes: await client.attendance.list({ date }) }))) };
509
+ }
510
+ /** Lesson notes counted by kind (absences, lateness, feedback…): this school year, or from a date. */
511
+ async lessonNotesSummary(opts = {}) {
512
+ const from = opts.from ? parseIsoDate(opts.from) : undefined;
513
+ const to = opts.to ? parseIsoDate(opts.to) : undefined;
514
+ return this.perStudent(opts.student, async (client) => ({ summary: await client.attendance.summary({ from, to }) }));
515
+ }
516
+ async gradebook(opts = {}) {
517
+ return this.perStudent(opts.student, async (client) => ({ gradebook: await client.gradebook.get() }));
518
+ }
519
+ async printouts(opts = {}) {
520
+ return this.perStudent(opts.student, async (client) => ({ printouts: await client.printouts.list() }));
521
+ }
522
+ async printout(opts) {
523
+ const { student, item } = await this.firstStudentWith(opts.student, async (client) => {
524
+ const { printout, response } = await client.printouts.fetch(opts.id).catch((err) => {
525
+ throw /not found/.test(String(err?.message)) ? new WilmaAiError("not_found", `Printout "${opts.id}" not found`) : err;
526
+ });
527
+ const resource = { id: printout.id, label: printout.title, url: printout.path, authContext: "wilma" };
528
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || null;
529
+ const fileName = fileNameFromResponse(resource, response.headers.get("content-disposition"), contentType);
530
+ const data = await readResponseCapped(response);
531
+ return { status: "fetched", printoutId: printout.id, resource, fileName, contentType, data };
532
+ });
533
+ return { ...item, student };
534
+ }
535
+ async messages(opts = {}) {
536
+ return this.perStudent(opts.student, async (client) => {
537
+ const messages = await client.messages.list(opts.folder ?? "inbox");
538
+ return { messages: messages.slice(0, opts.limit ?? 20) };
539
+ });
540
+ }
541
+ async message(opts) {
542
+ const { student, item } = await this.firstStudentWith(opts.student, (client) => client.messages.get(opts.id));
543
+ return { student, message: item };
544
+ }
545
+ async news(opts = {}) {
546
+ return this.perStudent(opts.student, async (client) => ({
547
+ news: selectNews(await client.news.list(), { limit: opts.limit, includeOlder: opts.includeOlder }),
548
+ }));
549
+ }
550
+ async newsItem(opts) {
551
+ const { student, item } = await this.firstStudentWith(opts.student, (client) => client.news.get(opts.id));
552
+ return { student, news: item };
553
+ }
554
+ async newsAttachment(opts) {
555
+ const { student, item } = await this.firstStudentWith(opts.student, async (client) => {
556
+ const news = await client.news.get(opts.newsId);
557
+ const resource = news.resources?.find((r) => r.id === opts.resourceId);
558
+ if (!resource) {
559
+ const known = (news.resources ?? []).map((r) => r.id).join(", ") || "none";
560
+ throw new WilmaAiError("not_found", `Resource "${opts.resourceId}" not found in news item ${opts.newsId} (available: ${known})`);
561
+ }
562
+ const fetched = await client.news.fetchResource(opts.newsId, opts.resourceId, { item: news });
563
+ if (fetched.status === "not_a_file" || !fetched.response) {
564
+ return {
565
+ status: "not_a_file",
566
+ newsId: opts.newsId,
567
+ resource,
568
+ message: "The link answered with a web page instead of a file. It may require signing in — open the URL in a browser instead.",
569
+ };
570
+ }
571
+ const contentType = fetched.response.headers.get("content-type")?.split(";", 1)[0]?.trim() || null;
572
+ const fileName = fileNameFromResponse(resource, fetched.response.headers.get("content-disposition"), contentType);
573
+ const data = await readResponseCapped(fetched.response);
574
+ return { status: "fetched", newsId: opts.newsId, resource, fileName, contentType, data };
575
+ });
576
+ return { ...item, student };
577
+ }
578
+ }
@@ -0,0 +1,33 @@
1
+ import type { MessageFolder } from "@wilm-ai/wilma-client";
2
+ export interface Flags {
3
+ json?: boolean;
4
+ text?: boolean;
5
+ debug?: boolean;
6
+ help?: boolean;
7
+ version?: boolean;
8
+ student?: string;
9
+ limit?: number;
10
+ days?: number;
11
+ since?: string;
12
+ from?: string;
13
+ to?: string;
14
+ date?: string;
15
+ when?: string;
16
+ weekday?: string;
17
+ folder?: MessageFolder;
18
+ older?: boolean;
19
+ output?: string;
20
+ totpSecret?: string;
21
+ }
22
+ export interface ParsedCommand {
23
+ /** Canonical command name, or null for none (interactive menu / help). */
24
+ command: string | null;
25
+ /** e.g. "read", "download", "summary", "remove", "clear" — null for the default (list). */
26
+ action: string | null;
27
+ /** Ids and other positional values, already checked. */
28
+ args: string[];
29
+ flags: Flags;
30
+ /** Login and mcp parse their own options. */
31
+ raw: string[];
32
+ }
33
+ export declare function parseCliArgs(argv: string[]): ParsedCommand;