moodle-cli 0.9.2 → 0.9.4

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/moodle.js CHANGED
@@ -11,7 +11,10 @@ var fields = (keys) => Object.fromEntries(keys.map((key) => [key, s]));
11
11
  var file = z.object({ name: s, url: s, requires_authentication: z.boolean().optional() });
12
12
  var activityFields = { id, name: s, type: s, unit_id: n, section_id: n, hidden: z.boolean().optional(), due: s, due_at: n, files: z.array(file).optional() };
13
13
  var activityListSchema = z.object({ ...activityFields, section: s, unit_code: s, description: s });
14
- var activitySchema = z.object({ ...activityFields, ...fields(["url", "target_url", "section", "unit_code", "description", "submission_status", "grading_status", "grade", "due_pretty", "opens_pretty", "closes_pretty", "attempts_allowed", "availability", "time_remaining", "content_text"]), files: z.array(file).optional() });
14
+ var criterion = z.object({ name: s, level: s, score: s, remark: s });
15
+ var attemptSummary = { id, status: s, started: s, completed: s, duration: s, marks: s, grade: s };
16
+ var question = z.object({ number: id, type: s, state: s, mark: s, text: s, response: s, correct: s, feedback: s });
17
+ var activitySchema = z.object({ ...activityFields, ...fields(["url", "target_url", "section", "unit_code", "description", "submission_status", "grading_status", "grade", "graded_on", "graded_by", "feedback_comments", "due_pretty", "opens_pretty", "closes_pretty", "attempts_allowed", "time_limit", "availability", "time_remaining", "content_text"]), criteria: z.array(criterion).optional(), attempts: z.array(z.object({ ...attemptSummary, number: id })).optional(), files: z.array(file).optional() });
15
18
  var sectionSchema = z.object({ id, name: s, activity_count: id, hidden: z.boolean().optional(), positional: z.boolean().optional(), activities: z.array(activityListSchema).optional() });
16
19
  var current = z.object({ id, name: s, estimated: z.boolean().optional() });
17
20
  var unitSchema = z.object({ id, code: s, name: s, start: s, end: s, start_at: n, end_at: n, hidden: z.boolean().optional(), current_section: current.optional() });
@@ -25,25 +28,30 @@ var receiptSchema = z.object({ id, name: s, unit_id: n, url: s, action: z.enum([
25
28
  var input = (shape) => z.object(shape).strict();
26
29
  var list = (key, value) => z.object({ [key]: z.array(value).optional(), total: id });
27
30
  var intentContracts = {
28
- home: { when: "dashboard", command: "moodle", what: "Today, timezone, due items and unread counts.", instead: "due for longer deadline lists", refs: "days defaults to 14", then: "unit or item", cost: "small dashboard", input: input({ days: z.number().int().min(1).max(365).default(14) }), output: z.object({ home: z.object({ today: z.string(), timezone: z.string(), timezone_source: s, name: s, siteurl: s, units: z.array(unitSchema).optional(), due: z.array(dueSchema).optional(), total: id, unread: counts.optional(), errors: z.array(z.string()).optional() }) }) },
31
+ home: { when: "dashboard", command: "moodle", what: "Today's date and timezone, items due soon, unread counts and the current section of each unit.", instead: "due for longer deadline lists", refs: "days defaults to 14", then: "unit or item", cost: "small dashboard", input: input({ days: z.number().int().min(1).max(365).default(14) }), output: z.object({ home: z.object({ today: z.string(), timezone: z.string(), timezone_source: s, name: s, siteurl: s, units: z.array(unitSchema).optional(), due: z.array(dueSchema).optional(), total: id, unread: counts.optional(), errors: z.array(z.string()).optional() }) }) },
29
32
  due: { when: "deadlines", command: "moodle due [UNIT] --days 14", what: "Items due in a date window.", instead: "item for submission details", refs: "unit code, name, id or URL", then: "item with activity_id", cost: "up to 20 rows by default", input: input({ unit: ref.optional(), days: z.number().int().min(1).max(365).default(14), limit }), output: list("due", dueSchema) },
30
33
  units: { when: "unit names", command: "moodle units", what: "Enrolled units with ids, codes and names.", instead: "unit for sections", refs: "none", then: "unit with a name or id", cost: "small list", input: input({ limit: limit.default(200) }), output: list("units", unitSchema) },
31
34
  unit: { when: "a unit or section", command: "moodle UNIT [SECTION]", what: "Section index and current section; section argument returns activities and files.", instead: "find for a named item", refs: "unit code, name, id or URL; section number or name", then: "item or file with activity id", cost: "index about 4 KB; section about 0.5 KB", input: input({ unit: ref, section: ref.optional() }), output: z.object({ unit: unitSchema, sections: z.array(sectionSchema).optional(), total: id }) },
32
35
  find: { when: "find slides or a task", command: 'moodle find "QUERY" [UNIT]', what: "Ranked sections, activities and discussion subjects.", instead: "search_forums for post text; due for deadlines", refs: "query and optional unit code, name, id or URL", then: "item or file with id", cost: "up to 20 rows by default", input: input({ query: z.string().trim().min(1), unit: ref.optional(), types: z.array(z.string()).optional(), limit }), output: list("results", activityListSchema) },
33
- item: { when: "submission or item detail", command: 'moodle UNIT "TASK"', what: "Activity due dates, status, grades, files, or latest forum threads.", instead: "file for binary content", refs: "id, same-site URL, or UNIT TASK phrase", then: "file with id or files URL", cost: "one item", input: input({ ref }), output: z.object({ item: activitySchema, threads: z.array(z.object({ id, name: s })).optional(), total: n }) },
34
- grades: { when: "my grades", command: "moodle grades [UNIT]", what: "Grades across units or in one unit.", instead: "item for submission status", refs: "optional unit code, name, id or URL", then: "item for an ungraded task", cost: "graded_only narrows output", input: input({ unit: ref.optional(), graded_only: z.boolean().default(false) }), output: list("grades", gradeSchema) },
36
+ item: { when: "submission or item detail", command: 'moodle UNIT "TASK"', what: "One activity in full: due date, status, grade, marker feedback with rubric or marking-guide rows and feedback files, attached files, quiz attempts, or a forum's latest threads.", instead: "file for binary content; attempt for quiz answers", refs: "id, same-site URL, or UNIT TASK phrase", then: "file with a file id, or attempt with an attempt id", cost: "one item", input: input({ ref }), output: z.object({ item: activitySchema, threads: z.array(z.object({ id, name: s })).optional(), total: n }) },
37
+ attempt: { when: "a quiz attempt", command: "moodle attempt ID", what: "Each question with your response, and mark, correct answer and feedback when the site shows them.", instead: "item for the attempt list", refs: "attempt id or review URL", then: "item with quiz_id", cost: "one attempt", input: input({ attempt: ref }), output: z.object({ attempt: z.object({ ...attemptSummary, quiz_id: n, unit_id: n, url: s, questions: z.array(question).optional() }) }) },
38
+ grades: { when: "my grades", command: "moodle grades [UNIT]", what: "Gradebook rows across units or in one unit, with grade, range, percentage and grader feedback.", instead: "item for submission status and rubric detail", refs: "optional unit code, name, id or URL", then: "item for an ungraded task", cost: "graded_only narrows output", input: input({ unit: ref.optional(), graded_only: z.boolean().default(false) }), output: list("grades", gradeSchema) },
35
39
  news: { when: "announcements", command: "moodle news [UNIT]", what: "Latest announcement threads with first-post text.", instead: "search_forums for other discussions", refs: "optional unit code, name, id or URL", then: "thread with discussion id", cost: "up to 5 announcements by default", input: input({ unit: ref.optional(), limit: limit.default(5) }), output: list("news", z.object({ id, name: s, unit_id: id, unit_code: s, forum_id: id, post: postSchema.optional() })) },
36
40
  thread: { when: "discussion posts", command: "moodle threads show ID", what: "A discussion and a page of compact posts, with attachment links.", instead: "news for announcements", refs: "discussion_id; offset and limit", then: "increase offset while posts_total exceeds returned", cost: "up to 20 posts by default", input: input({ discussion_id: z.number().int().positive(), limit, offset: z.number().int().nonnegative().default(0) }), output: z.object({ thread: z.object({ id, name: s, unit_id: id, forum_id: id, url: s, posts: z.array(postSchema).optional(), posts_total: id, offset: id }) }) },
37
41
  search_forums: { when: "forum post text", command: 'moodle forums search "QUERY" --unit UNIT', what: "Matching posts with unit and forum name maps.", instead: "find for activity names", refs: "query, optional unit or courseId and forumId", then: "thread with discussion_id", cost: "bounded forum scan; total covers scanned scope", input: input({ query: z.string().trim().min(1), unit: ref.optional(), courseId: z.number().int().positive().optional(), forumId: z.number().int().positive().optional(), limit, includePostText: z.boolean().default(true), titlesOnly: z.boolean().default(false), unreadOnly: z.boolean().default(false), sortBy: z.enum(["relevance", "recent"]).default("relevance"), maxForums: limit.default(20), maxDiscussionsPerForum: limit.default(50) }), output: z.object({ results: z.array(searchSchema).optional(), total: id, forums: z.record(z.string(), z.string()).optional(), units: z.record(z.string(), z.string()).optional(), scope: z.object({ max_forums: id, max_discussions_per_forum: id }) }) },
38
42
  submit: { when: "upload assignment files", command: 'moodle submit "UNIT TASK" FILE... [--final]', what: "Upload local files into an assignment; returns the receipt Moodle shows afterwards.", instead: "item for status only", refs: "assignment id, same-site URL or UNIT TASK phrase; local file paths", then: "dry_run (default) only plans; show the plan to the person, then rerun with dry_run false; final submits for grading and cannot be undone", cost: "writes to Moodle", input: input({ ref, files: z.array(z.string().trim().min(1)).max(20).default([]), final: z.boolean().default(false), replace: z.boolean().default(false), accept_statement: z.boolean().default(false), dry_run: z.boolean().default(true) }), output: z.object({ submission: receiptSchema }) },
39
- file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file as embedded content, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned resource", cost: "binary content up to 16 MiB", input: input({ ref }), output: z.object({ file: z.object({ name: z.string(), mime_type: z.string(), bytes: id, uri: z.string() }) }) }
43
+ file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file: an image as image content, anything else as embedded binary, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned image or resource", cost: "binary content up to 16 MiB", input: input({ ref }), output: z.object({ file: z.object({ name: z.string(), mime_type: z.string(), bytes: id, uri: z.string() }) }) }
40
44
  };
41
45
  function humanDescription(name) {
42
46
  return intentContracts[name].what;
43
47
  }
44
48
  function intentDescription(name) {
45
49
  const c = intentContracts[name];
46
- return `${c.what} Use when: ${c.when}. Not for: ${c.instead}. Refs: ${c.refs}. Then: ${c.then}. Cost: ${c.cost}.`;
50
+ const alternatives = c.instead.split("; ").map((rule) => {
51
+ const [tool, ...purpose] = rule.split(" for ");
52
+ return `for ${purpose.join(" for ")} call ${tool}`;
53
+ }).join("; ");
54
+ return `${c.what} Use it for ${c.when}; ${alternatives}. Input: ${c.refs}. Next: ${c.then}. Cost: ${c.cost}.`;
47
55
  }
48
56
 
49
57
  // src/results.ts
@@ -182,11 +190,17 @@ import { join as join5 } from "path";
182
190
  // src/constants.ts
183
191
  var PACKAGE_NAME = "moodle-cli";
184
192
  var NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
193
+ var GITHUB_RELEASES_URL = "https://github.com/bunizao/moodle-cli/releases/latest";
185
194
  var AJAX_SERVICE_PATH = "/lib/ajax/service.php";
186
195
  var DASHBOARD_PATH = "/my/";
187
196
  var COURSE_PATH = "/course/view.php";
188
197
  var ASSIGN_VIEW_PATH = "/mod/assign/view.php";
189
198
  var QUIZ_VIEW_PATH = "/mod/quiz/view.php";
199
+ var QUIZ_REVIEW_PATH = "/mod/quiz/review.php";
200
+ var QUIZ_START_PATH = "/mod/quiz/startattempt.php";
201
+ var QUIZ_ATTEMPT_PATH = "/mod/quiz/attempt.php";
202
+ var QUIZ_SUMMARY_PATH = "/mod/quiz/summary.php";
203
+ var QUIZ_PROCESS_PATH = "/mod/quiz/processattempt.php";
190
204
  var RESOURCE_VIEW_PATH = "/mod/resource/view.php";
191
205
  var URL_VIEW_PATH = "/mod/url/view.php";
192
206
  var PAGE_VIEW_PATH = "/mod/page/view.php";
@@ -381,16 +395,16 @@ async function findChromiumBrowser(options = {}) {
381
395
  if (options.browserPath) {
382
396
  return { name: "Chromium", path: options.browserPath };
383
397
  }
384
- const platform = options.platform ?? process.platform;
398
+ const platform2 = options.platform ?? process.platform;
385
399
  const env = options.env ?? process.env;
386
- if (platform === "linux") {
400
+ if (platform2 === "linux") {
387
401
  for (const candidate of LINUX_BROWSERS) {
388
- const resolved = resolveFromPath(candidate.path, env, platform);
402
+ const resolved = resolveFromPath(candidate.path, env, platform2);
389
403
  if (resolved) return { name: candidate.name, path: resolved };
390
404
  }
391
405
  return null;
392
406
  }
393
- const candidates = platform === "win32" ? windowsBrowsers(env) : MAC_BROWSERS;
407
+ const candidates = platform2 === "win32" ? windowsBrowsers(env) : MAC_BROWSERS;
394
408
  for (const candidate of candidates) {
395
409
  if (await isExecutable(candidate.path)) return candidate;
396
410
  }
@@ -547,9 +561,9 @@ async function isExecutable(path5) {
547
561
  return false;
548
562
  }
549
563
  }
550
- function resolveFromPath(name, env, platform) {
564
+ function resolveFromPath(name, env, platform2) {
551
565
  if (name.includes("/")) return null;
552
- const separator = platform === "win32" ? ";" : ":";
566
+ const separator = platform2 === "win32" ? ";" : ":";
553
567
  for (const dir of (env.PATH ?? "").split(separator).filter(Boolean)) {
554
568
  const candidate = join2(dir, name);
555
569
  try {
@@ -841,6 +855,14 @@ function createDeploymentCredentials(createToken) {
841
855
  function isUnavailable(error) {
842
856
  return error instanceof CredentialBackendUnavailableError;
843
857
  }
858
+ async function readCredentialsForReport(read) {
859
+ try {
860
+ return { value: await read(), available: true };
861
+ } catch (error) {
862
+ if (!isUnavailable(error)) throw error;
863
+ return { value: null, available: false };
864
+ }
865
+ }
844
866
 
845
867
  // src/mcp/credentials/node-store.ts
846
868
  var SERVICE = "moodle-cli-mcp";
@@ -1252,13 +1274,13 @@ var PrivateFileCredentialBackend = class {
1252
1274
  }
1253
1275
  };
1254
1276
  function createDefaultCredentialStore(options = {}) {
1255
- const platform = options.platform ?? process.platform;
1277
+ const platform2 = options.platform ?? process.platform;
1256
1278
  const runner = options.runner ?? new NodeCredentialCommandRunner();
1257
- const preferred = platform === "darwin" ? new MacOSKeychainCredentialBackend(runner) : platform === "linux" ? new LinuxSecretServiceCredentialBackend(runner) : platform === "win32" ? new WindowsCredentialManagerBackend(runner) : new UnavailableCredentialBackend(`${platform} credential store`);
1279
+ const preferred = platform2 === "darwin" ? new MacOSKeychainCredentialBackend(runner) : platform2 === "linux" ? new LinuxSecretServiceCredentialBackend(runner) : platform2 === "win32" ? new WindowsCredentialManagerBackend(runner) : new UnavailableCredentialBackend(`${platform2} credential store`);
1258
1280
  const home = options.homeDirectory ?? homedir3();
1259
- const fallbackDirectory = platform === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join3(home, ".config", "moodle-cli", "mcp", "credentials");
1260
- const fallback = platform === "win32" ? new WindowsDpapiFileCredentialBackend(fallbackDirectory, runner) : new PrivateFileCredentialBackend(fallbackDirectory);
1261
- return new SafeCredentialStore(preferred, fallback, platform !== "win32");
1281
+ const fallbackDirectory = platform2 === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join3(home, ".config", "moodle-cli", "mcp", "credentials");
1282
+ const fallback = platform2 === "win32" ? new WindowsDpapiFileCredentialBackend(fallbackDirectory, runner) : new PrivateFileCredentialBackend(fallbackDirectory);
1283
+ return new SafeCredentialStore(preferred, fallback, platform2 !== "win32");
1262
1284
  }
1263
1285
  function parseCredentials(value) {
1264
1286
  const parsed = JSON.parse(value);
@@ -1764,11 +1786,11 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
1764
1786
  }
1765
1787
  async function braveProfilePaths(options = {}) {
1766
1788
  const home = options.homeDir ?? homedir5();
1767
- const platform = options.platform ?? process.platform;
1768
- const roots = platform === "linux" ? [
1789
+ const platform2 = options.platform ?? process.platform;
1790
+ const roots = platform2 === "linux" ? [
1769
1791
  join5(home, ".config/BraveSoftware/Brave-Browser"),
1770
1792
  join5(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
1771
- ] : platform === "win32" ? [join5(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join5(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
1793
+ ] : platform2 === "win32" ? [join5(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform2 === "darwin" ? [join5(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
1772
1794
  const profiles = [];
1773
1795
  for (const root of roots) {
1774
1796
  try {
@@ -1802,8 +1824,8 @@ var MINIMUM_NODE_FOR_BROWSER_COOKIES = "22.13.0";
1802
1824
  function cookieAccessBlocked(warnings) {
1803
1825
  return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning));
1804
1826
  }
1805
- function cookieAccessHint(warnings, platform = process.platform, unreadable = [], env = process.env) {
1806
- const grant = platform === "darwin" ? [
1827
+ function cookieAccessHint(warnings, platform2 = process.platform, unreadable = [], env = process.env) {
1828
+ const grant = platform2 === "darwin" ? [
1807
1829
  `Grant Full Disk Access to ${hostApplicationName(env) ?? "the application running this command"}, then restart it:`,
1808
1830
  ` open "${FULL_DISK_ACCESS_PANE}"`
1809
1831
  ].join("\n") : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
@@ -1835,9 +1857,9 @@ function cookieDiagnostics(warnings, unreadable) {
1835
1857
  ];
1836
1858
  return lines.length ? ["", "Cookie store diagnostics:", ...lines] : [];
1837
1859
  }
1838
- function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform, unreadable = [], env = process.env) {
1860
+ function authFailureHint(baseUrl, cookieWarnings = [], platform2 = process.platform, unreadable = [], env = process.env) {
1839
1861
  if (cookieAccessBlocked(cookieWarnings) || unreadable.length) {
1840
- return cookieAccessHint(cookieWarnings, platform, unreadable, env);
1862
+ return cookieAccessHint(cookieWarnings, platform2, unreadable, env);
1841
1863
  }
1842
1864
  return [
1843
1865
  `Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
@@ -2444,8 +2466,8 @@ function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
2444
2466
  ].join("\n");
2445
2467
  }
2446
2468
  async function installKeepalive(options = {}) {
2447
- const platform = options.platform ?? process.platform;
2448
- if (platform !== "darwin") {
2469
+ const platform2 = options.platform ?? process.platform;
2470
+ if (platform2 !== "darwin") {
2449
2471
  throw new Error(
2450
2472
  "Automatic keepalive install requires macOS launchd. Add a cron entry instead: */30 * * * * moodle auth keepalive --json"
2451
2473
  );
@@ -2556,8 +2578,8 @@ async function doctor(options = {}) {
2556
2578
  }
2557
2579
 
2558
2580
  // src/cli.ts
2559
- import { rm as rm7, readdir as readdir4 } from "fs/promises";
2560
- import { homedir as homedir15 } from "os";
2581
+ import { readFile as readFile11, rm as rm8, readdir as readdir4 } from "fs/promises";
2582
+ import { homedir as homedir16 } from "os";
2561
2583
 
2562
2584
  // src/mcp/renewal/decision.ts
2563
2585
  function decideRenewal(snapshot) {
@@ -2674,6 +2696,19 @@ var RenewalInstaller = class {
2674
2696
  }
2675
2697
  }
2676
2698
  };
2699
+ function describeRenewalJob(platform2, homeDirectory, profile, intervalMinutes = 30) {
2700
+ const schedule = `silent check every ${intervalMinutes} minutes`;
2701
+ if (platform2 === "darwin") {
2702
+ const label2 = `com.moodle-cli.mcp-renewal.${profile}`;
2703
+ return { scheduler: "launchd agent", label: label2, schedule, log: `${trimEnd(homeDirectory, "/")}/Library/Logs/${label2}.log` };
2704
+ }
2705
+ if (platform2 === "linux") {
2706
+ const label2 = `moodle-cli-mcp-renewal-${profile}`;
2707
+ return { scheduler: "systemd user timer", label: label2, schedule, log: `journalctl --user -u ${label2}` };
2708
+ }
2709
+ const label = `Moodle CLI MCP Renewal (${profile})`;
2710
+ return { scheduler: "Task Scheduler task", label, schedule, log: "Task Scheduler history" };
2711
+ }
2677
2712
  function buildRenewalInstallPlan(options) {
2678
2713
  validateOptions(options);
2679
2714
  const intervalMinutes = options.intervalMinutes ?? 30;
@@ -2883,13 +2918,13 @@ var NodeRenewalInstallerIO = class {
2883
2918
  }
2884
2919
  };
2885
2920
  function createDefaultRenewalInstaller(profile, options = {}) {
2886
- const platform = options.platform ?? process.platform;
2887
- if (!isSupportedPlatform(platform)) {
2888
- throw new Error(`Moodle MCP renewal is not supported on ${platform}`);
2921
+ const platform2 = options.platform ?? process.platform;
2922
+ if (!isSupportedPlatform(platform2)) {
2923
+ throw new Error(`Moodle MCP renewal is not supported on ${platform2}`);
2889
2924
  }
2890
2925
  const runtime = runtimeCommand(options.executable, options.executableArgs);
2891
2926
  const plan = buildRenewalInstallPlan({
2892
- platform,
2927
+ platform: platform2,
2893
2928
  profile,
2894
2929
  executable: runtime.command,
2895
2930
  executableArgs: runtime.args,
@@ -2915,8 +2950,8 @@ var DefaultRenewalIntegration = class {
2915
2950
  }
2916
2951
  };
2917
2952
  var NodeRenewalNotificationSender = class {
2918
- constructor(platform = process.platform) {
2919
- this.platform = platform;
2953
+ constructor(platform2 = process.platform) {
2954
+ this.platform = platform2;
2920
2955
  }
2921
2956
  platform;
2922
2957
  async send(notification) {
@@ -2960,11 +2995,11 @@ var NodeRenewalNotificationSender = class {
2960
2995
  }
2961
2996
  }
2962
2997
  };
2963
- function notifyRenewalSignInRequired(platform = process.platform) {
2964
- return sendRenewalNotification("sign_in_required", new NodeRenewalNotificationSender(platform));
2998
+ function notifyRenewalSignInRequired(platform2 = process.platform) {
2999
+ return sendRenewalNotification("sign_in_required", new NodeRenewalNotificationSender(platform2));
2965
3000
  }
2966
- function isSupportedPlatform(platform) {
2967
- return platform === "darwin" || platform === "linux" || platform === "win32";
3001
+ function isSupportedPlatform(platform2) {
3002
+ return platform2 === "darwin" || platform2 === "linux" || platform2 === "win32";
2968
3003
  }
2969
3004
  function isMissing2(error) {
2970
3005
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
@@ -3006,6 +3041,7 @@ function createMoodleGateway(client, hooks = {}) {
3006
3041
  return limit2 === void 0 ? activities : activities.slice(0, limit2);
3007
3042
  },
3008
3043
  getActivity: ({ activityId }) => client.getActivity(activityId),
3044
+ ...client.getQuizAttempt ? { getQuizAttempt: (attemptId) => client.getQuizAttempt(attemptId) } : {},
3009
3045
  getGrades: ({ courseId }) => client.getCourseGrades(courseId),
3010
3046
  async listForums({ courseId, limit: limit2 }) {
3011
3047
  const forums = await client.getForums(courseId);
@@ -3046,7 +3082,9 @@ function createMoodleGateway(client, hooks = {}) {
3046
3082
  name,
3047
3083
  mimeType: contentType(response),
3048
3084
  bytes: content.byteLength,
3049
- uri: publicFileUrl(response.url || target.url),
3085
+ // The Moodle-origin URL, never the post-redirect CDN one: stripping query parameters from
3086
+ // a signed CDN link leaves an address that can only ever answer MissingKey.
3087
+ uri: publicFileUrl(target.url),
3050
3088
  blob: encodeBase64(content)
3051
3089
  };
3052
3090
  }
@@ -3411,6 +3449,15 @@ function createIntentService(gateway, now = () => Date.now()) {
3411
3449
  result = { item: { ...itemRow(activity), ...due }, threads: threads?.slice(0, 20).map((t) => ({ id: t.id, name: t.subject })), total: threads?.length };
3412
3450
  break;
3413
3451
  }
3452
+ case "attempt": {
3453
+ if (!gateway.getQuizAttempt) throw new ReferenceError("not_found", "This gateway cannot read quiz attempts.", []);
3454
+ const raw = String(input2.attempt);
3455
+ const id2 = raw.includes("://") ? Number(new URL(raw).searchParams.get("attempt")) : Number(raw);
3456
+ if (!Number.isInteger(id2) || id2 <= 0) throw new ReferenceError("not_found", "Use a quiz attempt id or a review URL with ?attempt=.", []);
3457
+ const { course_id, questions, ...attempt } = await gateway.getQuizAttempt(id2);
3458
+ result = { attempt: { ...attempt, unit_id: course_id || void 0, questions } };
3459
+ break;
3460
+ }
3414
3461
  case "home":
3415
3462
  case "due": {
3416
3463
  const data = name === "due" && gateway.getDue ? { user: await user(), courses: await courses(), todo: await gateway.getDue(Number(input2.days)), errors: [] } : await overview(Number(input2.days));
@@ -3541,12 +3588,12 @@ function renderTerminalTable(columns, rows, options = {}) {
3541
3588
  const minimum = widths.reduce((n2, w, i) => n2 + (flexible.includes(i) ? 8 : w), 0) + columns.length * 3 + 1;
3542
3589
  const title = options.title ? [theme.subject(sanitizeTerminalText(options.title))] : [];
3543
3590
  if (width < 60 || minimum > width) {
3544
- const wrap = (line2) => Array.from(line2).reduce((lines, char) => {
3591
+ const wrap2 = (line2) => Array.from(line2).reduce((lines, char) => {
3545
3592
  if (!lines.length || Array.from(lines.at(-1)).length >= width) lines.push("");
3546
3593
  lines[lines.length - 1] += char;
3547
3594
  return lines;
3548
3595
  }, []);
3549
- return [...title, ...clean.flatMap((row) => [...columns.flatMap((c, i) => wrap(`${c.label}: ${row[i]}`)), ""])].join("\n").trimEnd();
3596
+ return [...title, ...clean.flatMap((row) => [...columns.flatMap((c, i) => wrap2(`${c.label}: ${row[i]}`)), ""])].join("\n").trimEnd();
3550
3597
  }
3551
3598
  while (widths.reduce((n2, v) => n2 + v, 0) + columns.length * 3 + 1 > width) {
3552
3599
  const index = flexible.reduce((best, i) => widths[i] > (widths[best] ?? 0) ? i : best, -1);
@@ -3670,9 +3717,20 @@ function renderScreen(data, options = {}) {
3670
3717
  const i = record(data.item);
3671
3718
  lines.push(`${text(i.name)} \xB7 ${text(i.type)} \xB7 #${i.id}`);
3672
3719
  for (const [k, v] of Object.entries(i)) if (!["id", "name", "type", "files"].includes(k) && !k.endsWith("_at") && typeof v !== "object") lines.push(`${k.replaceAll("_", " ")}: ${moment(v, now)}`);
3720
+ for (const a of array(i.attempts)) lines.push(`Attempt ${a.number} \xB7 #${a.id} ${[a.status, a.marks, a.grade, a.completed].map(text).filter(Boolean).join(" \xB7 ")}`);
3721
+ for (const c of array(i.criteria)) lines.push(`${text(c.name)} ${[c.score, c.level, c.remark].map(text).filter(Boolean).join(" \xB7 ")}`);
3673
3722
  for (const f of array(i.files)) lines.push(`File ${text(f.name)} ${text(f.url)}`);
3674
3723
  if (data.threads) rows(array(data.threads), "Threads");
3675
3724
  next = [`moodle get ${i.id} --to DIR`];
3725
+ } else if (data.attempt) {
3726
+ const a = record(data.attempt);
3727
+ lines.push(`Attempt #${a.id} \xB7 ${[a.status, a.marks, a.grade].map(text).filter(Boolean).join(" \xB7 ")}`);
3728
+ for (const q of array(a.questions)) {
3729
+ lines.push("", `Q${q.number} \xB7 ${[q.type, q.state, q.mark].map(text).filter(Boolean).join(" \xB7 ")}`, text(q.text), `You: ${text(q.response)}`);
3730
+ if (q.correct) lines.push(`Correct: ${text(q.correct)}`);
3731
+ if (q.feedback) lines.push(`Feedback: ${text(q.feedback)}`);
3732
+ }
3733
+ next = [`moodle item ${a.quiz_id}`];
3676
3734
  } else if (data.thread) {
3677
3735
  const t = record(data.thread);
3678
3736
  lines.push(text(t.name));
@@ -3713,7 +3771,7 @@ function renderScreen(data, options = {}) {
3713
3771
  }
3714
3772
 
3715
3773
  // src/cli.ts
3716
- import { spawn as spawn4 } from "child_process";
3774
+ import { spawn as spawn5 } from "child_process";
3717
3775
  import {
3718
3776
  banner,
3719
3777
  colorEnabled as colorEnabled2,
@@ -3734,7 +3792,7 @@ import {
3734
3792
  mutating,
3735
3793
  writeOutput
3736
3794
  } from "@bunizao/cli-kit";
3737
- import { realpathSync as realpathSync3 } from "fs";
3795
+ import { realpathSync as realpathSync4 } from "fs";
3738
3796
  import path4 from "path";
3739
3797
  import { fileURLToPath as fileURLToPath2 } from "url";
3740
3798
 
@@ -4038,6 +4096,8 @@ function parseGradeOverviewRows(html, baseUrl) {
4038
4096
  return rows;
4039
4097
  }
4040
4098
  function parseAssignmentHtml(html, assignmentId, baseUrl) {
4099
+ const root = parse3(html);
4100
+ const feedback = root.querySelector(".feedback");
4041
4101
  return {
4042
4102
  id: assignmentId,
4043
4103
  name: pageTitle(html),
@@ -4047,9 +4107,39 @@ function parseAssignmentHtml(html, assignmentId, baseUrl) {
4047
4107
  grading_status: findTableValue(html, "Grading status"),
4048
4108
  time_remaining: findTableValue(html, "Time remaining"),
4049
4109
  grade: findTableValue(html, "Grade"),
4110
+ graded_on: feedback ? findTableValue(feedback.toString(), "Graded on") : "",
4111
+ graded_by: feedback ? findTableValue(feedback.toString(), "Graded by") : "",
4112
+ feedback_comments: feedback ? findTableValue(feedback.toString(), "Feedback comments") : "",
4113
+ criteria: feedback ? parseFeedbackCriteria(feedback) : [],
4114
+ file_entries: feedback ? parseFeedbackFiles(feedback, baseUrl) : [],
4050
4115
  url: `${baseUrl.replace(/\/$/, "")}/mod/assign/view.php?id=${assignmentId}`
4051
4116
  };
4052
4117
  }
4118
+ function parseFeedbackCriteria(feedback) {
4119
+ const criteria = [];
4120
+ for (const row of feedback.querySelectorAll("tr.criterion")) {
4121
+ const level = row.querySelector("td.level.checked");
4122
+ const name = cleanNodeText(row.querySelector(".criterionshortname") ?? row.querySelector("td.description"));
4123
+ if (!name) continue;
4124
+ criteria.push({
4125
+ name,
4126
+ level: cleanNodeText(level?.querySelector(".definition")),
4127
+ score: cleanNodeText(row.querySelector("td.score") ?? level?.querySelector(".score")),
4128
+ remark: cleanTableCell(row.querySelector("td.remark"))
4129
+ });
4130
+ }
4131
+ return criteria;
4132
+ }
4133
+ function parseFeedbackFiles(feedback, baseUrl) {
4134
+ const entries = [];
4135
+ for (const link2 of feedback.querySelectorAll('a[href*="pluginfile.php"]')) {
4136
+ const url = resolveUrl(baseUrl, link2.getAttribute("href") ?? "");
4137
+ const label = cleanNodeText(link2);
4138
+ const name = /\.\w{1,5}$/u.test(label) ? label : decodeURIComponent(new URL(url).pathname.split("/").at(-1) || "file");
4139
+ if (!entries.some((entry) => entry.url === url)) entries.push(fileEntry(name, url, baseUrl));
4140
+ }
4141
+ return entries;
4142
+ }
4053
4143
  function parseQuizHtml(html, quizId, baseUrl) {
4054
4144
  const root = parse3(html);
4055
4145
  return {
@@ -4058,12 +4148,85 @@ function parseQuizHtml(html, quizId, baseUrl) {
4058
4148
  ...activityContext(html),
4059
4149
  opens_pretty: extractLabeledText(html, "Opens:"),
4060
4150
  closes_pretty: extractLabeledText(html, "Closes:"),
4061
- attempts_allowed: cleanText(root.textContent.match(/Attempts allowed:\s*([^\n]+)/i)?.[1] ?? ""),
4151
+ attempts_allowed: labeledParagraph(root, "Attempts allowed:"),
4152
+ time_limit: labeledParagraph(root, "Time limit:"),
4062
4153
  availability: cleanText(root.textContent.match(/This quiz is currently[^\n]+/i)?.[0] ?? ""),
4063
4154
  grade: findTableValue(html, "Grade"),
4155
+ attempts: parseQuizAttempts(root, baseUrl),
4064
4156
  url: `${baseUrl.replace(/\/$/, "")}/mod/quiz/view.php?id=${quizId}`
4065
4157
  };
4066
4158
  }
4159
+ function labeledParagraph(root, label) {
4160
+ for (const p of root.querySelectorAll("p")) {
4161
+ const line = cleanNodeText(p);
4162
+ if (line.startsWith(label)) return cleanText(line.slice(label.length));
4163
+ }
4164
+ return "";
4165
+ }
4166
+ function parseQuizAttempts(root, baseUrl) {
4167
+ const attempts = [];
4168
+ for (const table of root.querySelectorAll("table.quizreviewsummary")) {
4169
+ const card = table.closest(".card") ?? table.parentNode;
4170
+ const link2 = card?.querySelector('a[href*="/mod/quiz/review.php"]');
4171
+ const reviewUrl = link2 ? resolveUrl(baseUrl, link2.getAttribute("href") ?? "") : "";
4172
+ const id2 = numberQueryValue(reviewUrl, "attempt");
4173
+ if (!link2 || id2 === null) continue;
4174
+ const summary = tableValues(table);
4175
+ const number = Number(cleanNodeText(card?.querySelector(".card-title")).match(/\d+/)?.[0] ?? attempts.length + 1);
4176
+ attempts.push({ id: id2, number, status: summary.Status ?? "", started: summary.Started ?? "", completed: summary.Completed ?? "", duration: summary.Duration ?? "", marks: summary.Marks ?? "", grade: summary.Grade ?? "", review_url: reviewUrl });
4177
+ }
4178
+ return attempts;
4179
+ }
4180
+ function parseQuizReviewHtml(html, attemptId, baseUrl) {
4181
+ const root = parse3(html);
4182
+ const summary = tableValues(root.querySelector("table.quizreviewsummary"));
4183
+ const form = root.querySelector("form.questionflagsaveform");
4184
+ const url = `${baseUrl.replace(/\/$/, "")}/mod/quiz/review.php?attempt=${attemptId}`;
4185
+ return {
4186
+ id: attemptId,
4187
+ quiz_id: numberQueryValue(form?.getAttribute("action") ?? "", "cmid") ?? 0,
4188
+ course_id: parseCourseIdFromPageHtml(html) ?? 0,
4189
+ status: summary.Status ?? "",
4190
+ started: summary.Started ?? "",
4191
+ completed: summary.Completed ?? "",
4192
+ duration: summary.Duration ?? "",
4193
+ marks: summary.Marks ?? "",
4194
+ grade: summary.Grade ?? "",
4195
+ questions: root.querySelectorAll("div.que").map(parseQuizQuestion),
4196
+ url
4197
+ };
4198
+ }
4199
+ function parseQuizQuestion(que) {
4200
+ const answer = que.querySelector(".answer");
4201
+ const picked = answer?.querySelectorAll("input:checked, input[checked]").map((input2) => {
4202
+ const label = input2.getAttribute("aria-labelledby");
4203
+ return cleanTableCell(label ? que.querySelector(`[id="${label}"]`) : input2.parentNode);
4204
+ }).filter(Boolean) ?? [];
4205
+ const typed = cleanText(que.querySelector('input[type="text"], input[type="number"]')?.getAttribute("value") ?? "");
4206
+ const response = picked.length ? picked.join("; ") : typed || blockText(answer?.querySelector(".qtype_essay_response") ?? answer);
4207
+ return {
4208
+ number: Number(cleanNodeText(que.querySelector(".qno")) || 0),
4209
+ type: que.classList.value[1] ?? "",
4210
+ state: cleanNodeText(que.querySelector(".info .state")),
4211
+ mark: cleanNodeText(que.querySelector(".info .grade")).replace(/^Mark\s+/u, ""),
4212
+ text: blockText(que.querySelector(".qtext")),
4213
+ response: response.replace(/\s*Word count: \d+$/u, ""),
4214
+ correct: blockText(que.querySelector(".rightanswer")).replace(/^The correct answers? (?:is|are):?\s*/iu, "").replace(/^'(.*)'\.?$/u, "$1"),
4215
+ feedback: blockText(que.querySelector(".outcome .feedback"))
4216
+ };
4217
+ }
4218
+ function blockText(node) {
4219
+ if (!node) return "";
4220
+ return cleanTableCell(parse3(node.toString().replace(/<br\s*\/?>|<\/(?:p|div|li|h\d|tr)>/giu, "$& ")));
4221
+ }
4222
+ function tableValues(table) {
4223
+ const values = {};
4224
+ for (const row of table?.querySelectorAll("tr") ?? []) {
4225
+ const cells = row.querySelectorAll("th, td");
4226
+ if (cells.length >= 2) values[cleanNodeText(cells[0])] = cleanTableCell(cells[1]);
4227
+ }
4228
+ return values;
4229
+ }
4067
4230
  function parseResourceHtml(html, resourceId, baseUrl) {
4068
4231
  const root = parse3(html);
4069
4232
  const link2 = root.querySelector(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]");
@@ -4243,12 +4406,12 @@ function parseForumGroupsHtml(html) {
4243
4406
  }
4244
4407
  const groups = [];
4245
4408
  const seen = /* @__PURE__ */ new Set();
4246
- for (const option of select.querySelectorAll("option")) {
4247
- const groupId = safeInt(option.getAttribute("value"));
4409
+ for (const option2 of select.querySelectorAll("option")) {
4410
+ const groupId = safeInt(option2.getAttribute("value"));
4248
4411
  if (!groupId) {
4249
4412
  continue;
4250
4413
  }
4251
- const groupName = cleanNodeText(option);
4414
+ const groupName = cleanNodeText(option2);
4252
4415
  const key = `${groupId}:${groupName}`;
4253
4416
  if (seen.has(key)) {
4254
4417
  continue;
@@ -4268,9 +4431,9 @@ function selectedGroupName(root, groupId) {
4268
4431
  return "";
4269
4432
  }
4270
4433
  for (const selector of ["select[name='groupinfo']", "select[name='group']"]) {
4271
- const option = root.querySelector(selector)?.querySelector(`option[value='${groupId}']`) ?? null;
4272
- if (option) {
4273
- return cleanNodeText(option);
4434
+ const option2 = root.querySelector(selector)?.querySelector(`option[value='${groupId}']`) ?? null;
4435
+ if (option2) {
4436
+ return cleanNodeText(option2);
4274
4437
  }
4275
4438
  }
4276
4439
  return "";
@@ -4386,7 +4549,7 @@ function cleanTableCell(node) {
4386
4549
  return "";
4387
4550
  }
4388
4551
  const clone = parse3(node.toString());
4389
- for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
4552
+ for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, .hidden, .accesshide, script, style")) {
4390
4553
  unwanted.remove();
4391
4554
  }
4392
4555
  return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
@@ -4524,8 +4687,6 @@ function parseSubmissionForm(html, deps) {
4524
4687
  const repositories = (Array.isArray(picker.repositories) ? picker.repositories : Object.values(record2(picker.repositories))).map(record2);
4525
4688
  const upload = repositories.find((repo) => repo.type === "upload");
4526
4689
  if (!upload || upload.id === void 0) throw deps.fail("The site does not allow direct file uploads for this assignment.");
4527
- const accepted = options.accepted_types;
4528
- const acceptedTypes = Array.isArray(accepted) ? accepted.map(String).filter(Boolean) : accepted === void 0 || accepted === "*" ? "*" : [String(accepted)];
4529
4690
  return {
4530
4691
  action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`),
4531
4692
  fields: fields2,
@@ -4539,7 +4700,7 @@ function parseSubmissionForm(html, deps) {
4539
4700
  maxBytes: integer(options.maxbytes),
4540
4701
  areaMaxBytes: integer(options.areamaxbytes),
4541
4702
  maxFiles: integer(options.maxfiles),
4542
- acceptedTypes: acceptedTypes.length === 1 && acceptedTypes[0] === "*" ? "*" : acceptedTypes,
4703
+ acceptedTypes: acceptedTypesOf(options.accepted_types),
4543
4704
  ...statementOf(form)
4544
4705
  };
4545
4706
  }
@@ -4604,7 +4765,7 @@ function formFields(form) {
4604
4765
  }
4605
4766
  if (tag === "select") {
4606
4767
  const options = element.querySelectorAll("option");
4607
- const chosen = options.find((option) => option.hasAttribute("selected")) ?? options[0];
4768
+ const chosen = options.find((option2) => option2.hasAttribute("selected")) ?? options[0];
4608
4769
  if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
4609
4770
  continue;
4610
4771
  }
@@ -4637,6 +4798,11 @@ function filemanagerOptions(html, itemid) {
4637
4798
  }
4638
4799
  return null;
4639
4800
  }
4801
+ function acceptedTypesOf(value) {
4802
+ const list2 = Array.isArray(value) ? value : isRecord4(value) ? Object.values(value) : value === void 0 ? [] : [value];
4803
+ const types = list2.map((type) => String(type).trim()).filter(Boolean);
4804
+ return types.length === 0 || types.includes("*") ? "*" : types;
4805
+ }
4640
4806
  function balancedObject(text2, start) {
4641
4807
  if (text2[start] !== "{") return null;
4642
4808
  let depth = 0;
@@ -4778,6 +4944,292 @@ function record2(value) {
4778
4944
  return isRecord4(value) ? value : {};
4779
4945
  }
4780
4946
 
4947
+ // src/moodle-quiz-core.ts
4948
+ import { parse as parse5 } from "node-html-parser";
4949
+ async function startQuizAttempt(deps, quizId, options = {}) {
4950
+ if (!Number.isSafeInteger(quizId) || quizId <= 0) throw deps.usage("The quiz id must be a positive integer.");
4951
+ const viewUrl = `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`;
4952
+ const viewHtml = await pageText2(deps, viewUrl);
4953
+ const root = parse5(viewHtml);
4954
+ if (/safeexambrowser|Safe Exam Browser/iu.test(viewHtml)) throw deps.usage("This quiz requires the Safe Exam Browser, which the CLI cannot provide.", "Open it in the browser Moodle asks for.");
4955
+ const resume = formWithAction2(root, QUIZ_ATTEMPT_PATH) ?? root.querySelector(`a[href*="${QUIZ_ATTEMPT_PATH}?"]`);
4956
+ if (resume) {
4957
+ const target = resume.tagName.toLowerCase() === "form" ? `${resolveUrl(deps.baseUrl, resume.getAttribute("action") ?? "")}?${new URLSearchParams(formFields2(resume)).toString()}` : resolveUrl(deps.baseUrl, resume.getAttribute("href") ?? "");
4958
+ const attempt = numberParam(target, "attempt");
4959
+ if (attempt) return getAttemptPage(deps, attempt, quizId, 0);
4960
+ }
4961
+ const start = formWithAction2(root, QUIZ_START_PATH);
4962
+ if (!start) {
4963
+ const reason = cleanText(root.querySelector(".quizattempt, .quizinfo")?.textContent) || "the quiz page shows no attempt button";
4964
+ throw deps.usage(`Moodle offers no new attempt: ${reason}`, `See ${viewUrl}`);
4965
+ }
4966
+ let response = await deps.request(resolveUrl(deps.baseUrl, start.getAttribute("action") ?? ""), postInit(formFields2(start)));
4967
+ let html = await response.text();
4968
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) {
4969
+ const preflight = formWithAction2(parse5(html), QUIZ_START_PATH);
4970
+ if (!preflight) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned an unexpected page"}`);
4971
+ const fields2 = [...formFields2(preflight).filter(([name]) => name !== "quizpassword"), ["submitbutton", "Start attempt"]];
4972
+ if (preflight.querySelector("input[name=quizpassword]")) {
4973
+ const password = options.password ? await options.password() : null;
4974
+ if (!password) throw deps.usage("This quiz needs its access password to start.", "Run moodle quiz start again at a terminal to be asked for it, or pass --password.");
4975
+ fields2.push(["quizpassword", password]);
4976
+ }
4977
+ response = await deps.request(resolveUrl(deps.baseUrl, preflight.getAttribute("action") ?? ""), postInit(fields2));
4978
+ html = await response.text();
4979
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned the pre-flight form again"}`);
4980
+ }
4981
+ return withoutForm(parseAttemptPage(html, response.url, deps));
4982
+ }
4983
+ async function getAttemptPage(deps, attemptId, quizId, page) {
4984
+ return withoutForm(await loadAttemptPage(deps, attemptId, quizId, page));
4985
+ }
4986
+ async function loadAttemptPage(deps, attemptId, quizId, page) {
4987
+ const url = attemptUrl(deps.baseUrl, attemptId, quizId, page);
4988
+ const response = await deps.request(url);
4989
+ const html = await response.text();
4990
+ if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
4991
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not show attempt ${attemptId}: ${noticesOf2(html) || "it redirected elsewhere"}`);
4992
+ return parseAttemptPage(html, response.url, deps);
4993
+ }
4994
+ function withoutForm({ form: _form, ...page }) {
4995
+ return page;
4996
+ }
4997
+ async function answerQuizQuestion(deps, request) {
4998
+ const first2 = await loadAttemptPage(deps, request.attemptId, request.quizId, 0);
4999
+ const entry = first2.navigation.find((item) => item.number === request.question.trim());
5000
+ if (!entry) throw deps.usage(`Attempt ${request.attemptId} has no question ${request.question}.`, `Questions: ${first2.navigation.map((item) => item.number).join(", ")}`);
5001
+ const page = entry.page === first2.page ? first2 : await loadAttemptPage(deps, request.attemptId, request.quizId, entry.page);
5002
+ const question2 = page.questions.find((item) => item.slot === entry.slot);
5003
+ if (!question2) throw deps.fail(`Page ${entry.page + 1} does not contain question ${request.question}.`);
5004
+ const fields2 = encodeAnswer(deps, page.form, question2, request.value);
5005
+ const replay = fields2.map(([name, value]) => name === "nextpage" ? [name, String(page.page)] : [name, value]);
5006
+ const response = await deps.request(page.form.action, postInit(replay));
5007
+ const html = await response.text();
5008
+ if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not save the answer: ${noticesOf2(html) || "it left the attempt page"}`);
5009
+ const after = parseAttemptPage(html, response.url, deps);
5010
+ const saved = after.questions.find((item) => item.slot === question2.slot);
5011
+ if (!saved || /not yet answered|not answered/iu.test(saved.state)) throw deps.fail(`Moodle accepted the post but still reports question ${request.question} as "${saved?.state || "missing"}".`);
5012
+ return withoutForm(after);
5013
+ }
5014
+ async function getAttemptSummary(deps, attemptId, quizId) {
5015
+ const { form: _form, ...summary } = await loadAttemptSummary(deps, attemptId, quizId);
5016
+ return summary;
5017
+ }
5018
+ async function loadAttemptSummary(deps, attemptId, quizId) {
5019
+ const url = `${deps.baseUrl}${QUIZ_SUMMARY_PATH}?attempt=${attemptId}&cmid=${quizId}`;
5020
+ const response = await deps.request(url);
5021
+ const html = await response.text();
5022
+ if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
5023
+ const root = parse5(html);
5024
+ const rows = root.querySelectorAll("table.quizsummaryofattempt tbody tr").flatMap((row) => {
5025
+ const cells = row.querySelectorAll("td");
5026
+ if (cells.length < 2) return [];
5027
+ const link2 = cells[0].querySelector("a")?.getAttribute("href") ?? "";
5028
+ return [{ number: cleanText(cells[0].textContent), state: cleanText(cells[1].textContent), page: numberParam(link2, "page") ?? 0 }];
5029
+ });
5030
+ const finish = root.querySelector("form#frm-finishattempt") ?? formWithAction2(root, QUIZ_PROCESS_PATH);
5031
+ if (!finish || !rows.length) throw deps.fail(`Moodle did not show the summary of attempt ${attemptId}: ${noticesOf2(html) || "the page has no finish button"}`);
5032
+ const form = { action: resolveUrl(deps.baseUrl, finish.getAttribute("action") ?? ""), fields: formFields2(finish) };
5033
+ return { attempt: attemptId, quiz_id: quizId, name: pageHeading(root), rows, url, form };
5034
+ }
5035
+ async function finishQuizAttempt(deps, attemptId, quizId) {
5036
+ const summary = await loadAttemptSummary(deps, attemptId, quizId);
5037
+ const response = await deps.request(summary.form.action, postInit(summary.form.fields));
5038
+ const html = await response.text();
5039
+ const receipt = { attempt: attemptId, quiz_id: quizId, name: summary.name, summary: summary.rows, url: response.url };
5040
+ if (onPath(response.url, QUIZ_REVIEW_PATH)) {
5041
+ receipt.review = parseQuizReviewHtml(html, attemptId, deps.baseUrl);
5042
+ return receipt;
5043
+ }
5044
+ const quiz = parseQuizHtml(onPath(response.url, QUIZ_VIEW_PATH) ? html : await pageText2(deps, `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`), quizId, deps.baseUrl);
5045
+ const row = quiz.attempts.find((attempt) => attempt.id === attemptId);
5046
+ if (row && /in progress/iu.test(row.status)) throw deps.fail(`Moodle accepted the finish request but still lists attempt ${attemptId} as ${row.status}; check the quiz in a browser.`);
5047
+ receipt.url = quiz.url;
5048
+ if (row) {
5049
+ receipt.result = { status: row.status, marks: row.marks, grade: row.grade, completed: row.completed };
5050
+ return receipt;
5051
+ }
5052
+ const probe2 = await deps.request(attemptUrl(deps.baseUrl, attemptId, quizId, 0));
5053
+ if (onPath(probe2.url, QUIZ_ATTEMPT_PATH) && parse5(await probe2.text()).querySelector("form#responseform")) throw deps.fail(`Moodle accepted the finish request but attempt ${attemptId} is still open; check the quiz in a browser.`);
5054
+ return receipt;
5055
+ }
5056
+ function parseAttemptPage(html, url, deps) {
5057
+ const root = parse5(html);
5058
+ const form = root.querySelector("form#responseform");
5059
+ if (!form) throw deps.fail(`Moodle did not render an attempt page: ${noticesOf2(html) || "no response form found"}`);
5060
+ const attempt = numberParam(url, "attempt") ?? Number(form.querySelector("input[name=attempt]")?.getAttribute("value"));
5061
+ const quizId = numberParam(form.getAttribute("action") ?? "", "cmid") ?? numberParam(url, "cmid") ?? 0;
5062
+ const page = Number(form.querySelector("input[name=thispage]")?.getAttribute("value") ?? numberParam(url, "page") ?? 0);
5063
+ const navigation = root.querySelectorAll("a.qnbutton").map((button) => {
5064
+ const title = button.getAttribute("title") ?? "";
5065
+ const match = title.match(/^(?:Question|Information)?\s*(\S+)\s*-\s*(.+)$/u);
5066
+ return {
5067
+ slot: Number(button.getAttribute("id")?.replace(/^quiznavbutton/u, "") ?? 0),
5068
+ number: match?.[1] ?? cleanText(button.textContent),
5069
+ page: Number(button.getAttribute("data-quiz-page") ?? 0),
5070
+ state: match?.[2] ?? ""
5071
+ };
5072
+ });
5073
+ const questions = form.querySelectorAll("div.que").map(parseAttemptQuestion);
5074
+ return {
5075
+ attempt,
5076
+ quiz_id: quizId,
5077
+ name: pageHeading(root),
5078
+ page,
5079
+ pages: Math.max(page + 1, ...navigation.map((entry) => entry.page + 1)),
5080
+ questions,
5081
+ navigation,
5082
+ url: attemptUrl(deps.baseUrl, attempt, quizId, page),
5083
+ form: { action: resolveUrl(deps.baseUrl, form.getAttribute("action") ?? ""), fields: formFields2(form) }
5084
+ };
5085
+ }
5086
+ function parseAttemptQuestion(que) {
5087
+ const slot = Number(que.getAttribute("id")?.split("-").at(-1) ?? 0);
5088
+ const base = {
5089
+ slot,
5090
+ number: cleanText(que.querySelector(".info .qno, .info .no")?.textContent).replace(/^Question\s*/iu, "") || "i",
5091
+ type: que.classList.value[1] ?? "",
5092
+ kind: "unsupported",
5093
+ state: cleanText(que.querySelector(".info .state")?.textContent),
5094
+ text: blockText(que.querySelector(".qtext"))
5095
+ };
5096
+ if (que.classList.contains("description")) return { ...base, number: "i", kind: "info" };
5097
+ const inputs = que.querySelectorAll("input, textarea, select").filter((input2) => {
5098
+ const name = input2.getAttribute("name") ?? "";
5099
+ return name.startsWith("q") && !/_:(?:flagged|sequencecheck)$|_-seen$|_answerformat$/u.test(name) && !/^(?:hidden|submit)$/u.test(input2.getAttribute("type") ?? "");
5100
+ });
5101
+ const tag = (input2) => input2.tagName.toLowerCase();
5102
+ const type = (input2) => tag(input2) === "input" ? (input2.getAttribute("type") ?? "text").toLowerCase() : tag(input2);
5103
+ const isClearChoice = (input2) => type(input2) === "radio" && (input2.closest(".qtype_multichoice_clearchoice") !== null || input2.getAttribute("aria-hidden") === "true");
5104
+ const radios = inputs.filter((input2) => type(input2) === "radio" && !isClearChoice(input2));
5105
+ const boxes = inputs.filter((input2) => type(input2) === "checkbox");
5106
+ const texts = inputs.filter((input2) => ["textarea", "text", "number"].includes(type(input2)));
5107
+ const selects = inputs.filter((input2) => type(input2) === "select");
5108
+ const others = inputs.length - radios.length - boxes.length - texts.length - selects.length - inputs.filter(isClearChoice).length;
5109
+ const radioNames = new Set(radios.map((input2) => input2.getAttribute("name")));
5110
+ const only = (group) => group.length === inputs.length - inputs.filter(isClearChoice).length && others === 0;
5111
+ if (radios.length && radioNames.size === 1 && only(radios)) {
5112
+ return { ...base, kind: "choice", field: radios[0].getAttribute("name"), options: radios.map((input2, index) => option(que, input2, index)) };
5113
+ }
5114
+ if (boxes.length && only(boxes)) return { ...base, kind: "multi", options: boxes.map((input2, index) => option(que, input2, index)) };
5115
+ if (selects.length === 1 && only(selects)) {
5116
+ const select = selects[0];
5117
+ const name = select.getAttribute("name");
5118
+ const choices = select.querySelectorAll("option").filter((item) => (item.getAttribute("value") ?? "") !== "");
5119
+ return { ...base, kind: "choice", field: name, options: choices.map((item, index) => ({ key: String.fromCharCode(97 + index), field: name, value: item.getAttribute("value") ?? "", text: cleanText(item.textContent), chosen: item.hasAttribute("selected") })) };
5120
+ }
5121
+ if (texts.length === 1 && only(texts)) {
5122
+ const text2 = texts[0];
5123
+ const html = tag(text2) === "textarea";
5124
+ return { ...base, kind: "text", field: text2.getAttribute("name"), answer: html ? blockText(parse5(text2.textContent)) : cleanText(text2.getAttribute("value") ?? "") };
5125
+ }
5126
+ return base;
5127
+ }
5128
+ function option(que, input2, index) {
5129
+ const labelId = input2.getAttribute("aria-labelledby");
5130
+ const id2 = input2.getAttribute("id");
5131
+ const label = (labelId ? que.querySelector(`[id="${labelId}"]`) : null) ?? (id2 ? que.querySelector(`label[for="${id2}"]`) : null) ?? input2.parentNode;
5132
+ const text2 = blockText(label) || label?.querySelectorAll("img").map((img) => cleanText(img.getAttribute("alt"))).filter(Boolean).join(" ") || "(image; see the quiz in a browser)";
5133
+ return { key: String.fromCharCode(97 + index), field: input2.getAttribute("name") ?? "", value: input2.getAttribute("value") ?? "", text: text2, chosen: input2.hasAttribute("checked") };
5134
+ }
5135
+ function encodeAnswer(deps, form, question2, value) {
5136
+ const raw = value.trim();
5137
+ if (question2.kind === "info") throw deps.usage(`Question ${question2.number} is an information block; it takes no answer.`);
5138
+ if (question2.kind === "unsupported" || !question2.field && question2.kind !== "multi") throw deps.usage(`Question ${question2.number} is a ${question2.type || "question"} type the CLI cannot answer.`, "Answer it in a browser; other questions can still be answered here.");
5139
+ if (question2.kind === "text") {
5140
+ if (!raw) throw deps.usage(`Question ${question2.number} needs a written answer.`);
5141
+ const format = form.fields.find(([name]) => name === `${question2.field}format`)?.[1];
5142
+ return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, format === "1" ? paragraphs(value) : raw]];
5143
+ }
5144
+ const options = question2.options ?? [];
5145
+ const picks = raw.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
5146
+ const match = options.find((item) => item.key === part.toLowerCase()) ?? options.find((item) => cleanText(item.text).toLowerCase() === part.toLowerCase());
5147
+ if (!match) throw deps.usage(`Question ${question2.number} has no option '${part}'.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
5148
+ return match;
5149
+ });
5150
+ if (!picks.length) throw deps.usage(`Question ${question2.number} needs an option letter.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
5151
+ if (question2.kind === "choice") {
5152
+ if (picks.length > 1) throw deps.usage(`Question ${question2.number} takes one option, not ${picks.length}.`);
5153
+ return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, picks[0].value]];
5154
+ }
5155
+ const chosen = new Set(picks.map((pick) => pick.key));
5156
+ const boxNames = new Set(options.map((item) => item.field));
5157
+ return [
5158
+ ...form.fields.filter(([name]) => !boxNames.has(name)),
5159
+ ...options.map((item) => [item.field, chosen.has(item.key) ? "1" : "0"])
5160
+ ];
5161
+ }
5162
+ function noticesOf2(html) {
5163
+ const root = parse5(html);
5164
+ const texts = [];
5165
+ for (const node of root.querySelectorAll(".alert, .errorbox, .error, #notice")) {
5166
+ for (const junk of node.querySelectorAll("button, .close")) junk.remove();
5167
+ const text2 = cleanText(node.textContent);
5168
+ if (text2 && !texts.includes(text2)) texts.push(text2);
5169
+ }
5170
+ return texts.join(" ");
5171
+ }
5172
+ function formWithAction2(root, path5) {
5173
+ return root.querySelectorAll("form").find((form) => onPath(form.getAttribute("action") ?? "", path5)) ?? null;
5174
+ }
5175
+ function formFields2(form) {
5176
+ const fields2 = [];
5177
+ for (const element of form.querySelectorAll("input, textarea, select")) {
5178
+ const name = element.getAttribute("name");
5179
+ if (!name) continue;
5180
+ const tag = element.tagName.toLowerCase();
5181
+ if (tag === "textarea") {
5182
+ fields2.push([name, element.textContent]);
5183
+ continue;
5184
+ }
5185
+ if (tag === "select") {
5186
+ const options = element.querySelectorAll("option");
5187
+ const chosen = options.find((item) => item.hasAttribute("selected")) ?? options[0];
5188
+ if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
5189
+ continue;
5190
+ }
5191
+ const type = (element.getAttribute("type") ?? "text").toLowerCase();
5192
+ if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
5193
+ if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
5194
+ fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
5195
+ }
5196
+ return fields2;
5197
+ }
5198
+ function postInit(fields2) {
5199
+ return { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields2).toString() };
5200
+ }
5201
+ async function pageText2(deps, url) {
5202
+ return (await deps.request(url)).text();
5203
+ }
5204
+ function attemptUrl(baseUrl, attempt, quizId, page) {
5205
+ return `${baseUrl}${QUIZ_ATTEMPT_PATH}?attempt=${attempt}&cmid=${quizId}${page ? `&page=${page}` : ""}`;
5206
+ }
5207
+ function pageHeading(root) {
5208
+ const heading = cleanText(root.querySelector(".page-header-headings h1, #page-header h1")?.textContent);
5209
+ if (heading) return heading;
5210
+ const title = cleanText(root.querySelector("title")?.textContent).replace(/\s*\(page \d+ of \d+\)/iu, "").split(" | ")[0].trim();
5211
+ return title || cleanText(root.querySelector("h2")?.textContent);
5212
+ }
5213
+ function onPath(url, path5) {
5214
+ try {
5215
+ return new URL(url, "https://moodle.invalid").pathname.endsWith(path5);
5216
+ } catch {
5217
+ return false;
5218
+ }
5219
+ }
5220
+ function numberParam(url, key) {
5221
+ try {
5222
+ const value = Number(new URL(url, "https://moodle.invalid").searchParams.get(key));
5223
+ return Number.isSafeInteger(value) && value > 0 ? value : key === "page" && value === 0 ? 0 : null;
5224
+ } catch {
5225
+ return null;
5226
+ }
5227
+ }
5228
+ function paragraphs(text2) {
5229
+ const escaped = text2.trim().replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
5230
+ return escaped.split(/\n\s*\n/u).map((block) => `<p>${block.trim().replace(/\n/gu, "<br>")}</p>`).join("");
5231
+ }
5232
+
4781
5233
  // src/parsers.ts
4782
5234
  function schema(parser) {
4783
5235
  return { parse: parser };
@@ -5778,6 +6230,10 @@ var MoodleClientCore = class {
5778
6230
  async getQuiz(id2) {
5779
6231
  return parseQuizHtml(await this.get(QUIZ_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
5780
6232
  }
6233
+ async getQuizAttempt(attemptId) {
6234
+ await this.ensureSession();
6235
+ return parseQuizReviewHtml(await this.get(QUIZ_REVIEW_PATH, { attempt: attemptId, showall: 1 }), attemptId, this.baseUrl);
6236
+ }
5781
6237
  async getResource(id2) {
5782
6238
  const url = `${this.baseUrl}${RESOURCE_VIEW_PATH}?id=${id2}`;
5783
6239
  const response = await this.requestAbsolute(url);
@@ -5826,6 +6282,32 @@ var MoodleClientCore = class {
5826
6282
  usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
5827
6283
  }, request);
5828
6284
  }
6285
+ /** Starts a new attempt, or resumes the one already in progress, and returns its first page. */
6286
+ async startQuizAttempt(quizId, options = {}) {
6287
+ return startQuizAttempt(await this.quizDeps(), quizId, options);
6288
+ }
6289
+ async getQuizAttemptPage(attemptId, quizId, page = 0) {
6290
+ return getAttemptPage(await this.quizDeps(), attemptId, quizId, page);
6291
+ }
6292
+ async getQuizAttemptSummary(attemptId, quizId) {
6293
+ return getAttemptSummary(await this.quizDeps(), attemptId, quizId);
6294
+ }
6295
+ async answerQuizQuestion(request) {
6296
+ return answerQuizQuestion(await this.quizDeps(), request);
6297
+ }
6298
+ /** Submits the attempt for grading. Moodle treats this as final. */
6299
+ async finishQuizAttempt(attemptId, quizId) {
6300
+ return finishQuizAttempt(await this.quizDeps(), attemptId, quizId);
6301
+ }
6302
+ async quizDeps() {
6303
+ await this.ensureSession();
6304
+ return {
6305
+ baseUrl: this.baseUrl,
6306
+ request: (url, init, options) => this.requestAbsolute(url, init, options),
6307
+ fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
6308
+ usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
6309
+ };
6310
+ }
5829
6311
  async getNewsForums(courseId) {
5830
6312
  const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
5831
6313
  const forums = [];
@@ -6294,6 +6776,202 @@ function authToClientSession(auth) {
6294
6776
  };
6295
6777
  }
6296
6778
 
6779
+ // src/update-check.ts
6780
+ import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
6781
+ import { chmod as chmod4, mkdir as mkdir7, readFile as readFile7, rename as rename2, rm as rm5, writeFile as writeFile6 } from "fs/promises";
6782
+ import { realpathSync as realpathSync3 } from "fs";
6783
+ import { arch, homedir as homedir11, platform } from "os";
6784
+ import { join as join10 } from "path";
6785
+
6786
+ // src/update-core.ts
6787
+ var PACKAGE_NAME2 = "moodle-cli";
6788
+ var LATEST_VERSION_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME2}/dist-tags`;
6789
+ var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
6790
+ var UPDATE_RETRY_MS = 60 * 60 * 1e3;
6791
+ var GITHUB_REPOSITORY = "bunizao/moodle-cli";
6792
+ function compareVersions(a, b) {
6793
+ const [aMain, aPre] = a.split("-", 2);
6794
+ const [bMain, bPre] = b.split("-", 2);
6795
+ const left = aMain.split(".").map(Number);
6796
+ const right = bMain.split(".").map(Number);
6797
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
6798
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
6799
+ if (diff !== 0) return Math.sign(diff);
6800
+ }
6801
+ if (Boolean(aPre) === Boolean(bPre)) return (aPre ?? "").localeCompare(bPre ?? "");
6802
+ return aPre ? -1 : 1;
6803
+ }
6804
+ function isNewerVersion(candidate, current2) {
6805
+ return Boolean(candidate) && /^\d+\.\d+\.\d+/u.test(candidate) && compareVersions(candidate, current2) > 0;
6806
+ }
6807
+ async function fetchLatestVersion(fetchImpl = fetch, timeoutMs = 5e3) {
6808
+ try {
6809
+ const response = await fetchImpl(LATEST_VERSION_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
6810
+ if (!response.ok) return null;
6811
+ const tags = await response.json();
6812
+ return typeof tags.latest === "string" ? tags.latest : null;
6813
+ } catch {
6814
+ return null;
6815
+ }
6816
+ }
6817
+ function updateHint(current2, latest) {
6818
+ return `moodle-cli ${latest} is available (running ${current2}). Run: moodle update`;
6819
+ }
6820
+ function standaloneUpdateHint(current2, latest) {
6821
+ return `moodle-cli ${latest} is available (running ${current2}). Run: moodle update`;
6822
+ }
6823
+ var STANDALONE_TARGETS = /* @__PURE__ */ new Set(["darwin-arm64", "linux-x64"]);
6824
+ function standaloneAssetUrl(version, platform2, arch2) {
6825
+ const target = `${platform2}-${arch2}`;
6826
+ if (!STANDALONE_TARGETS.has(target)) return null;
6827
+ return `https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}/moodle-${target}`;
6828
+ }
6829
+
6830
+ // src/version.ts
6831
+ var VERSION = "0.9.4";
6832
+
6833
+ // src/update-check.ts
6834
+ var UPDATE_CACHE_FILENAME = "update-check.json";
6835
+ var ENV_NO_UPDATE_CHECK = "MOODLE_NO_UPDATE_CHECK";
6836
+ function updateCachePath(homeDir = homedir11()) {
6837
+ return join10(homeDir, CONFIG_DIR_NAME, UPDATE_CACHE_FILENAME);
6838
+ }
6839
+ async function readUpdateCache(homeDir) {
6840
+ try {
6841
+ const parsed = JSON.parse(await readFile7(updateCachePath(homeDir), "utf8"));
6842
+ return parsed && typeof parsed === "object" ? parsed : {};
6843
+ } catch {
6844
+ return {};
6845
+ }
6846
+ }
6847
+ async function writeUpdateCache(cache, homeDir) {
6848
+ const file2 = updateCachePath(homeDir);
6849
+ await mkdir7(join10(file2, ".."), { recursive: true });
6850
+ await writeFile6(file2, `${JSON.stringify(cache)}
6851
+ `, { mode: 384 });
6852
+ }
6853
+ async function refreshLatestVersion(options = {}) {
6854
+ const latest = await fetchLatestVersion(options.fetchImpl);
6855
+ const now = (options.now ?? Date.now)();
6856
+ const cache = await readUpdateCache(options.homeDir);
6857
+ await writeUpdateCache(latest ? { ...cache, latest, checked_at: now, failed_at: void 0 } : { ...cache, failed_at: now }, options.homeDir);
6858
+ return latest;
6859
+ }
6860
+ function refreshDue(cache, now) {
6861
+ if (now - (cache.checked_at ?? 0) < UPDATE_CHECK_TTL_MS) return false;
6862
+ return now - (cache.failed_at ?? 0) >= UPDATE_RETRY_MS;
6863
+ }
6864
+ var QUIET_COMMANDS = /* @__PURE__ */ new Set(["update", "dev", "completion", "commands", "skills", "mcp", "doctor"]);
6865
+ function startupCheckApplies(args, env = process.env) {
6866
+ if (env[ENV_NO_UPDATE_CHECK] || env.CI) return false;
6867
+ const first2 = args.find((arg) => !arg.startsWith("-"));
6868
+ return first2 === void 0 || !QUIET_COMMANDS.has(first2);
6869
+ }
6870
+ async function startupUpdateNotice(args, stderr, options = {}) {
6871
+ const env = options.env ?? process.env;
6872
+ if (!startupCheckApplies(args, env)) return;
6873
+ const now = (options.now ?? Date.now)();
6874
+ const cache = await readUpdateCache(options.homeDir);
6875
+ if (isNewerVersion(cache.latest, VERSION) && now - (cache.notified_at ?? 0) >= UPDATE_CHECK_TTL_MS) {
6876
+ stderr.write(`${selfCommand().args.length ? updateHint(VERSION, cache.latest) : standaloneUpdateHint(VERSION, cache.latest)}
6877
+ `);
6878
+ await writeUpdateCache({ ...cache, notified_at: now }, options.homeDir);
6879
+ }
6880
+ if (refreshDue(cache, now)) spawnRefresh(env);
6881
+ }
6882
+ function spawnRefresh(env) {
6883
+ const self = selfCommand();
6884
+ try {
6885
+ const child = spawn3(self.command, [...self.args, "update", "--check", "--quiet"], { detached: true, stdio: "ignore", env: { ...env, [ENV_NO_UPDATE_CHECK]: "1" } });
6886
+ child.unref();
6887
+ } catch {
6888
+ }
6889
+ }
6890
+ function detectInstallKind(argv = process.argv, execPath = process.execPath) {
6891
+ const self = selfCommand(argv, execPath);
6892
+ if (!self.args.length) return "standalone";
6893
+ let script = self.args[0];
6894
+ try {
6895
+ script = realpathSync3(script);
6896
+ } catch {
6897
+ }
6898
+ return /[\\/]\.bun[\\/]/u.test(script) || /[\\/]bun[\\/]install[\\/]global[\\/]/u.test(script) ? "bun" : "npm";
6899
+ }
6900
+ function installCommand(kind) {
6901
+ if (kind === "bun") return { command: findExecutable("bun") ?? "bun", args: ["add", "--global", "moodle-cli@latest"] };
6902
+ if (kind === "npm") return { command: findExecutable("npm") ?? "npm", args: ["install", "-g", "moodle-cli@latest"] };
6903
+ return null;
6904
+ }
6905
+ async function replaceStandalone(execPath, version, fetchImpl = fetch, host = { platform: platform(), arch: arch() }) {
6906
+ const url = standaloneAssetUrl(version, host.platform, host.arch);
6907
+ if (!url) return `No standalone build is published for ${host.platform}-${host.arch}. See ${GITHUB_RELEASES_URL}`;
6908
+ const staging = `${execPath}.${process.pid}.download`;
6909
+ try {
6910
+ const response = await fetchImpl(url, { redirect: "follow" });
6911
+ if (!response.ok) return `Download failed with HTTP ${response.status} for ${url}`;
6912
+ await writeFile6(staging, new Uint8Array(await response.arrayBuffer()), { mode: 493 });
6913
+ await chmod4(staging, 493);
6914
+ await rename2(staging, execPath);
6915
+ return null;
6916
+ } catch (error) {
6917
+ await rm5(staging, { force: true });
6918
+ return `Could not replace ${execPath}: ${error instanceof Error ? error.message : String(error)}`;
6919
+ }
6920
+ }
6921
+ function readOutput(command, args) {
6922
+ const result = spawnSync3(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
6923
+ return result.status === 0 ? result.stdout : null;
6924
+ }
6925
+ async function runUpdate(options) {
6926
+ const run = options.runCommand ?? ((command, args) => spawnSync3(command, args, { stdio: ["inherit", 2, "inherit"], env: options.env }));
6927
+ const install = detectInstallKind(options.argv, options.execPath);
6928
+ const latest = await refreshLatestVersion(options);
6929
+ const report = { current: VERSION, latest, install, updated: false, deployed: false, ok: true, note: "" };
6930
+ const newer = isNewerVersion(latest ?? void 0, VERSION);
6931
+ const self = selfCommand(options.argv, options.execPath);
6932
+ if (newer) {
6933
+ const command = installCommand(install);
6934
+ if (command) {
6935
+ const result2 = run(command.command, command.args);
6936
+ if (result2.status !== 0) {
6937
+ report.ok = false;
6938
+ report.note = `${command.command} exited with ${result2.status ?? "a signal"}; the package was not updated.`;
6939
+ return report;
6940
+ }
6941
+ } else {
6942
+ const failure = await replaceStandalone(options.execPath ?? process.execPath, latest, options.fetchImpl);
6943
+ if (failure) {
6944
+ report.ok = false;
6945
+ report.note = `${standaloneUpdateHint(VERSION, latest)} ${failure}`;
6946
+ return report;
6947
+ }
6948
+ }
6949
+ const installed = (options.readOutput ?? readOutput)(self.command, [...self.args, "--version"])?.trim();
6950
+ if (installed !== latest) {
6951
+ report.ok = false;
6952
+ report.note = `The installer finished but ${self.args[0] ?? self.command} reports ${installed || "no version"} instead of ${latest}; another moodle install may be on PATH.`;
6953
+ return report;
6954
+ }
6955
+ report.updated = true;
6956
+ }
6957
+ const unreachable = latest === null ? "The npm registry could not be reached, so the installed version was not checked." : "";
6958
+ if (options.workerBehind === void 0) {
6959
+ report.ok = !unreachable;
6960
+ report.note = unreachable || (newer ? `Updated to ${latest}.` : "Already up to date.");
6961
+ return report;
6962
+ }
6963
+ if (!newer && !options.workerBehind) {
6964
+ report.ok = !unreachable;
6965
+ report.note = unreachable ? `${unreachable} The Worker is current.` : "Package and Worker are up to date.";
6966
+ return report;
6967
+ }
6968
+ const result = run(self.command, [...self.args, "mcp", "deploy", "--yes"]);
6969
+ report.deployed = result.status === 0;
6970
+ report.ok = report.deployed && !unreachable;
6971
+ report.note = [unreachable, report.deployed ? `Worker redeployed from ${newer ? latest : VERSION}.` : "Worker deploy failed; run moodle mcp deploy to retry."].filter(Boolean).join(" ");
6972
+ return report;
6973
+ }
6974
+
6297
6975
  // src/formatters.ts
6298
6976
  function formatUser(user) {
6299
6977
  return renderKeyValueTable([
@@ -6479,15 +7157,58 @@ function formatTimestamp(value) {
6479
7157
  const pad = (part) => String(part).padStart(2, "0");
6480
7158
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
6481
7159
  }
7160
+ function formatAttemptPage(page) {
7161
+ const lines = [`${page.name} attempt ${page.attempt} page ${page.page + 1} of ${page.pages}`, ""];
7162
+ for (const question2 of page.questions) lines.push(...attemptQuestionLines(question2), "");
7163
+ const elsewhere = page.navigation.filter((entry) => entry.page !== page.page && entry.number !== "i");
7164
+ if (elsewhere.length) lines.push(`Other pages: ${elsewhere.map((entry) => `Q${entry.number} p${entry.page + 1} (${entry.state.toLowerCase()})`).join(", ")}`);
7165
+ return lines.join("\n").trimEnd();
7166
+ }
7167
+ function attemptQuestionLines(question2) {
7168
+ const head = question2.kind === "info" ? "Information" : `Question ${question2.number} ${question2.state}`;
7169
+ const lines = [head, ...wrap(question2.text)];
7170
+ for (const option2 of question2.options ?? []) {
7171
+ const [first2, ...rest] = wrap(option2.text, 88);
7172
+ lines.push(` ${option2.chosen ? "[x]" : "[ ]"} ${option2.key})${first2.slice(1)}`, ...rest.map((line) => ` ${line}`));
7173
+ }
7174
+ if (question2.kind === "text") lines.push(` Answer: ${question2.answer || "(empty)"}`);
7175
+ if (question2.kind === "unsupported") lines.push(` (${question2.type} questions can only be answered in a browser)`);
7176
+ return lines;
7177
+ }
7178
+ function wrap(text2, width = 96) {
7179
+ const lines = [];
7180
+ let line = "";
7181
+ for (const word of text2.split(/\s+/u).filter(Boolean)) {
7182
+ if (line && line.length + word.length + 1 > width) {
7183
+ lines.push(` ${line}`);
7184
+ line = word;
7185
+ } else line = line ? `${line} ${word}` : word;
7186
+ }
7187
+ if (line || !lines.length) lines.push(` ${line}`);
7188
+ return lines;
7189
+ }
7190
+ function formatAttemptSummary(summary) {
7191
+ return renderTerminalTable([{ label: "Question" }, { label: "Status" }], summary.rows.map((row) => [row.number, row.state]), { title: `${summary.name} attempt ${summary.attempt}` });
7192
+ }
7193
+ function formatAttemptFinish(receipt) {
7194
+ const result = receipt.review ? [["Status", receipt.review.status], ["Marks", receipt.review.marks], ["Grade", receipt.review.grade], ["Completed", receipt.review.completed]] : receipt.result ? [["Status", receipt.result.status], ["Marks", receipt.result.marks], ["Grade", receipt.result.grade], ["Completed", receipt.result.completed]] : [];
7195
+ return renderKeyValueTable([
7196
+ ["Quiz", receipt.name],
7197
+ ["Attempt", String(receipt.attempt)],
7198
+ ...result,
7199
+ ["Answered", `${receipt.summary.filter((row) => !/not yet answered/iu.test(row.state)).length} of ${receipt.summary.length}`],
7200
+ ["URL", receipt.url]
7201
+ ], { title: "Attempt submitted" });
7202
+ }
6482
7203
 
6483
7204
  // src/download.ts
6484
7205
  import { createWriteStream } from "fs";
6485
- import { link, lstat, rename as rename2, unlink } from "fs/promises";
7206
+ import { link, lstat, rename as rename3, unlink } from "fs/promises";
6486
7207
  import { randomUUID } from "crypto";
6487
7208
  import path2 from "path";
6488
7209
  import { Readable, Transform } from "stream";
6489
7210
  import { pipeline } from "stream/promises";
6490
- import { parse as parse5 } from "node-html-parser";
7211
+ import { parse as parse6 } from "node-html-parser";
6491
7212
  var ACCEPTED_SOURCE_HINT = "Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL.";
6492
7213
  var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
6493
7214
  "EACCES",
@@ -6609,7 +7330,7 @@ async function responseOrWrapper(client, requestUrl, sourceUrl, targetName, sign
6609
7330
  };
6610
7331
  }
6611
7332
  function resourceLinks2(html, baseUrl) {
6612
- const root = parse5(html);
7333
+ const root = parse6(html);
6613
7334
  const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((linkNode) => ({
6614
7335
  name: linkNode.textContent.trim(),
6615
7336
  url: new URL(linkNode.getAttribute("href") ?? "", baseUrl).toString()
@@ -6625,7 +7346,7 @@ function isHtmlWrapper(response) {
6625
7346
  return type.includes("text/html") || type.includes("application/xhtml+xml");
6626
7347
  }
6627
7348
  function looksLikeLoginPage3(html) {
6628
- const root = parse5(html);
7349
+ const root = parse6(html);
6629
7350
  return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
6630
7351
  }
6631
7352
  function chooseUpstreamFilename(resolved) {
@@ -6699,7 +7420,7 @@ async function writeResponse(response, destination, force, signal) {
6699
7420
  const input2 = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
6700
7421
  await pipeline(input2, counter, createWriteStream(temporaryPath, { flags: "wx" }), { signal });
6701
7422
  if (force) {
6702
- await rename2(temporaryPath, destination);
7423
+ await rename3(temporaryPath, destination);
6703
7424
  } else {
6704
7425
  try {
6705
7426
  await link(temporaryPath, destination);
@@ -6756,7 +7477,7 @@ function isFileSystemError(error) {
6756
7477
  }
6757
7478
 
6758
7479
  // src/skills.ts
6759
- import { spawnSync as spawnSync3 } from "child_process";
7480
+ import { spawnSync as spawnSync4 } from "child_process";
6760
7481
  import { mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
6761
7482
  import path3 from "path";
6762
7483
 
@@ -6768,7 +7489,7 @@ function describeProgram(program) {
6768
7489
  name: program.name(),
6769
7490
  version: program.version() ?? "",
6770
7491
  description: program.description(),
6771
- commands: program.commands.filter((command) => command.name() !== "help").map((command) => describeCommand(command))
7492
+ commands: program.commands.filter((command) => command.name() !== "help" && command.name() !== "dev").map((command) => describeCommand(command))
6772
7493
  };
6773
7494
  }
6774
7495
  function describeCommand(command, noun) {
@@ -6795,20 +7516,20 @@ function describeArgument(argument) {
6795
7516
  ...argument.argChoices ? { enumValues: argument.argChoices } : {}
6796
7517
  };
6797
7518
  }
6798
- function describeOption(option) {
7519
+ function describeOption(option2) {
6799
7520
  return {
6800
- flags: option.flags,
6801
- description: option.description,
6802
- required: option.required,
6803
- variadic: option.variadic,
6804
- ...option.argChoices ? { enumValues: option.argChoices } : {}
7521
+ flags: option2.flags,
7522
+ description: option2.description,
7523
+ required: option2.required,
7524
+ variadic: option2.variadic,
7525
+ ...option2.argChoices ? { enumValues: option2.argChoices } : {}
6805
7526
  };
6806
7527
  }
6807
7528
  function isMutating(command) {
6808
7529
  if (VERB_SET.has(command.name())) {
6809
7530
  return ["send", "submit", "set", "mark-read"].includes(command.name());
6810
7531
  }
6811
- return ["install", "uninstall", "login", "deploy", "connect", "pair", "revoke", "remove", "push"].includes(command.name());
7532
+ return ["install", "uninstall", "login", "deploy", "connect", "pair", "revoke", "remove", "push", "update", "start", "answer", "finish"].includes(command.name());
6812
7533
  }
6813
7534
 
6814
7535
  // src/skills.ts
@@ -6839,7 +7560,7 @@ function buildSkillsAddCommand(extraArgs = [], launcher = "npx") {
6839
7560
  return ["npm", "exec", "--yes", "--", "skills", "add", SKILL_SOURCE, ...extraArgs];
6840
7561
  }
6841
7562
  function addSkill(extraArgs = [], options = {}) {
6842
- const runCommand = options.runCommand ?? spawnSync3;
7563
+ const runCommand = options.runCommand ?? spawnSync4;
6843
7564
  const commandExists = options.commandExists ?? ((name) => isCommandAvailable(name, runCommand));
6844
7565
  const command = commandExists("npx") ? buildSkillsAddCommand(extraArgs, "npx") : commandExists("npm") ? buildSkillsAddCommand(extraArgs, "npm") : void 0;
6845
7566
  if (!command) {
@@ -6872,11 +7593,11 @@ function commandDescriptionRows(command, parentPath = []) {
6872
7593
  required: argument.required,
6873
7594
  variadic: argument.variadic
6874
7595
  })),
6875
- flags: command.options.map((option) => {
6876
- const names = option.flags.match(/-{1,2}[\w-]+/g) ?? [];
6877
- const name = names.find((value) => value.startsWith("--")) ?? names[0] ?? option.flags;
7596
+ flags: command.options.map((option2) => {
7597
+ const names = option2.flags.match(/-{1,2}[\w-]+/g) ?? [];
7598
+ const name = names.find((value) => value.startsWith("--")) ?? names[0] ?? option2.flags;
6878
7599
  const alias = names.find((value) => value !== name);
6879
- return { name, alias, description: option.description, required: option.required };
7600
+ return { name, alias, description: option2.description, required: option2.required };
6880
7601
  })
6881
7602
  };
6882
7603
  return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path5))];
@@ -7155,9 +7876,6 @@ async function readAll(input2) {
7155
7876
  return chunks2.join("");
7156
7877
  }
7157
7878
 
7158
- // src/version.ts
7159
- var VERSION = "0.9.2";
7160
-
7161
7879
  // src/forum.ts
7162
7880
  function parseDiscussionReference(value) {
7163
7881
  const raw = value.trim();
@@ -7312,9 +8030,9 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
7312
8030
  // src/mcp/cli.ts
7313
8031
  import { createTheme as createTheme4 } from "@bunizao/cli-kit";
7314
8032
  import { createHash as createHash3 } from "crypto";
7315
- import { readFile as readFile9 } from "fs/promises";
7316
- import { homedir as homedir14 } from "os";
7317
- import { join as join13 } from "path";
8033
+ import { readFile as readFile10 } from "fs/promises";
8034
+ import { homedir as homedir15 } from "os";
8035
+ import { join as join14 } from "path";
7318
8036
  import { createInterface } from "readline/promises";
7319
8037
  import { fileURLToPath } from "url";
7320
8038
 
@@ -7848,9 +8566,9 @@ function resolveConnection(options) {
7848
8566
  }
7849
8567
 
7850
8568
  // src/mcp/connectors/node-connectors.ts
7851
- import { chmod as chmod4, mkdir as mkdir7, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
7852
- import { homedir as homedir11 } from "os";
7853
- import { dirname as dirname6, join as join10 } from "path";
8569
+ import { chmod as chmod5, mkdir as mkdir8, readFile as readFile8, rm as rm6, stat as stat3, writeFile as writeFile7 } from "fs/promises";
8570
+ import { homedir as homedir12 } from "os";
8571
+ import { dirname as dirname6, join as join11 } from "path";
7854
8572
  var NodeConnectorFileSystem = class {
7855
8573
  async exists(path5) {
7856
8574
  try {
@@ -7864,20 +8582,20 @@ var NodeConnectorFileSystem = class {
7864
8582
  }
7865
8583
  }
7866
8584
  async readText(path5) {
7867
- return readFile7(path5, "utf8");
8585
+ return readFile8(path5, "utf8");
7868
8586
  }
7869
8587
  async writePrivate(path5, content) {
7870
- await mkdir7(dirname6(path5), { recursive: true, mode: 448 });
7871
- await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
7872
- await chmod4(path5, 384);
8588
+ await mkdir8(dirname6(path5), { recursive: true, mode: 448 });
8589
+ await writeFile7(path5, content, { encoding: "utf8", mode: 384 });
8590
+ await chmod5(path5, 384);
7873
8591
  }
7874
8592
  async remove(path5) {
7875
- await rm5(path5, { force: true });
8593
+ await rm6(path5, { force: true });
7876
8594
  }
7877
8595
  };
7878
8596
  function createDefaultClientConnectors(profile, options = {}) {
7879
- const home = options.homeDirectory ?? homedir11();
7880
- const platform = options.platform ?? process.platform;
8597
+ const home = options.homeDirectory ?? homedir12();
8598
+ const platform2 = options.platform ?? process.platform;
7881
8599
  const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
7882
8600
  const runtime = runtimeCommand(options.command, options.commandArgs);
7883
8601
  const shared = {
@@ -7888,14 +8606,14 @@ function createDefaultClientConnectors(profile, options = {}) {
7888
8606
  endpoint: options.endpoint,
7889
8607
  accessToken: options.accessToken
7890
8608
  };
7891
- const claudeDesktop = platform === "darwin" ? join10(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform === "win32" ? join10(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join10(home, ".config", "Claude", "claude_desktop_config.json");
7892
- const vscodeUser = platform === "darwin" ? join10(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform === "win32" ? join10(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join10(home, ".config", "Code", "User", "mcp.json");
8609
+ const claudeDesktop = platform2 === "darwin" ? join11(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform2 === "win32" ? join11(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join11(home, ".config", "Claude", "claude_desktop_config.json");
8610
+ const vscodeUser = platform2 === "darwin" ? join11(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform2 === "win32" ? join11(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join11(home, ".config", "Code", "User", "mcp.json");
7893
8611
  return [
7894
- createCodexConnector({ ...shared, configPath: join10(home, ".codex", "config.toml"), detectionPath: join10(home, ".codex") }, fileSystem),
8612
+ createCodexConnector({ ...shared, configPath: join11(home, ".codex", "config.toml"), detectionPath: join11(home, ".codex") }, fileSystem),
7895
8613
  createClaudeDesktopConnector({ ...shared, configPath: claudeDesktop, detectionPath: dirname6(claudeDesktop) }, fileSystem),
7896
- createClaudeCodeConnector({ ...shared, configPath: join10(home, ".claude.json"), detectionPath: join10(home, ".claude") }, fileSystem),
8614
+ createClaudeCodeConnector({ ...shared, configPath: join11(home, ".claude.json"), detectionPath: join11(home, ".claude") }, fileSystem),
7897
8615
  createVsCodeConnector({ ...shared, configPath: vscodeUser, detectionPath: dirname6(vscodeUser) }, fileSystem),
7898
- createCursorConnector({ ...shared, configPath: join10(home, ".cursor", "mcp.json"), detectionPath: join10(home, ".cursor") }, fileSystem)
8616
+ createCursorConnector({ ...shared, configPath: join11(home, ".cursor", "mcp.json"), detectionPath: join11(home, ".cursor") }, fileSystem)
7899
8617
  ];
7900
8618
  }
7901
8619
  var DefaultClientIntegration = class {
@@ -8056,13 +8774,14 @@ function successfulDeploymentCopy(input2, theme = PLAIN) {
8056
8774
  field("Session", theme.status("ready", { ready: "success" })),
8057
8775
  "",
8058
8776
  theme.subject("Renewal"),
8059
- ` ${theme.dim("Installed on this computer")}`,
8060
- ` ${theme.dim("Next check: within 30 minutes")}`,
8777
+ // People did not ask for a scheduler, so the first thing to say is what they will
8778
+ // notice: nothing, unless Moodle signs them out. The mechanics come last and dim.
8779
+ " A silent background check every 30 minutes; you never see it.",
8780
+ ` If Moodle signs you out it re-uploads your browser cookie, or sends one notification to run ${theme.key("moodle mcp login")}.`,
8781
+ ...input2.renewal ? [` ${theme.dim(`${input2.renewal.scheduler} ${input2.renewal.label} \xB7 log ${input2.renewal.log} \xB7 remove with moodle mcp remove`)}`] : [],
8061
8782
  "",
8062
8783
  theme.subject("Connected clients"),
8063
- connectedClients,
8064
- "",
8065
- `Run ${theme.key("moodle mcp status")} at any time.`
8784
+ connectedClients
8066
8785
  ].join("\n");
8067
8786
  }
8068
8787
 
@@ -8368,6 +9087,7 @@ var ManagedMcpDeployment = class {
8368
9087
  profile,
8369
9088
  worker: null,
8370
9089
  credentialsStored: false,
9090
+ credentialsAvailable: true,
8371
9091
  renewalInstalled: await this.dependencies.renewal.inspect(profile),
8372
9092
  clientsConnected: await this.dependencies.clients.inspect(profile),
8373
9093
  readiness: "unknown",
@@ -8378,15 +9098,16 @@ var ManagedMcpDeployment = class {
8378
9098
  }
8379
9099
  const [worker, credentials, renewalInstalled, clientsConnected] = await Promise.all([
8380
9100
  this.dependencies.wrangler.inspect(receipt.accountId, receipt.workerName),
8381
- this.dependencies.credentials.read(profile),
9101
+ // Reporting is not worth failing over: an unopenable keychain is reported, not thrown.
9102
+ readCredentialsForReport(() => this.dependencies.credentials.read(profile)),
8382
9103
  this.dependencies.renewal.inspect(profile),
8383
9104
  this.dependencies.clients.inspect(profile)
8384
9105
  ]);
8385
9106
  let readiness = "unknown";
8386
9107
  let readinessReasonCode = null;
8387
9108
  let sessionRevision = null;
8388
- if (worker && credentials) {
8389
- const target = { endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.sessionSyncToken };
9109
+ if (worker && credentials.value) {
9110
+ const target = { endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.value.sessionSyncToken };
8390
9111
  await this.dependencies.worker.touchSession(target);
8391
9112
  const remoteReadiness = await this.dependencies.worker.getReadiness(target);
8392
9113
  readiness = remoteReadiness.status;
@@ -8397,7 +9118,8 @@ var ManagedMcpDeployment = class {
8397
9118
  return {
8398
9119
  profile,
8399
9120
  worker: resolvedWorker,
8400
- credentialsStored: credentials !== null,
9121
+ credentialsStored: credentials.value !== null,
9122
+ credentialsAvailable: credentials.available,
8401
9123
  renewalInstalled,
8402
9124
  clientsConnected,
8403
9125
  readiness,
@@ -8671,10 +9393,10 @@ function asDeploymentError(error) {
8671
9393
 
8672
9394
  // src/mcp/wrangler.ts
8673
9395
  import { createUi as createUi2 } from "@bunizao/cli-kit";
8674
- import { mkdir as mkdir8, writeFile as writeFile7 } from "fs/promises";
9396
+ import { mkdir as mkdir9, writeFile as writeFile8 } from "fs/promises";
8675
9397
  import { existsSync } from "fs";
8676
- import { homedir as homedir12 } from "os";
8677
- import { join as join11 } from "path";
9398
+ import { homedir as homedir13 } from "os";
9399
+ import { join as join12 } from "path";
8678
9400
  async function resolveWrangler(runner, options = {}) {
8679
9401
  const env = options.env ?? process.env;
8680
9402
  const notice = options.notice ?? ((text2) => process.stderr.write(`${text2}
@@ -8685,8 +9407,8 @@ async function resolveWrangler(runner, options = {}) {
8685
9407
  if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
8686
9408
  notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
8687
9409
  }
8688
- const root = join11(options.homeDir ?? homedir12(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
8689
- const script = join11(root, "node_modules", "wrangler", "bin", "wrangler.js");
9410
+ const root = join12(options.homeDir ?? homedir13(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
9411
+ const script = join12(root, "node_modules", "wrangler", "bin", "wrangler.js");
8690
9412
  const bun = findExecutable("bun", env);
8691
9413
  const node = findExecutable("node", env);
8692
9414
  if (!bun && !node) throw new Error("Cloudflare management needs Bun or Node 22.13+. Install either, then retry moodle mcp deploy.");
@@ -8702,8 +9424,8 @@ async function resolveWrangler(runner, options = {}) {
8702
9424
  }
8703
9425
  }
8704
9426
  notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
8705
- await mkdir8(root, { recursive: true, mode: 448 });
8706
- await writeFile7(join11(root, "package.json"), '{ "private": true }\n');
9427
+ await mkdir9(root, { recursive: true, mode: 448 });
9428
+ await writeFile8(join12(root, "package.json"), '{ "private": true }\n');
8707
9429
  const result = await runner.run(bun ?? npm, bun ? ["install", "--cwd", root, "--no-save", `wrangler@${WRANGLER_VERSION}`] : ["install", "--prefix", root, "--no-save", "--package-lock=false", "--no-audit", "--no-fund", `wrangler@${WRANGLER_VERSION}`]);
8708
9430
  if (!existsSync(script)) {
8709
9431
  const output = `${result.stderr}
@@ -8723,18 +9445,18 @@ function sameMajorAtLeast(actual, pinned) {
8723
9445
 
8724
9446
  // src/mcp/deployment/node-adapters.ts
8725
9447
  import { isDeepStrictEqual } from "util";
8726
- import { spawn as spawn3 } from "child_process";
9448
+ import { spawn as spawn4 } from "child_process";
8727
9449
  import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
8728
- import { chmod as chmod5, mkdir as mkdir9, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile8 } from "fs/promises";
8729
- import { homedir as homedir13, tmpdir } from "os";
8730
- import { basename, dirname as dirname7, join as join12 } from "path";
9450
+ import { chmod as chmod6, mkdir as mkdir10, mkdtemp, readFile as readFile9, rm as rm7, writeFile as writeFile9 } from "fs/promises";
9451
+ import { homedir as homedir14, tmpdir } from "os";
9452
+ import { basename, dirname as dirname7, join as join13 } from "path";
8731
9453
  var MODERN_MCP_VERSION = "2026-07-28";
8732
9454
  var WORKER_PROPAGATION_ATTEMPTS = 10;
8733
9455
  var WORKER_PROPAGATION_MAX_DELAY_MS = 4e3;
8734
9456
  var NodeDeploymentCommandRunner = class {
8735
9457
  async run(command, args, environment = {}) {
8736
9458
  return new Promise((resolve, reject) => {
8737
- const child = spawn3(command, args, {
9459
+ const child = spawn4(command, args, {
8738
9460
  env: { ...process.env, ...environment },
8739
9461
  stdio: ["ignore", "pipe", "pipe"],
8740
9462
  windowsHide: true
@@ -8968,7 +9690,7 @@ ${error.stderr}`)) {
8968
9690
  }
8969
9691
  };
8970
9692
  async function copyReleaseBundle(source, destination) {
8971
- await writeFile8(destination, await readFile8(source));
9693
+ await writeFile9(destination, await readFile9(source));
8972
9694
  }
8973
9695
  var NodeReleaseMaterializer = class {
8974
9696
  constructor(options) {
@@ -8977,13 +9699,13 @@ var NodeReleaseMaterializer = class {
8977
9699
  options;
8978
9700
  async prepare(plan, credentials) {
8979
9701
  const temporaryRoot = this.options.temporaryRoot ?? tmpdir();
8980
- await mkdir9(temporaryRoot, { recursive: true });
8981
- const artifactDirectory = await mkdtemp(join12(temporaryRoot, "moodle-mcp-"));
8982
- await chmod5(artifactDirectory, 448);
8983
- const workerFile = join12(artifactDirectory, basename(this.options.workerBundlePath));
9702
+ await mkdir10(temporaryRoot, { recursive: true });
9703
+ const artifactDirectory = await mkdtemp(join13(temporaryRoot, "moodle-mcp-"));
9704
+ await chmod6(artifactDirectory, 448);
9705
+ const workerFile = join13(artifactDirectory, basename(this.options.workerBundlePath));
8984
9706
  await copyReleaseBundle(this.options.workerBundlePath, workerFile);
8985
- const wranglerConfigPath = join12(artifactDirectory, "wrangler.json");
8986
- const secretsFilePath = join12(artifactDirectory, "secrets.json");
9707
+ const wranglerConfigPath = join13(artifactDirectory, "wrangler.json");
9708
+ const secretsFilePath = join13(artifactDirectory, "secrets.json");
8987
9709
  const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
8988
9710
  const config = {
8989
9711
  $schema: "node_modules/wrangler/config-schema.json",
@@ -9023,18 +9745,18 @@ var NodeReleaseMaterializer = class {
9023
9745
  if (credentials.previousTokensExpireAt !== void 0 && Number.isFinite(credentials.previousTokensExpireAt)) {
9024
9746
  secrets.TOKEN_OVERLAP_EXPIRES_AT = String(credentials.previousTokensExpireAt);
9025
9747
  }
9026
- await writeFile8(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
9748
+ await writeFile9(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
9027
9749
  `, { mode: 384 });
9028
- await writeFile8(secretsFilePath, `${JSON.stringify(secrets)}
9750
+ await writeFile9(secretsFilePath, `${JSON.stringify(secrets)}
9029
9751
  `, { mode: 384 });
9030
- await chmod5(wranglerConfigPath, 384);
9031
- await chmod5(secretsFilePath, 384);
9752
+ await chmod6(wranglerConfigPath, 384);
9753
+ await chmod6(secretsFilePath, 384);
9032
9754
  let recoveryConfigPath;
9033
9755
  try {
9034
- const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join12(dirname7(this.options.workerBundlePath), "recovery.js");
9035
- await copyReleaseBundle(recoveryBundle, join12(artifactDirectory, "recovery.js"));
9036
- recoveryConfigPath = join12(artifactDirectory, "wrangler-recovery.json");
9037
- await writeFile8(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
9756
+ const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join13(dirname7(this.options.workerBundlePath), "recovery.js");
9757
+ await copyReleaseBundle(recoveryBundle, join13(artifactDirectory, "recovery.js"));
9758
+ recoveryConfigPath = join13(artifactDirectory, "wrangler-recovery.json");
9759
+ await writeFile9(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
9038
9760
  `, { mode: 384 });
9039
9761
  } catch (error) {
9040
9762
  if (!isMissing4(error) || plan.existing) throw error;
@@ -9042,7 +9764,7 @@ var NodeReleaseMaterializer = class {
9042
9764
  return { artifactDirectory, wranglerConfigPath, secretsFilePath, recoveryConfigPath, encryptionKeyId: config.vars.SESSION_KEY_ID, credentialId: config.vars.SESSION_CREDENTIAL_ID };
9043
9765
  }
9044
9766
  async cleanup(release) {
9045
- await rm6(release.artifactDirectory, { recursive: true, force: true });
9767
+ await rm7(release.artifactDirectory, { recursive: true, force: true });
9046
9768
  }
9047
9769
  };
9048
9770
  var DefaultMoodleSessionSource = class {
@@ -9239,13 +9961,13 @@ var FetchManagedWorkerClient = class {
9239
9961
  }
9240
9962
  };
9241
9963
  var PrivateDeploymentReceiptStore = class {
9242
- constructor(baseDirectory = join12(homedir13(), ".config", "moodle-cli", "mcp", "deployments")) {
9964
+ constructor(baseDirectory = join13(homedir14(), ".config", "moodle-cli", "mcp", "deployments")) {
9243
9965
  this.baseDirectory = baseDirectory;
9244
9966
  }
9245
9967
  baseDirectory;
9246
9968
  async read(profile) {
9247
9969
  try {
9248
- const parsed = JSON.parse(await readFile8(this.path(profile), "utf8"));
9970
+ const parsed = JSON.parse(await readFile9(this.path(profile), "utf8"));
9249
9971
  return isReceipt(parsed) ? parsed : null;
9250
9972
  } catch (error) {
9251
9973
  if (isMissing4(error)) {
@@ -9256,24 +9978,24 @@ var PrivateDeploymentReceiptStore = class {
9256
9978
  }
9257
9979
  async write(receipt) {
9258
9980
  const path5 = this.path(receipt.profile);
9259
- await mkdir9(dirname7(path5), { recursive: true, mode: 448 });
9260
- await writeFile8(path5, `${JSON.stringify(receipt, null, 2)}
9981
+ await mkdir10(dirname7(path5), { recursive: true, mode: 448 });
9982
+ await writeFile9(path5, `${JSON.stringify(receipt, null, 2)}
9261
9983
  `, { mode: 384 });
9262
- await chmod5(path5, 384);
9984
+ await chmod6(path5, 384);
9263
9985
  }
9264
9986
  async delete(profile) {
9265
- await rm6(this.path(profile), { force: true });
9987
+ await rm7(this.path(profile), { force: true });
9266
9988
  }
9267
9989
  path(profile) {
9268
9990
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) {
9269
9991
  throw new Error("Invalid Moodle MCP profile name");
9270
9992
  }
9271
- return join12(this.baseDirectory, `${profile}.json`);
9993
+ return join13(this.baseDirectory, `${profile}.json`);
9272
9994
  }
9273
9995
  };
9274
9996
  function createDefaultManagedDeployment(options) {
9275
- const homeDirectory = options.homeDirectory ?? homedir13();
9276
- const platform = options.platform ?? process.platform;
9997
+ const homeDirectory = options.homeDirectory ?? homedir14();
9998
+ const platform2 = options.platform ?? process.platform;
9277
9999
  const runtime = runtimeCommand(options.executable, options.executableArgs);
9278
10000
  const defaults = {
9279
10001
  wrangler: new NodeWranglerDeploymentAdapter({ wranglerBinPath: options.wranglerBinPath }),
@@ -9281,12 +10003,12 @@ function createDefaultManagedDeployment(options) {
9281
10003
  workerBundlePath: options.workerBundlePath,
9282
10004
  compatibilityDate: options.compatibilityDate
9283
10005
  }),
9284
- credentials: createDefaultCredentialStore({ platform, homeDirectory }),
10006
+ credentials: createDefaultCredentialStore({ platform: platform2, homeDirectory }),
9285
10007
  sessions: createInteractiveMoodleSessionSource(options.auth),
9286
10008
  worker: new FetchManagedWorkerClient(options.fetch),
9287
10009
  renewal: new DefaultRenewalIntegration({
9288
10010
  ...options.renewal,
9289
- platform,
10011
+ platform: platform2,
9290
10012
  homeDirectory,
9291
10013
  executable: runtime.command,
9292
10014
  executableArgs: runtime.args,
@@ -9294,12 +10016,12 @@ function createDefaultManagedDeployment(options) {
9294
10016
  }),
9295
10017
  clients: new DefaultClientIntegration({
9296
10018
  ...options.connector,
9297
- platform,
10019
+ platform: platform2,
9298
10020
  homeDirectory,
9299
10021
  command: runtime.command,
9300
10022
  commandArgs: runtime.args
9301
10023
  }),
9302
- receipts: new PrivateDeploymentReceiptStore(join12(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
10024
+ receipts: new PrivateDeploymentReceiptStore(join13(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
9303
10025
  createToken: () => randomBytes2(32).toString("base64url")
9304
10026
  };
9305
10027
  return new ManagedMcpDeployment({ ...defaults, ...options.dependencies });
@@ -9316,7 +10038,7 @@ function endpointUrl(endpoint, path5) {
9316
10038
  async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
9317
10039
  let config;
9318
10040
  try {
9319
- config = JSON.parse(await readFile8(configPath, "utf8"));
10041
+ config = JSON.parse(await readFile9(configPath, "utf8"));
9320
10042
  } catch {
9321
10043
  throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
9322
10044
  }
@@ -9328,7 +10050,7 @@ async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
9328
10050
  throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
9329
10051
  }
9330
10052
  config.vars.EXPECTED_HOSTS = hosts.join(",");
9331
- await writeFile8(configPath, `${JSON.stringify(config, null, 2)}
10053
+ await writeFile9(configPath, `${JSON.stringify(config, null, 2)}
9332
10054
  `, { mode: 384 });
9333
10055
  }
9334
10056
  function endpointHosts(workerName, endpoint) {
@@ -9593,7 +10315,10 @@ function createMoodleMcpServer(gateway, options = {}) {
9593
10315
  protocolVersion: protocolVersion2,
9594
10316
  capabilities: { tools: { listChanged: false } },
9595
10317
  serverInfo,
9596
- instructions: gateway.submitAssignment ? "Access to the authenticated user's Moodle data. Only submit writes; it defaults to a dry run." : "Read-only access to the authenticated user's Moodle data."
10318
+ instructions: [
10319
+ gateway.submitAssignment ? "Access to the authenticated user's Moodle data. Only submit writes; it defaults to a dry run." : "Read-only access to the authenticated user's Moodle data.",
10320
+ ...options.instructions ?? []
10321
+ ].join(" ")
9597
10322
  });
9598
10323
  }
9599
10324
  if (request.method === "ping") {
@@ -9714,6 +10439,9 @@ async function callTool(gateway, params) {
9714
10439
  function toolContent(name, payload, structuredContent) {
9715
10440
  const text2 = { type: "text", text: JSON.stringify(structuredContent) };
9716
10441
  if (name !== "get_file" || !isMoodleFile(payload)) return [text2];
10442
+ if (payload.mimeType.startsWith("image/")) {
10443
+ return [text2, { type: "image", data: payload.blob, mimeType: payload.mimeType }];
10444
+ }
9717
10445
  return [
9718
10446
  text2,
9719
10447
  {
@@ -9838,9 +10566,9 @@ function deriveMcpWorkerName(moodleOrigin) {
9838
10566
  var DefaultMcpCommandService = class {
9839
10567
  constructor(options) {
9840
10568
  this.options = options;
9841
- this.homeDirectory = options.homeDir ?? homedir14();
10569
+ this.homeDirectory = options.homeDir ?? homedir15();
9842
10570
  this.wranglerInstance = options.wrangler;
9843
- this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join13(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
10571
+ this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join14(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
9844
10572
  this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
9845
10573
  this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
9846
10574
  this.renewal = options.renewal ?? new DefaultRenewalIntegration({
@@ -9877,7 +10605,8 @@ var DefaultMcpCommandService = class {
9877
10605
  const recovery = await deployment.rollback(identity.profile);
9878
10606
  return {
9879
10607
  data: recovery,
9880
- text: `Moodle MCP restored release ${recovery.versionId}.`
10608
+ text: `${theme.tone("success", "Moodle MCP restored release")} ${theme.key(recovery.versionId)}.`,
10609
+ next: ["moodle mcp status"]
9881
10610
  };
9882
10611
  }
9883
10612
  progress.begin("Planning the deployment");
@@ -9899,11 +10628,12 @@ var DefaultMcpCommandService = class {
9899
10628
  uploadCandidate: plan.uploadCandidate
9900
10629
  },
9901
10630
  text: [
9902
- "Moodle MCP deployment plan",
9903
- `Operation: ${plan.operation}`,
9904
- `Worker: ${plan.intent.workerName}`,
9905
- `Candidate upload: ${plan.uploadCandidate ? "yes" : "no"}`
9906
- ].join("\n")
10631
+ theme.subject("Moodle MCP deployment plan"),
10632
+ ` ${theme.dim("Operation:")} ${plan.operation}`,
10633
+ ` ${theme.dim("Worker:")} ${theme.key(plan.intent.workerName)}`,
10634
+ ` ${theme.dim("Candidate upload:")} ${plan.uploadCandidate ? "yes" : "no"}`
10635
+ ].join("\n"),
10636
+ next: ["moodle mcp deploy"]
9907
10637
  };
9908
10638
  }
9909
10639
  const events = [];
@@ -9932,8 +10662,10 @@ var DefaultMcpCommandService = class {
9932
10662
  endpoint: `${endpoint.replace(/\/$/u, "")}/mcp`,
9933
10663
  moodleSite: identity.moodleOrigin,
9934
10664
  moodleUser: events.find((event2) => event2.moodleUser)?.moodleUser ?? "Unknown Moodle user",
9935
- clients: await this.connectedClientNames(identity.profile)
9936
- }, this.theme())
10665
+ clients: await this.connectedClientNames(identity.profile),
10666
+ renewal: this.renewalJob(identity.profile)
10667
+ }, this.theme()),
10668
+ next: ["moodle mcp status", "moodle mcp pair"]
9937
10669
  };
9938
10670
  }
9939
10671
  async planDeployment(deployment, initialIntent) {
@@ -9996,29 +10728,54 @@ var DefaultMcpCommandService = class {
9996
10728
  localAuthentication = { status: "unknown" };
9997
10729
  }
9998
10730
  const updateAvailable = await this.remoteWorkerBehindLocal(profile);
10731
+ const [receipt, credentials] = await Promise.all([
10732
+ this.receipts.read(profile),
10733
+ readCredentialsForReport(() => this.credentials.read(profile))
10734
+ ]);
10735
+ const job = this.renewalJob(profile);
10736
+ const hosted = receipt && credentials.value ? await this.worker.manageClients({ endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.value.sessionSyncToken }).then((data2) => isClientList(data2) ? data2.clients.filter((client) => client.approved).length : null).catch(() => null) : null;
10737
+ const credentialsState = !credentials.available || !managed.credentialsAvailable ? "unavailable" : managed.credentialsStored ? "stored" : "missing";
10738
+ const renewal = {
10739
+ installed: managed.renewalInstalled,
10740
+ ...job ? { scheduler: job.scheduler, label: job.label, schedule: job.schedule, log: job.log } : {},
10741
+ lastRun: receipt?.lastRenewal ?? null
10742
+ };
9999
10743
  const data = {
10000
10744
  profile,
10001
10745
  localAuthentication,
10002
10746
  managed,
10747
+ credentials: { state: credentialsState },
10748
+ renewal,
10749
+ hostedClients: hosted,
10003
10750
  protocols: [...SUPPORTED_PROTOCOL_VERSIONS],
10004
10751
  updateAvailable,
10005
10752
  ...input2.verbose ? { serviceVersion: VERSION } : {},
10006
10753
  ...input2.logs ? { logs: { available: false, reason: "live_tail_required" } } : {}
10007
10754
  };
10008
10755
  const theme = this.theme();
10009
- const row = (label, value) => `${theme.dim(`${label}:`)} ${theme.status(value, { pass: "success", warn: "warning", fail: "danger", unknown: "muted", "not deployed": "warning", missing: "warning", "not connected": "warning", stored: "success", installed: "success", connected: "success" })}`;
10756
+ const row = (label, value) => `${theme.dim(`${label}:`)} ${theme.status(value, { pass: "success", warn: "warning", fail: "danger", unknown: "muted", "not deployed": "warning", missing: "warning", "not connected": "warning", unavailable: "warning", stored: "success", installed: "success", connected: "success" })}`;
10010
10757
  return {
10011
10758
  data,
10012
10759
  text: [
10013
10760
  row("Moodle MCP", managed.readiness),
10014
10761
  row("Worker", managed.worker?.workerName ?? "not deployed"),
10015
- row("Credentials", managed.credentialsStored ? "stored" : "missing"),
10762
+ row("Credentials", credentialsState),
10016
10763
  row("Renewal", managed.renewalInstalled ? "installed" : "missing"),
10017
- row("Clients", managed.clientsConnected ? "connected" : "not connected"),
10764
+ ...managed.renewalInstalled && job ? [` ${theme.dim(`${job.schedule}; you only hear from it when Moodle signs you out`)}`] : [],
10765
+ ` ${theme.dim("Last run:")} ${renewalRunText(renewal.lastRun, theme)}`,
10766
+ ...managed.renewalInstalled && job && input2.verbose ? [` ${theme.dim("Log:")} ${theme.dim(job.log)}`] : [],
10767
+ row("Local clients", managed.clientsConnected ? "connected" : "not connected"),
10768
+ `${theme.dim("Hosted clients:")} ${hosted === null ? theme.dim("unknown") : hosted ? `${hosted} approved` : theme.dim("none")}`,
10018
10769
  ...managed.recoveryActive ? [`${theme.dim("Release:")} ${theme.tone("warning", "the recovery Worker is live; OAuth sign-in is disabled until")} ${theme.key("moodle mcp deploy")} ${theme.tone("warning", "succeeds.")}`] : [],
10019
- ...updateAvailable ? [`${theme.dim("Update:")} remote Worker is behind this CLI. Run ${theme.key("moodle mcp deploy")} to update it.`] : [],
10770
+ ...updateAvailable ? [`${theme.dim("Update:")} remote Worker is behind this CLI. Run ${theme.key("moodle update")} to update it.`] : [],
10020
10771
  ...input2.logs ? [`${theme.dim("Logs:")} use a live sanitized tail from an interactive terminal`] : []
10021
- ].join("\n")
10772
+ ].join("\n"),
10773
+ next: [
10774
+ ...updateAvailable ? ["moodle update"] : [],
10775
+ ...managed.readiness === "fail" ? ["moodle mcp login"] : [],
10776
+ ...!managed.clientsConnected ? ["moodle mcp connect"] : [],
10777
+ ...hosted ? [] : ["moodle mcp pair"]
10778
+ ]
10022
10779
  };
10023
10780
  }
10024
10781
  async login() {
@@ -10028,9 +10785,12 @@ var DefaultMcpCommandService = class {
10028
10785
  try {
10029
10786
  progress.begin("Reading your Moodle session (a browser sign-in may be required)");
10030
10787
  const recovery = await this.deployment(false).recover(profile);
10788
+ const theme = this.theme();
10789
+ const done = (line) => `${theme.tone("success", "\u2713")} ${line}`;
10031
10790
  return {
10032
10791
  data: recovery,
10033
- text: "\u2713 New Moodle session acquired.\n\u2713 Remote session updated.\n\u2713 MCP readiness restored."
10792
+ text: [done("New Moodle session acquired."), done("Remote session updated."), done("MCP readiness restored.")].join("\n"),
10793
+ next: ["moodle mcp status"]
10034
10794
  };
10035
10795
  } finally {
10036
10796
  progress.clear();
@@ -10059,13 +10819,15 @@ var DefaultMcpCommandService = class {
10059
10819
  }
10060
10820
  const connected = [];
10061
10821
  for (const connector of selected) connected.push(await connectClient(connector));
10822
+ const theme = this.theme();
10062
10823
  const text2 = [
10063
- ...connected.map((item) => `\u2713 ${item.client}`),
10064
- ...input2.showToken ? ["", "MCP access token", credentials.mcpAccessToken] : []
10824
+ ...connected.map((item) => `${theme.tone("success", "\u2713")} ${item.client} ${theme.dim(item.changed ? `\xB7 ${item.configPath}` : "\xB7 already connected")}`),
10825
+ ...input2.showToken ? ["", theme.subject("MCP access token"), ` ${credentials.mcpAccessToken}`] : []
10065
10826
  ].join("\n");
10066
10827
  return {
10067
10828
  data: { profile, mode: input2.mode, connected: connected.map(({ client, configPath, changed }) => ({ client, configPath, changed })) },
10068
- text: text2
10829
+ text: text2,
10830
+ next: ["moodle mcp status"]
10069
10831
  };
10070
10832
  }
10071
10833
  async manageClients(input2) {
@@ -10075,7 +10837,19 @@ var DefaultMcpCommandService = class {
10075
10837
  const credentials = await this.credentials.read(profile);
10076
10838
  if (!receipt || !credentials) throw new UsageError(`No managed Moodle MCP deployment exists for profile ${profile}.`);
10077
10839
  const data = await this.worker.manageClients({ endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.sessionSyncToken, ...input2 });
10078
- return { data, text: input2.revoke ? "OAuth authorization revoked." : JSON.stringify(data, null, 2) };
10840
+ const theme = this.theme();
10841
+ if (input2.revoke) {
10842
+ return { data, text: theme.tone("success", input2.clientId ? "OAuth client revoked." : "All OAuth access revoked."), next: ["moodle mcp pair"] };
10843
+ }
10844
+ const clients = isClientList(data) ? data.clients : [];
10845
+ return {
10846
+ data,
10847
+ text: clients.length ? [
10848
+ theme.subject("OAuth clients"),
10849
+ ...clients.map((client) => ` ${theme.status(client.approved ? "approved" : "pending", { approved: "success", pending: "warning" }).padEnd(theme.enabled ? 18 : 9)} ${client.clientName} ${theme.dim(client.clientId)}`)
10850
+ ].join("\n") : theme.dim("No OAuth clients."),
10851
+ next: clients.length ? [`moodle mcp revoke ${clients[0].clientId}`, "moodle mcp pair"] : ["moodle mcp pair"]
10852
+ };
10079
10853
  }
10080
10854
  async pair() {
10081
10855
  const profile = deriveMcpProfile((await this.config()).baseUrl);
@@ -10097,17 +10871,8 @@ var DefaultMcpCommandService = class {
10097
10871
  expiresAt: pairing.expiresAt,
10098
10872
  authorizationServer: pairing.authorizationServer
10099
10873
  },
10100
- text: [
10101
- "Add this custom connector in Claude, then approve it with the pairing code.",
10102
- "",
10103
- "Connector URL",
10104
- ` ${endpoint}`,
10105
- "",
10106
- "Pairing code",
10107
- ` ${formatPairingCode(pairing.code)}`,
10108
- "",
10109
- `The code expires at ${pairing.expiresAt} and works for one approval.`
10110
- ].join("\n")
10874
+ text: pairingCopy({ endpoint, code: pairing.code, expiresAt: pairing.expiresAt }, this.theme()),
10875
+ next: ["moodle mcp clients"]
10111
10876
  };
10112
10877
  }
10113
10878
  async remove(input2) {
@@ -10123,11 +10888,12 @@ var DefaultMcpCommandService = class {
10123
10888
  return {
10124
10889
  data: result,
10125
10890
  text: [
10126
- "Moodle MCP has been removed.",
10127
- `Worker: ${result.workerRemoved ? "deleted" : "not present"}`,
10128
- "Local renewal, client registrations, and deployment credentials: deleted",
10129
- "Local Moodle configuration: kept; matching authentication cache: removed"
10130
- ].join("\n")
10891
+ this.theme().tone("success", "Moodle MCP has been removed."),
10892
+ ` ${this.theme().dim("Worker:")} ${result.workerRemoved ? "deleted" : "not present"}`,
10893
+ ` ${this.theme().dim("Renewal job, client registrations, deployment credentials:")} deleted`,
10894
+ ` ${this.theme().dim("Local Moodle configuration:")} kept ${this.theme().dim("(its authentication cache was removed)")}`
10895
+ ].join("\n"),
10896
+ next: ["moodle mcp deploy"]
10131
10897
  };
10132
10898
  }
10133
10899
  async serveStdio() {
@@ -10237,16 +11003,22 @@ var DefaultMcpCommandService = class {
10237
11003
  notifySignIn: this.notifyRenewalSignIn
10238
11004
  };
10239
11005
  const decision = await executeRenewalWithRecovery(decideRenewal(snapshot), snapshot, executor);
11006
+ const outcome = uploaded ? { state: "healthy", reasonCode: "SESSION_VALID" } : { state: decision.state, reasonCode: decision.reasonCode };
11007
+ receipt = { ...receipt, lastRenewal: { at: (/* @__PURE__ */ new Date()).toISOString(), ...outcome } };
11008
+ await this.receipts.write(receipt);
11009
+ const theme = this.theme();
10240
11010
  if (uploaded) {
10241
11011
  return {
10242
- data: { profile, state: "healthy", reasonCode: "SESSION_VALID", revision: receipt.sessionRevision },
10243
- text: "Moodle MCP session renewed."
11012
+ data: { profile, ...outcome, revision: receipt.sessionRevision },
11013
+ text: theme.tone("success", "Moodle MCP session renewed."),
11014
+ next: ["moodle mcp status"]
10244
11015
  };
10245
11016
  }
10246
11017
  const detail = decision.state === "needs_sign_in" && signInDetail ? { detail: signInDetail } : {};
10247
11018
  return {
10248
- data: { profile, state: decision.state, reasonCode: decision.reasonCode, revision: receipt.sessionRevision, ...detail },
10249
- text: [renewalResultText(decision), signInDetail].filter(Boolean).join("\n")
11019
+ data: { profile, ...outcome, revision: receipt.sessionRevision, ...detail },
11020
+ text: [renewalResultText(decision, theme), signInDetail].filter(Boolean).join("\n"),
11021
+ next: [decision.state === "needs_sign_in" ? "moodle mcp login" : "moodle mcp status"]
10250
11022
  };
10251
11023
  }
10252
11024
  async writeRenewalRevision(receipt, revision) {
@@ -10288,7 +11060,7 @@ var DefaultMcpCommandService = class {
10288
11060
  }
10289
11061
  });
10290
11062
  await this.receipts.write({ ...receipt, sessionRevision: uploaded.revision });
10291
- return { data: { profile, revision: uploaded.revision }, text: "Moodle MCP session updated." };
11063
+ return { data: { profile, revision: uploaded.revision }, text: this.theme().tone("success", "Moodle MCP session updated."), next: ["moodle mcp status"] };
10292
11064
  }
10293
11065
  deployment(background) {
10294
11066
  if (this.options.createDeployment) return this.options.createDeployment(background);
@@ -10397,13 +11169,13 @@ Selection: `)).trim());
10397
11169
  fetch: this.options.fetchImpl
10398
11170
  });
10399
11171
  }
10400
- prompt(question) {
11172
+ prompt(question2) {
10401
11173
  this.progress().clear();
10402
- if (this.options.prompt) return this.options.prompt(question);
11174
+ if (this.options.prompt) return this.options.prompt(question2);
10403
11175
  const input2 = this.options.stdin ?? process.stdin;
10404
11176
  const output = this.options.stderr ?? process.stderr;
10405
11177
  const readline = createInterface({ input: input2, output });
10406
- return readline.question(question).finally(() => readline.close());
11178
+ return readline.question(question2).finally(() => readline.close());
10407
11179
  }
10408
11180
  workerBundlePath() {
10409
11181
  return this.options.workerBundlePath ?? process.env.MOODLE_BUNDLED_WORKER ?? fileURLToPath(new URL("./worker/worker.js", import.meta.url));
@@ -10412,6 +11184,21 @@ Selection: `)).trim());
10412
11184
  this.wranglerInstance ??= new NodeWranglerDeploymentAdapter();
10413
11185
  return this.wranglerInstance;
10414
11186
  }
11187
+ async workerState() {
11188
+ try {
11189
+ const profile = deriveMcpProfile((await this.config()).baseUrl);
11190
+ const [receipt, credentials] = await Promise.all([
11191
+ this.receipts.read(profile),
11192
+ readCredentialsForReport(() => this.credentials.read(profile))
11193
+ ]);
11194
+ if (!receipt || !credentials.value) return null;
11195
+ const behind = await this.remoteWorkerBehindLocal(profile);
11196
+ const readiness = await this.worker.getReadiness({ endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.value.sessionSyncToken }).catch(() => null);
11197
+ return { behind, ready: readiness?.status === "pass" };
11198
+ } catch {
11199
+ return null;
11200
+ }
11201
+ }
10415
11202
  // The first-use Wrangler download asks a question, so it runs before a spinner
10416
11203
  // owns the terminal rather than drawing its prompt underneath one.
10417
11204
  async prepareToolchain(yes = false) {
@@ -10419,7 +11206,7 @@ Selection: `)).trim());
10419
11206
  await this.wrangler().prepare({ yes });
10420
11207
  }
10421
11208
  releaseDigest() {
10422
- return readFile9(this.workerBundlePath()).then((content) => sha256(content));
11209
+ return readFile10(this.workerBundlePath()).then((content) => sha256(content));
10423
11210
  }
10424
11211
  // True when a deployment receipt exists but its recorded release digest no
10425
11212
  // longer matches the Worker bundle shipped with this CLI, i.e. the remote
@@ -10442,6 +11229,10 @@ Selection: `)).trim());
10442
11229
  theme() {
10443
11230
  return createTheme4(this.options.color?.() ?? false);
10444
11231
  }
11232
+ renewalJob(profile) {
11233
+ const platform2 = process.platform;
11234
+ return platform2 === "darwin" || platform2 === "linux" || platform2 === "win32" ? describeRenewalJob(platform2, this.homeDirectory, profile) : void 0;
11235
+ }
10445
11236
  progress() {
10446
11237
  this.progressReporter ??= createProgressReporter({ stream: this.options.stderr ?? process.stderr });
10447
11238
  return this.progressReporter;
@@ -10506,12 +11297,36 @@ function isWranglerAuthRequired(error) {
10506
11297
  return error instanceof WranglerCommandError && /not authenticated|not logged in|wrangler login/iu.test(`${error.stdout}
10507
11298
  ${error.stderr}`);
10508
11299
  }
10509
- function renewalResultText(decision) {
10510
- if (decision.state === "offline") return "Moodle is unreachable. The remote session was preserved.";
10511
- if (decision.state === "needs_sign_in") return "Moodle MCP needs sign-in. Run `moodle mcp login`.";
11300
+ function renewalResultText(decision, theme) {
11301
+ if (decision.state === "offline") return `${theme.tone("warning", "Moodle is unreachable.")} The remote session was preserved.`;
11302
+ if (decision.state === "needs_sign_in") return `${theme.tone("warning", "Moodle MCP needs sign-in.")} Run ${theme.key("moodle mcp login")}.`;
10512
11303
  if (decision.state === "conflict") return "Moodle MCP refreshed the remote session revision without overwriting it.";
10513
- if (decision.reasonCode === "RENEWAL_AGENT_MISSING") return "Moodle MCP renewal agent installed.";
10514
- return "Moodle MCP session is ready.";
11304
+ if (decision.reasonCode === "RENEWAL_AGENT_MISSING") return theme.tone("success", "Moodle MCP renewal agent installed.");
11305
+ return theme.tone("success", "Moodle MCP session is ready.");
11306
+ }
11307
+ function isClientList(value) {
11308
+ return typeof value === "object" && value !== null && Array.isArray(value.clients);
11309
+ }
11310
+ function renewalRunText(lastRun, theme) {
11311
+ if (!lastRun) return theme.dim("never (the job has not reported yet)");
11312
+ const ago = Math.max(0, Math.round((Date.now() - Date.parse(lastRun.at)) / 6e4));
11313
+ const when = ago < 1 ? "just now" : ago < 120 ? `${ago} min ago` : ago < 48 * 60 ? `${Math.round(ago / 60)} h ago` : `${Math.round(ago / 1440)} days ago`;
11314
+ return `${when} ${theme.status(lastRun.state.replaceAll("_", " "), { healthy: "success", "needs sign in": "warning", offline: "warning", conflict: "warning" })}`;
11315
+ }
11316
+ function pairingCopy(input2, theme) {
11317
+ const expires = new Date(input2.expiresAt);
11318
+ const minutes = Math.max(0, Math.round((expires.getTime() - Date.now()) / 6e4));
11319
+ return [
11320
+ "Add this custom connector in Claude, then approve it with the pairing code.",
11321
+ "",
11322
+ theme.subject("Connector URL"),
11323
+ ` ${theme.key(input2.endpoint)}`,
11324
+ "",
11325
+ theme.subject("Pairing code"),
11326
+ ` ${theme.key(formatPairingCode(input2.code))}`,
11327
+ "",
11328
+ theme.dim(`One approval, valid for ${minutes} minutes (until ${expires.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}).`)
11329
+ ].join("\n");
10515
11330
  }
10516
11331
  async function selectConnectors(connectors, requested) {
10517
11332
  const normalized = normalizeClientName(requested);
@@ -10595,7 +11410,7 @@ function buildProgram(io = {}) {
10595
11410
  program.option("--pretty", "Indent JSON output.");
10596
11411
  program.option("--limit <number>", "Maximum returned rows.", parsePositiveInt);
10597
11412
  program.option("--days <number>", "Deadline window in days.", parsePositiveInt);
10598
- const verbose = program.options.find((option) => option.long === "--verbose");
11413
+ const verbose = program.options.find((option2) => option2.long === "--verbose");
10599
11414
  if (verbose) {
10600
11415
  verbose.short = "-v";
10601
11416
  verbose.flags = "-v, --verbose";
@@ -10798,6 +11613,46 @@ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
10798
11613
  command.option("--limit <number>", "Maximum returned rows.", parsePositiveInt).action(async (unit, options) => execute(name, { unit, limit: count("limit", options.limit), ...name === "due" ? { days: count("days", options.days) } : {} }, options));
10799
11614
  }
10800
11615
  addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>", "Words to look for").argument("[unit]", "Unit code, name, id or URL")).option("--limit <number>", "Maximum returned rows.", parsePositiveInt).option("--types <types>", "Comma-separated activity types.").action(async (query, unit, options) => execute("find", { query, unit, limit: count("limit", options.limit), types: options.types?.split(",") }, options));
11616
+ addOutputOptions(program.command("attempt").description(humanDescription("attempt")).argument("<ref>", "Quiz attempt id or review URL")).action(async (ref2, options) => execute("attempt", { attempt: ref2 }, options));
11617
+ const dev = program.command("dev", { hidden: true }).description("Maintainer utilities.");
11618
+ dev.command("fetch").description("Print a same-site page as HTML with the session; sesskey values are redacted.").argument("<url>").action(async (url) => {
11619
+ const client = await runtime.getClient();
11620
+ if (new URL(url).origin !== new URL(client.baseUrl).origin) throw new UsageError("The URL must belong to the configured Moodle site.");
11621
+ const html = await (await client.requestAbsolute(url)).text();
11622
+ stdout.write(`${redactSesskey(html)}
11623
+ `);
11624
+ });
11625
+ addOutputOptions(mutating(program.command("update").description("Update the package and redeploy the managed MCP Worker when either is behind."))).option("--check", "Report versions without installing or deploying.").option("--quiet", "Print nothing; refresh the cached version only.").action(async (options) => {
11626
+ const updateOptions = { homeDir: io.homeDir, env: io.env, fetchImpl: io.fetchImpl };
11627
+ if (options.quiet) {
11628
+ await refreshLatestVersion(updateOptions);
11629
+ return;
11630
+ }
11631
+ const worker = await getMcpService().workerState();
11632
+ const palette = theme();
11633
+ const workerLine = worker === null ? [] : [`${palette.dim("Worker:")} ${palette.status(worker.behind ? "behind this package" : worker.ready ? "current" : "not ready", { current: "success", "behind this package": "warning", "not ready": "danger" })}`];
11634
+ if (options.check) {
11635
+ const latest = await refreshLatestVersion(updateOptions);
11636
+ const available = isNewerVersion(latest ?? void 0, VERSION);
11637
+ const report2 = { current: VERSION, latest, update_available: available, ...worker ? { worker_behind: worker.behind, worker_ready: worker.ready } : {} };
11638
+ await runtime.output(report2, () => [
11639
+ `${palette.dim("moodle-cli:")} ${palette.key(VERSION)} ${available ? palette.tone("warning", `\u2192 ${latest} available`) : latest ? palette.dim("(latest)") : palette.tone("warning", "(npm unreachable)")}`,
11640
+ ...workerLine,
11641
+ ...available || worker?.behind ? ["", tryLines(["moodle update"])] : []
11642
+ ].join("\n"), options);
11643
+ return;
11644
+ }
11645
+ if (program.opts().dryRun) {
11646
+ const latest = await refreshLatestVersion(updateOptions);
11647
+ const steps = [...isNewerVersion(latest ?? void 0, VERSION) ? [`install moodle-cli ${latest}`] : [], ...worker?.behind ? ["redeploy the Worker"] : []];
11648
+ await runtime.output({ planned: steps, current: VERSION, latest }, () => steps.length ? `Would ${steps.join(", then ")}.` : "Nothing to do; everything is current.", options);
11649
+ return;
11650
+ }
11651
+ const report = await runUpdate({ ...updateOptions, workerBehind: worker?.behind });
11652
+ const stale = worker && !worker.ready && !report.deployed ? [`${palette.tone("warning", "The Worker is deployed but not answering.")} ${tryLines(["moodle mcp status"])}`] : [];
11653
+ await runtime.output({ ...report, ...worker ? { worker_ready: worker.ready } : {} }, () => [palette.tone(report.ok ? report.updated || report.deployed ? "success" : "muted" : "danger", report.note), ...stale].join("\n"), options);
11654
+ if (!report.ok) throw new CliError2("upstream", report.note);
11655
+ });
10801
11656
  addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>", "Resource id, URL, or UNIT TASK phrase")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
10802
11657
  const client = await runtime.getClient();
10803
11658
  const service = createIntentService(createMoodleGateway(client));
@@ -10832,6 +11687,75 @@ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
10832
11687
  }
10833
11688
  await runtime.output(result, () => formatSubmissionReceipt(result.submission), options);
10834
11689
  });
11690
+ const quiz = program.command("quiz").description("Take a quiz: start an attempt, answer questions, finish it. Beta.").summary("Take a quiz (beta)");
11691
+ quiz.addHelpText("after", `
11692
+ ${QUIZ_NOTICE.join("\n")}
11693
+ `);
11694
+ const quizConsent = async (summary) => {
11695
+ const palette = theme();
11696
+ if (!program.opts().yes && !human()) throw new UsageError("Quiz actions need --yes when stdin is not interactive.", QUIZ_NOTICE.join(" "));
11697
+ const notice = [palette.tone("warning", "BETA"), ...QUIZ_NOTICE.map((line) => palette.dim(line))].join("\n");
11698
+ return confirm({ summary: `${notice}
11699
+
11700
+ ${summary}` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: human() });
11701
+ };
11702
+ const attemptNext = (page) => {
11703
+ const open = page.navigation.find((entry) => entry.number !== "i" && /not yet|not answered/iu.test(entry.state));
11704
+ if (!open) return [`moodle quiz finish ${page.attempt} ${page.quiz_id}`];
11705
+ return [
11706
+ `moodle quiz answer ${page.attempt} ${page.quiz_id} ${open.number} <answer>`,
11707
+ ...open.page === page.page ? [] : [`moodle quiz show ${page.attempt} ${page.quiz_id} --page ${open.page + 1}`]
11708
+ ];
11709
+ };
11710
+ const showPage = (page, palette) => `${palette.tone("warning", "BETA")} ${formatAttemptPage(page)}
11711
+
11712
+ ${tryLines(attemptNext(page))}`;
11713
+ addOutputOptions(mutating(quiz.command("start").description("Start a new attempt, or continue the one in progress, and show its first page.").argument("<ref>", "Quiz id, URL, or UNIT TASK phrase"))).option("--password <password>", "Quiz access password for scripts; at a terminal you are asked for it instead.").action(async (ref2, options) => {
11714
+ const client = await runtime.getClient();
11715
+ const service = createIntentService(createMoodleGateway(client));
11716
+ const id2 = await choose(() => service.resolveItem(ref2), (id3) => Promise.resolve(id3));
11717
+ if (!program.opts().dryRun && !await quizConsent(`Start or continue an attempt on quiz ${theme().target(String(id2))}. Moodle records the attempt and its start time.`)) return;
11718
+ if (program.opts().dryRun) return runtime.output({ planned: "start", quiz_id: id2 }, () => `Would start an attempt on quiz ${id2}.`, options);
11719
+ const password = async () => {
11720
+ if (options.password) return options.password;
11721
+ const input2 = io.stdin ?? process.stdin;
11722
+ if (!human() || !input2.isTTY) return null;
11723
+ return readSecretLine(input2, stderr, "Quiz password (not echoed): ");
11724
+ };
11725
+ const page = await client.startQuizAttempt(id2, { password });
11726
+ await runtime.output({ attempt: page }, () => showPage(page, theme()), options);
11727
+ });
11728
+ addOutputOptions(quiz.command("show").description("Show one page of an attempt in progress: questions, options and saved answers.").argument("<attempt>", "Attempt id").argument("<quiz>", "Quiz id")).option("--page <n>", "Page number, starting at 1.", parsePositiveInt).action(async (attempt, quizId, options) => {
11729
+ const client = await runtime.getClient();
11730
+ const page = await client.getQuizAttemptPage(parsePositiveInt(attempt), parsePositiveInt(quizId), (options.page ?? 1) - 1);
11731
+ await runtime.output({ attempt: page }, () => showPage(page, theme()), options);
11732
+ });
11733
+ addOutputOptions(mutating(quiz.command("answer").description("Save one answer: option letters for a choice question (b, or a,c), the text otherwise.").argument("<attempt>", "Attempt id").argument("<quiz>", "Quiz id").argument("<question>", "Question number as shown").argument("[answer]", "Option letters or answer text"))).option("--from <file>", "Read the answer text from a file.").action(async (attempt, quizId, question2, answer, options) => {
11734
+ const value = options.from ? await readFile11(path4.resolve(io.cwd ?? process.cwd(), options.from), "utf8") : answer;
11735
+ if (!value?.trim()) throw new UsageError("Give the answer as an argument or with --from <file>.");
11736
+ const request = { attemptId: parsePositiveInt(attempt), quizId: parsePositiveInt(quizId), question: question2, value };
11737
+ const palette = theme();
11738
+ const preview2 = value.trim().length > 80 ? `${value.trim().slice(0, 77)}...` : value.trim();
11739
+ if (program.opts().dryRun) return runtime.output({ planned: "answer", ...request }, () => `Would answer question ${question2} of attempt ${attempt} with: ${preview2}`, options);
11740
+ if (!await quizConsent(`Save ${palette.subject(preview2)} as the answer to question ${palette.target(question2)} of attempt ${attempt}.`)) return;
11741
+ const client = await runtime.getClient();
11742
+ const page = await client.answerQuizQuestion(request);
11743
+ await runtime.output({ attempt: page }, () => showPage(page, palette), options);
11744
+ });
11745
+ addOutputOptions(mutating(quiz.command("finish").description("Submit the attempt for grading. Moodle does not allow undoing this.").argument("<attempt>", "Attempt id").argument("<quiz>", "Quiz id"))).action(async (attempt, quizId, options) => {
11746
+ const client = await runtime.getClient();
11747
+ const ids = [parsePositiveInt(attempt), parsePositiveInt(quizId)];
11748
+ const summary = await client.getQuizAttemptSummary(...ids);
11749
+ if (program.opts().dryRun) return runtime.output({ planned: "finish", summary }, () => formatAttemptSummary(summary), options);
11750
+ const palette = theme();
11751
+ const open = summary.rows.filter((row) => /not yet answered/iu.test(row.state));
11752
+ const warning = open.length ? `
11753
+ ${palette.tone("danger", `${open.length} question${open.length === 1 ? "" : "s"} not yet answered: ${open.map((row) => row.number).join(", ")}`)}` : "";
11754
+ if (!await quizConsent(`${formatAttemptSummary(summary)}${warning}
11755
+ ${palette.tone("warning", "Submit all and finish. Moodle does not allow undoing this.")}`)) return;
11756
+ const receipt = await client.finishQuizAttempt(...ids);
11757
+ await runtime.output({ finished: receipt }, () => formatAttemptFinish(receipt), options);
11758
+ });
10835
11759
  addOutputOptions(program.command("open").description("Open a unit or activity reference in the browser.").argument("<ref>", "Unit or activity id, URL, or UNIT TASK phrase")).action(async (ref2, options) => {
10836
11760
  const client = await runtime.getClient();
10837
11761
  let url;
@@ -10958,7 +11882,7 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
10958
11882
  else throw new UsageError("Choose zsh, bash or fish.");
10959
11883
  });
10960
11884
  addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration.").summary("Remove background jobs, Worker and config"))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
10961
- const home = io.homeDir ?? homedir15();
11885
+ const home = io.homeDir ?? homedir16();
10962
11886
  const jobs = await ownedJobs(home);
10963
11887
  const receipts = await readdir4(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10964
11888
  const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config: path4.join(home, CONFIG_DIR_NAME), cache: path4.join(home, CACHE_DIR_NAME), package_command: "npm rm -g moodle-cli (or bun remove -g moodle-cli); for the standalone install: rm ~/.local/bin/moodle", remaining: options.remote ? "Only the configured Worker is removed. Other profiles remain remote." : "Remote Workers and credentials remain unless removed with moodle mcp remove." };
@@ -10972,11 +11896,11 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
10972
11896
  const profiles = new Set([...jobs.map((j) => j.profile), ...receipts.map((n2) => n2.replace(/\.json$/u, ""))].filter((p) => Boolean(p) && /^[a-z0-9_-]+$/u.test(p)));
10973
11897
  for (const profile of profiles) {
10974
11898
  await renewal2.remove(profile);
10975
- if (options.purge) await rm7(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
11899
+ if (options.purge) await rm8(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
10976
11900
  }
10977
11901
  if (options.purge) {
10978
- await rm7(result.config, { recursive: true, force: true });
10979
- await rm7(result.cache, { recursive: true, force: true });
11902
+ await rm8(result.config, { recursive: true, force: true });
11903
+ await rm8(result.cache, { recursive: true, force: true });
10980
11904
  }
10981
11905
  await runtime.output(result, () => `Moodle background jobs removed.
10982
11906
  ${result.remaining}
@@ -11170,7 +12094,7 @@ Log: ${result.log_path}`, options);
11170
12094
  return program;
11171
12095
  }
11172
12096
  var HELP_SECTIONS = {
11173
- "Core commands": ["due", "news", "find", "get", "open", "submit", "units", "activities", "grades", "threads", "forums"],
12097
+ "Core commands": ["due", "news", "find", "get", "open", "submit", "quiz", "units", "activities", "grades", "threads", "forums"],
11174
12098
  "Additional commands": ["user", "todo", "alerts", "overview", "download", "auth", "doctor", "completion", "uninstall"],
11175
12099
  "Agent commands": ["mcp", "commands", "skills"]
11176
12100
  };
@@ -11189,6 +12113,7 @@ async function runCli(argv = process.argv, io = {}) {
11189
12113
  }) === "human"
11190
12114
  });
11191
12115
  try {
12116
+ await startupUpdateNotice(args, stderr, { homeDir: io.homeDir, env: io.env });
11192
12117
  await parseWithPrompts(() => buildProgram({ ...io, rootArgs: args }), args, { ui, fillers: { unit: pickUnit(io, ui) } });
11193
12118
  return 0;
11194
12119
  } catch (error) {
@@ -11217,7 +12142,7 @@ ${hint}
11217
12142
  }
11218
12143
  function openInBrowser(url) {
11219
12144
  return new Promise((resolve, reject) => {
11220
- const child = spawn4(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
12145
+ const child = spawn5(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
11221
12146
  child.once("error", reject);
11222
12147
  child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
11223
12148
  });
@@ -11301,6 +12226,14 @@ function addOutputOptions(command) {
11301
12226
  function outputFormat(options, stdout) {
11302
12227
  return resolveFormat(options, Boolean(stdout && "isTTY" in stdout && stdout.isTTY));
11303
12228
  }
12229
+ var QUIZ_NOTICE = [
12230
+ "moodle quiz is beta: it replays the browser's quiz forms, and a Moodle update can break it without warning.",
12231
+ "Academic integrity: answers you send are your own submission under your institution's rules. Only use this",
12232
+ "where the quiz allows it, and check the attempt in a browser before you finish."
12233
+ ];
12234
+ function redactSesskey(html) {
12235
+ return html.replace(/<input\b[^>]*>/giu, (tag) => /\bname\s*=\s*["']?sesskey["']?/iu.test(tag) ? tag.replace(/(\bvalue\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)/iu, '$1"REDACTED"') : tag).replace(/(["']?sesskey["']?\s*[:=]\s*["']?)[A-Za-z0-9]{8,}/gu, "$1REDACTED");
12236
+ }
11304
12237
  function submissionSummary(plan, final, theme) {
11305
12238
  const destination = `${theme.target(plan.name)}${plan.unit_id ? theme.dim(` unit ${plan.unit_id}`) : ""}`;
11306
12239
  const lines = plan.uploads.length ? [`${theme.dim("Upload")} ${theme.subject(plan.uploads.map((file2) => file2.name).join(", "))}`, `${theme.dim(" to")} ${destination}`] : [`${theme.dim("Submit")} ${destination}`, `${theme.dim(" ")} ${theme.subject("the files already there")} for grading`];
@@ -11321,7 +12254,9 @@ function parseMcpConnectionMode(value) {
11321
12254
  throw new UsageError("MCP connection mode must be 'bridge' or 'remote'.");
11322
12255
  }
11323
12256
  async function outputMcpResult(runtime, result, options) {
11324
- await runtime.output(result.data, () => result.text, options);
12257
+ await runtime.output(result.data, () => result.next?.length ? `${result.text}
12258
+
12259
+ ${tryLines(result.next)}` : result.text, options);
11325
12260
  }
11326
12261
  function errorOutputFormat(args, stdout) {
11327
12262
  try {
@@ -11391,7 +12326,7 @@ function queryMatches(text2, query) {
11391
12326
  function pathsReferToSameFile(moduleUrl, executable) {
11392
12327
  if (!executable) return false;
11393
12328
  try {
11394
- return realpathSync3(fileURLToPath2(moduleUrl)) === realpathSync3(executable);
12329
+ return realpathSync4(fileURLToPath2(moduleUrl)) === realpathSync4(executable);
11395
12330
  } catch {
11396
12331
  return false;
11397
12332
  }
@@ -11404,5 +12339,6 @@ if (isMain) {
11404
12339
  }
11405
12340
  export {
11406
12341
  buildProgram,
12342
+ redactSesskey,
11407
12343
  runCli
11408
12344
  };