@wilm-ai/wilma-cli 2.1.2 → 2.3.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.
package/README.md CHANGED
@@ -18,6 +18,12 @@ Opens a one-time login page in your browser: pick your school's Wilma and log in
18
18
 
19
19
  Without a browser: `wilma login --tenant <url|city> --username <name>` with the password in `WILMA_PASSWORD` or piped via `--password-stdin`. Or set `WILMA_TENANT`, `WILMA_USERNAME`, `WILMA_PASSWORD` (and `WILMA_TOTP_SECRET`) and skip the saved login.
20
20
 
21
+ ## Log out
22
+ ```bash
23
+ wilma logout # every saved login; `wilma logout 2` for one (numbers from `wilma accounts`)
24
+ ```
25
+ Wilma allows one session per account: when WilmAI logs in, Wilma logs you out in your browser, and the other way round. `wilma logout` ends WilmAI's session, as Wilma's own Log out does, so you can use Wilma in the browser. The logins stay saved, and the next command logs in again. `wilma config clear` deletes the logins as well.
26
+
21
27
  ## Use it
22
28
  ```bash
23
29
  wilma summary # the daily briefing for every child — start here
@@ -53,7 +59,7 @@ Running `wilma` without arguments in a terminal opens an interactive menu.
53
59
  ```bash
54
60
  wilma mcp
55
61
  ```
56
- Starts a stdio MCP server with read-only Wilma tools (`wilma_summary`, `wilma_schedule`, `wilma_read_news`, …) that return the same data as the commands. Add it to any MCP client as `npx -y @wilm-ai/wilma-cli@2 mcp`. Setup guides for Claude, ChatGPT, Grok Bot and OpenClaw: https://wilm.ai
62
+ Starts a stdio MCP server with read-only Wilma tools (`wilma_summary`, `wilma_schedule`, `wilma_read_news`, …) that return the same data as the commands, plus `wilma_logout`, which ends WilmAI's Wilma session like `wilma logout`. Add it to any MCP client as `npx -y @wilm-ai/wilma-cli@2 mcp`. Setup guides for Claude, ChatGPT, Grok Bot and OpenClaw: https://wilm.ai
57
63
 
58
64
  ## Bulletin attachments
59
65
  `wilma news <id>` lists every link in a bulletin (`resources`). Any resource can be downloaded with `wilma news <id> download <resource> [--output <directory>]` (the resource id also accepts a bare number, e.g. `1` for `resource-1`). Wilma-hosted files download through the session; external links are fetched without your Wilma login, and only from public websites. The result's `status` says what happened: `downloaded` (use the returned `path`) or `not_a_file` when the link answered with a web page (for example a sharing link that needs signing in — open it in a browser instead). `--output` defaults to the current directory; existing files are never overwritten.
@@ -62,7 +68,7 @@ Starts a stdio MCP server with read-only Wilma tools (`wilma_summary`, `wilma_sc
62
68
  Children at schools on different Wilmas need one login per Wilma. Run `wilma login` again (or press "Add another Wilma" on the login page); commands then cover every child, and each child carries the `wilma` it belongs to.
63
69
  ```bash
64
70
  wilma accounts # saved logins, numbered
65
- wilma accounts remove <number> # or the Wilma name or username
71
+ wilma accounts remove <number> # or the Wilma name or username; also ends that login's Wilma session
66
72
  ```
67
73
 
68
74
  ## Find your Wilma
@@ -74,6 +80,7 @@ wilma find-school <city or school>
74
80
  ```bash
75
81
  wilma update # when a newer version is out, a command says so on stderr, at most once a day (WILMAI_NO_UPDATE_CHECK=1 turns it off)
76
82
  wilma config clear # deletes every saved login and session
83
+ wilma home # where to download WilmAI Home, the Wilma app for Mac and Windows
77
84
  wilma --version
78
85
  ```
79
86
 
@@ -86,7 +93,7 @@ If your Wilma account has MFA/TOTP enabled:
86
93
  **Without a saved login:** set `WILMA_TOTP_SECRET` along with the other `WILMA_*` variables. `--totp-secret <key>` also works, but a key on the command line ends up in shell history and is visible to other programs on the computer.
87
94
 
88
95
  ## Config
89
- Logins are saved in `~/.config/wilmai/config.json` (or `$XDG_CONFIG_HOME/wilmai/config.json`; override with `WILMAI_CONFIG_PATH`) and the current Wilma sessions in `wilmai-sessions.json` next to it, both readable only by you. `wilma config clear` deletes both.
96
+ Logins are saved in `~/.config/wilmai/config.json` (or `$XDG_CONFIG_HOME/wilmai/config.json`; override with `WILMAI_CONFIG_PATH`) and the current Wilma sessions in `wilmai-sessions.json` next to it, both readable only by you. `wilma logout` ends the sessions and deletes them, keeping the logins; `wilma config clear` deletes both.
90
97
 
91
98
  ## Troubleshooting: TLS errors on a managed laptop
92
99
 
package/dist/access.d.ts CHANGED
@@ -10,3 +10,17 @@ export declare function createAccess(accounts: ActiveAccount[], opts?: {
10
10
  debug?: boolean;
11
11
  totpSecret?: string;
12
12
  }): WilmaAccess;
13
+ /**
14
+ * End WilmAI's Wilma session for each login (see logoutSession), for `wilma
15
+ * logout` and the wilma_logout tool. The logins stay saved. Tries every login
16
+ * (different Wilmas don't affect each other), then throws the first failure.
17
+ */
18
+ export declare function logoutAccounts(accounts: ActiveAccount[]): Promise<{
19
+ status: "ok";
20
+ logins: {
21
+ wilma: string;
22
+ username: string;
23
+ session: "none" | "ended";
24
+ }[];
25
+ hint: string;
26
+ }>;
package/dist/access.js CHANGED
@@ -1,4 +1,4 @@
1
- import { WilmaAccess } from "./agent-data.js";
1
+ import { WilmaAccess, logoutSession } from "./agent-data.js";
2
2
  import { loadConfig, saveConfig } from "./config.js";
3
3
  import { mfaCallbackFor } from "./credentials.js";
4
4
  /**
@@ -31,3 +31,26 @@ export function createAccess(accounts, opts = {}) {
31
31
  },
32
32
  })));
33
33
  }
34
+ /**
35
+ * End WilmAI's Wilma session for each login (see logoutSession), for `wilma
36
+ * logout` and the wilma_logout tool. The logins stay saved. Tries every login
37
+ * (different Wilmas don't affect each other), then throws the first failure.
38
+ */
39
+ export async function logoutAccounts(accounts) {
40
+ const results = await Promise.allSettled(accounts.map((account) => logoutSession(account.profile)));
41
+ const failed = results.find((result) => result.status === "rejected");
42
+ if (failed)
43
+ throw failed.reason;
44
+ const logins = accounts.map((account, index) => ({
45
+ wilma: account.stored?.tenantName ?? account.profile.baseUrl,
46
+ username: account.profile.username,
47
+ session: results[index].value ? "ended" : "none",
48
+ }));
49
+ return {
50
+ status: "ok",
51
+ logins,
52
+ hint: logins.length
53
+ ? "The logins stay saved. The next time WilmAI reads Wilma it logs in again, and that ends the user's Wilma session in the browser."
54
+ : "No saved Wilma login, so there was nothing to log out.",
55
+ };
56
+ }
@@ -73,8 +73,23 @@ export declare function useSessionStore(store: SessionStore | undefined): void;
73
73
  * verification, no second code).
74
74
  */
75
75
  export declare function adoptSession(profile: WilmaProfile, client: WilmaClient): void;
76
- /** Save any sessions still being written (call before a short-lived process exits). */
76
+ /**
77
+ * Finish saving sessions (call before a short-lived process exits). Only
78
+ * logins made here are saved, never an older session over a newer one
79
+ * another process saved meanwhile.
80
+ */
77
81
  export declare function flushSessions(): Promise<void>;
82
+ /**
83
+ * End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
84
+ * it, in this process and in the session store, so nothing continues it. The
85
+ * login itself stays saved; the next command or tool call logs in again.
86
+ * Never logs in. Returns true when a live session was ended, false when there
87
+ * was none. If Wilma can't be reached, throws and keeps the saved session, so
88
+ * logging out can be tried again.
89
+ */
90
+ export declare function logoutSession(profile: WilmaProfile): Promise<boolean>;
91
+ /** Forget one login's session without ending it in Wilma (when the login itself is removed). */
92
+ export declare function forgetSession(profile: WilmaProfile): Promise<void>;
78
93
  /**
79
94
  * Read-only Wilma access for agent tools (MCP). Covers every Wilma login the
80
95
  * family has saved; every method defaults to all children, and a student
@@ -237,14 +237,29 @@ let sessionStore;
237
237
  export function useSessionStore(store) {
238
238
  sessionStore = store;
239
239
  }
240
- function rememberSession(key, client) {
240
+ /**
241
+ * Save the session for the other processes, and share theirs: when Wilma ends
242
+ * this one, the client first continues the newest session another process
243
+ * saved (a CLI command, an assistant's connector, the desktop app), and logs
244
+ * in only when that one has ended too. Each login ends the others, so this
245
+ * keeps WilmAI's programs from logging each other out.
246
+ */
247
+ function rememberSession(key, client, opts) {
241
248
  const store = sessionStore;
242
249
  if (!store)
243
250
  return;
244
- const save = () => store.save(key, client.exportSession()).catch(() => { });
245
- client.onLogin(() => void save());
246
- void save();
251
+ const save = (state) => {
252
+ const saving = store.save(key, state).catch(() => { });
253
+ pendingSaves.add(saving);
254
+ void saving.finally(() => pendingSaves.delete(saving));
255
+ return saving;
256
+ };
257
+ client.useSharedSession({ load: () => store.load(key), save });
258
+ // A resumed session is already saved (and another process may have saved a newer one since).
259
+ if (opts.fresh)
260
+ void save(client.exportSession());
247
261
  }
262
+ const pendingSaves = new Set();
248
263
  /** Identifies one Wilma account and password (a changed password starts fresh). */
249
264
  function poolKey(profile) {
250
265
  const { baseUrl, username, password } = profile;
@@ -261,17 +276,50 @@ function poolKey(profile) {
261
276
  export function adoptSession(profile, client) {
262
277
  const key = poolKey(profile);
263
278
  pool.set(key, { client: Promise.resolve(client), lastUsed: Date.now() });
264
- rememberSession(key, client);
279
+ rememberSession(key, client, { fresh: true });
265
280
  }
266
- /** Save any sessions still being written (call before a short-lived process exits). */
281
+ /**
282
+ * Finish saving sessions (call before a short-lived process exits). Only
283
+ * logins made here are saved, never an older session over a newer one
284
+ * another process saved meanwhile.
285
+ */
267
286
  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
- }
287
+ for (const entry of pool.values())
288
+ await entry.client.catch(() => null);
289
+ await Promise.all([...pendingSaves]);
290
+ }
291
+ /**
292
+ * End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
293
+ * it, in this process and in the session store, so nothing continues it. The
294
+ * login itself stays saved; the next command or tool call logs in again.
295
+ * Never logs in. Returns true when a live session was ended, false when there
296
+ * was none. If Wilma can't be reached, throws and keeps the saved session, so
297
+ * logging out can be tried again.
298
+ */
299
+ export async function logoutSession(profile) {
300
+ const key = poolKey(profile);
301
+ const entry = pool.get(key);
302
+ // The session in use here (a login in progress finishes first) and the saved
303
+ // one, which another process may have replaced since. At most one is live.
304
+ const pooled = entry ? await entry.client.catch(() => null) : null;
305
+ const pooledState = pooled?.exportSession();
306
+ const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
307
+ let ended = pooled ? await pooled.logout() : false;
308
+ if (pool.get(key) === entry)
309
+ pool.delete(key);
310
+ if (saved && saved !== pooledState) {
311
+ const resumed = WilmaClient.resume({ ...profile, studentNumber: null }, saved);
312
+ if (resumed && (await resumed.logout()))
313
+ ended = true;
314
+ }
315
+ await sessionStore?.remove(key);
316
+ return ended;
317
+ }
318
+ /** Forget one login's session without ending it in Wilma (when the login itself is removed). */
319
+ export async function forgetSession(profile) {
320
+ const key = poolKey(profile);
321
+ pool.delete(key);
322
+ await sessionStore?.remove(key);
275
323
  }
276
324
  function pooledSession(account) {
277
325
  const now = Date.now();
@@ -287,9 +335,9 @@ function pooledSession(account) {
287
335
  // A saved session first; it logs in again by itself if Wilma has ended it.
288
336
  const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
289
337
  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;
338
+ const client = resumed ?? (await WilmaClient.login(profile, account.mfa));
339
+ rememberSession(key, client, { fresh: !resumed });
340
+ return client;
293
341
  })();
294
342
  const created = { client, lastUsed: now };
295
343
  entry = created;
package/dist/cli-args.js CHANGED
@@ -13,7 +13,7 @@ const DATA_COMMANDS = [
13
13
  "printouts",
14
14
  "students",
15
15
  ];
16
- const OTHER_COMMANDS = ["accounts", "find-school", "login", "mcp", "update", "config", "help"];
16
+ const OTHER_COMMANDS = ["accounts", "find-school", "home", "login", "logout", "mcp", "update", "config", "help"];
17
17
  const ALIASES = {
18
18
  kids: "students",
19
19
  tenants: "find-school",
@@ -226,6 +226,8 @@ export function parseCliArgs(argv) {
226
226
  return parsed("remove", args.slice(1));
227
227
  noMore(args);
228
228
  return parsed(null);
229
+ case "logout":
230
+ return parsed(null, args);
229
231
  case "config":
230
232
  if (args[0] !== "clear" || args.length > 1)
231
233
  throw usage('Expected "wilma config clear".');
package/dist/cli-help.js CHANGED
@@ -68,14 +68,26 @@ const COMMAND_HELP = {
68
68
  },
69
69
  accounts: {
70
70
  usage: ["wilma accounts", "wilma accounts remove <number|Wilma name|username>"],
71
- about: "Saved Wilma logins, numbered (one per Wilma the children use); remove one.",
71
+ about: "Saved Wilma logins, numbered (one per Wilma the children use). `remove` deletes one and ends its Wilma session; the other logins keep theirs.",
72
72
  examples: ["wilma accounts", "wilma accounts remove 2"],
73
73
  },
74
+ logout: {
75
+ usage: ["wilma logout [<number|Wilma name|username>]"],
76
+ about: "End WilmAI's Wilma session, as Wilma's own Log out does, so you can use Wilma in the browser (Wilma allows one session per account). Every saved login by default, or one by its number from `wilma accounts`. The logins stay saved: the next command logs in again, and that ends your Wilma session in the browser. To delete the logins as well, use `wilma config clear`.",
77
+ examples: ["wilma logout", "wilma logout 2"],
78
+ output: '{ status: "ok", logins: [{ wilma, username, session: "ended" | "none" }], hint }. "none": there was no open session to end.',
79
+ },
74
80
  "find-school": {
75
81
  usage: ["wilma find-school <city or school>"],
76
82
  about: "Find a Wilma address. Most city schools share their city's Wilma (big cities don't list each school); colleges, private schools and many small municipalities' schools have their own.",
77
83
  examples: ["wilma find-school tampere", "wilma find-school kalevan lukio"],
78
84
  },
85
+ home: {
86
+ usage: ["wilma home"],
87
+ about: "Where to download WilmAI Home, a Wilma app for Mac and Windows from the same project, and what its first start asks for. Mention it when the parent or student wants a Wilma app of their own. Needs no login.",
88
+ examples: ["wilma home"],
89
+ output: '{ app, about, page, downloads: [{ platform: "macos" | "windows", requires, url, firstStart }], logins, hint }',
90
+ },
79
91
  mcp: {
80
92
  usage: ["wilma mcp"],
81
93
  about: "Run the MCP server over stdio for Claude, ChatGPT and other assistants (the same data as these commands, as tools).",
@@ -98,8 +110,10 @@ const OVERVIEW = [
98
110
  ["students", "the children"],
99
111
  ["login", "log in (opens a page in the browser)"],
100
112
  ["accounts", "saved logins; accounts remove <n>"],
113
+ ["logout", "end WilmAI's Wilma session (the logins stay saved)"],
101
114
  ["find-school <query>", "find a Wilma address by city or school"],
102
115
  ["mcp", "MCP server for AI assistants"],
116
+ ["home", "WilmAI Home, the desktop app: where to download it"],
103
117
  ["update", "update the CLI"],
104
118
  ];
105
119
  export function generalHelp() {
@@ -8,7 +8,8 @@ function formatContent(value) {
8
8
  const lines = value
9
9
  .replace(/\r/g, "")
10
10
  .split("\n")
11
- .map((line) => line.trim())
11
+ // Leading spaces stay: they indent nested list items.
12
+ .map((line) => line.trimEnd())
12
13
  .filter((line, index, arr) => line !== "" || arr[index - 1] !== "");
13
14
  return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
14
15
  }
package/dist/home.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface HomeInfo {
2
+ app: string;
3
+ about: string;
4
+ page: string;
5
+ downloads: {
6
+ platform: "macos" | "windows";
7
+ requires: string;
8
+ url: string;
9
+ firstStart: string;
10
+ }[];
11
+ logins: string;
12
+ hint: string;
13
+ }
14
+ export declare const HOME: HomeInfo;
15
+ export declare function homeText(info?: HomeInfo): string;
package/dist/home.js ADDED
@@ -0,0 +1,39 @@
1
+ /*
2
+ * `wilma home`: where to get WilmAI Home, the desktop app. The addresses are
3
+ * wilm.ai's own, which forward to the current installer, so a new Home release
4
+ * needs no new CLI.
5
+ */
6
+ export const HOME = {
7
+ app: "WilmAI Home (beta)",
8
+ about: "A Wilma app for your computer, from the same project: today's lessons, messages, homework, exams and grades in one view, for students and parents. Free and open source; AI is optional.",
9
+ page: "https://wilm.ai/home",
10
+ downloads: [
11
+ {
12
+ platform: "macos",
13
+ requires: "Apple Silicon (M1 or newer), macOS 13.3 or later",
14
+ url: "https://wilm.ai/get/mac",
15
+ firstStart: "The app isn't signed by Apple yet, so macOS stops it the first time: choose Done, then System Settings → Privacy & Security → Open Anyway.",
16
+ },
17
+ {
18
+ platform: "windows",
19
+ requires: "Windows 10 or 11, 64-bit",
20
+ url: "https://wilm.ai/get/windows",
21
+ firstStart: "The installer isn't signed yet. Windows warns before the first start: choose More info, then Run anyway. A school computer may not allow it.",
22
+ },
23
+ ],
24
+ logins: "On a computer where `wilma login` was run, Home offers those logins with one click and shares WilmAI's Wilma session, so they don't log each other out.",
25
+ hint: "Give the user the link for their computer; downloading and installing the app is their step.",
26
+ };
27
+ export function homeText(info = HOME) {
28
+ const names = { macos: "Mac", windows: "Windows" };
29
+ const width = Math.max(...info.downloads.map((d) => d.url.length));
30
+ return [
31
+ info.app,
32
+ info.about,
33
+ "",
34
+ ...info.downloads.flatMap((d) => [` ${names[d.platform].padEnd(8)} ${d.url.padEnd(width)} ${d.requires}`, ` ${"".padEnd(8)} ${d.firstStart}`]),
35
+ "",
36
+ info.logins,
37
+ `More: ${info.page}`,
38
+ ].join("\n");
39
+ }
package/dist/index.js CHANGED
@@ -8,13 +8,14 @@ import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { APIError, AuthenticationError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
10
10
  import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig } from "./config.js";
11
- import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, useSessionStore, } from "./agent-data.js";
12
- import { createAccess } from "./access.js";
11
+ import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, forgetSession, logoutSession, useSessionStore, } from "./agent-data.js";
12
+ import { createAccess, logoutAccounts } from "./access.js";
13
13
  import { parseCliArgs } from "./cli-args.js";
14
14
  import { commandHelp, generalHelp } from "./cli-help.js";
15
15
  import { compactText, formatText, messageLine, newsLine } from "./cli-output.js";
16
16
  import { ENV_VARS, isMfaFailure, mfaCallbackFor, resolveAccounts, saveLogin, verifyLoginSession } from "./credentials.js";
17
17
  import { createUniqueDownloadFile } from "./downloads.js";
18
+ import { HOME, homeText } from "./home.js";
18
19
  import { openBrowser, startLoginServer } from "./login-server.js";
19
20
  import { runMcpServer } from "./mcp.js";
20
21
  import { claimNotice, cliUpdateNotice, isNewerVersion, latestWithin, startUpdateCheck, updateChecksEnabled } from "./update-check.js";
@@ -117,9 +118,15 @@ async function runCommand(argv) {
117
118
  case "accounts":
118
119
  await handleAccounts(action, args, out);
119
120
  return;
121
+ case "logout":
122
+ await handleLogout(args.join(" "), flags, out);
123
+ return;
120
124
  case "find-school":
121
125
  await handleFindSchool(args.join(" "), out);
122
126
  return;
127
+ case "home":
128
+ out.data(HOME, () => homeText());
129
+ return;
123
130
  case "update":
124
131
  await handleUpdate();
125
132
  return;
@@ -680,24 +687,18 @@ const HEADLESS_LOGIN_HELP = [
680
687
  async function handleAccounts(action, args, out) {
681
688
  const config = await loadConfig();
682
689
  if (action === "remove") {
683
- const target = args.join(" ").trim().toLowerCase();
684
- if (!target)
685
- throw new WilmaAiError("invalid_argument", "Which login? Use the number from `wilma accounts`, the Wilma name or the username.");
686
- const byNumber = /^\d+$/.test(target) ? config.profiles[Number(target) - 1] : undefined;
687
- const matches = byNumber
688
- ? [byNumber]
689
- : config.profiles.filter((p) => [p.tenantName ?? "", p.tenantUrl, p.username].some((v) => v.toLowerCase() === target));
690
- if (matches.length !== 1) {
691
- throw new WilmaAiError(matches.length ? "ambiguous_account" : "unknown_account", matches.length ? `"${target}" matches several logins; use its number.` : `No saved login matches "${target}".`);
692
- }
693
- const removed = matches[0];
690
+ const removed = findSavedLogin(config.profiles, args.join(" "));
694
691
  config.profiles = config.profiles.filter((p) => p.id !== removed.id);
695
692
  if (config.lastProfileId === removed.id)
696
693
  config.lastProfileId = config.profiles[0]?.id ?? null;
697
694
  await saveConfig(config);
698
- await clearSessions();
699
- const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username };
700
- out.data(result, () => `Removed ${removed.username} @ ${result.wilma}.`);
695
+ const session = await endRemovedSession(removed);
696
+ const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username, ...session };
697
+ out.data(result, () => session.session === "ended"
698
+ ? `Removed ${removed.username} @ ${result.wilma} and ended its Wilma session.`
699
+ : session.session === "not_ended"
700
+ ? `Removed ${removed.username} @ ${result.wilma}. Its Wilma session couldn't be ended (${session.message}); Wilma ends it when it expires.`
701
+ : `Removed ${removed.username} @ ${result.wilma}.`);
701
702
  return;
702
703
  }
703
704
  const accounts = config.profiles.map((p, i) => ({
@@ -715,6 +716,64 @@ async function handleAccounts(action, args, out) {
715
716
  ].join("\n")
716
717
  : "No saved Wilma logins. Run `wilma login`.");
717
718
  }
719
+ /**
720
+ * End a removed login's Wilma session and forget it; the other logins keep
721
+ * theirs. The login is removed even when Wilma can't be reached: its saved
722
+ * session is forgotten anyway, and Wilma ends the session when it expires.
723
+ */
724
+ async function endRemovedSession(removed) {
725
+ const password = revealSecret(removed.passwordObfuscated);
726
+ if (!password)
727
+ return { session: "none" };
728
+ const profile = { baseUrl: removed.tenantUrl, username: removed.username, password };
729
+ try {
730
+ return { session: (await logoutSession(profile)) ? "ended" : "none" };
731
+ }
732
+ catch (err) {
733
+ await forgetSession(profile);
734
+ return { session: "not_ended", message: err instanceof Error ? err.message : String(err) };
735
+ }
736
+ }
737
+ /** A saved login by its number in `wilma accounts`, its Wilma's name or address, or its username. */
738
+ function findSavedLogin(profiles, rawTarget) {
739
+ const target = rawTarget.trim().toLowerCase();
740
+ if (!target)
741
+ throw new WilmaAiError("invalid_argument", "Which login? Use the number from `wilma accounts`, the Wilma name or the username.");
742
+ const byNumber = /^\d+$/.test(target) ? profiles[Number(target) - 1] : undefined;
743
+ const matches = byNumber
744
+ ? [byNumber]
745
+ : profiles.filter((p) => [p.tenantName ?? "", p.tenantUrl, p.username].some((v) => v.toLowerCase() === target));
746
+ if (matches.length !== 1) {
747
+ throw new WilmaAiError(matches.length ? "ambiguous_account" : "unknown_account", matches.length ? `"${target}" matches several logins; use its number.` : `No saved login matches "${target}".`);
748
+ }
749
+ return matches[0];
750
+ }
751
+ /**
752
+ * End WilmAI's Wilma sessions so the parent can use Wilma in the browser:
753
+ * every login commands use, or one saved login. The logins stay saved.
754
+ */
755
+ async function handleLogout(target, flags, out) {
756
+ if (flags.student) {
757
+ throw new WilmaAiError("invalid_argument", "A Wilma session belongs to a login, not to one child. Use `wilma logout <number>` with the number from `wilma accounts`, or no number for every login.");
758
+ }
759
+ const config = await loadConfig();
760
+ // The logins commands use (the environment's, or every saved one); a picked
761
+ // login is one of the saved ones, numbered as in `wilma accounts`.
762
+ let accounts = await resolveAccounts(config, target ? {} : process.env);
763
+ if (target) {
764
+ const login = findSavedLogin(config.profiles, target);
765
+ const account = (p) => `${normalizeTenantUrl(p.tenantUrl)}|${p.username.toLowerCase()}`;
766
+ accounts = accounts.filter((a) => a.stored && account(a.stored) === account(login));
767
+ }
768
+ const result = await logoutAccounts(accounts);
769
+ out.data(result, () => result.logins.length
770
+ ? [
771
+ ...result.logins.map((l) => `${l.session === "ended" ? "Logged out" : "No open session"}: ${l.username} @ ${l.wilma}`),
772
+ "",
773
+ "The logins stay saved. The next wilma command logs in again, and that ends your Wilma session in the browser.",
774
+ ].join("\n")
775
+ : result.hint);
776
+ }
718
777
  async function readStdin() {
719
778
  const chunks = [];
720
779
  for await (const chunk of process.stdin)
@@ -1,7 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
3
  import type { FetchedAttachment, WilmaAccess } from "./agent-data.js";
4
- export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default; pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish \"ke 7.10.\" (with the final period), English \"Wed 7 Oct\".\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.\n- If a result ends with an \"Update note\", answer the question first, then pass the note on in one sentence.";
4
+ export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default; pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish \"ke 7.10.\" (with the final period), English \"Wed 7 Oct\".\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.\n- Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.\n- If a result ends with an \"Update note\", answer the question first, then pass the note on in one sentence.";
5
5
  export declare const READ_ONLY: {
6
6
  readonly readOnlyHint: true;
7
7
  readonly destructiveHint: false;
package/dist/mcp-tools.js CHANGED
@@ -12,6 +12,7 @@ export const INSTRUCTIONS = `Read-only access to Finland's Wilma school system f
12
12
  - Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.
13
13
  - Teachers' feedback ("forgot books", "did well") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.
14
14
  - If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.
15
+ - Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.
15
16
  - If a result ends with an "Update note", answer the question first, then pass the note on in one sentence.`;
16
17
  export const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
17
18
  const INLINE_ATTACHMENT_LIMIT = 10 * 1024 * 1024;
package/dist/mcp.js CHANGED
@@ -5,8 +5,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { AuthenticationError } from "@wilm-ai/wilma-client";
7
7
  import { useSessionStore } from "./agent-data.js";
8
- import { fileSessionStore } from "./session-store.js";
9
- import { createAccess } from "./access.js";
8
+ import { fileSessionStore, nameSessionOwner } from "./session-store.js";
9
+ import { createAccess, logoutAccounts } from "./access.js";
10
10
  import { loadConfig } from "./config.js";
11
11
  import { ENV_VARS, isMfaFailure, resolveAccounts } from "./credentials.js";
12
12
  import { createUniqueDownloadFile } from "./downloads.js";
@@ -161,6 +161,20 @@ function createWilmaMcpServer(version) {
161
161
  inputSchema: {},
162
162
  annotations: { title: "Log in to Wilma", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
163
163
  }, async () => startBrowserLogin(true));
164
+ server.registerTool("wilma_logout", {
165
+ title: "Log out of Wilma",
166
+ description: "End WilmAI's Wilma session, as Wilma's own Log out does. Wilma allows one session per account, so use this when the user wants to use Wilma in their browser or asks to log out. The login stays saved: the next Wilma tool call logs in again, and that ends the user's session in the browser, so don't call other Wilma tools afterwards unless the user asks.",
167
+ inputSchema: {},
168
+ annotations: { title: "Log out of Wilma", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
169
+ }, async () => {
170
+ try {
171
+ return json(await logoutAccounts(await resolveAccounts(await loadConfig())));
172
+ }
173
+ catch (err) {
174
+ const message = err instanceof Error ? err.message : String(err);
175
+ return textResult(["Couldn't log out of Wilma; an open session was kept, so this can be tried again.", message].join("\n"), true);
176
+ }
177
+ });
164
178
  return server;
165
179
  }
166
180
  export async function runMcpServer(version) {
@@ -168,6 +182,7 @@ export async function runMcpServer(version) {
168
182
  console.log = (...args) => console.error(...args);
169
183
  console.info = (...args) => console.error(...args);
170
184
  // Share saved sessions with the CLI: no login per process, no extra codes.
185
+ nameSessionOwner("mcp");
171
186
  useSessionStore(fileSessionStore);
172
187
  // Checked once a day at most; npx already runs the newest version.
173
188
  if (updateChecksEnabled())
@@ -1,7 +1,16 @@
1
1
  export interface SessionStore {
2
2
  load(key: string): Promise<string | null>;
3
3
  save(key: string, state: string): Promise<void>;
4
+ /** Forget one session (after logging out). */
5
+ remove(key: string): Promise<void>;
4
6
  }
7
+ /** Name this program in the sessions it saves (the MCP server says "mcp"). */
8
+ export declare function nameSessionOwner(name: string): void;
5
9
  export declare const fileSessionStore: SessionStore;
6
- /** Forget every saved session (after removing logins or clearing the config). */
10
+ /** When one login's session was saved, and by which program (null when there's none to try). */
11
+ export declare function sessionInfo(key: string): Promise<{
12
+ savedAt: number;
13
+ by: string | null;
14
+ } | null>;
15
+ /** Forget every saved session (after clearing the config). */
7
16
  export declare function clearSessions(): Promise<void>;
@@ -16,6 +16,11 @@ import { getConfigPath } from "./config.js";
16
16
  */
17
17
  /** A session older than this is not tried (Wilma ends idle sessions anyway). */
18
18
  const SESSION_TTL_MS = 6 * 60 * 60 * 1000;
19
+ let savedBy = "cli";
20
+ /** Name this program in the sessions it saves (the MCP server says "mcp"). */
21
+ export function nameSessionOwner(name) {
22
+ savedBy = name;
23
+ }
19
24
  function sessionsPath() {
20
25
  return resolve(dirname(getConfigPath()), "wilmai-sessions.json");
21
26
  }
@@ -46,16 +51,35 @@ export const fileSessionStore = {
46
51
  if (now - entry.savedAt > SESSION_TTL_MS)
47
52
  delete all[k];
48
53
  }
49
- all[key] = { state, savedAt: now };
50
- const path = sessionsPath();
51
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
52
- // Temp file + rename: a parallel command never reads half a file.
53
- const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
54
- await writeFile(tmp, JSON.stringify(all), { encoding: "utf-8", mode: 0o600 });
55
- await rename(tmp, path);
54
+ all[key] = { state, savedAt: now, by: savedBy };
55
+ await writeAll(all);
56
+ },
57
+ async remove(key) {
58
+ const all = await readAll();
59
+ if (!(key in all))
60
+ return;
61
+ delete all[key];
62
+ await writeAll(all);
56
63
  },
57
64
  };
58
- /** Forget every saved session (after removing logins or clearing the config). */
65
+ async function writeAll(all) {
66
+ const path = sessionsPath();
67
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
68
+ // Temp file + rename: a parallel command never reads half a file.
69
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
70
+ await writeFile(tmp, JSON.stringify(all), { encoding: "utf-8", mode: 0o600 });
71
+ await rename(tmp, path);
72
+ }
73
+ /** When one login's session was saved, and by which program (null when there's none to try). */
74
+ export async function sessionInfo(key) {
75
+ if (process.env.WILMAI_NO_SESSION_CACHE === "1")
76
+ return null;
77
+ const entry = (await readAll())[key];
78
+ if (!entry || typeof entry.state !== "string" || Date.now() - entry.savedAt > SESSION_TTL_MS)
79
+ return null;
80
+ return { savedAt: entry.savedAt, by: typeof entry.by === "string" ? entry.by : null };
81
+ }
82
+ /** Forget every saved session (after clearing the config). */
59
83
  export async function clearSessions() {
60
84
  await rm(sessionsPath(), { force: true });
61
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-cli",
3
- "version": "2.1.2",
3
+ "version": "2.3.0",
4
4
  "description": "Read your children's Wilma (Finnish school system) from the terminal or an AI assistant: schedule, homework, exams, messages and news.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "build": "tsc -p tsconfig.json",
23
23
  "lint": "tsc -p tsconfig.json --noEmit",
24
- "test": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs && node test/mcp-login.mjs && node test/mfa.mjs && node test/audit.mjs && node test/update-notice.mjs",
24
+ "test": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs && node test/mcp-login.mjs && node test/mfa.mjs && node test/logout.mjs && node test/shared-session.mjs && node test/audit.mjs && node test/update-notice.mjs",
25
25
  "start": "node dist/index.js",
26
26
  "prepack": "pnpm --filter @wilm-ai/wilma-client build && pnpm --filter @wilm-ai/wilma-cli build",
27
27
  "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
@@ -30,8 +30,8 @@
30
30
  "dependencies": {
31
31
  "@inquirer/prompts": "^5.3.8",
32
32
  "@modelcontextprotocol/sdk": "^1.32.0",
33
- "@wilm-ai/wilma-client": "^1.6.1",
34
- "zod": "^4.1.0"
33
+ "@wilm-ai/wilma-client": "^1.7.1",
34
+ "zod": "^4.6.6"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@anthropic-ai/mcpb": "^2.1.2",