@wilm-ai/wilma-cli 2.3.2 → 2.4.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/dist/index.js CHANGED
@@ -7,9 +7,9 @@ import { homedir } from "node:os";
7
7
  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
- import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig } from "./config.js";
11
- import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, forgetSession, logoutSession, useSessionStore, } from "./agent-data.js";
12
- import { createAccess, logoutAccounts } from "./access.js";
10
+ import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig, savedStudent } from "./config.js";
11
+ import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, roleOf, useSessionStore, } from "./agent-data.js";
12
+ import { createAccess, logoutAccounts, removeLogin } 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";
@@ -52,6 +52,7 @@ const ACTIONS = [
52
52
  { value: "grades", name: "Exam grades" },
53
53
  { value: "notes", name: "Lesson notes (last 14 days)" },
54
54
  { value: "gradebook", name: "Gradebook" },
55
+ { value: "contacts", name: "Class and teachers" },
55
56
  { value: "news", name: "List news" },
56
57
  { value: "messages", name: "List messages" },
57
58
  { value: "exit", name: "Exit" },
@@ -158,15 +159,17 @@ async function runDataCommand(parsed, out) {
158
159
  case "summary":
159
160
  return out.data(await access.summary({ student, days: flags.days, since: flags.since }));
160
161
  case "schedule":
161
- return out.data(await access.schedule({ student, when: flags.when, date: flags.date, weekday: flags.weekday }));
162
+ return out.data(await access.schedule({ student, when: flags.when, date: flags.date, weekday: flags.weekday, from: flags.from, to: flags.to }));
162
163
  case "homework":
163
164
  return out.data(await access.homework({ student, limit: flags.limit }));
164
165
  case "exams":
165
166
  return out.data(await access.upcomingExams({ student, limit: flags.limit }));
166
167
  case "grades":
167
- return out.data(await access.grades({ student, limit: flags.limit }));
168
+ return out.data(await access.grades({ student, limit: flags.limit, from: flags.from, to: flags.to }));
168
169
  case "gradebook":
169
170
  return out.data(await access.gradebook({ student }));
171
+ case "contacts":
172
+ return out.data(await access.contacts({ student }));
170
173
  case "notes":
171
174
  if (action === "summary")
172
175
  return out.data(await access.lessonNotesSummary({ student, from: flags.from, to: flags.to }));
@@ -266,6 +269,8 @@ async function runInteractive(config) {
266
269
  show("notes", await access.lessonNotes({ student, days: 14 }));
267
270
  if (nextAction === "gradebook")
268
271
  show("gradebook", await access.gradebook({ student }));
272
+ if (nextAction === "contacts")
273
+ show("contacts", await access.contacts({ student }));
269
274
  if (nextAction === "news")
270
275
  await selectNewsToRead(access, student);
271
276
  if (nextAction === "messages") {
@@ -528,7 +533,7 @@ async function chooseStudentFromProfile(stored, baseProfile, onMfa) {
528
533
  });
529
534
  if (fresh.length) {
530
535
  students = fresh;
531
- stored.students = fresh.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
536
+ stored.students = fresh.map(savedStudent);
532
537
  }
533
538
  else if (stored.students && stored.students.length) {
534
539
  students = stored.students.map((s) => ({
@@ -632,7 +637,7 @@ async function handleLogin(args, out) {
632
637
  console.log(`\nLogged in to ${stored.tenantName ?? stored.tenantUrl} as ${stored.username}.`);
633
638
  if (students.length)
634
639
  console.log(`Children: ${students.join(", ")}`);
635
- console.log("Add another Wilma in the browser if a child is on a different one, or press Done there (or Enter here) to finish.");
640
+ console.log("Add another school's Wilma in the browser if a child is on a different one, or press Done there (or Enter here) to finish.");
636
641
  },
637
642
  });
638
643
  const opened = flags.browser && openBrowser(server.url) === "opened";
@@ -655,22 +660,27 @@ async function handleLogin(args, out) {
655
660
  process.stdin.off("data", finishOnEnter);
656
661
  process.stdin.pause();
657
662
  }
658
- if (!logins.length) {
663
+ // Done without a new login is fine when logins are saved already: the page also logs in again, unticks and removes.
664
+ const saved = (await loadConfig()).profiles;
665
+ if (!saved.length) {
659
666
  throw new WilmaAiError("login_cancelled", "Login page closed before logging in. Run `wilma login` again.");
660
667
  }
661
668
  await flushSessions();
662
669
  if (out.json) {
670
+ // Every saved login, as the page left them.
663
671
  out.data({
664
672
  status: "ok",
665
- logins: logins.map((stored) => ({
673
+ logins: saved.map((stored) => ({
666
674
  wilma: stored.tenantName ?? stored.tenantUrl,
667
675
  username: stored.username,
668
- students: (stored.students ?? []).map((s) => s.name),
676
+ students: (stored.students ?? []).filter((s) => !stored.hidden?.includes(s.studentNumber)).map((s) => s.name),
669
677
  })),
670
678
  configPath: getConfigPath(),
671
679
  });
672
680
  return;
673
681
  }
682
+ if (!logins.length)
683
+ console.log(`\nNo new login. On this computer: ${saved.map((p) => `${p.tenantName ?? p.tenantUrl} — ${p.username}`).join(", ")}.`);
674
684
  console.log(`\nSaved to ${getConfigPath()}`);
675
685
  }
676
686
  const HEADLESS_LOGIN_HELP = [
@@ -687,12 +697,7 @@ const HEADLESS_LOGIN_HELP = [
687
697
  async function handleAccounts(action, args, out) {
688
698
  const config = await loadConfig();
689
699
  if (action === "remove") {
690
- const removed = findSavedLogin(config.profiles, args.join(" "));
691
- config.profiles = config.profiles.filter((p) => p.id !== removed.id);
692
- if (config.lastProfileId === removed.id)
693
- config.lastProfileId = config.profiles[0]?.id ?? null;
694
- await saveConfig(config);
695
- const session = await endRemovedSession(removed);
700
+ const { removed, ...session } = await removeLogin(findSavedLogin(config.profiles, args.join(" ")).id);
696
701
  const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username, ...session };
697
702
  out.data(result, () => session.session === "ended"
698
703
  ? `Removed ${removed.username} @ ${result.wilma} and ended its Wilma session.`
@@ -701,39 +706,29 @@ async function handleAccounts(action, args, out) {
701
706
  : `Removed ${removed.username} @ ${result.wilma}.`);
702
707
  return;
703
708
  }
704
- const accounts = config.profiles.map((p, i) => ({
705
- number: i + 1,
706
- wilma: p.tenantName ?? p.tenantUrl,
707
- url: p.tenantUrl,
708
- username: p.username,
709
- children: (p.students ?? []).map((st) => st.name),
710
- }));
709
+ const accounts = config.profiles.map((p, i) => {
710
+ // As Wilma said when the login's children were last read.
711
+ const role = roleOf(p.students ?? []);
712
+ const hidden = (p.students ?? []).filter((st) => p.hidden?.includes(st.studentNumber)).map((st) => st.name);
713
+ return {
714
+ number: i + 1,
715
+ wilma: p.tenantName ?? p.tenantUrl,
716
+ url: p.tenantUrl,
717
+ username: p.username,
718
+ ...(role ? { role } : {}),
719
+ children: (p.students ?? []).filter((st) => !p.hidden?.includes(st.studentNumber)).map((st) => st.name),
720
+ // Unticked on the login page: not read.
721
+ ...(hidden.length ? { hidden } : {}),
722
+ };
723
+ });
711
724
  out.data({ accounts }, () => accounts.length
712
725
  ? [
713
- ...accounts.map((a) => `${a.number}. ${a.wilma} — ${a.username}${a.children.length ? ` (${a.children.join(", ")})` : ""}`),
726
+ ...accounts.map((a) => `${a.number}. ${a.wilma} — ${a.username}${a.children.length ? ` (${a.children.join(", ")})` : ""}${a.role === "student" ? ", the student's own login" : ""}${a.hidden ? `; not read: ${a.hidden.join(", ")}` : ""}`),
714
727
  "",
715
728
  "Add another with `wilma login`; remove one with `wilma accounts remove <number>`.",
716
729
  ].join("\n")
717
730
  : "No saved Wilma logins. Run `wilma login`.");
718
731
  }
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
732
  /** A saved login by its number in `wilma accounts`, its Wilma's name or address, or its username. */
738
733
  function findSavedLogin(profiles, rawTarget) {
739
734
  const target = rawTarget.trim().toLowerCase();
@@ -8,11 +8,13 @@ export interface LoginServer {
8
8
  close(): void;
9
9
  }
10
10
  /**
11
- * Serve a one-time login page on 127.0.0.1. The parent picks their school's
12
- * Wilma, logs in, and the verified login is saved to the local config file —
13
- * the password never passes through an agent's chat. Families with children
14
- * on different Wilmas can add another login on the same page; it closes when
15
- * the parent presses Done or after 15 idle minutes.
11
+ * Serve a one-time login page on 127.0.0.1, laid out like WilmAI Home's Wilma
12
+ * page: who uses WilmAI, one row per Wilma (a saved one with its children to
13
+ * tick, Log in again and Remove; a new one to fill in), Add another school's
14
+ * Wilma, and one button that logs in every new row. Each verified login is
15
+ * saved to the local config file — the password never passes through an
16
+ * agent's chat. One login per Wilma, as in Home. The page closes when the
17
+ * parent presses Done or after 15 idle minutes.
16
18
  */
17
19
  /**
18
20
  * The assistant app that asked for the login (by the name it gives when it
@@ -37,12 +39,30 @@ export type BrowserOpenResult = "opened" | "skipped" | "headless";
37
39
  export declare function openBrowser(url: string, opts?: {
38
40
  dryRun?: boolean;
39
41
  }): BrowserOpenResult;
42
+ /** A saved login as the page shows it (see accountsSummary). */
43
+ export interface SavedLogin {
44
+ id: string;
45
+ wilma: string;
46
+ url: string;
47
+ username: string;
48
+ role?: "guardian" | "student";
49
+ children: {
50
+ number: string;
51
+ name: string;
52
+ hidden?: boolean;
53
+ }[];
54
+ }
40
55
  export interface LoginPageOptions {
41
56
  nonce: string;
42
57
  apiBase: string;
43
58
  /** Shown in the privacy note under the form. */
44
59
  configPath?: string;
45
60
  openedBy?: OpenedBy;
61
+ /** The logins already saved. */
62
+ accounts?: SavedLogin[];
46
63
  }
47
- /** The login page. The login API answers {status: "ok", students, accounts}. */
64
+ /**
65
+ * The login page, laid out like WilmAI Home's Wilma page. The API answers
66
+ * {status: "ok", accounts} after a login, a removal or a tick.
67
+ */
48
68
  export declare function renderLoginPage(opts: LoginPageOptions): string;