@wilm-ai/wilma-cli 0.0.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wilm.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @wilm-ai/wilma-cli
2
+
3
+ Command line interface for Wilma (Finnish school system), built for parents and AI agents.
4
+
5
+ ## Install
6
+ ```bash
7
+ npm i -g @wilm-ai/wilma-cli
8
+ # or
9
+ pnpm add -g @wilm-ai/wilma-cli
10
+ ```
11
+
12
+ ## Run
13
+ ```bash
14
+ wilma
15
+ # or
16
+ wilmai
17
+ ```
18
+
19
+ ## Non-interactive (agent-friendly)
20
+ ```bash
21
+ wilma kids list --json
22
+ wilma news list --all --json
23
+ wilma messages list --folder inbox --all --json
24
+ ```
25
+
26
+ ## Config
27
+ Local config is stored in `.wilmai/config.json` in the current working directory.
28
+ Use `wilma config clear` to remove it.
29
+
30
+ ## Notes
31
+ - Credentials are stored with lightweight obfuscation for convenience.
32
+ - For multi-child accounts, you can pass `--student <id>` or `--all`.
@@ -0,0 +1,23 @@
1
+ export interface StoredProfile {
2
+ id: string;
3
+ tenantUrl: string;
4
+ username: string;
5
+ passwordObfuscated: string;
6
+ students?: {
7
+ studentNumber: string;
8
+ name: string;
9
+ }[];
10
+ lastStudentNumber?: string | null;
11
+ lastStudentName?: string | null;
12
+ lastUsedAt: string;
13
+ }
14
+ export interface CliConfig {
15
+ profiles: StoredProfile[];
16
+ lastProfileId?: string | null;
17
+ }
18
+ export declare function getConfigPath(): string;
19
+ export declare function loadConfig(): Promise<CliConfig>;
20
+ export declare function saveConfig(config: CliConfig): Promise<void>;
21
+ export declare function clearConfig(): Promise<void>;
22
+ export declare function obfuscateSecret(value: string): string;
23
+ export declare function revealSecret(value: string): string | null;
package/dist/config.js ADDED
@@ -0,0 +1,61 @@
1
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ const SALT = "wilmai::";
4
+ export function getConfigPath() {
5
+ const override = process.env.WILMAI_CONFIG_PATH;
6
+ if (override) {
7
+ return resolve(override);
8
+ }
9
+ return resolve(process.cwd(), ".wilmai", "config.json");
10
+ }
11
+ export async function loadConfig() {
12
+ const path = getConfigPath();
13
+ try {
14
+ const raw = await readFile(path, "utf-8");
15
+ const data = JSON.parse(raw);
16
+ if (!data.profiles) {
17
+ return { profiles: [] };
18
+ }
19
+ // Backward-compat: migrate single-student fields if present
20
+ data.profiles = data.profiles.map((p) => {
21
+ if (!p.students && p.studentNumber) {
22
+ p.students = [{ studentNumber: p.studentNumber, name: p.studentName ?? p.studentNumber }];
23
+ }
24
+ if (!p.lastStudentNumber && p.studentNumber) {
25
+ p.lastStudentNumber = p.studentNumber;
26
+ p.lastStudentName = p.studentName ?? p.studentNumber;
27
+ }
28
+ delete p.studentNumber;
29
+ delete p.studentName;
30
+ return p;
31
+ });
32
+ return data;
33
+ }
34
+ catch {
35
+ return { profiles: [] };
36
+ }
37
+ }
38
+ export async function saveConfig(config) {
39
+ const path = getConfigPath();
40
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
41
+ await writeFile(path, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
42
+ }
43
+ export async function clearConfig() {
44
+ const path = getConfigPath();
45
+ await rm(path, { force: true });
46
+ }
47
+ export function obfuscateSecret(value) {
48
+ return Buffer.from(SALT + value, "utf-8").toString("base64");
49
+ }
50
+ export function revealSecret(value) {
51
+ try {
52
+ const decoded = Buffer.from(value, "base64").toString("utf-8");
53
+ if (!decoded.startsWith(SALT)) {
54
+ return null;
55
+ }
56
+ return decoded.slice(SALT.length);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,762 @@
1
+ #!/usr/bin/env node
2
+ import { select, input, password } from "@inquirer/prompts";
3
+ import { WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
4
+ import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
5
+ const ACTIONS = [
6
+ { value: "news", name: "List news" },
7
+ { value: "exams", name: "List exams" },
8
+ { value: "messages", name: "List messages" },
9
+ { value: "exit", name: "Exit" },
10
+ ];
11
+ async function main() {
12
+ const args = process.argv.slice(2);
13
+ if (args[0] === "config" && args[1] === "clear") {
14
+ await clearConfig();
15
+ console.log(`Cleared config at ${getConfigPath()}`);
16
+ return;
17
+ }
18
+ const config = await loadConfig();
19
+ if (args.length) {
20
+ await handleCommand(args, config);
21
+ return;
22
+ }
23
+ await runInteractive(config);
24
+ }
25
+ async function chooseProfile(config) {
26
+ if (config.profiles.length) {
27
+ const choices = config.profiles.map((p) => ({
28
+ value: p.id,
29
+ name: `${p.username} @ ${p.tenantUrl} ${p.lastStudentName ? `(${p.lastStudentName})` : ""}`.trim(),
30
+ }));
31
+ choices.push({ value: "new", name: "Use a new login" });
32
+ const selected = await selectOrCancel({
33
+ message: "Choose a saved profile or create a new one",
34
+ choices,
35
+ default: config.lastProfileId ?? undefined,
36
+ });
37
+ if (selected === null)
38
+ return null;
39
+ if (selected !== "new") {
40
+ const stored = config.profiles.find((p) => p.id === selected);
41
+ if (!stored) {
42
+ throw new Error("Stored profile not found");
43
+ }
44
+ const secret = revealSecret(stored.passwordObfuscated);
45
+ if (!secret) {
46
+ throw new Error("Stored password could not be decoded");
47
+ }
48
+ const selectedStudent = await chooseStudentFromProfile(stored, {
49
+ baseUrl: stored.tenantUrl,
50
+ username: stored.username,
51
+ password: secret,
52
+ });
53
+ if (!selectedStudent) {
54
+ return null;
55
+ }
56
+ stored.lastUsedAt = new Date().toISOString();
57
+ stored.lastStudentNumber = selectedStudent?.studentNumber ?? null;
58
+ stored.lastStudentName = selectedStudent?.name ?? null;
59
+ config.lastProfileId = stored.id;
60
+ await saveConfig(config);
61
+ return {
62
+ baseUrl: stored.tenantUrl,
63
+ username: stored.username,
64
+ password: secret,
65
+ studentNumber: selectedStudent?.studentNumber ?? undefined,
66
+ };
67
+ }
68
+ }
69
+ const tenant = await selectTenant();
70
+ if (!tenant)
71
+ return null;
72
+ const username = await inputOrCancel({ message: "Wilma username" });
73
+ if (username === null)
74
+ return null;
75
+ const passwordValue = await passwordOrCancel({ message: "Wilma password" });
76
+ if (passwordValue === null)
77
+ return null;
78
+ const profileBase = {
79
+ baseUrl: tenant.url,
80
+ username,
81
+ password: passwordValue,
82
+ };
83
+ const students = await WilmaClient.listStudents(profileBase);
84
+ const student = await chooseStudent(students);
85
+ if (!student)
86
+ return null;
87
+ const finalProfile = {
88
+ ...profileBase,
89
+ studentNumber: student?.studentNumber ?? undefined,
90
+ };
91
+ const stored = {
92
+ id: `${tenant.url}|${username}`,
93
+ tenantUrl: tenant.url,
94
+ username,
95
+ passwordObfuscated: obfuscateSecret(passwordValue),
96
+ students: students.map((s) => ({ studentNumber: s.studentNumber, name: s.name })),
97
+ lastStudentNumber: student?.studentNumber ?? null,
98
+ lastStudentName: student?.name ?? null,
99
+ lastUsedAt: new Date().toISOString(),
100
+ };
101
+ config.profiles = config.profiles.filter((p) => p.id !== stored.id).concat(stored);
102
+ config.lastProfileId = stored.id;
103
+ await saveConfig(config);
104
+ return finalProfile;
105
+ }
106
+ async function runInteractive(config) {
107
+ while (true) {
108
+ const profile = await chooseProfile(config);
109
+ if (!profile)
110
+ return;
111
+ const client = await WilmaClient.login(profile);
112
+ let nextAction = await selectOrCancel({
113
+ message: "What do you want to view?",
114
+ choices: [
115
+ ...ACTIONS.filter((a) => a.value !== "exit"),
116
+ { value: "back", name: "Back to students" },
117
+ { value: "exit", name: "Exit" },
118
+ ],
119
+ });
120
+ if (nextAction === null) {
121
+ continue;
122
+ }
123
+ while (nextAction !== "exit" && nextAction !== "back") {
124
+ if (nextAction === "news") {
125
+ await selectNewsToRead(client);
126
+ }
127
+ if (nextAction === "exams") {
128
+ await outputExams(client, { limit: 20, json: false });
129
+ }
130
+ if (nextAction === "messages") {
131
+ const folder = await selectOrCancel({
132
+ message: "Select folder",
133
+ choices: [
134
+ { value: "inbox", name: "Inbox" },
135
+ { value: "archive", name: "Archive" },
136
+ { value: "outbox", name: "Outbox" },
137
+ { value: "drafts", name: "Drafts" },
138
+ { value: "appointments", name: "Appointments" },
139
+ ],
140
+ });
141
+ if (folder !== null) {
142
+ await selectMessageToRead(client, folder);
143
+ }
144
+ }
145
+ nextAction = await selectOrCancel({
146
+ message: "What next?",
147
+ choices: [
148
+ ...ACTIONS.filter((a) => a.value !== "exit"),
149
+ { value: "back", name: "Back to students" },
150
+ { value: "exit", name: "Exit" },
151
+ ],
152
+ });
153
+ if (nextAction === null) {
154
+ nextAction = "back";
155
+ }
156
+ }
157
+ if (nextAction === "exit") {
158
+ return;
159
+ }
160
+ }
161
+ }
162
+ async function selectTenant() {
163
+ const tenants = await listTenants();
164
+ let query = await inputOrCancel({
165
+ message: "Search tenant by city/name (blank to list all, or type URL)",
166
+ default: "",
167
+ });
168
+ if (query === null)
169
+ return null;
170
+ while (true) {
171
+ if (query.startsWith("http://") || query.startsWith("https://")) {
172
+ return {
173
+ url: query.trim().replace(/\/$/, ""),
174
+ name: query.trim(),
175
+ municipalities: [],
176
+ };
177
+ }
178
+ let filtered = tenants;
179
+ if (query.trim()) {
180
+ const search = query.trim().toLowerCase();
181
+ filtered = tenants.filter((t) => tenantMatches(search, t));
182
+ }
183
+ const choices = filtered.slice(0, 20).map((t) => ({
184
+ value: t.url,
185
+ name: `${t.name ?? t.url} (${t.url})`,
186
+ }));
187
+ choices.push({ value: "search", name: "Search again" });
188
+ choices.push({ value: "manual", name: "Enter URL manually" });
189
+ const selected = await selectOrCancel({
190
+ message: "Select tenant",
191
+ choices,
192
+ });
193
+ if (selected === null)
194
+ return null;
195
+ if (selected === "search") {
196
+ const nextQuery = await inputOrCancel({ message: "Search tenant by city/name (or type URL)" });
197
+ if (nextQuery === null)
198
+ return null;
199
+ query = nextQuery;
200
+ continue;
201
+ }
202
+ if (selected === "manual") {
203
+ const manual = await inputOrCancel({ message: "Tenant URL" });
204
+ if (manual === null)
205
+ return null;
206
+ return {
207
+ url: manual.trim().replace(/\/$/, ""),
208
+ name: manual.trim(),
209
+ municipalities: [],
210
+ };
211
+ }
212
+ const tenant = tenants.find((t) => t.url === selected);
213
+ if (!tenant) {
214
+ throw new Error("Tenant not found");
215
+ }
216
+ return tenant;
217
+ }
218
+ }
219
+ function tenantMatches(search, tenant) {
220
+ const needle = (search ?? "").toLowerCase();
221
+ if (fuzzyIncludes(tenant.name ?? "", needle)) {
222
+ return true;
223
+ }
224
+ if (fuzzyIncludes(tenant.url ?? "", needle)) {
225
+ return true;
226
+ }
227
+ const municipalities = Array.isArray(tenant.municipalities) ? tenant.municipalities : [];
228
+ for (const m of municipalities) {
229
+ if (!m)
230
+ continue;
231
+ if (fuzzyIncludes(m.nameFi ?? "", needle))
232
+ return true;
233
+ if (fuzzyIncludes(m.nameSv ?? "", needle))
234
+ return true;
235
+ }
236
+ return false;
237
+ }
238
+ function fuzzyIncludes(target, needle) {
239
+ const hay = (target ?? "").toLowerCase();
240
+ if (!needle)
241
+ return true;
242
+ if (hay.includes(needle))
243
+ return true;
244
+ let i = 0;
245
+ for (const ch of hay) {
246
+ if (ch === needle[i]) {
247
+ i += 1;
248
+ if (i >= needle.length)
249
+ return true;
250
+ }
251
+ }
252
+ return false;
253
+ }
254
+ async function chooseStudent(students) {
255
+ if (!students.length) {
256
+ const manual = await inputOrCancel({ message: "Student number (not found automatically)" });
257
+ if (manual === null)
258
+ return null;
259
+ return { studentNumber: manual.trim(), name: manual.trim(), href: `/!${manual.trim()}/` };
260
+ }
261
+ if (students.length === 1) {
262
+ return students[0];
263
+ }
264
+ const selected = await selectOrCancel({
265
+ message: "Select student",
266
+ choices: students.map((s) => ({
267
+ value: s.studentNumber,
268
+ name: `${s.name} (${s.studentNumber})`,
269
+ })),
270
+ });
271
+ if (selected === null)
272
+ return null;
273
+ return students.find((s) => s.studentNumber === selected) ?? null;
274
+ }
275
+ async function chooseStudentFromProfile(stored, baseProfile) {
276
+ let students = [];
277
+ const fresh = await WilmaClient.listStudents(baseProfile);
278
+ if (fresh.length) {
279
+ students = fresh;
280
+ stored.students = fresh.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
281
+ }
282
+ else if (stored.students && stored.students.length) {
283
+ students = stored.students.map((s) => ({
284
+ studentNumber: s.studentNumber,
285
+ name: s.name,
286
+ href: `/!${s.studentNumber}/`,
287
+ }));
288
+ }
289
+ const defaultStudent = stored.lastStudentNumber
290
+ ? students.find((s) => s.studentNumber === stored.lastStudentNumber)
291
+ : undefined;
292
+ if (!students.length) {
293
+ return null;
294
+ }
295
+ if (students.length === 1) {
296
+ return students[0];
297
+ }
298
+ const selected = await selectOrCancel({
299
+ message: "Select student",
300
+ default: defaultStudent?.studentNumber,
301
+ choices: students.map((s) => ({
302
+ value: s.studentNumber,
303
+ name: `${s.name} (${s.studentNumber})`,
304
+ })),
305
+ });
306
+ if (selected === null)
307
+ return null;
308
+ return students.find((s) => s.studentNumber === selected) ?? null;
309
+ }
310
+ async function handleCommand(args, config) {
311
+ const { command, subcommand, flags } = parseArgs(args);
312
+ if (command === "config" && subcommand === "clear") {
313
+ await clearConfig();
314
+ console.log(`Cleared config at ${getConfigPath()}`);
315
+ return;
316
+ }
317
+ const profile = await getProfileForCommandNonInteractive(config, flags);
318
+ if (!profile)
319
+ return;
320
+ const client = await WilmaClient.login(profile);
321
+ if (command === "kids") {
322
+ const students = await getStudentsForCommand(profile, config);
323
+ if (flags.json) {
324
+ console.log(JSON.stringify(students, null, 2));
325
+ return;
326
+ }
327
+ console.log("\nKids");
328
+ students.forEach((s) => {
329
+ console.log(`- ${s.studentNumber} ${s.name}`);
330
+ });
331
+ return;
332
+ }
333
+ if (command === "news") {
334
+ if (subcommand === "read" && flags.id) {
335
+ await outputNewsItem(client, Number(flags.id), flags.json);
336
+ return;
337
+ }
338
+ if (flags.allStudents) {
339
+ await outputAllNews(profile, config, flags.limit ?? 20, flags.json);
340
+ return;
341
+ }
342
+ const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
343
+ if (!studentInfo && !profile.studentNumber) {
344
+ await printStudentSelectionHelp(profile, config);
345
+ return;
346
+ }
347
+ const perStudentClient = await WilmaClient.login({
348
+ ...profile,
349
+ studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
350
+ });
351
+ await outputNews(perStudentClient, {
352
+ limit: flags.limit ?? 20,
353
+ json: flags.json,
354
+ label: studentInfo?.name ?? undefined,
355
+ });
356
+ return;
357
+ }
358
+ if (command === "messages") {
359
+ if (subcommand === "read" && flags.id) {
360
+ await outputMessageItem(client, Number(flags.id), flags.json);
361
+ return;
362
+ }
363
+ if (flags.allStudents) {
364
+ await outputAllMessages(profile, config, {
365
+ folder: flags.folder ?? "inbox",
366
+ limit: flags.limit ?? 20,
367
+ json: flags.json,
368
+ });
369
+ return;
370
+ }
371
+ const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
372
+ if (!studentInfo && !profile.studentNumber) {
373
+ await printStudentSelectionHelp(profile, config);
374
+ return;
375
+ }
376
+ const perStudentClient = await WilmaClient.login({
377
+ ...profile,
378
+ studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
379
+ });
380
+ await outputMessages(perStudentClient, {
381
+ folder: flags.folder ?? "inbox",
382
+ limit: flags.limit ?? 20,
383
+ json: flags.json,
384
+ label: studentInfo?.name ?? undefined,
385
+ });
386
+ return;
387
+ }
388
+ if (command === "exams") {
389
+ if (flags.allStudents) {
390
+ await outputAllExams(profile, config, flags.limit ?? 20, flags.json);
391
+ return;
392
+ }
393
+ const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
394
+ if (!studentInfo && !profile.studentNumber) {
395
+ await printStudentSelectionHelp(profile, config);
396
+ return;
397
+ }
398
+ const perStudentClient = await WilmaClient.login({
399
+ ...profile,
400
+ studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
401
+ });
402
+ await outputExams(perStudentClient, {
403
+ limit: flags.limit ?? 20,
404
+ json: flags.json,
405
+ label: studentInfo?.name ?? undefined,
406
+ });
407
+ return;
408
+ }
409
+ console.log("Usage:");
410
+ console.log(" wilma kids list [--json]");
411
+ console.log(" wilma news list [--limit 20] [--student <id|name>] [--all-students] [--json]");
412
+ console.log(" wilma news read <id> [--json]");
413
+ console.log(" wilma messages list [--folder inbox] [--limit 20] [--student <id|name>] [--all-students] [--json]");
414
+ console.log(" wilma messages read <id> [--json]");
415
+ console.log(" wilma exams list [--limit 20] [--student <id|name>] [--all-students] [--json]");
416
+ console.log(" wilma config clear");
417
+ }
418
+ async function getProfileForCommandNonInteractive(config, flags) {
419
+ if (!config.lastProfileId) {
420
+ console.error("No saved profile found. Run the interactive CLI first.");
421
+ return null;
422
+ }
423
+ const stored = config.profiles.find((p) => p.id === config.lastProfileId);
424
+ if (!stored) {
425
+ console.error("Saved profile not found. Run the interactive CLI first.");
426
+ return null;
427
+ }
428
+ const secret = revealSecret(stored.passwordObfuscated);
429
+ if (!secret) {
430
+ console.error("Stored password could not be decoded. Re-login interactively.");
431
+ return null;
432
+ }
433
+ return {
434
+ baseUrl: stored.tenantUrl,
435
+ username: stored.username,
436
+ password: secret,
437
+ studentNumber: stored.lastStudentNumber ?? undefined,
438
+ debug: Boolean(flags.debug),
439
+ };
440
+ }
441
+ function parseArgs(args) {
442
+ const [command, subcommand, ...rest] = args;
443
+ const flags = {};
444
+ let i = 0;
445
+ while (i < rest.length) {
446
+ const arg = rest[i];
447
+ if (arg === "--json") {
448
+ flags.json = true;
449
+ i += 1;
450
+ continue;
451
+ }
452
+ if (arg === "--all-students" || arg === "--all") {
453
+ flags.allStudents = true;
454
+ i += 1;
455
+ continue;
456
+ }
457
+ if (arg === "--debug") {
458
+ flags.debug = true;
459
+ i += 1;
460
+ continue;
461
+ }
462
+ if (arg === "--limit") {
463
+ const value = Number(rest[i + 1]);
464
+ if (!Number.isNaN(value)) {
465
+ flags.limit = value;
466
+ }
467
+ i += 2;
468
+ continue;
469
+ }
470
+ if (arg === "--student") {
471
+ flags.student = rest[i + 1];
472
+ i += 2;
473
+ continue;
474
+ }
475
+ if (arg === "--folder") {
476
+ flags.folder = rest[i + 1];
477
+ i += 2;
478
+ continue;
479
+ }
480
+ if (!flags.id && !arg.startsWith("--")) {
481
+ flags.id = arg;
482
+ i += 1;
483
+ continue;
484
+ }
485
+ i += 1;
486
+ }
487
+ return { command, subcommand, flags };
488
+ }
489
+ async function outputNews(client, opts) {
490
+ const news = await client.news.list();
491
+ const slice = news.slice(0, opts.limit);
492
+ if (opts.json) {
493
+ console.log(JSON.stringify(slice, null, 2));
494
+ return;
495
+ }
496
+ console.log(`\nNews (${news.length})`);
497
+ slice.forEach((item) => {
498
+ const date = item.published ? item.published.toISOString().slice(0, 10) : "";
499
+ const prefix = opts.label ? `[${opts.label}] ` : "";
500
+ console.log(`- ${prefix}${date} ${compactText(item.title)} (id:${item.wilmaId})`.trim());
501
+ });
502
+ }
503
+ async function outputNewsItem(client, id, json) {
504
+ const item = await client.news.get(id);
505
+ if (json) {
506
+ console.log(JSON.stringify(item, null, 2));
507
+ return;
508
+ }
509
+ console.log(`\n${item.title}`);
510
+ if (item.subtitle)
511
+ console.log(item.subtitle);
512
+ if (item.published)
513
+ console.log(item.published.toISOString());
514
+ if (item.content)
515
+ console.log(`\n${formatContent(item.content)}`);
516
+ }
517
+ async function outputExams(client, opts) {
518
+ const exams = await client.exams.list();
519
+ const slice = exams.slice(0, opts.limit);
520
+ if (opts.json) {
521
+ console.log(JSON.stringify(slice, null, 2));
522
+ return;
523
+ }
524
+ console.log(`\nExams (${exams.length})`);
525
+ slice.forEach((exam) => {
526
+ const date = exam.examDate.toISOString().slice(0, 10);
527
+ const prefix = opts.label ? `[${opts.label}] ` : "";
528
+ console.log(`- ${prefix}${date} ${compactText(exam.subject)}`);
529
+ });
530
+ }
531
+ async function outputMessages(client, opts) {
532
+ const messages = await client.messages.list(opts.folder);
533
+ const slice = messages.slice(0, opts.limit);
534
+ if (opts.json) {
535
+ console.log(JSON.stringify(slice, null, 2));
536
+ return;
537
+ }
538
+ console.log(`\nMessages (${messages.length})`);
539
+ slice.forEach((msg) => {
540
+ const date = msg.sentAt.toISOString().slice(0, 10);
541
+ const prefix = opts.label ? `[${opts.label}] ` : "";
542
+ console.log(`- ${prefix}${date} ${compactText(msg.subject)} (id:${msg.wilmaId})`);
543
+ });
544
+ }
545
+ async function outputMessageItem(client, id, json) {
546
+ const msg = await client.messages.get(id);
547
+ if (json) {
548
+ console.log(JSON.stringify(msg, null, 2));
549
+ return;
550
+ }
551
+ console.log(`\n${msg.subject}`);
552
+ if (msg.senderName)
553
+ console.log(`From: ${msg.senderName}`);
554
+ console.log(`Sent: ${msg.sentAt.toISOString()}`);
555
+ if (msg.content)
556
+ console.log(`\n${formatContent(msg.content)}`);
557
+ }
558
+ async function selectNewsToRead(client) {
559
+ const news = await client.news.list();
560
+ if (!news.length)
561
+ return;
562
+ const choices = news.slice(0, 30).map((item) => {
563
+ const date = item.published ? item.published.toISOString().slice(0, 10) : "";
564
+ return {
565
+ value: String(item.wilmaId),
566
+ name: `${date} ${compactText(item.title)}`.trim(),
567
+ };
568
+ });
569
+ choices.unshift({ value: "back", name: "Back" });
570
+ const selected = await selectOrCancel({
571
+ message: "Read which news item?",
572
+ choices,
573
+ });
574
+ if (!selected || selected === "back")
575
+ return;
576
+ await outputNewsItem(client, Number(selected), false);
577
+ }
578
+ async function selectMessageToRead(client, folder) {
579
+ const messages = await client.messages.list(folder);
580
+ if (!messages.length)
581
+ return;
582
+ const choices = messages.slice(0, 30).map((msg) => {
583
+ const date = msg.sentAt.toISOString().slice(0, 10);
584
+ return {
585
+ value: String(msg.wilmaId),
586
+ name: `${date} ${compactText(msg.subject)}`.trim(),
587
+ };
588
+ });
589
+ choices.unshift({ value: "back", name: "Back" });
590
+ const selected = await selectOrCancel({
591
+ message: "Read which message?",
592
+ choices,
593
+ });
594
+ if (!selected || selected === "back")
595
+ return;
596
+ await outputMessageItem(client, Number(selected), false);
597
+ }
598
+ async function selectOrCancel(opts) {
599
+ try {
600
+ return (await select(opts));
601
+ }
602
+ catch (err) {
603
+ if (isPromptCancel(err)) {
604
+ return null;
605
+ }
606
+ throw err;
607
+ }
608
+ }
609
+ async function inputOrCancel(opts) {
610
+ try {
611
+ return await input(opts);
612
+ }
613
+ catch (err) {
614
+ if (isPromptCancel(err)) {
615
+ return null;
616
+ }
617
+ throw err;
618
+ }
619
+ }
620
+ async function passwordOrCancel(opts) {
621
+ try {
622
+ return await password(opts);
623
+ }
624
+ catch (err) {
625
+ if (isPromptCancel(err)) {
626
+ return null;
627
+ }
628
+ throw err;
629
+ }
630
+ }
631
+ function isPromptCancel(err) {
632
+ if (!err)
633
+ return false;
634
+ const message = err instanceof Error ? err.message : String(err);
635
+ const name = err instanceof Error ? err.name : "";
636
+ return (name === "AbortError" ||
637
+ name === "ExitPromptError" ||
638
+ message.includes("User force closed the prompt") ||
639
+ message.toLowerCase().includes("cancel") ||
640
+ message.toLowerCase().includes("aborted"));
641
+ }
642
+ function compactText(value) {
643
+ return (value ?? "").replace(/\s+/g, " ").trim();
644
+ }
645
+ function formatContent(value) {
646
+ const lines = value
647
+ .replace(/\r/g, "")
648
+ .split("\n")
649
+ .map((line) => line.trim())
650
+ .filter((line, index, arr) => {
651
+ if (line !== "")
652
+ return true;
653
+ // Keep a single blank line between blocks
654
+ return arr[index - 1] !== "";
655
+ });
656
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
657
+ }
658
+ async function getStudentsForCommand(profile, config) {
659
+ const stored = config.profiles.find((p) => p.id === config.lastProfileId);
660
+ const fresh = await WilmaClient.listStudents(profile);
661
+ if (stored) {
662
+ stored.students = fresh.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
663
+ await saveConfig(config);
664
+ }
665
+ return fresh;
666
+ }
667
+ async function resolveStudentForFlags(profile, config, student) {
668
+ if (student) {
669
+ const students = await getStudentsForCommand(profile, config);
670
+ const exact = students.find((s) => s.studentNumber === student);
671
+ if (exact)
672
+ return exact;
673
+ const match = students.find((s) => fuzzyIncludes(s.name, student));
674
+ if (match)
675
+ return match;
676
+ return { studentNumber: student, name: student, href: `/!${student}/` };
677
+ }
678
+ const stored = config.profiles.find((p) => p.id === config.lastProfileId);
679
+ if (stored?.lastStudentNumber) {
680
+ return {
681
+ studentNumber: stored.lastStudentNumber,
682
+ name: stored.lastStudentName ?? stored.lastStudentNumber,
683
+ href: `/!${stored.lastStudentNumber}/`,
684
+ };
685
+ }
686
+ const students = await getStudentsForCommand(profile, config);
687
+ return students[0] ?? null;
688
+ }
689
+ async function outputAllNews(profile, config, limit, json) {
690
+ const students = await getStudentsForCommand(profile, config);
691
+ const results = [];
692
+ for (const student of students) {
693
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
694
+ const news = await client.news.list();
695
+ results.push({ student, items: news.slice(0, limit) });
696
+ }
697
+ if (json) {
698
+ console.log(JSON.stringify({ students: results }, null, 2));
699
+ return;
700
+ }
701
+ results.forEach((entry) => {
702
+ console.log(`\n[${entry.student.name}]`);
703
+ entry.items.forEach((item) => {
704
+ const date = item.published ? item.published.toISOString().slice(0, 10) : "";
705
+ console.log(`- ${date} ${item.title} (id:${item.wilmaId})`.trim());
706
+ });
707
+ });
708
+ }
709
+ async function outputAllMessages(profile, config, opts) {
710
+ const students = await getStudentsForCommand(profile, config);
711
+ const results = [];
712
+ for (const student of students) {
713
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
714
+ const messages = await client.messages.list(opts.folder);
715
+ results.push({ student, items: messages.slice(0, opts.limit) });
716
+ }
717
+ if (opts.json) {
718
+ console.log(JSON.stringify({ students: results }, null, 2));
719
+ return;
720
+ }
721
+ results.forEach((entry) => {
722
+ console.log(`\n[${entry.student.name}]`);
723
+ entry.items.forEach((msg) => {
724
+ const date = msg.sentAt.toISOString().slice(0, 10);
725
+ console.log(`- ${date} ${msg.subject} (id:${msg.wilmaId})`);
726
+ });
727
+ });
728
+ }
729
+ async function outputAllExams(profile, config, limit, json) {
730
+ const students = await getStudentsForCommand(profile, config);
731
+ const results = [];
732
+ for (const student of students) {
733
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
734
+ const exams = await client.exams.list();
735
+ results.push({ student, items: exams.slice(0, limit) });
736
+ }
737
+ if (json) {
738
+ console.log(JSON.stringify({ students: results }, null, 2));
739
+ return;
740
+ }
741
+ results.forEach((entry) => {
742
+ console.log(`\n[${entry.student.name}]`);
743
+ entry.items.forEach((exam) => {
744
+ const date = exam.examDate.toISOString().slice(0, 10);
745
+ console.log(`- ${date} ${exam.subject}`);
746
+ });
747
+ });
748
+ }
749
+ async function printStudentSelectionHelp(profile, config) {
750
+ const students = await getStudentsForCommand(profile, config);
751
+ console.error("Multiple students found. Use --student <id|name> or --all-students.");
752
+ students.forEach((s) => {
753
+ console.error(`- ${s.studentNumber} ${s.name}`);
754
+ });
755
+ }
756
+ main().catch((err) => {
757
+ if (isPromptCancel(err)) {
758
+ process.exit(0);
759
+ }
760
+ console.error("CLI error:", err instanceof Error ? err.message : err);
761
+ process.exit(1);
762
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@wilm-ai/wilma-cli",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "wilmai": "dist/index.js",
9
+ "wilma": "dist/index.js"
10
+ },
11
+ "dependencies": {
12
+ "@inquirer/prompts": "^5.3.8",
13
+ "@wilm-ai/wilma-client": "^0.0.1"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.6.3"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "lint": "echo 'add lint'",
28
+ "test": "echo 'add tests'",
29
+ "start": "node dist/index.js",
30
+ "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
31
+ }
32
+ }