@wilm-ai/wilma-cli 2.0.0 → 2.1.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
@@ -72,7 +72,7 @@ wilma find-school <city or school>
72
72
 
73
73
  ## Other
74
74
  ```bash
75
- wilma update
75
+ wilma update # commands say on stderr when a newer version is out (checked once a day; WILMAI_NO_UPDATE_CHECK=1 turns it off)
76
76
  wilma config clear # deletes every saved login and session
77
77
  wilma --version
78
78
  ```
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { select, input, password } from "@inquirer/prompts";
4
- import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { readFile, mkdir } from "node:fs/promises";
5
5
  import { spawn } from "node:child_process";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, resolve } from "node:path";
@@ -17,6 +17,7 @@ import { ENV_VARS, isMfaFailure, mfaCallbackFor, resolveAccounts, saveLogin, ver
17
17
  import { createUniqueDownloadFile } from "./downloads.js";
18
18
  import { openBrowser, startLoginServer } from "./login-server.js";
19
19
  import { runMcpServer } from "./mcp.js";
20
+ import { cliUpdateNotice, isNewerVersion, latestWithin, startUpdateCheck, updateChecksEnabled } from "./update-check.js";
20
21
  import { toAgentJson } from "./output-json.js";
21
22
  import { clearSessions, fileSessionStore } from "./session-store.js";
22
23
  import { normalizeTenantUrl, resolveTenant, searchTenants } from "./tenant-search.js";
@@ -72,8 +73,9 @@ async function main() {
72
73
  }
73
74
  // Commands continue the last Wilma session instead of logging in each time.
74
75
  useSessionStore(fileSessionStore);
75
- // Update notices are for people; agents and scripts skip the check.
76
- const updateCheck = process.stderr.isTTY ? startUpdateCheck() : null;
76
+ // Update notices go to stderr, where both people and agents (OpenClaw and
77
+ // the like) see them. Not under npx, which already runs the newest version.
78
+ const updateCheck = updateChecksEnabled() ? startUpdateCheck() : null;
77
79
  try {
78
80
  await runCommand(argv);
79
81
  if (updateCheck)
@@ -749,100 +751,19 @@ async function handleUpdate() {
749
751
  }
750
752
  console.log("\nUpdate complete.");
751
753
  }
752
- // --- Version check / update notification ---
753
- const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
754
- function getVersionCachePath() {
755
- return resolve(dirname(getConfigPath()), "version-check.json");
756
- }
757
- async function readVersionCache() {
758
- try {
759
- const raw = await readFile(getVersionCachePath(), "utf-8");
760
- return JSON.parse(raw);
761
- }
762
- catch {
763
- return null;
764
- }
765
- }
766
- async function writeVersionCache(cache) {
767
- const cachePath = getVersionCachePath();
768
- await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
769
- await writeFile(cachePath, JSON.stringify(cache), { encoding: "utf-8", mode: 0o600 });
770
- }
771
- /**
772
- * Ask npm for the latest version at most once a day. A failed check counts as
773
- * a check too: agents in sandboxes without internet access shouldn't pay for
774
- * a timeout on every command.
775
- */
776
- function startUpdateCheck() {
777
- const controller = new AbortController();
778
- let cancelled = false;
779
- const result = (async () => {
780
- const cache = await readVersionCache();
781
- if (cache && Date.now() - cache.checkedAt < VERSION_CHECK_INTERVAL_MS) {
782
- return cache.latestVersion;
783
- }
784
- const timeout = setTimeout(() => controller.abort(), 3000);
785
- try {
786
- const response = await fetch("https://registry.npmjs.org/@wilm-ai/wilma-cli/latest", { signal: controller.signal });
787
- const data = response.ok ? (await response.json()) : {};
788
- const latestVersion = data.version ?? cache?.latestVersion ?? null;
789
- await writeVersionCache({ latestVersion, checkedAt: Date.now() });
790
- return latestVersion;
791
- }
792
- catch {
793
- // Cancelled because the command finished: try again next time.
794
- if (!cancelled)
795
- await writeVersionCache({ latestVersion: cache?.latestVersion ?? null, checkedAt: Date.now() }).catch(() => { });
796
- return cache?.latestVersion ?? null;
797
- }
798
- finally {
799
- clearTimeout(timeout);
800
- }
801
- })().catch(() => null);
802
- return {
803
- result,
804
- cancel() {
805
- cancelled = true;
806
- controller.abort();
807
- },
808
- };
809
- }
810
- function isNewerVersion(latest, current) {
811
- const latestParts = latest.split(".").map(Number);
812
- const currentParts = current.split(".").map(Number);
813
- for (let i = 0; i < 3; i++) {
814
- const l = latestParts[i] ?? 0;
815
- const c = currentParts[i] ?? 0;
816
- if (l > c)
817
- return true;
818
- if (l < c)
819
- return false;
820
- }
821
- return false;
822
- }
823
754
  async function showUpdateNotice(check) {
824
- let timer;
825
755
  try {
826
- const latestVersion = await Promise.race([
827
- check.result,
828
- new Promise((resolve) => {
829
- timer = setTimeout(() => resolve(null), 1000);
830
- }),
831
- ]);
756
+ const latestVersion = await latestWithin(check, 1000);
832
757
  if (!latestVersion)
833
758
  return;
834
759
  const currentVersion = await readPackageVersion();
835
760
  if (isNewerVersion(latestVersion, currentVersion)) {
836
- process.stderr.write(`\nUpdate available: ${currentVersion} → ${latestVersion}\n` +
837
- `Run "wilma update" to update.\n`);
761
+ process.stderr.write(`\n${cliUpdateNotice(currentVersion, latestVersion)}\n`);
838
762
  }
839
763
  }
840
764
  catch {
841
765
  // Silently ignore any errors
842
766
  }
843
- finally {
844
- clearTimeout(timer);
845
- }
846
767
  }
847
768
  async function selectOrCancel(opts, clearScreen = true) {
848
769
  if (clearScreen) {
@@ -26,27 +26,8 @@ export declare function openBrowser(url: string, opts?: {
26
26
  export interface LoginPageOptions {
27
27
  nonce: string;
28
28
  apiBase: string;
29
- /** Shown under the form; "{path}" is replaced with the config path. */
30
- privacy?: {
31
- en: string;
32
- fi: string;
33
- };
29
+ /** Shown in the privacy note under the form. */
34
30
  configPath?: string;
35
- /** Extra fields posted with the login (e.g. a sealed OAuth request). */
36
- extra?: Record<string, string>;
37
- /** Label for the finishing button, for hosts that continue elsewhere after the login. */
38
- finishLabel?: {
39
- en: string;
40
- fi: string;
41
- };
42
- /** Shown above the form, e.g. which app is connecting. */
43
- notice?: {
44
- en: string;
45
- fi: string;
46
- };
47
31
  }
48
- /**
49
- * The login page. The login API answers {status: "ok", students, redirect?};
50
- * with a redirect the page navigates there (e.g. an OAuth callback).
51
- */
32
+ /** The login page. The login API answers {status: "ok", students, accounts}. */
52
33
  export declare function renderLoginPage(opts: LoginPageOptions): string;
@@ -227,10 +227,7 @@ function jsonForScript(value) {
227
227
  function escapeHtml(value) {
228
228
  return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
229
229
  }
230
- /**
231
- * The login page. The login API answers {status: "ok", students, redirect?};
232
- * with a redirect the page navigates there (e.g. an OAuth callback).
233
- */
230
+ /** The login page. The login API answers {status: "ok", students, accounts}. */
234
231
  export function renderLoginPage(opts) {
235
232
  const strings = {
236
233
  en: {
@@ -262,7 +259,6 @@ export function renderLoginPage(opts) {
262
259
  badTotp: "Wilma didn't accept the two-step verification. Paste the authenticator setup key (a long base32 key or an otpauth:// link), not a 6-digit code.",
263
260
  tooMany: "Too many attempts. Close this tab and start again from your assistant.",
264
261
  missing: "Pick your school's Wilma and fill in both fields.",
265
- notAllowed: "This Wilma account isn't on the test list for the hosted connection yet.",
266
262
  expired: "This login page is no longer active. Start the login again from your assistant, or run wilma login.",
267
263
  privacy: "Your login is saved only on this computer, in {path}. WilmAI has no server; it talks only to your school's Wilma.",
268
264
  disclaimer: "WilmAI is an independent open-source project, not affiliated with Visma or the Wilma service.",
@@ -296,20 +292,11 @@ export function renderLoginPage(opts) {
296
292
  badTotp: "Wilma ei hyväksynyt kaksivaiheista tunnistautumista. Liitä todennussovelluksen asetusavain (pitkä base32-avain tai otpauth://-linkki), ei 6-numeroista koodia.",
297
293
  tooMany: "Liian monta yritystä. Sulje välilehti ja aloita uudelleen avustajasi kautta.",
298
294
  missing: "Valitse koulusi Wilma ja täytä molemmat kentät.",
299
- notAllowed: "Tämä Wilma-tunnus ei ole vielä verkkoyhteyden testilistalla.",
300
295
  expired: "Tämä kirjautumissivu ei ole enää käytössä. Aloita kirjautuminen uudelleen avustajasi kautta tai komennolla wilma login.",
301
296
  privacy: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle, tiedostoon {path}. WilmAI:lla ei ole palvelinta; se on yhteydessä vain koulusi Wilmaan.",
302
297
  disclaimer: "WilmAI on itsenäinen avoimen lähdekoodin projekti, eikä se liity Vismaan tai Wilma-palveluun.",
303
298
  },
304
299
  };
305
- if (opts.finishLabel) {
306
- strings.en.finish = opts.finishLabel.en;
307
- strings.fi.finish = opts.finishLabel.fi;
308
- }
309
- if (opts.privacy) {
310
- strings.en.privacy = opts.privacy.en;
311
- strings.fi.privacy = opts.privacy.fi;
312
- }
313
300
  const nonce = escapeHtml(opts.nonce);
314
301
  return `<!doctype html>
315
302
  <html lang="en">
@@ -354,7 +341,6 @@ export function renderLoginPage(opts) {
354
341
  ul.children { margin:6px 0 0; padding-left:22px; }
355
342
  ul.children li { padding:3px 0; }
356
343
  .add-hint { margin-top:8px; }
357
- .notice { margin:0 0 16px; padding:10px 12px; border-radius:8px; background:var(--paper); border:1px solid var(--line); font-size:14px; }
358
344
  .error { color:var(--err); margin-top:12px; }
359
345
  .done h2 { margin:0 0 8px; }
360
346
  [hidden] { display:none !important; }
@@ -365,7 +351,6 @@ export function renderLoginPage(opts) {
365
351
  <h1 id="heading"></h1>
366
352
  <p id="intro"></p>
367
353
  <section class="card" id="form-card">
368
- <p class="notice" id="notice" hidden></p>
369
354
  <div id="school-pick">
370
355
  <label for="school" id="school-label"></label>
371
356
  <input id="school" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-controls="results" aria-expanded="false">
@@ -411,8 +396,6 @@ export function renderLoginPage(opts) {
411
396
  const STRINGS = ${jsonForScript(strings)};
412
397
  const API = ${jsonForScript(opts.apiBase)};
413
398
  const CONFIG_PATH = ${jsonForScript(opts.configPath ?? "")};
414
- const EXTRA = ${jsonForScript(opts.extra ?? {})};
415
- const NOTICE = ${jsonForScript(opts.notice ?? null)};
416
399
  const lang = (navigator.language || "en").toLowerCase().startsWith("fi") ? "fi" : "en";
417
400
  const t = STRINGS[lang];
418
401
  document.documentElement.lang = lang;
@@ -434,8 +417,6 @@ export function renderLoginPage(opts) {
434
417
  text("submit", t.submit); text("done-title", t.doneTitle);
435
418
  text("finish", t.finish); text("add-another", t.addAnother); text("add-hint", t.addAnotherHint);
436
419
  text("finished-title", t.finishedTitle); text("finished-body", t.finishedBody);
437
- if (NOTICE) { text("notice", NOTICE[lang]); $("notice").hidden = false; }
438
- let pending = null;
439
420
 
440
421
  function renderAccounts(accounts) {
441
422
  const box = $("accounts"); box.replaceChildren();
@@ -467,10 +448,9 @@ export function renderLoginPage(opts) {
467
448
  const res = await fetch(API + "/done", {
468
449
  method: "POST",
469
450
  headers: { "Content-Type": "application/json" },
470
- body: JSON.stringify({ ...EXTRA, pending: pending || undefined }),
451
+ body: "{}",
471
452
  });
472
453
  const data = await res.json();
473
- if (data.redirect) { window.location.assign(data.redirect); return; }
474
454
  if (data.status !== "ok") { showError(data.message || t.expired); btn.disabled = false; return; }
475
455
  } catch (err) {
476
456
  // The page may already have closed; the login is saved either way.
@@ -563,11 +543,10 @@ export function renderLoginPage(opts) {
563
543
  const res = await fetch(API + "/login", {
564
544
  method: "POST",
565
545
  headers: { "Content-Type": "application/json" },
566
- body: JSON.stringify({ ...EXTRA, pending: pending || undefined, tenantUrl: tenant.url, tenantName: tenant.name, username, password, totpSecret: $("totp").value.trim() || undefined }),
546
+ body: JSON.stringify({ tenantUrl: tenant.url, tenantName: tenant.name, username, password, totpSecret: $("totp").value.trim() || undefined }),
567
547
  });
568
548
  const data = await res.json();
569
549
  if (data.status === "ok") {
570
- if (data.pending) pending = data.pending;
571
550
  renderAccounts(data.accounts || [{ wilma: tenant.name || tenant.url, students: data.students || [] }]);
572
551
  $("form-card").hidden = true; $("done").hidden = false;
573
552
  $("finish").focus();
@@ -577,7 +556,6 @@ export function renderLoginPage(opts) {
577
556
  if (data.code === "bad_credentials") showError(t.badCredentials);
578
557
  else if (data.code === "too_many_attempts") showError(t.tooMany);
579
558
  else if (data.code === "missing_fields") showError(t.missing);
580
- else if (data.code === "not_allowed") showError(t.notAllowed);
581
559
  else if (data.code === "bad_totp") { $("mfa").hidden = false; showError(lang === "fi" ? t.badTotp : (data.message || t.badTotp)); }
582
560
  else showError(data.message || "Error");
583
561
  } catch (err) {
@@ -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.\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.";
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.";
5
5
  export declare const READ_ONLY: {
6
6
  readonly readOnlyHint: true;
7
7
  readonly destructiveHint: false;
@@ -13,8 +13,6 @@ export declare function textResult(text: string, isError?: boolean): CallToolRes
13
13
  export interface ToolHost {
14
14
  /** Run a tool with Wilma access, or return a result explaining why there is none (e.g. not logged in). */
15
15
  withAccess(run: (access: WilmaAccess) => Promise<CallToolResult>): Promise<CallToolResult>;
16
- /** Largest attachment to return inline (hosts with a response size cap set less). */
17
- inlineAttachmentLimit?: number;
18
16
  /** Local hosts can save attachments to disk; returns the saved path. */
19
17
  saveAttachment?: (fetched: Extract<FetchedAttachment, {
20
18
  status: "fetched";
package/dist/mcp-tools.js CHANGED
@@ -8,10 +8,11 @@ import { toAgentJson } from "./output-json.js";
8
8
  export const INSTRUCTIONS = `Read-only access to Finland's Wilma school system for a parent/guardian.
9
9
  - Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.
10
10
  - Tools cover all children by default; pass "student" (name or student number) to narrow to one child.
11
- - Wilma content is usually in Finnish. Answer in the user's language and translate as needed.
11
+ - 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".
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
- - 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.`;
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
+ - If a result ends with an "Update note", answer the question first, then pass the note on in one sentence.`;
15
16
  export const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
16
17
  const INLINE_ATTACHMENT_LIMIT = 10 * 1024 * 1024;
17
18
  const studentArg = z
@@ -24,7 +25,7 @@ export function json(data) {
24
25
  export function textResult(text, isError = false) {
25
26
  return { content: [{ type: "text", text }], isError };
26
27
  }
27
- function attachmentResult(fetched, savedPath, inlineLimit = INLINE_ATTACHMENT_LIMIT) {
28
+ function attachmentResult(fetched, savedPath) {
28
29
  if (fetched.status === "not_a_file") {
29
30
  return json({
30
31
  status: "not_a_file",
@@ -45,7 +46,7 @@ function attachmentResult(fetched, savedPath, inlineLimit = INLINE_ATTACHMENT_LI
45
46
  };
46
47
  const content = [{ type: "text", text: toAgentJson(meta) }];
47
48
  const type = fetched.contentType ?? "application/octet-stream";
48
- if (fetched.data.byteLength > inlineLimit) {
49
+ if (fetched.data.byteLength > INLINE_ATTACHMENT_LIMIT) {
49
50
  content.push({
50
51
  type: "text",
51
52
  text: `The file is too large to include here (${(fetched.data.byteLength / 1048576).toFixed(1)} MB). ${fetched.resource.authContext === "wilma" ? "Ask the user to open it in Wilma" : `It can be opened at ${fetched.resource.url}`}${savedPath ? `; a copy was saved to ${savedPath}` : ""}.`,
@@ -166,7 +167,7 @@ export function registerWilmaTools(server, ctx) {
166
167
  }, async (args) => ctx.withAccess(async (a) => {
167
168
  const fetched = await a.printout({ id: args.id, student: args.student });
168
169
  const savedPath = args.save && ctx.saveAttachment && fetched.status === "fetched" ? await ctx.saveAttachment(fetched) : undefined;
169
- return attachmentResult(fetched, savedPath, ctx.inlineAttachmentLimit);
170
+ return attachmentResult(fetched, savedPath);
170
171
  }));
171
172
  server.registerTool("wilma_list_messages", {
172
173
  title: "List messages",
@@ -230,6 +231,6 @@ export function registerWilmaTools(server, ctx) {
230
231
  student: args.student,
231
232
  });
232
233
  const savedPath = args.save && ctx.saveAttachment && fetched.status === "fetched" ? await ctx.saveAttachment(fetched) : undefined;
233
- return attachmentResult(fetched, savedPath, ctx.inlineAttachmentLimit);
234
+ return attachmentResult(fetched, savedPath);
234
235
  }));
235
236
  }
package/dist/mcp.js CHANGED
@@ -14,6 +14,7 @@ import { openBrowser, startLoginServer } from "./login-server.js";
14
14
  import { z } from "zod";
15
15
  import { INSTRUCTIONS, READ_ONLY, json, registerWilmaTools, textResult } from "./mcp-tools.js";
16
16
  import { searchTenants } from "./tenant-search.js";
17
+ import { installKind, isNewerVersion, latestWithin, mcpUpdateNote, startUpdateCheck, updateChecksEnabled } from "./update-check.js";
17
18
  const SECRET_SETTINGS_HINT = [
18
19
  "If the user is not at this computer (for example, this agent runs on a cloud computer), the link won't work for them. Instead:",
19
20
  " 1. Ask which city their children's school is in (or the school's name), call wilma_find_school, and let them pick their Wilma from the results.",
@@ -60,6 +61,23 @@ async function startBrowserLogin(requested = false) {
60
61
  SECRET_SETTINGS_HINT,
61
62
  ].join("\n"), !requested);
62
63
  }
64
+ let updateCheck = null;
65
+ let updateNoted = false;
66
+ /**
67
+ * Once per server process, add a note when a newer WilmAI is out, so the
68
+ * assistant can tell the user how to update (see INSTRUCTIONS).
69
+ */
70
+ async function withUpdateNote(result, version) {
71
+ if (!updateCheck || updateNoted)
72
+ return result;
73
+ const latest = await latestWithin(updateCheck, 300).catch(() => null);
74
+ if (!latest)
75
+ return result;
76
+ updateNoted = true;
77
+ if (!isNewerVersion(latest, version))
78
+ return result;
79
+ return { ...result, content: [...result.content, { type: "text", text: mcpUpdateNote(installKind(), version, latest) }] };
80
+ }
63
81
  async function withAccess(run) {
64
82
  const config = await loadConfig();
65
83
  let accounts;
@@ -88,7 +106,7 @@ async function withAccess(run) {
88
106
  function createWilmaMcpServer(version) {
89
107
  const server = new McpServer({ name: "wilma", title: "WilmAI", version }, { instructions: INSTRUCTIONS });
90
108
  registerWilmaTools(server, {
91
- withAccess,
109
+ withAccess: async (run) => withUpdateNote(await withAccess(run), version),
92
110
  saveAttachment: async (fetched) => {
93
111
  const directory = resolve(homedir(), "Downloads", "WilmAI");
94
112
  await mkdir(directory, { recursive: true });
@@ -148,6 +166,9 @@ export async function runMcpServer(version) {
148
166
  console.info = (...args) => console.error(...args);
149
167
  // Share saved sessions with the CLI: no login per process, no extra codes.
150
168
  useSessionStore(fileSessionStore);
169
+ // Checked once a day at most; npx already runs the newest version.
170
+ if (updateChecksEnabled())
171
+ updateCheck = startUpdateCheck();
151
172
  const server = createWilmaMcpServer(version);
152
173
  await server.connect(new StdioServerTransport());
153
174
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * - npm: installed with npm (`wilma update` updates it)
3
+ * - claude-desktop: the Claude Desktop extension (a new .mcpb file updates it)
4
+ * - npx: run through npx, which already fetches the newest version, so no notice
5
+ */
6
+ export type InstallKind = "npm" | "claude-desktop" | "npx";
7
+ export declare function installKind(env?: NodeJS.ProcessEnv, scriptPath?: string): InstallKind;
8
+ export declare function updateChecksEnabled(env?: NodeJS.ProcessEnv): boolean;
9
+ export declare function isNewerVersion(latest: string, current: string): boolean;
10
+ /** The CLI's notice, for a person or an agent reading stderr. */
11
+ export declare function cliUpdateNotice(current: string, latest: string): string;
12
+ /** The note the MCP server adds for the assistant, who passes it on. */
13
+ export declare function mcpUpdateNote(kind: InstallKind, current: string, latest: string): string;
14
+ export interface UpdateCheck {
15
+ /** The latest version on npm, or null if unknown. */
16
+ result: Promise<string | null>;
17
+ /** Stop a check still in flight, so it never keeps the command running. */
18
+ cancel(): void;
19
+ }
20
+ /**
21
+ * Ask npm for the latest version at most once a day. A failed check counts as
22
+ * a check too: agents in sandboxes without internet access shouldn't pay for
23
+ * a timeout on every command.
24
+ */
25
+ export declare function startUpdateCheck(): UpdateCheck;
26
+ /** The latest version if the check has an answer within `waitMs`, else null. */
27
+ export declare function latestWithin(check: UpdateCheck, waitMs: number): Promise<string | null>;
@@ -0,0 +1,116 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getConfigPath } from "./config.js";
5
+ /*
6
+ * "A newer WilmAI is available." The CLI prints it on stderr (people in a
7
+ * terminal and agents like OpenClaw both read it there); the MCP server adds it
8
+ * once to a tool result so the assistant can tell the user. How to update
9
+ * depends on how WilmAI was installed.
10
+ */
11
+ const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
+ export function installKind(env = process.env, scriptPath = fileURLToPath(import.meta.url)) {
13
+ // Set in the extension's manifest (mcpb/manifest.base.json).
14
+ if (env.WILMAI_INSTALL === "claude-desktop")
15
+ return "claude-desktop";
16
+ if (scriptPath.includes(`${sep}_npx${sep}`))
17
+ return "npx";
18
+ return "npm";
19
+ }
20
+ export function updateChecksEnabled(env = process.env) {
21
+ return env.WILMAI_NO_UPDATE_CHECK !== "1" && installKind(env) !== "npx";
22
+ }
23
+ export function isNewerVersion(latest, current) {
24
+ const latestParts = latest.split(".").map(Number);
25
+ const currentParts = current.split(".").map(Number);
26
+ for (let i = 0; i < 3; i++) {
27
+ const l = latestParts[i] ?? 0;
28
+ const c = currentParts[i] ?? 0;
29
+ if (l > c)
30
+ return true;
31
+ if (l < c)
32
+ return false;
33
+ }
34
+ return false;
35
+ }
36
+ /** The CLI's notice, for a person or an agent reading stderr. */
37
+ export function cliUpdateNotice(current, latest) {
38
+ return `Update available: ${current} → ${latest}\nRun "wilma update" to update.`;
39
+ }
40
+ /** The note the MCP server adds for the assistant, who passes it on. */
41
+ export function mcpUpdateNote(kind, current, latest) {
42
+ const how = kind === "claude-desktop"
43
+ ? "download https://wilm.ai/get/claude and open the file; Claude Desktop asks to install it"
44
+ : 'run "wilma update" in a terminal';
45
+ return `Update note: WilmAI ${latest} is available (this computer has ${current}). After answering, tell the user in one sentence that they can update: ${how}.`;
46
+ }
47
+ function versionCachePath() {
48
+ return resolve(dirname(getConfigPath()), "version-check.json");
49
+ }
50
+ async function readVersionCache() {
51
+ try {
52
+ return JSON.parse(await readFile(versionCachePath(), "utf-8"));
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ async function writeVersionCache(cache) {
59
+ const path = versionCachePath();
60
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
61
+ await writeFile(path, JSON.stringify(cache), { encoding: "utf-8", mode: 0o600 });
62
+ }
63
+ /**
64
+ * Ask npm for the latest version at most once a day. A failed check counts as
65
+ * a check too: agents in sandboxes without internet access shouldn't pay for
66
+ * a timeout on every command.
67
+ */
68
+ export function startUpdateCheck() {
69
+ const controller = new AbortController();
70
+ let cancelled = false;
71
+ const result = (async () => {
72
+ const cache = await readVersionCache();
73
+ if (cache && Date.now() - cache.checkedAt < VERSION_CHECK_INTERVAL_MS) {
74
+ return cache.latestVersion;
75
+ }
76
+ const timeout = setTimeout(() => controller.abort(), 3000);
77
+ try {
78
+ const response = await fetch("https://registry.npmjs.org/@wilm-ai/wilma-cli/latest", { signal: controller.signal });
79
+ const data = response.ok ? (await response.json()) : {};
80
+ const latestVersion = data.version ?? cache?.latestVersion ?? null;
81
+ await writeVersionCache({ latestVersion, checkedAt: Date.now() });
82
+ return latestVersion;
83
+ }
84
+ catch {
85
+ // Cancelled because the command finished: try again next time.
86
+ if (!cancelled)
87
+ await writeVersionCache({ latestVersion: cache?.latestVersion ?? null, checkedAt: Date.now() }).catch(() => { });
88
+ return cache?.latestVersion ?? null;
89
+ }
90
+ finally {
91
+ clearTimeout(timeout);
92
+ }
93
+ })().catch(() => null);
94
+ return {
95
+ result,
96
+ cancel() {
97
+ cancelled = true;
98
+ controller.abort();
99
+ },
100
+ };
101
+ }
102
+ /** The latest version if the check has an answer within `waitMs`, else null. */
103
+ export async function latestWithin(check, waitMs) {
104
+ let timer;
105
+ try {
106
+ return await Promise.race([
107
+ check.result,
108
+ new Promise((resolve) => {
109
+ timer = setTimeout(() => resolve(null), waitMs);
110
+ }),
111
+ ]);
112
+ }
113
+ finally {
114
+ clearTimeout(timer);
115
+ }
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
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",
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,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@inquirer/prompts": "^5.3.8",
32
32
  "@modelcontextprotocol/sdk": "^1.32.0",
33
- "@wilm-ai/wilma-client": "^1.6.0",
33
+ "@wilm-ai/wilma-client": "^1.6.1",
34
34
  "zod": "^4.1.0"
35
35
  },
36
36
  "devDependencies": {