moodle-cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/moodle.js CHANGED
@@ -20,6 +20,8 @@ var counts = z.object(Object.fromEntries(["notification_count", "unread_notifica
20
20
  var postSchema = z.object({ id, parent_id: n, name: s, author: z.object({ id, name: s }), time_created: id, created: s, message_text: s, links: z.array(z.object({ text: s, url: s })).optional() });
21
21
  var gradeSchema = z.object({ unit_id: id, code: s, graded: id, total: id, total_grade: s, total_range: s, total_percentage: s, items: z.array(z.object({ name: s, type: s, grade: s, range: s, percentage: s, weight: s, contribution: s, feedback: s, status: s, due: s, due_at: n })).optional() });
22
22
  var searchSchema = z.object({ unit_id: id, forum_id: id, discussion_id: id, name: s, post_id: id, snippet: s, time_created: n, created: s, url: s });
23
+ var submissionFile = z.object({ name: z.string(), bytes: n, url: s });
24
+ var receiptSchema = z.object({ id, name: s, unit_id: n, url: s, action: z.enum(["planned", "saved", "submitted"]), submission_status: s, grading_status: s, due: s, time_remaining: s, last_modified: s, statement: s, statement_accepted: z.boolean().optional(), files: z.array(submissionFile).optional(), uploads: z.array(z.object({ name: z.string(), bytes: id, path: s })).optional(), removed: z.array(z.string()).optional(), limits: z.object({ max_bytes: n, max_files: n, area_max_bytes: n, accepted_types: z.array(z.string()).optional() }).optional(), checked_at: z.string() });
23
25
  var input = (shape) => z.object(shape).strict();
24
26
  var list = (key, value) => z.object({ [key]: z.array(value).optional(), total: id });
25
27
  var intentContracts = {
@@ -33,6 +35,7 @@ var intentContracts = {
33
35
  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() })) },
34
36
  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 }) }) },
35
37
  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
+ 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 }) },
36
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() }) }) }
37
40
  };
38
41
  function humanDescription(name) {
@@ -723,9 +726,9 @@ var PrivateFileCredentialBackend = class {
723
726
  baseDirectory;
724
727
  name = "private credential file";
725
728
  async read(profile) {
726
- const path4 = this.path(profile);
729
+ const path5 = this.path(profile);
727
730
  try {
728
- return parseCredentials(await readFile(path4, "utf8"));
731
+ return parseCredentials(await readFile(path5, "utf8"));
729
732
  } catch (error) {
730
733
  if (isMissing(error)) {
731
734
  return null;
@@ -734,15 +737,15 @@ var PrivateFileCredentialBackend = class {
734
737
  }
735
738
  }
736
739
  async write(profile, credentials) {
737
- const path4 = this.path(profile);
738
- const temporary = `${path4}.${process.pid}.tmp`;
739
- await mkdir(dirname(path4), { recursive: true, mode: 448 });
740
- await chmod(dirname(path4), 448);
740
+ const path5 = this.path(profile);
741
+ const temporary = `${path5}.${process.pid}.tmp`;
742
+ await mkdir(dirname(path5), { recursive: true, mode: 448 });
743
+ await chmod(dirname(path5), 448);
741
744
  await writeFile(temporary, `${JSON.stringify(credentials)}
742
745
  `, { encoding: "utf8", mode: 384 });
743
746
  await chmod(temporary, 384);
744
- await rename(temporary, path4);
745
- await chmod(path4, 384);
747
+ await rename(temporary, path5);
748
+ await chmod(path5, 384);
746
749
  }
747
750
  async delete(profile) {
748
751
  await rm(this.path(profile), { force: true });
@@ -798,9 +801,9 @@ function windowsCredentialInput(profile, credentials) {
798
801
  ...credentials ? { credentials: JSON.stringify(credentials) } : {}
799
802
  });
800
803
  }
801
- function windowsDpapiInput(path4, credentials) {
804
+ function windowsDpapiInput(path5, credentials) {
802
805
  return JSON.stringify({
803
- path: path4,
806
+ path: path5,
804
807
  ...credentials ? { credentials: JSON.stringify(credentials) } : {}
805
808
  });
806
809
  }
@@ -910,10 +913,10 @@ async function readCachedSession(baseUrl, options = {}) {
910
913
  return null;
911
914
  }
912
915
  const fs = options.fs ?? nodeFs;
913
- const path4 = sessionCachePath(options.homeDir);
916
+ const path5 = sessionCachePath(options.homeDir);
914
917
  let raw;
915
918
  try {
916
- raw = await fs.readFile(path4, "utf8");
919
+ raw = await fs.readFile(path5, "utf8");
917
920
  } catch (error) {
918
921
  if (isMissingFileError(error)) {
919
922
  return null;
@@ -931,7 +934,7 @@ async function readCachedSession(baseUrl, options = {}) {
931
934
  if (session) await writeCachedSession(session, { ...options, noCache: false });
932
935
  }
933
936
  } catch {
934
- if (parseCachedSession(raw)) await fs.rm(path4, { force: true });
937
+ if (parseCachedSession(raw)) await fs.rm(path5, { force: true });
935
938
  return null;
936
939
  }
937
940
  if (!session || !sameBaseUrl(session.baseUrl, baseUrl)) {
@@ -943,13 +946,13 @@ async function readCachedSession(baseUrl, options = {}) {
943
946
  async function writeCachedSession(session, options = {}) {
944
947
  if (options.noCache) return;
945
948
  const fs = options.fs ?? nodeFs;
946
- const path4 = sessionCachePath(options.homeDir);
949
+ const path5 = sessionCachePath(options.homeDir);
947
950
  const keyring = await createEncryptionKeyring(await cacheEncryptionKey(options));
948
951
  const encrypted = { version: 2, encrypted_session: await encryptValue(JSON.stringify(session), keyring) };
949
- await fs.mkdir(dirname2(path4), { recursive: true, mode: 448 });
950
- await fs.writeFile(path4, `${JSON.stringify(encrypted)}
952
+ await fs.mkdir(dirname2(path5), { recursive: true, mode: 448 });
953
+ await fs.writeFile(path5, `${JSON.stringify(encrypted)}
951
954
  `, { encoding: "utf8", mode: 384 });
952
- await fs.chmod(path4, 384);
955
+ await fs.chmod(path5, 384);
953
956
  }
954
957
  async function deleteCachedSession(baseUrl, options = {}) {
955
958
  const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, ttlMs: Number.MAX_SAFE_INTEGER });
@@ -1347,8 +1350,8 @@ function isLoginRedirect(responseUrl, baseUrl) {
1347
1350
  if (!responseUrl) {
1348
1351
  return false;
1349
1352
  }
1350
- const path4 = new URL(responseUrl, baseUrl).pathname;
1351
- return path4 === LOGIN_PATH || path4.startsWith("/login/");
1353
+ const path5 = new URL(responseUrl, baseUrl).pathname;
1354
+ return path5 === LOGIN_PATH || path5.startsWith("/login/");
1352
1355
  }
1353
1356
  function looksLikeLoginPage(html) {
1354
1357
  return /name=["']username["']/i.test(html) && /name=["']password["']/i.test(html);
@@ -1391,7 +1394,24 @@ var defaultExecFile = (file2, args) => new Promise((resolve) => {
1391
1394
  import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1392
1395
  import { homedir as homedir4 } from "os";
1393
1396
  import { dirname as dirname3, join as join4 } from "path";
1394
- import { createInterface } from "readline/promises";
1397
+ import { createUi, isAgentEnvironment } from "@bunizao/cli-kit";
1398
+
1399
+ // src/wordmark.ts
1400
+ var MOODLE_TAGLINE = "Read Moodle and submit work from the command line.";
1401
+ var MOODLE_WORDMARK = [
1402
+ " _ _",
1403
+ " _ __ ___ ___ __| | |___",
1404
+ " | ' \\/ _ \\/ _ \\/ _` | / -_)",
1405
+ " |_|_|_\\___/\\___/\\__,_|_\\___|"
1406
+ ].join("\n");
1407
+ var shown = false;
1408
+ function showWordmark(ui) {
1409
+ if (shown) return;
1410
+ shown = true;
1411
+ ui.banner(MOODLE_WORDMARK, MOODLE_TAGLINE);
1412
+ }
1413
+
1414
+ // src/config.ts
1395
1415
  import YAML from "yaml";
1396
1416
  var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir3 };
1397
1417
  function cwdConfigPath(cwd = process.cwd()) {
@@ -1453,29 +1473,32 @@ async function loadConfig(options = {}) {
1453
1473
  return toMoodleConfig(loaded.config, baseUrl);
1454
1474
  }
1455
1475
  async function promptForBaseUrl(options = {}) {
1456
- const prompt = options.prompt ?? defaultPrompt(options);
1457
- const output = options.stderr ?? process.stderr;
1458
- output.write("Configuration required\n");
1459
- output.write("Moodle base URL is not configured yet.\n");
1460
- output.write(`Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`}. Browser SQLite needs Bun or Node 22.13+. Run moodle doctor for diagnostics.
1461
- `);
1462
- output.write("Required format: https://school.example.edu\n");
1463
- output.write("Use the site root only. Do not include paths like /login/index.php or /my/.\n");
1476
+ const ui = createUi({ input: process.stdin, output: options.stderr ?? process.stderr, ...options.prompt ? { interactive: false } : {} });
1477
+ const prompt = options.prompt ?? defaultPrompt(ui);
1478
+ showWordmark(ui);
1479
+ ui.intro("Moodle setup");
1480
+ ui.note([
1481
+ "Moodle base URL is not configured yet.",
1482
+ `Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`}. Browser SQLite needs Bun or Node 22.13+. Run moodle doctor for diagnostics.`,
1483
+ "Use the site root only, for example https://school.example.edu.",
1484
+ "Do not include paths like /login/index.php or /my/."
1485
+ ].join("\n"), "Configuration required");
1464
1486
  while (true) {
1465
1487
  let baseUrl;
1466
1488
  try {
1467
1489
  baseUrl = normalizeBaseUrl(await prompt("Moodle base URL"));
1468
1490
  } catch (error) {
1469
- output.write(`Invalid URL: ${error instanceof Error ? error.message : String(error)}
1470
- `);
1491
+ ui.warn(`Invalid URL: ${error instanceof Error ? error.message : String(error)}`);
1471
1492
  continue;
1472
1493
  }
1494
+ const spin = ui.spinner();
1495
+ spin.start(`Checking ${baseUrl}`);
1473
1496
  const probe = await probeBaseUrl(baseUrl, options);
1474
1497
  if (probe.ok) {
1498
+ spin.stop(`${baseUrl} looks like Moodle`);
1475
1499
  return baseUrl;
1476
1500
  }
1477
- output.write(`Validation failed: ${probe.message ?? "site did not look like Moodle"}
1478
- `);
1501
+ spin.error(`Validation failed: ${probe.message ?? "site did not look like Moodle"}`);
1479
1502
  }
1480
1503
  }
1481
1504
  async function probeBaseUrl(baseUrl, options = {}) {
@@ -1523,19 +1546,19 @@ async function loadExistingConfig(options) {
1523
1546
  const paths = [explicitPath, cwdConfigPath(options.cwd), userConfigPath(options.homeDir)].filter(
1524
1547
  (value) => Boolean(value)
1525
1548
  );
1526
- for (const path4 of paths) {
1527
- const config = await readConfigFile(path4, options);
1549
+ for (const path5 of paths) {
1550
+ const config = await readConfigFile(path5, options);
1528
1551
  if (config) {
1529
- return { config, path: path4 };
1552
+ return { config, path: path5 };
1530
1553
  }
1531
1554
  }
1532
1555
  return { config: {}, path: explicitPath ?? null };
1533
1556
  }
1534
- async function readConfigFile(path4, options) {
1557
+ async function readConfigFile(path5, options) {
1535
1558
  const fs = options.fs ?? nodeFs2;
1536
1559
  let raw;
1537
1560
  try {
1538
- raw = await fs.readFile(path4, "utf8");
1561
+ raw = await fs.readFile(path5, "utf8");
1539
1562
  } catch (error) {
1540
1563
  if (isMissingFileError2(error)) {
1541
1564
  return null;
@@ -1544,34 +1567,24 @@ async function readConfigFile(path4, options) {
1544
1567
  }
1545
1568
  const parsed = YAML.parse(raw) ?? {};
1546
1569
  if (!isRecord3(parsed)) {
1547
- throw new ConfigError(`${path4} must contain a YAML object.`);
1570
+ throw new ConfigError(`${path5} must contain a YAML object.`);
1548
1571
  }
1549
1572
  return parsed;
1550
1573
  }
1551
- async function saveConfigFile(path4, config, options) {
1574
+ async function saveConfigFile(path5, config, options) {
1552
1575
  const fs = options.fs ?? nodeFs2;
1553
- await fs.mkdir(dirname3(path4), { recursive: true });
1554
- await fs.writeFile(path4, YAML.stringify(config, { sortMapEntries: true }), "utf8");
1576
+ await fs.mkdir(dirname3(path5), { recursive: true });
1577
+ await fs.writeFile(path5, YAML.stringify(config, { sortMapEntries: true }), "utf8");
1555
1578
  }
1556
1579
  function toMoodleConfig(config, baseUrl) {
1557
1580
  const { base_url: _baseUrl, ...rest } = config;
1558
1581
  return { ...rest, baseUrl };
1559
1582
  }
1560
- function defaultPrompt(options) {
1561
- return async (label) => {
1562
- const rl = createInterface({
1563
- input: process.stdin,
1564
- output: options.stdout ?? process.stdout
1565
- });
1566
- try {
1567
- return await rl.question(`${label} > `);
1568
- } finally {
1569
- rl.close();
1570
- }
1571
- };
1583
+ function defaultPrompt(ui) {
1584
+ return (label) => ui.text(label, { placeholder: "https://school.example.edu" });
1572
1585
  }
1573
1586
  function isInteractive(options) {
1574
- return Boolean((options.stdin ?? process.stdin).isTTY);
1587
+ return Boolean((options.stdin ?? process.stdin).isTTY) && !isAgentEnvironment(options.env ?? process.env);
1575
1588
  }
1576
1589
  function isRecord3(value) {
1577
1590
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -1834,9 +1847,9 @@ async function ownedJobs(homeDir = homedir6()) {
1834
1847
  const files = await readdir2(root).catch(() => []);
1835
1848
  const jobs = [];
1836
1849
  for (const name of files.filter((n2) => n2 === "com.moodle-cli.keepalive.plist" || /^com\.moodle-cli\.mcp-renewal\.[a-z0-9_-]+\.plist$/u.test(n2))) {
1837
- const path4 = join7(root, name);
1838
- const content = await readFile4(path4, "utf8");
1839
- jobs.push({ path: path4, profile: name.match(/mcp-renewal\.(.+)\.plist$/u)?.[1], interpreter: content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/u)?.[1] });
1850
+ const path5 = join7(root, name);
1851
+ const content = await readFile4(path5, "utf8");
1852
+ jobs.push({ path: path5, profile: name.match(/mcp-renewal\.(.+)\.plist$/u)?.[1], interpreter: content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/u)?.[1] });
1840
1853
  }
1841
1854
  return jobs;
1842
1855
  }
@@ -1910,7 +1923,7 @@ async function doctor(options = {}) {
1910
1923
 
1911
1924
  // src/cli.ts
1912
1925
  import { rm as rm7, readdir as readdir3 } from "fs/promises";
1913
- import { homedir as homedir12 } from "os";
1926
+ import { homedir as homedir13 } from "os";
1914
1927
 
1915
1928
  // src/mcp/renewal/decision.ts
1916
1929
  function decideRenewal(snapshot) {
@@ -2047,7 +2060,7 @@ function macOSPlan(options, intervalMinutes) {
2047
2060
  throw new Error("macOS renewal installation requires the current user ID");
2048
2061
  }
2049
2062
  const label = `com.moodle-cli.mcp-renewal.${options.profile}`;
2050
- const path4 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
2063
+ const path5 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
2051
2064
  const target = `gui/${options.uid}`;
2052
2065
  const logPath = `${trimEnd(options.homeDirectory, "/")}/Library/Logs/${label}.log`;
2053
2066
  const plist = [
@@ -2069,12 +2082,12 @@ function macOSPlan(options, intervalMinutes) {
2069
2082
  platform: "darwin",
2070
2083
  profile: options.profile,
2071
2084
  label,
2072
- files: [{ path: path4, content: plist, mode: 384 }],
2085
+ files: [{ path: path5, content: plist, mode: 384 }],
2073
2086
  installCommands: [
2074
- { command: "launchctl", args: ["bootout", target, path4], ignoreFailure: true },
2075
- { command: "launchctl", args: ["bootstrap", target, path4] }
2087
+ { command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true },
2088
+ { command: "launchctl", args: ["bootstrap", target, path5] }
2076
2089
  ],
2077
- removeCommands: [{ command: "launchctl", args: ["bootout", target, path4], ignoreFailure: true }]
2090
+ removeCommands: [{ command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true }]
2078
2091
  };
2079
2092
  }
2080
2093
  function linuxPlan(options, intervalMinutes) {
@@ -2125,7 +2138,7 @@ function linuxPlan(options, intervalMinutes) {
2125
2138
  }
2126
2139
  function windowsPlan(options, intervalMinutes) {
2127
2140
  const label = `Moodle CLI MCP Renewal (${options.profile})`;
2128
- const path4 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
2141
+ const path5 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
2129
2142
  const argumentsText = [...options.executableArgs ?? [], ...renewalArgs(options.profile)].map(windowsArgument).join(" ");
2130
2143
  const task = [
2131
2144
  '<?xml version="1.0" encoding="UTF-8"?>',
@@ -2145,8 +2158,8 @@ function windowsPlan(options, intervalMinutes) {
2145
2158
  platform: "win32",
2146
2159
  profile: options.profile,
2147
2160
  label,
2148
- files: [{ path: path4, content: task, mode: 384 }],
2149
- installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML", path4, "/F"] }],
2161
+ files: [{ path: path5, content: task, mode: 384 }],
2162
+ installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML", path5, "/F"] }],
2150
2163
  removeCommands: [{ command: "schtasks.exe", args: ["/Delete", "/TN", label, "/F"], ignoreFailure: true }]
2151
2164
  };
2152
2165
  }
@@ -2206,17 +2219,17 @@ import { dirname as dirname5 } from "path";
2206
2219
  import { promisify } from "util";
2207
2220
  var execFile = promisify(execFileCallback2);
2208
2221
  var NodeRenewalInstallerIO = class {
2209
- async writePrivate(path4, content, mode) {
2210
- await mkdir5(dirname5(path4), { recursive: true, mode: 448 });
2211
- await writeFile5(path4, content, { encoding: "utf8", mode });
2212
- await chmod3(path4, mode);
2222
+ async writePrivate(path5, content, mode) {
2223
+ await mkdir5(dirname5(path5), { recursive: true, mode: 448 });
2224
+ await writeFile5(path5, content, { encoding: "utf8", mode });
2225
+ await chmod3(path5, mode);
2213
2226
  }
2214
- async removeFile(path4) {
2215
- await rm4(path4, { force: true });
2227
+ async removeFile(path5) {
2228
+ await rm4(path5, { force: true });
2216
2229
  }
2217
- async exists(path4) {
2230
+ async exists(path5) {
2218
2231
  try {
2219
- await readFile5(path4);
2232
+ await readFile5(path5);
2220
2233
  return true;
2221
2234
  } catch (error) {
2222
2235
  if (isMissing2(error)) {
@@ -2334,13 +2347,14 @@ var MoodleGatewayError = class extends Error {
2334
2347
  this.code = code;
2335
2348
  }
2336
2349
  };
2337
- function createMoodleGateway(client) {
2350
+ function createMoodleGateway(client, hooks = {}) {
2338
2351
  return {
2339
2352
  getUser: () => client.getSiteInfo(),
2340
2353
  listThreads: (id2) => client.getForumDiscussionRefs ? client.getForumDiscussionRefs(id2) : Promise.resolve([]),
2341
2354
  listNewsForums: (id2) => client.getNewsForums ? client.getNewsForums(id2) : Promise.resolve([]),
2342
2355
  getOverview: (input2) => client.getOverview(input2.todoLimit, input2.todoDays, input2.alertsLimit),
2343
2356
  ...client.getTodo ? { getDue: (days, courseId) => client.getTodo(Number.MAX_SAFE_INTEGER, days, courseId) } : {},
2357
+ ...client.submitAssignmentFiles ? { submitAssignment: (input2) => client.submitAssignmentFiles({ ...input2, ...hooks.onSubmitProgress ? { onProgress: hooks.onSubmitProgress } : {} }) } : {},
2344
2358
  listCourses: () => client.getCourses(),
2345
2359
  async getCourse({ courseId }) {
2346
2360
  const [courses, sections] = await Promise.all([
@@ -2658,9 +2672,9 @@ function splitUnitPhrase(phrase, courses) {
2658
2672
  }
2659
2673
 
2660
2674
  // src/intents.ts
2661
- async function inParallel(items, size, run) {
2675
+ async function inParallel(items, size2, run) {
2662
2676
  const results = [];
2663
- for (let index = 0; index < items.length; index += size) results.push(...await Promise.all(items.slice(index, index + size).map(run)));
2677
+ for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
2664
2678
  return results;
2665
2679
  }
2666
2680
  function createIntentService(gateway, now = () => Date.now()) {
@@ -2851,6 +2865,12 @@ function createIntentService(gateway, now = () => Date.now()) {
2851
2865
  result = { file: { name: file2.name, mime_type: file2.mimeType, bytes: file2.bytes, uri: file2.uri } };
2852
2866
  break;
2853
2867
  }
2868
+ case "submit": {
2869
+ if (!gateway.submitAssignment) throw new MoodleGatewayError("MOODLE_TOOL_UNAVAILABLE", "Submitting needs local files; run moodle submit or the local MCP server on the machine that holds them.");
2870
+ const activityId = await resolveItem(input2.ref);
2871
+ result = { submission: await gateway.submitAssignment({ activityId, files: input2.files, final: Boolean(input2.final), replace: Boolean(input2.replace), acceptStatement: Boolean(input2.accept_statement), dryRun: Boolean(input2.dry_run) }) };
2872
+ break;
2873
+ }
2854
2874
  }
2855
2875
  return intentContracts[name].output.parse(stripEmpty(result));
2856
2876
  }
@@ -2861,16 +2881,26 @@ function createIntentService(gateway, now = () => Date.now()) {
2861
2881
  return { run, resolveItem, fileSource, find, sections };
2862
2882
  }
2863
2883
 
2884
+ // src/screens.ts
2885
+ import { createTheme as createTheme2 } from "@bunizao/cli-kit";
2886
+
2864
2887
  // src/terminal-table.ts
2888
+ import { createTheme } from "@bunizao/cli-kit";
2865
2889
  var DEFAULT_WIDTH = 120;
2890
+ var TONED_LABEL = /status|state|type|grade|due|ok|action|grading/iu;
2891
+ var colorEnabled = () => false;
2892
+ function configureTerminalTables(options) {
2893
+ colorEnabled = options.color;
2894
+ }
2866
2895
  function renderTerminalTable(columns, rows, options = {}) {
2896
+ const theme = createTheme(colorEnabled());
2867
2897
  const width = Math.max(20, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
2868
2898
  const clean = rows.map((row) => columns.map((_, i) => sanitizeTerminalText(row[i] ?? "").replace(/\s+/gu, " ")));
2869
2899
  const lengths = columns.map((c, i) => Math.max(c.label.length, ...clean.map((r) => Array.from(r[i]).length)));
2870
2900
  const flexible = columns.map((c, i) => c.flex || /name|title|subject|feedback|description|value|course|unit/iu.test(c.label) ? i : -1).filter((i) => i >= 0);
2871
2901
  const widths = columns.map((c, i) => c.width ?? (flexible.includes(i) ? Math.min(lengths[i], 36) : lengths[i]));
2872
2902
  const minimum = widths.reduce((n2, w, i) => n2 + (flexible.includes(i) ? 8 : w), 0) + columns.length * 3 + 1;
2873
- const title = options.title ? [sanitizeTerminalText(options.title)] : [];
2903
+ const title = options.title ? [theme.subject(sanitizeTerminalText(options.title))] : [];
2874
2904
  if (width < 60 || minimum > width) {
2875
2905
  const wrap = (line2) => Array.from(line2).reduce((lines, char) => {
2876
2906
  if (!lines.length || Array.from(lines.at(-1)).length >= width) lines.push("");
@@ -2889,9 +2919,21 @@ function renderTerminalTable(columns, rows, options = {}) {
2889
2919
  const trimmed = chars.length > w ? `${chars.slice(0, w - 1).join("")}\u2026` : v;
2890
2920
  return trimmed + " ".repeat(Math.max(0, w - Array.from(trimmed).length));
2891
2921
  };
2892
- const border = (l, m, r) => l + widths.map((w) => "\u2500".repeat(w + 2)).join(m) + r;
2893
- const line = (row) => "\u2502" + widths.map((w, i) => ` ${fit(row[i] ?? "", w)} `).join("\u2502") + "\u2502";
2894
- return [...title, border("\u250C", "\u252C", "\u2510"), line(columns.map((c) => c.label)), border("\u251C", "\u253C", "\u2524"), ...clean.map(line), border("\u2514", "\u2534", "\u2518")].join("\n");
2922
+ const paint = (cell, i, row) => {
2923
+ if (options.keyValue) return i === 0 ? theme.dim(cell) : /status|grading|action/iu.test(row[0] ?? "") ? theme.status(cell) : cell;
2924
+ return i === 0 ? theme.key(cell) : TONED_LABEL.test(columns[i]?.label ?? "") ? theme.status(cell) : cell;
2925
+ };
2926
+ const border = (l, m, r) => theme.dim(l + widths.map((w) => "\u2500".repeat(w + 2)).join(m) + r);
2927
+ const bar = theme.dim("\u2502");
2928
+ const line = (row, paintCell) => bar + widths.map((w, i) => ` ${paintCell(fit(row[i] ?? "", w), i)} `).join(bar) + bar;
2929
+ return [
2930
+ ...title,
2931
+ border("\u250C", "\u252C", "\u2510"),
2932
+ line(columns.map((c) => c.label), (cell) => theme.dim(cell)),
2933
+ border("\u251C", "\u253C", "\u2524"),
2934
+ ...clean.map((row) => line(row, (cell, i) => paint(cell, i, row))),
2935
+ border("\u2514", "\u2534", "\u2518")
2936
+ ].join("\n");
2895
2937
  }
2896
2938
  function renderKeyValueTable(rows, options = {}) {
2897
2939
  const present = rows.filter(([, value]) => value !== "");
@@ -2903,10 +2945,10 @@ No details` : "No details";
2903
2945
  { label: "Field" },
2904
2946
  { label: "Value" }
2905
2947
  ];
2906
- return renderTerminalTable(columns, present, options);
2948
+ return renderTerminalTable(columns, present, { ...options, keyValue: true });
2907
2949
  }
2908
2950
  function sanitizeTerminalText(value) {
2909
- return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, "");
2951
+ return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[- --Ÿ]/gu, "");
2910
2952
  }
2911
2953
 
2912
2954
  // src/screens.ts
@@ -2927,16 +2969,17 @@ function moment(value, now) {
2927
2969
  function renderScreen(data, options = {}) {
2928
2970
  const lines = [];
2929
2971
  const now = options.now ?? Date.now();
2972
+ const theme = createTheme2(Boolean(options.color));
2930
2973
  const dueText = (row) => {
2931
- if (!row.due_at) return text(row.status || row.submission_status);
2974
+ if (!row.due_at) return theme.status(text(row.status || row.submission_status));
2932
2975
  const days = Math.ceil((Number(row.due_at) * 1e3 - now) / 864e5);
2933
2976
  const value = `${days < 0 ? `${-days} days overdue` : days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`} \xB7 ${moment(row.due, now)}`;
2934
- return options.color && days <= 2 ? `\x1B[${days < 0 ? 31 : 33}m${value}\x1B[0m` : value;
2977
+ return days < 0 ? theme.tone("danger", value) : days <= 2 ? theme.tone("warning", value) : theme.dim(value);
2935
2978
  };
2936
2979
  const rows = (items, title) => {
2937
- lines.push(title);
2938
- if (!items.length) lines.push(" None");
2939
- for (const r of items) lines.push(` ${text(r.unit_code || r.type)} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? ` #${r.id}` : ""}`);
2980
+ lines.push(theme.subject(title));
2981
+ if (!items.length) lines.push(theme.dim(" None"));
2982
+ for (const r of items) lines.push(` ${theme.key(text(r.unit_code || r.type))} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? ` ${theme.dim(`#${r.id}`)}` : ""}`);
2940
2983
  };
2941
2984
  let next = "moodle due --days 30 \xB7 moodle grades";
2942
2985
  if (data.home) {
@@ -3001,7 +3044,7 @@ function renderScreen(data, options = {}) {
3001
3044
  rows(array(key ? data[key] : []), key === "due" ? "Due" : "Matches");
3002
3045
  if (data.total !== void 0) lines.push(`${data.total} total`);
3003
3046
  }
3004
- lines.push("", `Try ${next}`);
3047
+ lines.push("", theme.dim(`Try ${next}`));
3005
3048
  const width = Math.max(40, options.width || 80);
3006
3049
  return lines.flatMap((line) => {
3007
3050
  if (line.includes("\x1B[") || line.startsWith("\u2502") || /^[┌└├]/u.test(line)) return [line];
@@ -3022,12 +3065,20 @@ function renderScreen(data, options = {}) {
3022
3065
  }
3023
3066
 
3024
3067
  // src/cli.ts
3025
- import { createInterface as createInterface4 } from "readline/promises";
3026
3068
  import { spawn as spawn3 } from "child_process";
3027
3069
  import {
3070
+ banner,
3071
+ colorEnabled as colorEnabled2,
3028
3072
  confirm,
3029
3073
  createProgram,
3074
+ createTheme as createTheme3,
3075
+ createUi as createUi3,
3076
+ detectAudience,
3077
+ examples,
3078
+ helpSection,
3030
3079
  insertDefaultVerb,
3080
+ isInformationalExit,
3081
+ parseWithPrompts,
3031
3082
  render,
3032
3083
  reportError,
3033
3084
  normalizeError,
@@ -3036,12 +3087,15 @@ import {
3036
3087
  writeOutput
3037
3088
  } from "@bunizao/cli-kit";
3038
3089
  import { realpathSync as realpathSync3 } from "fs";
3039
- import path3 from "path";
3090
+ import path4 from "path";
3040
3091
  import { fileURLToPath as fileURLToPath2 } from "url";
3041
3092
 
3042
3093
  // src/moodle-client-core.ts
3043
3094
  import { z as z2 } from "zod";
3044
3095
 
3096
+ // src/moodle-assign-core.ts
3097
+ import { parse as parse4 } from "node-html-parser";
3098
+
3045
3099
  // src/html-utils.ts
3046
3100
  import { parse as parse2 } from "node-html-parser";
3047
3101
  function htmlToStructuredContent(html, baseUrl) {
@@ -3111,262 +3165,6 @@ function decodeHtml2(value) {
3111
3165
  return value.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
3112
3166
  }
3113
3167
 
3114
- // src/parsers.ts
3115
- function schema(parser) {
3116
- return { parse: parser };
3117
- }
3118
- var UserInfoSchema = schema(parseUserInfo);
3119
- var CourseSchema = schema(parseCourse);
3120
- var CoursesSchema = schema(parseCourses);
3121
- var ActivitySchema = schema(parseActivity);
3122
- var SectionSchema = schema(parseSection);
3123
- var CourseContentsSchema = schema(parseCourseContents);
3124
- var TodoItemSchema = schema(parseTodoItem);
3125
- function parseUserInfo(value) {
3126
- const data = asRecord(value);
3127
- return {
3128
- userid: numberValue(data.userid),
3129
- username: stringValue(data.username),
3130
- fullname: stringValue(data.fullname),
3131
- sitename: stringValue(data.sitename),
3132
- siteurl: stringValue(data.siteurl),
3133
- lang: stringValue(data.lang),
3134
- ...data.timezone ? { timezone: String(data.timezone) } : {}
3135
- };
3136
- }
3137
- function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
3138
- const data = asRecord(value);
3139
- const course = {
3140
- id: numberValue(data.id),
3141
- shortname: stringValue(data.shortname),
3142
- fullname: stringValue(data.fullname),
3143
- category: numberValue(data.category),
3144
- visible: booleanValue(data.visible, true),
3145
- startdate: numberValue(data.startdate)
3146
- };
3147
- const enddate = numberValue(data.enddate);
3148
- if (enddate > 0) {
3149
- course.enddate = enddate;
3150
- }
3151
- return course;
3152
- }
3153
- function parseCourses(value) {
3154
- return asArray(value).map((item) => parseCourse(item));
3155
- }
3156
- function parseActivity(value) {
3157
- const data = asRecord(value);
3158
- return {
3159
- id: numberValue(data.id),
3160
- name: stringValue(data.name),
3161
- modname: stringValue(data.modname),
3162
- url: stringValue(data.url),
3163
- visible: booleanValue(data.visible, true),
3164
- description: stringValue(data.description),
3165
- ...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue(asRecord(data.completiondata).state) } : {},
3166
- ...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue(asRecord(f).filename), url: stringValue(asRecord(f).fileurl), requires_authentication: true })) } : {}
3167
- };
3168
- }
3169
- function parseSection(value) {
3170
- const data = asRecord(value);
3171
- return {
3172
- id: numberValue(data.id),
3173
- name: stringValue(data.name),
3174
- section: numberValue(data.section),
3175
- visible: booleanValue(data.visible, true),
3176
- summary: stringValue(data.summary),
3177
- ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
3178
- activities: asArray(data.modules).map((item) => parseActivity(item))
3179
- };
3180
- }
3181
- function parseCourseContents(value) {
3182
- return asArray(value).map((item) => parseSection(item));
3183
- }
3184
- function parseCourseFormatState(value, baseUrl) {
3185
- const state = asRecord(parseJsonValue(value));
3186
- const activities = /* @__PURE__ */ new Map();
3187
- const activitiesBySection = /* @__PURE__ */ new Map();
3188
- for (const item of asArray(state.cm)) {
3189
- const data = asRecord(item);
3190
- const id2 = numberValue(data.id);
3191
- const sectionId = stringValue(data.sectionid);
3192
- const module = stringValue(data.module) || stringValue(data.plugin).replace(/^mod_/u, "") || stringValue(data.modname).toLowerCase();
3193
- const activity = {
3194
- id: id2,
3195
- name: htmlText(data.name, baseUrl),
3196
- modname: module.toLowerCase(),
3197
- url: stringValue(data.url) ? resolveUrl(baseUrl, stringValue(data.url)) : "",
3198
- visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
3199
- description: htmlText(data.content ?? data.description, baseUrl),
3200
- ...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue(data.completionstate) } : {}
3201
- };
3202
- activities.set(String(id2), activity);
3203
- const sectionActivities = activitiesBySection.get(sectionId) ?? [];
3204
- sectionActivities.push(activity);
3205
- activitiesBySection.set(sectionId, sectionActivities);
3206
- }
3207
- return asArray(state.section).map((item) => {
3208
- const data = asRecord(item);
3209
- const id2 = numberValue(data.id);
3210
- const hasActivityList = Array.isArray(data.cmlist);
3211
- const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue(activityId))).filter((activity) => activity !== void 0);
3212
- return {
3213
- id: id2,
3214
- name: htmlText(data.title || data.rawtitle, baseUrl),
3215
- section: numberValue(data.section ?? data.number),
3216
- visible: booleanValue(data.visible, true),
3217
- summary: htmlText(data.summary, baseUrl),
3218
- ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
3219
- activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
3220
- };
3221
- });
3222
- }
3223
- function parseTodoItem(value) {
3224
- const data = asRecord(value);
3225
- const course = asRecord(data.course);
3226
- const action = asRecord(data.action);
3227
- const progress = course.progress;
3228
- return {
3229
- id: numberValue(data.id),
3230
- name: stringValue(data.name),
3231
- activity_name: stringValue(data.activityname),
3232
- modname: stringValue(data.modulename),
3233
- course_id: numberValue(course.id),
3234
- course_name: stringValue(course.fullname),
3235
- due_at: numberValue(data.timesort) || numberValue(data.timestart),
3236
- overdue: booleanValue(data.overdue),
3237
- actionable: booleanValue(action.actionable),
3238
- action_name: stringValue(action.name),
3239
- action_url: stringValue(action.url),
3240
- url: stringValue(data.url),
3241
- event_type: stringValue(data.eventtype),
3242
- course_progress: typeof progress === "number" ? progress : void 0
3243
- };
3244
- }
3245
- function parseTodoItems(value) {
3246
- return asArray(value).map((item) => parseTodoItem(item));
3247
- }
3248
- function parseAlertNotification(value) {
3249
- const data = asRecord(value);
3250
- return {
3251
- id: numberValue(data.id),
3252
- subject: stringValue(data.subject),
3253
- short_subject: stringValue(data.shortenedsubject),
3254
- event_type: stringValue(data.eventtype),
3255
- component: stringValue(data.component),
3256
- created_at: numberValue(data.timecreated),
3257
- created_pretty: stringValue(data.timecreatedpretty),
3258
- read: booleanValue(data.read),
3259
- context_url: stringValue(data.contexturl),
3260
- context_name: stringValue(data.contexturlname)
3261
- };
3262
- }
3263
- function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
3264
- const notificationsRecord = asRecord(notificationsData);
3265
- const counts2 = asRecord(countsData);
3266
- const unreadCounts = asRecord(unreadCountsData);
3267
- const types = asRecord(counts2.types);
3268
- const unreadTypes = asRecord(unreadCounts.types);
3269
- const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
3270
- return {
3271
- notifications,
3272
- notification_count: notifications.length,
3273
- unread_notification_count: notifications.filter((notification) => !notification.read).length,
3274
- starred_message_count: numberValue(counts2.favourites),
3275
- direct_message_count: numberValue(types["1"]),
3276
- group_message_count: numberValue(types["2"]),
3277
- self_message_count: numberValue(types["3"]),
3278
- unread_starred_message_count: numberValue(unreadCounts.favourites),
3279
- unread_direct_message_count: numberValue(unreadTypes["1"]),
3280
- unread_group_message_count: numberValue(unreadTypes["2"]),
3281
- unread_self_message_count: numberValue(unreadTypes["3"])
3282
- };
3283
- }
3284
- function parseForumPostAuthor(value) {
3285
- const data = asRecord(value);
3286
- const urls = asRecord(data.urls);
3287
- return {
3288
- id: numberValue(data.id),
3289
- fullname: stringValue(data.fullname),
3290
- profile_url: stringValue(urls.profile),
3291
- profile_image_url: stringValue(urls.profileimage)
3292
- };
3293
- }
3294
- function parseForumPost(value, baseUrl = "") {
3295
- const data = asRecord(value);
3296
- const urls = asRecord(data.urls);
3297
- const messageHtml = stringValue(data.message);
3298
- const structured = htmlToStructuredContent(messageHtml, stringValue(urls.view || urls.discuss) || baseUrl);
3299
- return {
3300
- id: numberValue(data.id),
3301
- discussion_id: numberValue(data.discussionid),
3302
- subject: stringValue(data.subject),
3303
- message_html: messageHtml,
3304
- message_text: structured.text,
3305
- image_urls: structured.image_urls,
3306
- links: structured.links,
3307
- tables: structured.tables,
3308
- author: parseForumPostAuthor(data.author),
3309
- parent_id: numberValue(data.parentid),
3310
- time_created: numberValue(data.timecreated),
3311
- time_modified: numberValue(data.timemodified),
3312
- created_pretty: "",
3313
- unread: booleanValue(data.unread),
3314
- is_deleted: booleanValue(data.isdeleted),
3315
- is_private_reply: booleanValue(data.isprivatereply),
3316
- url: stringValue(urls.view || urls.viewisolated),
3317
- reply_url: stringValue(urls.reply)
3318
- };
3319
- }
3320
- function parseForumDiscussion(value, discussionId, baseUrl = "") {
3321
- const data = asRecord(value);
3322
- const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
3323
- return {
3324
- id: discussionId,
3325
- subject: posts[0]?.subject ?? "",
3326
- course_id: numberValue(data.courseid),
3327
- forum_id: numberValue(data.forumid),
3328
- group_id: numberValue(data.groupid),
3329
- group_name: stringValue(data.groupname),
3330
- url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
3331
- posts
3332
- };
3333
- }
3334
- function asRecord(value) {
3335
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
3336
- }
3337
- function asArray(value) {
3338
- return Array.isArray(value) ? value : [];
3339
- }
3340
- function stringValue(value) {
3341
- return typeof value === "string" ? value : value == null ? "" : String(value);
3342
- }
3343
- function numberValue(value) {
3344
- if (typeof value === "number" && Number.isFinite(value)) {
3345
- return value;
3346
- }
3347
- if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
3348
- return Number(value);
3349
- }
3350
- return 0;
3351
- }
3352
- function booleanValue(value, defaultValue = false) {
3353
- if (value === void 0 || value === null) {
3354
- return defaultValue;
3355
- }
3356
- return Boolean(value);
3357
- }
3358
- function parseJsonValue(value) {
3359
- if (typeof value !== "string") return value;
3360
- try {
3361
- return JSON.parse(value);
3362
- } catch {
3363
- return {};
3364
- }
3365
- }
3366
- function htmlText(value, baseUrl) {
3367
- return htmlToStructuredContent(stringValue(value), baseUrl).text;
3368
- }
3369
-
3370
3168
  // src/scraper.ts
3371
3169
  import { parse as parse3 } from "node-html-parser";
3372
3170
  function parseMoodleErrorHtml(html) {
@@ -3398,8 +3196,8 @@ function parseMoodleErrorHtml(html) {
3398
3196
  function parsePageContext(html, baseUrl) {
3399
3197
  const root = parse3(html);
3400
3198
  const config = parseMoodleConfig(html);
3401
- const sesskey = stringValue2(config.sesskey).trim();
3402
- const userid = numberValue2(config.userId) || numberValue2(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
3199
+ const sesskey = stringValue(config.sesskey).trim();
3200
+ const userid = numberValue(config.userId) || numberValue(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
3403
3201
  if (!sesskey || !userid) {
3404
3202
  throw new Error("Session appears invalid: could not load authenticated Moodle context");
3405
3203
  }
@@ -3411,8 +3209,8 @@ function parsePageContext(html, baseUrl) {
3411
3209
  fullname: cleanNodeText(root.querySelector(".userfullname")),
3412
3210
  sitename: extractSitename(root),
3413
3211
  siteurl: baseUrl,
3414
- ...config.timezone ? { timezone: stringValue2(config.timezone) } : {},
3415
- lang: stringValue2(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
3212
+ ...config.timezone ? { timezone: stringValue(config.timezone) } : {},
3213
+ lang: stringValue(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
3416
3214
  }
3417
3215
  };
3418
3216
  }
@@ -3861,96 +3659,705 @@ function safeInt(value) {
3861
3659
  if (typeof value === "string" && /^\d+$/.test(value.trim())) {
3862
3660
  return Number(value.trim());
3863
3661
  }
3864
- return 0;
3662
+ return 0;
3663
+ }
3664
+ function parseMaybeUrl(href, baseUrl) {
3665
+ try {
3666
+ return new URL(href, baseUrl);
3667
+ } catch {
3668
+ return null;
3669
+ }
3670
+ }
3671
+ function numericQueryValue(url, key) {
3672
+ const value = url.searchParams.get(key);
3673
+ if (!value || !/^\d+$/.test(value)) {
3674
+ return null;
3675
+ }
3676
+ return Number(value);
3677
+ }
3678
+ function parseMoodleConfig(html) {
3679
+ const match = html.match(/M\.cfg\s*=\s*({[\s\S]*?});/);
3680
+ if (!match) {
3681
+ return {};
3682
+ }
3683
+ try {
3684
+ return JSON.parse(match[1]);
3685
+ } catch {
3686
+ return {};
3687
+ }
3688
+ }
3689
+ function extractSitename(root) {
3690
+ const title = cleanNodeText(root.querySelector("title"));
3691
+ return title.includes("|") ? title.split("|").at(-1)?.trim() ?? title : title;
3692
+ }
3693
+ function pageTitle(html) {
3694
+ return cleanNodeText(parse3(html).querySelector("h1"));
3695
+ }
3696
+ function activityContext(html) {
3697
+ const root = parse3(html);
3698
+ const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
3699
+ const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
3700
+ const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
3701
+ for (const link2 of links) {
3702
+ const href = link2.getAttribute("href") ?? "";
3703
+ const courseId = numberQueryValue(href, "id");
3704
+ if (courseId !== null) {
3705
+ context.course_id = courseId;
3706
+ }
3707
+ if (numberQueryValue(href, "section") === null) {
3708
+ context.course_name ||= cleanNodeText(link2);
3709
+ } else {
3710
+ context.section_name = cleanNodeText(link2);
3711
+ }
3712
+ }
3713
+ return context;
3714
+ }
3715
+ function extractLabeledText(html, label) {
3716
+ const root = parse3(html);
3717
+ for (const node of root.querySelectorAll("strong, b")) {
3718
+ if (cleanNodeText(node) !== label) {
3719
+ continue;
3720
+ }
3721
+ const parent = node.parentNode;
3722
+ return cleanText(parent?.textContent.replace(label, "") ?? "");
3723
+ }
3724
+ return "";
3725
+ }
3726
+ function findTableValue(html, label) {
3727
+ const root = parse3(html);
3728
+ for (const row of root.querySelectorAll("tr")) {
3729
+ const cells = row.querySelectorAll("th, td");
3730
+ if (cleanNodeText(cells[0]) === label) {
3731
+ return cleanTableCell(cells[1]);
3732
+ }
3733
+ }
3734
+ return "";
3735
+ }
3736
+ function cleanTableCell(node) {
3737
+ if (!node) {
3738
+ return "";
3739
+ }
3740
+ const clone = parse3(node.toString());
3741
+ for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
3742
+ unwanted.remove();
3743
+ }
3744
+ return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
3745
+ }
3746
+ function numberQueryValue(href, key) {
3747
+ try {
3748
+ return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
3749
+ } catch {
3750
+ return null;
3751
+ }
3752
+ }
3753
+ function numberValue(value) {
3754
+ if (typeof value === "number" && Number.isFinite(value)) {
3755
+ return value;
3756
+ }
3757
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
3758
+ return Number(value);
3759
+ }
3760
+ return 0;
3761
+ }
3762
+ function stringValue(value) {
3763
+ return typeof value === "string" ? value : value == null ? "" : String(value);
3764
+ }
3765
+ function fileEntry(name, url, baseUrl) {
3766
+ return {
3767
+ name,
3768
+ url,
3769
+ requires_authentication: new URL(url).origin === new URL(baseUrl).origin
3770
+ };
3771
+ }
3772
+ function unique(items) {
3773
+ return [...new Set(items)];
3774
+ }
3775
+
3776
+ // src/moodle-assign-core.ts
3777
+ async function submitAssignmentFiles(deps, request) {
3778
+ const id2 = request.activityId;
3779
+ if (!Number.isSafeInteger(id2) || id2 <= 0) throw deps.usage("The assignment id must be a positive integer.");
3780
+ if (!request.files.length && !request.final) throw deps.usage("Give at least one file to upload, or use --final to submit the existing draft.");
3781
+ const seen = /* @__PURE__ */ new Set();
3782
+ for (const file2 of request.files) {
3783
+ if (!file2.name || /[\\/]/u.test(file2.name)) throw deps.usage(`'${file2.name}' is not a plain file name.`);
3784
+ if (seen.has(file2.name.toLowerCase())) throw deps.usage(`'${file2.name}' is given twice; Moodle keeps one file per name.`);
3785
+ seen.add(file2.name.toLowerCase());
3786
+ }
3787
+ const viewUrl = `${deps.baseUrl}${ASSIGN_VIEW_PATH}?id=${id2}`;
3788
+ request.onProgress?.("Reading the assignment");
3789
+ const before = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
3790
+ const form = parseSubmissionForm(await pageText(deps, `${viewUrl}&action=editsubmission`), deps);
3791
+ const draft = await listDraftFiles(deps, form);
3792
+ const removed = request.replace ? draft.map((file2) => file2.name) : [];
3793
+ const kept = request.replace ? [] : draft.filter((file2) => !seen.has(file2.name.toLowerCase()));
3794
+ checkLimits(deps, form, kept, request.files);
3795
+ let statement = form.statement;
3796
+ let confirm2;
3797
+ if (request.final && !statement) {
3798
+ confirm2 = parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
3799
+ statement = confirm2.statement;
3800
+ }
3801
+ if (statement && !request.acceptStatement) {
3802
+ throw deps.usage(`Moodle requires you to accept this statement: "${statement}"`, "Re-run with --accept-statement once you agree.");
3803
+ }
3804
+ const limits = describeLimits(form);
3805
+ const uploads = request.files.map((file2) => ({ name: file2.name, bytes: file2.bytes.byteLength, ...file2.path ? { path: file2.path } : {} }));
3806
+ if (request.dryRun) {
3807
+ return {
3808
+ ...before,
3809
+ action: "planned",
3810
+ files: draft.map((file2) => ({ name: file2.name, bytes: file2.bytes })),
3811
+ uploads,
3812
+ removed,
3813
+ limits,
3814
+ ...statement ? { statement, statement_accepted: true } : {},
3815
+ checked_at: timestamp(deps)
3816
+ };
3817
+ }
3818
+ if (removed.length) {
3819
+ request.onProgress?.(`Removing ${removed.join(", ")}`);
3820
+ await deleteDraftFiles(deps, form, draft);
3821
+ }
3822
+ const storedNames = [];
3823
+ for (const [index, file2] of request.files.entries()) {
3824
+ request.onProgress?.(`Uploading ${file2.name} (${index + 1}/${request.files.length})`);
3825
+ storedNames.push(await uploadDraftFile(deps, form, file2));
3826
+ }
3827
+ if (storedNames.length || removed.length) {
3828
+ request.onProgress?.("Saving the submission");
3829
+ const savedHtml = await postForm(deps, form.action, [...form.fields, ...form.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Save changes"]]);
3830
+ if (savedHtml !== null) throw deps.fail(`Moodle did not save the submission: ${noticesOf(savedHtml) || "it returned the edit form again without a reason"}`);
3831
+ }
3832
+ request.onProgress?.("Reading the receipt");
3833
+ let receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
3834
+ const listed = new Set(receipt.files.map((file2) => file2.name.toLowerCase()));
3835
+ const missing = storedNames.filter((name) => !listed.has(name.toLowerCase()));
3836
+ if (missing.length) throw deps.fail(`Moodle saved the submission but its page does not list ${missing.join(", ")}; check the assignment in a browser before submitting.`);
3837
+ let action = "saved";
3838
+ if (isSubmitted(receipt.submission_status)) action = "submitted";
3839
+ else if (request.final) {
3840
+ request.onProgress?.("Submitting for grading");
3841
+ confirm2 ??= parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
3842
+ const errorHtml = await postForm(deps, confirm2.action, [...confirm2.fields, ...confirm2.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Continue"]]);
3843
+ if (errorHtml !== null) throw deps.fail(`Moodle did not submit the assignment for grading: ${noticesOf(errorHtml) || "it returned the confirmation page again without a reason"}`);
3844
+ receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
3845
+ if (!isSubmitted(receipt.submission_status)) throw deps.fail(`Moodle accepted the confirmation but still reports "${receipt.submission_status || "no status"}"; check the assignment in a browser.`);
3846
+ action = "submitted";
3847
+ }
3848
+ return {
3849
+ ...receipt,
3850
+ action,
3851
+ uploads,
3852
+ removed,
3853
+ limits,
3854
+ ...statement ? { statement, statement_accepted: true } : {},
3855
+ checked_at: timestamp(deps)
3856
+ };
3857
+ }
3858
+ function parseSubmissionForm(html, deps) {
3859
+ const root = parse4(html);
3860
+ const form = formWithAction(root, "savesubmission");
3861
+ if (!form) {
3862
+ const notice = noticesOf(html);
3863
+ if (root.querySelectorAll("input[type=submit], button").some((button) => /begin assignment/iu.test(cleanText(button.getAttribute("value") ?? button.textContent)))) {
3864
+ throw deps.fail("This is a timed assignment; start it in a browser before uploading files.");
3865
+ }
3866
+ throw deps.fail(notice ? `Moodle is not accepting a submission: ${notice}` : "Moodle did not show a submission form for this assignment.");
3867
+ }
3868
+ const itemid = form.querySelector("input[name=files_filemanager]")?.getAttribute("value")?.trim() ?? "";
3869
+ if (!itemid) throw deps.fail("This assignment does not accept file uploads.");
3870
+ const options = filemanagerOptions(html, itemid);
3871
+ if (!options) throw deps.fail("Moodle did not describe the file upload area for this assignment.");
3872
+ const fields2 = formFields(form);
3873
+ const sesskey = fields2.find(([name]) => name === "sesskey")?.[1] ?? "";
3874
+ if (!sesskey) throw deps.fail("The submission form has no session key.");
3875
+ const picker = record2(options.filepicker);
3876
+ const repositories = (Array.isArray(picker.repositories) ? picker.repositories : Object.values(record2(picker.repositories))).map(record2);
3877
+ const upload = repositories.find((repo) => repo.type === "upload");
3878
+ if (!upload || upload.id === void 0) throw deps.fail("The site does not allow direct file uploads for this assignment.");
3879
+ const accepted = options.accepted_types;
3880
+ const acceptedTypes = Array.isArray(accepted) ? accepted.map(String).filter(Boolean) : accepted === void 0 || accepted === "*" ? "*" : [String(accepted)];
3881
+ return {
3882
+ action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`),
3883
+ fields: fields2,
3884
+ sesskey,
3885
+ itemid,
3886
+ clientId: String(options.client_id ?? ""),
3887
+ contextId: String(record2(options.context).id ?? ""),
3888
+ repoId: String(upload.id),
3889
+ author: String(picker.author ?? ""),
3890
+ license: String(picker.defaultlicense ?? ""),
3891
+ maxBytes: integer(options.maxbytes),
3892
+ areaMaxBytes: integer(options.areamaxbytes),
3893
+ maxFiles: integer(options.maxfiles),
3894
+ acceptedTypes: acceptedTypes.length === 1 && acceptedTypes[0] === "*" ? "*" : acceptedTypes,
3895
+ ...statementOf(form)
3896
+ };
3897
+ }
3898
+ function parseConfirmForm(html, deps) {
3899
+ const root = parse4(html);
3900
+ const form = formWithAction(root, "confirmsubmit");
3901
+ if (!form) {
3902
+ const notice = noticesOf(html);
3903
+ throw deps.fail(notice ? `Moodle is not accepting a submission for grading: ${notice}` : "Moodle did not show the submit-for-grading confirmation.");
3904
+ }
3905
+ return { action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`), fields: formFields(form), ...statementOf(form) };
3906
+ }
3907
+ function parseReceiptPage(html, activityId, baseUrl) {
3908
+ const page = parseAssignmentHtml(html, activityId, baseUrl);
3909
+ const root = parse4(html);
3910
+ const files = /* @__PURE__ */ new Map();
3911
+ const add = (link2) => {
3912
+ const name = cleanText(link2.textContent);
3913
+ const href = link2.getAttribute("href") ?? "";
3914
+ if (name && !files.has(name.toLowerCase())) files.set(name.toLowerCase(), { name, ...href ? { url: resolveUrl(baseUrl, href) } : {} });
3915
+ };
3916
+ for (const link2 of root.querySelectorAll(".fileuploadsubmission a[href]")) add(link2);
3917
+ for (const link2 of tableCell(root, "File submissions")?.querySelectorAll("a[href]") ?? []) add(link2);
3918
+ return {
3919
+ id: activityId,
3920
+ name: page.name,
3921
+ ...page.course_id ? { unit_id: page.course_id } : {},
3922
+ url: page.url,
3923
+ submission_status: page.submission_status,
3924
+ grading_status: page.grading_status,
3925
+ due: page.due_pretty || cleanText(tableCell(root, "Due date")?.textContent),
3926
+ time_remaining: page.time_remaining,
3927
+ last_modified: cleanText(tableCell(root, "Last modified")?.textContent),
3928
+ files: [...files.values()]
3929
+ };
3930
+ }
3931
+ function noticesOf(html) {
3932
+ const root = parse4(html);
3933
+ const texts = [];
3934
+ for (const node of root.querySelectorAll(".alert, .invalid-feedback, .form-control-feedback, .error, [data-fieldtype] .text-danger")) {
3935
+ for (const junk of node.querySelectorAll("button, .close")) junk.remove();
3936
+ const text2 = cleanText(node.textContent);
3937
+ if (text2 && !texts.includes(text2)) texts.push(text2);
3938
+ }
3939
+ return texts.join(" ");
3940
+ }
3941
+ function formWithAction(root, action) {
3942
+ for (const form of root.querySelectorAll("form")) {
3943
+ if (form.querySelectorAll("input[name=action]").some((input2) => input2.getAttribute("value") === action)) return form;
3944
+ }
3945
+ return null;
3946
+ }
3947
+ function formFields(form) {
3948
+ const fields2 = [];
3949
+ for (const element of form.querySelectorAll("input, textarea, select")) {
3950
+ const name = element.getAttribute("name");
3951
+ if (!name) continue;
3952
+ const tag = element.tagName.toLowerCase();
3953
+ if (tag === "textarea") {
3954
+ fields2.push([name, element.textContent]);
3955
+ continue;
3956
+ }
3957
+ if (tag === "select") {
3958
+ const options = element.querySelectorAll("option");
3959
+ const chosen = options.find((option) => option.hasAttribute("selected")) ?? options[0];
3960
+ if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
3961
+ continue;
3962
+ }
3963
+ const type = (element.getAttribute("type") ?? "text").toLowerCase();
3964
+ if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
3965
+ if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
3966
+ fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
3967
+ }
3968
+ return fields2;
3969
+ }
3970
+ function statementOf(form) {
3971
+ const box = form.querySelector("input[name=submissionstatement]");
3972
+ if (!box) return {};
3973
+ const id2 = box.getAttribute("id");
3974
+ const label = (id2 ? form.querySelector(`label[for="${id2}"]`) : null) ?? box.closest("label") ?? box.parentNode?.querySelector("label") ?? null;
3975
+ const text2 = cleanText(label?.textContent).replace(/\s*Required\s*$/u, "").trim();
3976
+ return { statement: text2 || "Submission statement" };
3977
+ }
3978
+ function filemanagerOptions(html, itemid) {
3979
+ const pattern = /M\.form_filemanager\.init\(\s*Y\s*,\s*/gu;
3980
+ let match;
3981
+ while (match = pattern.exec(html)) {
3982
+ const json = balancedObject(html, match.index + match[0].length);
3983
+ if (!json) continue;
3984
+ try {
3985
+ const options = JSON.parse(json);
3986
+ if (isRecord4(options) && String(options.itemid) === itemid) return options;
3987
+ } catch {
3988
+ }
3989
+ }
3990
+ return null;
3991
+ }
3992
+ function balancedObject(text2, start) {
3993
+ if (text2[start] !== "{") return null;
3994
+ let depth = 0;
3995
+ let quoted = false;
3996
+ for (let index = start; index < text2.length; index += 1) {
3997
+ const char = text2[index];
3998
+ if (quoted) {
3999
+ if (char === "\\") index += 1;
4000
+ else if (char === '"') quoted = false;
4001
+ } else if (char === '"') quoted = true;
4002
+ else if (char === "{") depth += 1;
4003
+ else if (char === "}") {
4004
+ depth -= 1;
4005
+ if (depth === 0) return text2.slice(start, index + 1);
4006
+ }
4007
+ }
4008
+ return null;
4009
+ }
4010
+ function tableCell(root, label) {
4011
+ for (const row of root.querySelectorAll("tr")) {
4012
+ const cells = row.querySelectorAll("th, td");
4013
+ if (cells.length > 1 && cleanText(cells[0].textContent) === label) return cells[1];
4014
+ }
4015
+ return null;
4016
+ }
4017
+ async function pageText(deps, url) {
4018
+ return (await deps.request(url)).text();
4019
+ }
4020
+ async function postForm(deps, action, fields2) {
4021
+ const response = await deps.request(action, {
4022
+ method: "POST",
4023
+ headers: { "content-type": "application/x-www-form-urlencoded" },
4024
+ body: new URLSearchParams(fields2).toString()
4025
+ });
4026
+ const html = await response.text();
4027
+ return landedOnView(response.url) ? null : html;
4028
+ }
4029
+ function landedOnView(url) {
4030
+ try {
4031
+ const parsed = new URL(url);
4032
+ return parsed.pathname.endsWith(ASSIGN_VIEW_PATH) && (parsed.searchParams.get("action") ?? "view") === "view";
4033
+ } catch {
4034
+ return false;
4035
+ }
4036
+ }
4037
+ async function draftAjax(deps, form, action, params) {
4038
+ const body = new URLSearchParams({ sesskey: form.sesskey, client_id: form.clientId, itemid: form.itemid, ...params });
4039
+ const response = await deps.request(`${deps.baseUrl}/repository/draftfiles_ajax.php?action=${action}`, {
4040
+ method: "POST",
4041
+ headers: { "content-type": "application/x-www-form-urlencoded" },
4042
+ body: body.toString()
4043
+ }, { allowErrorStatus: true });
4044
+ return jsonOf(deps, response, `draft file ${action}`);
4045
+ }
4046
+ async function listDraftFiles(deps, form) {
4047
+ const data = record2(await draftAjax(deps, form, "list", { filepath: "/" }));
4048
+ const list2 = Array.isArray(data.list) ? data.list.map(record2) : [];
4049
+ return list2.filter((item) => item.type !== "folder").map((item) => ({ name: String(item.filename ?? item.fullname ?? ""), path: String(item.filepath ?? "/"), bytes: integer(item.size) })).filter((item) => item.name);
4050
+ }
4051
+ async function deleteDraftFiles(deps, form, files) {
4052
+ const selected = JSON.stringify(files.map((file2) => ({ filename: file2.name, filepath: file2.path })));
4053
+ const result = await draftAjax(deps, form, "deleteselected", { selected });
4054
+ if (result === false) throw deps.fail("Moodle did not remove the existing submission files.");
4055
+ }
4056
+ async function uploadDraftFile(deps, form, file2) {
4057
+ const body = new FormData();
4058
+ body.set("sesskey", form.sesskey);
4059
+ body.set("client_id", form.clientId);
4060
+ body.set("repo_id", form.repoId);
4061
+ body.set("itemid", form.itemid);
4062
+ body.set("env", "filemanager");
4063
+ body.set("ctx_id", form.contextId);
4064
+ body.set("title", file2.name);
4065
+ body.set("author", form.author);
4066
+ body.set("license", form.license);
4067
+ body.set("savepath", "/");
4068
+ body.set("maxbytes", String(form.maxBytes));
4069
+ body.set("areamaxbytes", String(form.areaMaxBytes));
4070
+ for (const type of form.acceptedTypes === "*" ? ["*"] : form.acceptedTypes) body.append("accepted_types[]", type);
4071
+ body.set("overwrite", "1");
4072
+ body.set("repo_upload_file", new Blob([Uint8Array.from(file2.bytes)]), file2.name);
4073
+ const response = await deps.request(`${deps.baseUrl}/repository/repository_ajax.php?action=upload`, { method: "POST", body }, { allowErrorStatus: true });
4074
+ const data = record2(await jsonOf(deps, response, `upload of ${file2.name}`));
4075
+ if (typeof data.error === "string" && data.error) throw deps.fail(`Moodle refused ${file2.name}: ${data.error}`, typeof data.errorcode === "string" ? data.errorcode : void 0);
4076
+ if (data.event === "fileexists") throw deps.fail(`Moodle reports ${file2.name} already exists and did not overwrite it.`);
4077
+ const stored = typeof data.file === "string" && data.file ? data.file : file2.name;
4078
+ if (!data.url && !data.id && !data.file) throw deps.fail(`Moodle did not confirm the upload of ${file2.name}.`);
4079
+ return stored;
4080
+ }
4081
+ async function jsonOf(deps, response, step) {
4082
+ const text2 = await response.text();
4083
+ try {
4084
+ return JSON.parse(text2);
4085
+ } catch {
4086
+ const notice = noticesOf(text2);
4087
+ throw deps.fail(`Moodle did not answer the ${step} with JSON (HTTP ${response.status})${notice ? `: ${notice}` : ""}`);
4088
+ }
4089
+ }
4090
+ function checkLimits(deps, form, kept, files) {
4091
+ if (form.maxFiles > 0 && kept.length + files.length > form.maxFiles) {
4092
+ throw deps.usage(`This assignment allows ${form.maxFiles} file${form.maxFiles === 1 ? "" : "s"}; the submission would hold ${kept.length + files.length}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
4093
+ }
4094
+ for (const file2 of files) {
4095
+ if (form.maxBytes > 0 && file2.bytes.byteLength > form.maxBytes) throw deps.usage(`${file2.name} is ${size(file2.bytes.byteLength)}; the limit is ${size(form.maxBytes)}.`);
4096
+ if (form.acceptedTypes !== "*" && form.acceptedTypes.every((type) => type.startsWith(".")) && !form.acceptedTypes.some((type) => file2.name.toLowerCase().endsWith(type.toLowerCase()))) {
4097
+ throw deps.usage(`${file2.name} is not an accepted type; allowed: ${form.acceptedTypes.join(", ")}.`);
4098
+ }
4099
+ }
4100
+ const total = kept.reduce((sum, file2) => sum + file2.bytes, 0) + files.reduce((sum, file2) => sum + file2.bytes.byteLength, 0);
4101
+ if (form.areaMaxBytes > 0 && total > form.areaMaxBytes) throw deps.usage(`The submission would total ${size(total)}; the limit is ${size(form.areaMaxBytes)}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
4102
+ }
4103
+ function describeLimits(form) {
4104
+ return {
4105
+ ...form.maxBytes > 0 ? { max_bytes: form.maxBytes } : {},
4106
+ ...form.maxFiles > 0 ? { max_files: form.maxFiles } : {},
4107
+ ...form.areaMaxBytes > 0 ? { area_max_bytes: form.areaMaxBytes } : {},
4108
+ ...form.acceptedTypes !== "*" ? { accepted_types: form.acceptedTypes } : {}
4109
+ };
4110
+ }
4111
+ function isSubmitted(status) {
4112
+ return /\bsubmitted\b/iu.test(status) && !/\bnot submitted\b/iu.test(status);
4113
+ }
4114
+ function size(bytes) {
4115
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
4116
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
4117
+ return `${bytes} B`;
4118
+ }
4119
+ function timestamp(deps) {
4120
+ return (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
4121
+ }
4122
+ function integer(value) {
4123
+ const number = Number(value);
4124
+ return Number.isSafeInteger(number) ? number : 0;
4125
+ }
4126
+ function isRecord4(value) {
4127
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4128
+ }
4129
+ function record2(value) {
4130
+ return isRecord4(value) ? value : {};
4131
+ }
4132
+
4133
+ // src/parsers.ts
4134
+ function schema(parser) {
4135
+ return { parse: parser };
4136
+ }
4137
+ var UserInfoSchema = schema(parseUserInfo);
4138
+ var CourseSchema = schema(parseCourse);
4139
+ var CoursesSchema = schema(parseCourses);
4140
+ var ActivitySchema = schema(parseActivity);
4141
+ var SectionSchema = schema(parseSection);
4142
+ var CourseContentsSchema = schema(parseCourseContents);
4143
+ var TodoItemSchema = schema(parseTodoItem);
4144
+ function parseUserInfo(value) {
4145
+ const data = asRecord(value);
4146
+ return {
4147
+ userid: numberValue2(data.userid),
4148
+ username: stringValue2(data.username),
4149
+ fullname: stringValue2(data.fullname),
4150
+ sitename: stringValue2(data.sitename),
4151
+ siteurl: stringValue2(data.siteurl),
4152
+ lang: stringValue2(data.lang),
4153
+ ...data.timezone ? { timezone: String(data.timezone) } : {}
4154
+ };
4155
+ }
4156
+ function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
4157
+ const data = asRecord(value);
4158
+ const course = {
4159
+ id: numberValue2(data.id),
4160
+ shortname: stringValue2(data.shortname),
4161
+ fullname: stringValue2(data.fullname),
4162
+ category: numberValue2(data.category),
4163
+ visible: booleanValue(data.visible, true),
4164
+ startdate: numberValue2(data.startdate)
4165
+ };
4166
+ const enddate = numberValue2(data.enddate);
4167
+ if (enddate > 0) {
4168
+ course.enddate = enddate;
4169
+ }
4170
+ return course;
4171
+ }
4172
+ function parseCourses(value) {
4173
+ return asArray(value).map((item) => parseCourse(item));
4174
+ }
4175
+ function parseActivity(value) {
4176
+ const data = asRecord(value);
4177
+ return {
4178
+ id: numberValue2(data.id),
4179
+ name: stringValue2(data.name),
4180
+ modname: stringValue2(data.modname),
4181
+ url: stringValue2(data.url),
4182
+ visible: booleanValue(data.visible, true),
4183
+ description: stringValue2(data.description),
4184
+ ...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue2(asRecord(data.completiondata).state) } : {},
4185
+ ...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue2(asRecord(f).filename), url: stringValue2(asRecord(f).fileurl), requires_authentication: true })) } : {}
4186
+ };
4187
+ }
4188
+ function parseSection(value) {
4189
+ const data = asRecord(value);
4190
+ return {
4191
+ id: numberValue2(data.id),
4192
+ name: stringValue2(data.name),
4193
+ section: numberValue2(data.section),
4194
+ visible: booleanValue(data.visible, true),
4195
+ summary: stringValue2(data.summary),
4196
+ ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
4197
+ activities: asArray(data.modules).map((item) => parseActivity(item))
4198
+ };
4199
+ }
4200
+ function parseCourseContents(value) {
4201
+ return asArray(value).map((item) => parseSection(item));
4202
+ }
4203
+ function parseCourseFormatState(value, baseUrl) {
4204
+ const state = asRecord(parseJsonValue(value));
4205
+ const activities = /* @__PURE__ */ new Map();
4206
+ const activitiesBySection = /* @__PURE__ */ new Map();
4207
+ for (const item of asArray(state.cm)) {
4208
+ const data = asRecord(item);
4209
+ const id2 = numberValue2(data.id);
4210
+ const sectionId = stringValue2(data.sectionid);
4211
+ const module = stringValue2(data.module) || stringValue2(data.plugin).replace(/^mod_/u, "") || stringValue2(data.modname).toLowerCase();
4212
+ const activity = {
4213
+ id: id2,
4214
+ name: htmlText(data.name, baseUrl),
4215
+ modname: module.toLowerCase(),
4216
+ url: stringValue2(data.url) ? resolveUrl(baseUrl, stringValue2(data.url)) : "",
4217
+ visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
4218
+ description: htmlText(data.content ?? data.description, baseUrl),
4219
+ ...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue2(data.completionstate) } : {}
4220
+ };
4221
+ activities.set(String(id2), activity);
4222
+ const sectionActivities = activitiesBySection.get(sectionId) ?? [];
4223
+ sectionActivities.push(activity);
4224
+ activitiesBySection.set(sectionId, sectionActivities);
4225
+ }
4226
+ return asArray(state.section).map((item) => {
4227
+ const data = asRecord(item);
4228
+ const id2 = numberValue2(data.id);
4229
+ const hasActivityList = Array.isArray(data.cmlist);
4230
+ const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue2(activityId))).filter((activity) => activity !== void 0);
4231
+ return {
4232
+ id: id2,
4233
+ name: htmlText(data.title || data.rawtitle, baseUrl),
4234
+ section: numberValue2(data.section ?? data.number),
4235
+ visible: booleanValue(data.visible, true),
4236
+ summary: htmlText(data.summary, baseUrl),
4237
+ ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
4238
+ activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
4239
+ };
4240
+ });
3865
4241
  }
3866
- function parseMaybeUrl(href, baseUrl) {
3867
- try {
3868
- return new URL(href, baseUrl);
3869
- } catch {
3870
- return null;
3871
- }
4242
+ function parseTodoItem(value) {
4243
+ const data = asRecord(value);
4244
+ const course = asRecord(data.course);
4245
+ const action = asRecord(data.action);
4246
+ const progress = course.progress;
4247
+ return {
4248
+ id: numberValue2(data.id),
4249
+ name: stringValue2(data.name),
4250
+ activity_name: stringValue2(data.activityname),
4251
+ modname: stringValue2(data.modulename),
4252
+ course_id: numberValue2(course.id),
4253
+ course_name: stringValue2(course.fullname),
4254
+ due_at: numberValue2(data.timesort) || numberValue2(data.timestart),
4255
+ overdue: booleanValue(data.overdue),
4256
+ actionable: booleanValue(action.actionable),
4257
+ action_name: stringValue2(action.name),
4258
+ action_url: stringValue2(action.url),
4259
+ url: stringValue2(data.url),
4260
+ event_type: stringValue2(data.eventtype),
4261
+ course_progress: typeof progress === "number" ? progress : void 0
4262
+ };
3872
4263
  }
3873
- function numericQueryValue(url, key) {
3874
- const value = url.searchParams.get(key);
3875
- if (!value || !/^\d+$/.test(value)) {
3876
- return null;
3877
- }
3878
- return Number(value);
4264
+ function parseTodoItems(value) {
4265
+ return asArray(value).map((item) => parseTodoItem(item));
3879
4266
  }
3880
- function parseMoodleConfig(html) {
3881
- const match = html.match(/M\.cfg\s*=\s*({[\s\S]*?});/);
3882
- if (!match) {
3883
- return {};
3884
- }
3885
- try {
3886
- return JSON.parse(match[1]);
3887
- } catch {
3888
- return {};
3889
- }
4267
+ function parseAlertNotification(value) {
4268
+ const data = asRecord(value);
4269
+ return {
4270
+ id: numberValue2(data.id),
4271
+ subject: stringValue2(data.subject),
4272
+ short_subject: stringValue2(data.shortenedsubject),
4273
+ event_type: stringValue2(data.eventtype),
4274
+ component: stringValue2(data.component),
4275
+ created_at: numberValue2(data.timecreated),
4276
+ created_pretty: stringValue2(data.timecreatedpretty),
4277
+ read: booleanValue(data.read),
4278
+ context_url: stringValue2(data.contexturl),
4279
+ context_name: stringValue2(data.contexturlname)
4280
+ };
3890
4281
  }
3891
- function extractSitename(root) {
3892
- const title = cleanNodeText(root.querySelector("title"));
3893
- return title.includes("|") ? title.split("|").at(-1)?.trim() ?? title : title;
4282
+ function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
4283
+ const notificationsRecord = asRecord(notificationsData);
4284
+ const counts2 = asRecord(countsData);
4285
+ const unreadCounts = asRecord(unreadCountsData);
4286
+ const types = asRecord(counts2.types);
4287
+ const unreadTypes = asRecord(unreadCounts.types);
4288
+ const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
4289
+ return {
4290
+ notifications,
4291
+ notification_count: notifications.length,
4292
+ unread_notification_count: notifications.filter((notification) => !notification.read).length,
4293
+ starred_message_count: numberValue2(counts2.favourites),
4294
+ direct_message_count: numberValue2(types["1"]),
4295
+ group_message_count: numberValue2(types["2"]),
4296
+ self_message_count: numberValue2(types["3"]),
4297
+ unread_starred_message_count: numberValue2(unreadCounts.favourites),
4298
+ unread_direct_message_count: numberValue2(unreadTypes["1"]),
4299
+ unread_group_message_count: numberValue2(unreadTypes["2"]),
4300
+ unread_self_message_count: numberValue2(unreadTypes["3"])
4301
+ };
3894
4302
  }
3895
- function pageTitle(html) {
3896
- return cleanNodeText(parse3(html).querySelector("h1"));
4303
+ function parseForumPostAuthor(value) {
4304
+ const data = asRecord(value);
4305
+ const urls = asRecord(data.urls);
4306
+ return {
4307
+ id: numberValue2(data.id),
4308
+ fullname: stringValue2(data.fullname),
4309
+ profile_url: stringValue2(urls.profile),
4310
+ profile_image_url: stringValue2(urls.profileimage)
4311
+ };
3897
4312
  }
3898
- function activityContext(html) {
3899
- const root = parse3(html);
3900
- const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
3901
- const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
3902
- const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
3903
- for (const link2 of links) {
3904
- const href = link2.getAttribute("href") ?? "";
3905
- const courseId = numberQueryValue(href, "id");
3906
- if (courseId !== null) {
3907
- context.course_id = courseId;
3908
- }
3909
- if (numberQueryValue(href, "section") === null) {
3910
- context.course_name ||= cleanNodeText(link2);
3911
- } else {
3912
- context.section_name = cleanNodeText(link2);
3913
- }
3914
- }
3915
- return context;
4313
+ function parseForumPost(value, baseUrl = "") {
4314
+ const data = asRecord(value);
4315
+ const urls = asRecord(data.urls);
4316
+ const messageHtml = stringValue2(data.message);
4317
+ const structured = htmlToStructuredContent(messageHtml, stringValue2(urls.view || urls.discuss) || baseUrl);
4318
+ return {
4319
+ id: numberValue2(data.id),
4320
+ discussion_id: numberValue2(data.discussionid),
4321
+ subject: stringValue2(data.subject),
4322
+ message_html: messageHtml,
4323
+ message_text: structured.text,
4324
+ image_urls: structured.image_urls,
4325
+ links: structured.links,
4326
+ tables: structured.tables,
4327
+ author: parseForumPostAuthor(data.author),
4328
+ parent_id: numberValue2(data.parentid),
4329
+ time_created: numberValue2(data.timecreated),
4330
+ time_modified: numberValue2(data.timemodified),
4331
+ created_pretty: "",
4332
+ unread: booleanValue(data.unread),
4333
+ is_deleted: booleanValue(data.isdeleted),
4334
+ is_private_reply: booleanValue(data.isprivatereply),
4335
+ url: stringValue2(urls.view || urls.viewisolated),
4336
+ reply_url: stringValue2(urls.reply)
4337
+ };
3916
4338
  }
3917
- function extractLabeledText(html, label) {
3918
- const root = parse3(html);
3919
- for (const node of root.querySelectorAll("strong, b")) {
3920
- if (cleanNodeText(node) !== label) {
3921
- continue;
3922
- }
3923
- const parent = node.parentNode;
3924
- return cleanText(parent?.textContent.replace(label, "") ?? "");
3925
- }
3926
- return "";
4339
+ function parseForumDiscussion(value, discussionId, baseUrl = "") {
4340
+ const data = asRecord(value);
4341
+ const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
4342
+ return {
4343
+ id: discussionId,
4344
+ subject: posts[0]?.subject ?? "",
4345
+ course_id: numberValue2(data.courseid),
4346
+ forum_id: numberValue2(data.forumid),
4347
+ group_id: numberValue2(data.groupid),
4348
+ group_name: stringValue2(data.groupname),
4349
+ url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
4350
+ posts
4351
+ };
3927
4352
  }
3928
- function findTableValue(html, label) {
3929
- const root = parse3(html);
3930
- for (const row of root.querySelectorAll("tr")) {
3931
- const cells = row.querySelectorAll("th, td");
3932
- if (cleanNodeText(cells[0]) === label) {
3933
- return cleanTableCell(cells[1]);
3934
- }
3935
- }
3936
- return "";
4353
+ function asRecord(value) {
4354
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
3937
4355
  }
3938
- function cleanTableCell(node) {
3939
- if (!node) {
3940
- return "";
3941
- }
3942
- const clone = parse3(node.toString());
3943
- for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
3944
- unwanted.remove();
3945
- }
3946
- return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
4356
+ function asArray(value) {
4357
+ return Array.isArray(value) ? value : [];
3947
4358
  }
3948
- function numberQueryValue(href, key) {
3949
- try {
3950
- return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
3951
- } catch {
3952
- return null;
3953
- }
4359
+ function stringValue2(value) {
4360
+ return typeof value === "string" ? value : value == null ? "" : String(value);
3954
4361
  }
3955
4362
  function numberValue2(value) {
3956
4363
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -3961,18 +4368,22 @@ function numberValue2(value) {
3961
4368
  }
3962
4369
  return 0;
3963
4370
  }
3964
- function stringValue2(value) {
3965
- return typeof value === "string" ? value : value == null ? "" : String(value);
4371
+ function booleanValue(value, defaultValue = false) {
4372
+ if (value === void 0 || value === null) {
4373
+ return defaultValue;
4374
+ }
4375
+ return Boolean(value);
3966
4376
  }
3967
- function fileEntry(name, url, baseUrl) {
3968
- return {
3969
- name,
3970
- url,
3971
- requires_authentication: new URL(url).origin === new URL(baseUrl).origin
3972
- };
4377
+ function parseJsonValue(value) {
4378
+ if (typeof value !== "string") return value;
4379
+ try {
4380
+ return JSON.parse(value);
4381
+ } catch {
4382
+ return {};
4383
+ }
3973
4384
  }
3974
- function unique(items) {
3975
- return [...new Set(items)];
4385
+ function htmlText(value, baseUrl) {
4386
+ return htmlToStructuredContent(stringValue2(value), baseUrl).text;
3976
4387
  }
3977
4388
 
3978
4389
  // src/moodle-forum-core.ts
@@ -4109,13 +4520,13 @@ var ForumModule = class {
4109
4520
  }
4110
4521
  };
4111
4522
  function shouldFallbackForumAjax(error) {
4112
- if (!isRecord4(error)) {
4523
+ if (!isRecord5(error)) {
4113
4524
  return false;
4114
4525
  }
4115
4526
  const code = typeof error.moodleErrorCode === "string" ? error.moodleErrorCode : "";
4116
4527
  return code === "servicenotavailable" || code === "accessexception" || error instanceof Error && error.message.includes("Web service is not available");
4117
4528
  }
4118
- function isRecord4(value) {
4529
+ function isRecord5(value) {
4119
4530
  return typeof value === "object" && value !== null && !Array.isArray(value);
4120
4531
  }
4121
4532
 
@@ -4247,9 +4658,9 @@ async function searchForumContent(source, query, options = {}) {
4247
4658
  hits.sort(sortBy === "recent" ? sortRecent : sortRelevant);
4248
4659
  return hits.slice(0, options.limit ?? 20).map(([, hit]) => includePostText ? hit : { ...hit, snippet: "" });
4249
4660
  }
4250
- async function inParallel2(items, size, run) {
4661
+ async function inParallel2(items, size2, run) {
4251
4662
  const results = [];
4252
- for (let index = 0; index < items.length; index += size) results.push(...await Promise.all(items.slice(index, index + size).map(run)));
4663
+ for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
4253
4664
  return results;
4254
4665
  }
4255
4666
  function normalizeQuery(value) {
@@ -4425,7 +4836,7 @@ var MoodleClientCore = class {
4425
4836
  await this.ensureSession();
4426
4837
  return this.call(functionName, args);
4427
4838
  },
4428
- getPage: (path4, params) => this.get(path4, params),
4839
+ getPage: (path5, params) => this.get(path5, params),
4429
4840
  getCourses: () => this.getCourses(),
4430
4841
  getCourseContents: (courseId) => this.getCourseContents(courseId)
4431
4842
  });
@@ -4435,7 +4846,7 @@ var MoodleClientCore = class {
4435
4846
  try {
4436
4847
  if (this.unavailable.has(FUNC_GET_SITE_INFO) && this.userInfo?.fullname) return this.userInfo;
4437
4848
  const data = await this.call(FUNC_GET_SITE_INFO);
4438
- if (isRecord5(data) && "userid" in data) {
4849
+ if (isRecord6(data) && "userid" in data) {
4439
4850
  const info = parseUserInfo(data);
4440
4851
  this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
4441
4852
  this.userid = info.userid;
@@ -4534,9 +4945,9 @@ var MoodleClientCore = class {
4534
4945
  let type = "";
4535
4946
  try {
4536
4947
  const data = await this.call(FUNC_GET_COURSE_MODULE, { cmid: id2 });
4537
- const module = isRecord5(data) && isRecord5(data.cm) ? data.cm : data;
4538
- type = isRecord5(module) && typeof module.modname === "string" ? module.modname : "";
4539
- courseId = isRecord5(module) && typeof module.course === "number" ? module.course : void 0;
4948
+ const module = isRecord6(data) && isRecord6(data.cm) ? data.cm : data;
4949
+ type = isRecord6(module) && typeof module.modname === "string" ? module.modname : "";
4950
+ courseId = isRecord6(module) && typeof module.course === "number" ? module.course : void 0;
4540
4951
  } catch (error) {
4541
4952
  if (!this.errors.isApi(error) || error.moodleErrorCode !== "servicenotavailable") {
4542
4953
  throw error;
@@ -4580,14 +4991,14 @@ var MoodleClientCore = class {
4580
4991
  } else {
4581
4992
  data = await this.call(FUNC_GET_ACTION_EVENTS, { ...window, limittononsuspendedevents: true });
4582
4993
  }
4583
- const events = isRecord5(data) && Array.isArray(data.events) ? data.events : [];
4994
+ const events = isRecord6(data) && Array.isArray(data.events) ? data.events : [];
4584
4995
  for (const item of parseTodoItems(events)) if (!seen.has(item.id)) {
4585
4996
  seen.add(item.id);
4586
4997
  items.push(item);
4587
4998
  }
4588
4999
  if (events.length < batchSize) break;
4589
5000
  const last = events.at(-1);
4590
- const next = isRecord5(last) && typeof last.id === "number" ? last.id : void 0;
5001
+ const next = isRecord6(last) && typeof last.id === "number" ? last.id : void 0;
4591
5002
  if (!next || next === aftereventid) throw this.errors.api("Moodle repeated a calendar page; refine the date window.");
4592
5003
  aftereventid = next;
4593
5004
  }
@@ -4754,8 +5165,18 @@ var MoodleClientCore = class {
4754
5165
  async getFolder(id2) {
4755
5166
  return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
4756
5167
  }
4757
- async requestAbsolute(url, init = {}) {
4758
- return this.requestAbsoluteInternal(url, init, true);
5168
+ async requestAbsolute(url, init = {}, options = {}) {
5169
+ return this.requestAbsoluteInternal(url, init, true, Boolean(options.allowErrorStatus));
5170
+ }
5171
+ /** Uploads files into an assignment and reads the receipt back from the site. */
5172
+ async submitAssignment(request) {
5173
+ await this.ensureSession();
5174
+ return submitAssignmentFiles({
5175
+ baseUrl: this.baseUrl,
5176
+ request: (url, init, options) => this.requestAbsolute(url, init, options),
5177
+ fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
5178
+ usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
5179
+ }, request);
4759
5180
  }
4760
5181
  async getNewsForums(courseId) {
4761
5182
  const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
@@ -4765,7 +5186,7 @@ var MoodleClientCore = class {
4765
5186
  await this.ensureSession();
4766
5187
  const data = await this.call("mod_forum_get_forums_by_courses", { courseids: units.map((c) => c.id) });
4767
5188
  for (const f of Array.isArray(data) ? data : []) {
4768
- if (!isRecord5(f) || f.type !== "news" || typeof f.cmid !== "number") continue;
5189
+ if (!isRecord6(f) || f.type !== "news" || typeof f.cmid !== "number") continue;
4769
5190
  const c = units.find((c2) => c2.id === f.course);
4770
5191
  forums.push({ id: f.cmid, name: String(f.name || ""), course_id: Number(f.course), course_name: c?.fullname || "", url: `${this.baseUrl}/mod/forum/view.php?id=${f.cmid}` });
4771
5192
  }
@@ -4893,16 +5314,16 @@ var MoodleClientCore = class {
4893
5314
  async getAbsolute(url) {
4894
5315
  return (await this.requestAbsolute(url)).text();
4895
5316
  }
4896
- async requestAbsoluteInternal(url, init, allowRetry) {
5317
+ async requestAbsoluteInternal(url, init, allowRetry, allowErrorStatus = false) {
4897
5318
  const response = await fetchWithSession(url, init, this.baseUrl, this.cookie, this.fetchImpl);
4898
5319
  if (response.url.includes("/login/")) {
4899
5320
  if (this.onLoginRequired && allowRetry && !this.retryingLogin) {
4900
5321
  await this.reauthenticate();
4901
- return this.requestAbsoluteInternal(url, init, false);
5322
+ return this.requestAbsoluteInternal(url, init, false, allowErrorStatus);
4902
5323
  }
4903
5324
  throw this.errors.api("Session expired", "servicerequireslogin");
4904
5325
  }
4905
- if (!response.ok) {
5326
+ if (!response.ok && !allowErrorStatus) {
4906
5327
  const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
4907
5328
  const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
4908
5329
  if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
@@ -4920,7 +5341,7 @@ var MoodleClientCore = class {
4920
5341
  let offset = 0;
4921
5342
  while (true) {
4922
5343
  const data = await this.call(FUNC_GET_COURSES_BY_TIMELINE, { classification: "all", limit: 100, offset });
4923
- if (!isRecord5(data) || !Array.isArray(data.courses) || !data.courses.length) {
5344
+ if (!isRecord6(data) || !Array.isArray(data.courses) || !data.courses.length) {
4924
5345
  break;
4925
5346
  }
4926
5347
  courses.push(...data.courses);
@@ -5080,19 +5501,19 @@ function placeholderUserInfo(baseUrl, userid) {
5080
5501
  };
5081
5502
  }
5082
5503
  function parseTodoPayload(value) {
5083
- return parseTodoItems(isRecord5(value) && Array.isArray(value.events) ? value.events : []);
5504
+ return parseTodoItems(isRecord6(value) && Array.isArray(value.events) ? value.events : []);
5084
5505
  }
5085
5506
  function errorMessage(value) {
5086
5507
  return value instanceof Error ? value.message : "Unknown Moodle error";
5087
5508
  }
5088
- function chunks(values, size) {
5509
+ function chunks(values, size2) {
5089
5510
  const result = [];
5090
- for (let index = 0; index < values.length; index += size) {
5091
- result.push(values.slice(index, index + size));
5511
+ for (let index = 0; index < values.length; index += size2) {
5512
+ result.push(values.slice(index, index + size2));
5092
5513
  }
5093
5514
  return result;
5094
5515
  }
5095
- function isRecord5(value) {
5516
+ function isRecord6(value) {
5096
5517
  return !!value && typeof value === "object" && !Array.isArray(value);
5097
5518
  }
5098
5519
  function isLoginErrorCode2(code) {
@@ -5114,10 +5535,30 @@ function safeUrl(value) {
5114
5535
  }
5115
5536
  }
5116
5537
 
5538
+ // src/submit.ts
5539
+ import { readFile as readFile6, stat as stat2 } from "fs/promises";
5540
+ import { homedir as homedir8 } from "os";
5541
+ import path from "path";
5542
+ function resolveSubmissionPath(given, cwd = process.cwd()) {
5543
+ return path.resolve(cwd, given.startsWith("~/") ? path.join(homedir8(), given.slice(2)) : given);
5544
+ }
5545
+ async function readSubmissionFiles(paths, cwd = process.cwd()) {
5546
+ const files = [];
5547
+ for (const given of paths) {
5548
+ const resolved = resolveSubmissionPath(given, cwd);
5549
+ const info = await stat2(resolved).catch(() => null);
5550
+ if (!info) throw new UsageError(`File not found: ${given}`);
5551
+ if (!info.isFile()) throw new UsageError(`Not a file: ${given}`, "Zip a folder before uploading it.");
5552
+ files.push({ name: path.basename(resolved), bytes: await readFile6(resolved), path: resolved });
5553
+ }
5554
+ return files;
5555
+ }
5556
+
5117
5557
  // src/client.ts
5118
5558
  var NODE_ERROR_ADAPTER = {
5119
5559
  api: (message, moodleErrorCode) => new MoodleAPIError(message, moodleErrorCode),
5120
5560
  notFound: (message) => new NotFoundError(message),
5561
+ usage: (message, hint) => new UsageError(message, hint),
5121
5562
  isApi: (error) => error instanceof MoodleAPIError,
5122
5563
  isLoginRequired: isLoginRequiredError
5123
5564
  };
@@ -5126,6 +5567,11 @@ var MoodleClient = class extends MoodleClientCore {
5126
5567
  const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
5127
5568
  super(baseUrl, { ...resolvedOptions, errorAdapter: NODE_ERROR_ADAPTER });
5128
5569
  }
5570
+ /** Reads local files, then uploads them into the assignment. Only the Node client has a filesystem. */
5571
+ async submitAssignmentFiles(request) {
5572
+ const { files, cwd, ...rest } = request;
5573
+ return this.submitAssignment({ ...rest, files: await readSubmissionFiles(files, cwd) });
5574
+ }
5129
5575
  };
5130
5576
  async function createMoodleClient(baseUrl, options = {}) {
5131
5577
  const cacheOptions2 = {
@@ -5234,6 +5680,35 @@ function formatDownloadReceipt(receipt) {
5234
5680
  ["Final URL", receipt.final_url]
5235
5681
  ], { title: "Download" });
5236
5682
  }
5683
+ function formatSubmissionReceipt(receipt) {
5684
+ const size2 = (bytes) => bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MiB` : bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${bytes} B`;
5685
+ const limits = [
5686
+ receipt.limits.max_files ? `${receipt.limits.max_files} files` : "",
5687
+ receipt.limits.max_bytes ? `${size2(receipt.limits.max_bytes)} each` : "",
5688
+ receipt.limits.area_max_bytes ? `${size2(receipt.limits.area_max_bytes)} total` : "",
5689
+ receipt.limits.accepted_types?.length ? receipt.limits.accepted_types.join(" ") : ""
5690
+ ].filter(Boolean).join(", ");
5691
+ const table = renderKeyValueTable([
5692
+ ["Assignment", receipt.name],
5693
+ ["Unit id", receipt.unit_id ? String(receipt.unit_id) : ""],
5694
+ ["URL", receipt.url],
5695
+ ["Action", receipt.action],
5696
+ ["Status", receipt.submission_status],
5697
+ ["Grading", receipt.grading_status],
5698
+ ["Due", receipt.due],
5699
+ ["Time remaining", receipt.time_remaining],
5700
+ ["Last modified", receipt.last_modified],
5701
+ [receipt.action === "planned" ? "Files now" : "Files", receipt.files.map((file2) => file2.bytes ? `${file2.name} (${size2(file2.bytes)})` : file2.name).join(", ")],
5702
+ ["Uploads", receipt.uploads.map((file2) => `${file2.name} (${size2(file2.bytes)})`).join(", ")],
5703
+ ["Removed", receipt.removed.join(", ")],
5704
+ ["Statement", receipt.statement ? `${receipt.statement_accepted ? "accepted" : "not accepted"}: ${receipt.statement}` : ""],
5705
+ ["Limits", limits],
5706
+ ["Checked", receipt.checked_at]
5707
+ ], { title: receipt.action === "planned" ? "Submission plan" : "Submission" });
5708
+ const note = receipt.action === "planned" ? "Plan only; nothing was uploaded. Re-run without --dry-run to upload." : receipt.action === "saved" && /draft|not submitted/iu.test(receipt.submission_status) ? "Saved as a draft. Re-run with --final to submit it for grading." : "";
5709
+ return note ? `${table}
5710
+ ${note}` : table;
5711
+ }
5237
5712
  function formatForumDiscussion(discussion, options = {}) {
5238
5713
  const lines = [`Discussion: ${discussion.id}`];
5239
5714
  if (discussion.subject) {
@@ -5351,10 +5826,10 @@ function formatTimestamp(value) {
5351
5826
  import { createWriteStream } from "fs";
5352
5827
  import { link, lstat, rename as rename2, unlink } from "fs/promises";
5353
5828
  import { randomUUID } from "crypto";
5354
- import path from "path";
5829
+ import path2 from "path";
5355
5830
  import { Readable, Transform } from "stream";
5356
5831
  import { pipeline } from "stream/promises";
5357
- import { parse as parse4 } from "node-html-parser";
5832
+ import { parse as parse5 } from "node-html-parser";
5358
5833
  var ACCEPTED_SOURCE_HINT = "Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL.";
5359
5834
  var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
5360
5835
  "EACCES",
@@ -5374,14 +5849,14 @@ var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
5374
5849
  ]);
5375
5850
  async function downloadMoodleFile(client, request, signal) {
5376
5851
  throwIfCancelled(signal);
5377
- const explicitDestination = request.destination ? path.resolve(request.destination) : void 0;
5852
+ const explicitDestination = request.destination ? path2.resolve(request.destination) : void 0;
5378
5853
  if (explicitDestination && !request.force) {
5379
5854
  await ensureDestinationAvailable(explicitDestination);
5380
5855
  }
5381
5856
  const resolved = await resolveDownload(client, request.source, signal);
5382
5857
  throwIfCancelled(signal);
5383
- const filename = explicitDestination ? path.basename(explicitDestination) : chooseUpstreamFilename(resolved);
5384
- const destination = explicitDestination ?? path.resolve(request.directory ?? process.cwd(), filename);
5858
+ const filename = explicitDestination ? path2.basename(explicitDestination) : chooseUpstreamFilename(resolved);
5859
+ const destination = explicitDestination ?? path2.resolve(request.directory ?? process.cwd(), filename);
5385
5860
  if (!explicitDestination && !request.force) {
5386
5861
  await ensureDestinationAvailable(destination);
5387
5862
  }
@@ -5476,7 +5951,7 @@ async function responseOrWrapper(client, requestUrl, sourceUrl, targetName, sign
5476
5951
  };
5477
5952
  }
5478
5953
  function resourceLinks2(html, baseUrl) {
5479
- const root = parse4(html);
5954
+ const root = parse5(html);
5480
5955
  const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((linkNode) => ({
5481
5956
  name: linkNode.textContent.trim(),
5482
5957
  url: new URL(linkNode.getAttribute("href") ?? "", baseUrl).toString()
@@ -5492,7 +5967,7 @@ function isHtmlWrapper(response) {
5492
5967
  return type.includes("text/html") || type.includes("application/xhtml+xml");
5493
5968
  }
5494
5969
  function looksLikeLoginPage3(html) {
5495
- const root = parse4(html);
5970
+ const root = parse5(html);
5496
5971
  return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
5497
5972
  }
5498
5973
  function chooseUpstreamFilename(resolved) {
@@ -5553,7 +6028,7 @@ async function ensureDestinationAvailable(destination) {
5553
6028
  throw new UsageError(`Destination already exists: ${destination}`, "Choose another --dest path or pass --force to replace this exact file.");
5554
6029
  }
5555
6030
  async function writeResponse(response, destination, force, signal) {
5556
- const temporaryPath = path.join(path.dirname(destination), `.${path.basename(destination)}.moodle-${randomUUID()}.tmp`);
6031
+ const temporaryPath = path2.join(path2.dirname(destination), `.${path2.basename(destination)}.moodle-${randomUUID()}.tmp`);
5557
6032
  let bytesWritten = 0;
5558
6033
  const counter = new Transform({
5559
6034
  transform(chunk, _encoding, callback) {
@@ -5625,7 +6100,7 @@ function isFileSystemError(error) {
5625
6100
  // src/skills.ts
5626
6101
  import { spawnSync as spawnSync3 } from "child_process";
5627
6102
  import { mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
5628
- import path2 from "path";
6103
+ import path3 from "path";
5629
6104
 
5630
6105
  // src/command-contract.ts
5631
6106
  import { VERBS } from "@bunizao/cli-kit";
@@ -5682,7 +6157,7 @@ function isMutating(command) {
5682
6157
  var SKILL_NAME = "moodle-cli";
5683
6158
  var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
5684
6159
  var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
5685
- var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; diagnose sign-in and manage a private MCP server.";
6160
+ var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; submit assignment files; diagnose sign-in and manage a private MCP server.";
5686
6161
  var SKILL_BUNDLE_TEMPLATES = [
5687
6162
  ["SKILL.md", "skill.template.md"],
5688
6163
  ["references/setup-and-auth.md", "skill-references/setup-and-auth.md"],
@@ -5729,10 +6204,10 @@ function extractCommanderCommands(program) {
5729
6204
  return describeProgram(program).commands.flatMap((command) => commandDescriptionRows(command));
5730
6205
  }
5731
6206
  function commandDescriptionRows(command, parentPath = []) {
5732
- const path4 = [...parentPath, command.name];
6207
+ const path5 = [...parentPath, command.name];
5733
6208
  const row = {
5734
6209
  name: command.name,
5735
- path: path4,
6210
+ path: path5,
5736
6211
  description: command.description,
5737
6212
  arguments: command.positionals.map((argument) => ({
5738
6213
  name: argument.name,
@@ -5746,15 +6221,15 @@ function commandDescriptionRows(command, parentPath = []) {
5746
6221
  return { name, alias, description: option.description, required: option.required };
5747
6222
  })
5748
6223
  };
5749
- return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path4))];
6224
+ return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path5))];
5750
6225
  }
5751
6226
  function writeGeneratedSkill(program, target = "SKILL.md") {
5752
6227
  const commands = extractCommanderCommands(program);
5753
- const targetDir = path2.dirname(target);
5754
- for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(path2.join(targetDir, "references", `${obsolete}.md`), { force: true });
6228
+ const targetDir = path3.dirname(target);
6229
+ for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(path3.join(targetDir, "references", `${obsolete}.md`), { force: true });
5755
6230
  for (const [relativeTarget, relativeTemplate] of SKILL_BUNDLE_TEMPLATES) {
5756
- const outputPath = relativeTarget === "SKILL.md" ? target : path2.join(targetDir, relativeTarget);
5757
- mkdirSync(path2.dirname(outputPath), { recursive: true });
6231
+ const outputPath = relativeTarget === "SKILL.md" ? target : path3.join(targetDir, relativeTarget);
6232
+ mkdirSync(path3.dirname(outputPath), { recursive: true });
5758
6233
  const template = readSkillTemplate(relativeTemplate);
5759
6234
  writeFileSync(outputPath, renderSkillMarkdown(commands, template), "utf8");
5760
6235
  }
@@ -5862,11 +6337,11 @@ function isCommandAvailable(name, runCommand) {
5862
6337
  return !result.error && result.status === 0;
5863
6338
  }
5864
6339
  function readSkillTemplate(relativePath = "skill.template.md") {
5865
- return readFileSync(path2.join(process.cwd(), "src", relativePath), "utf8");
6340
+ return readFileSync(path3.join(process.cwd(), "src", relativePath), "utf8");
5866
6341
  }
5867
6342
 
5868
6343
  // src/version.ts
5869
- var VERSION = "0.8.0";
6344
+ var VERSION = "0.9.0";
5870
6345
 
5871
6346
  // src/forum.ts
5872
6347
  function parseDiscussionReference(value) {
@@ -5957,7 +6432,7 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
5957
6432
  if (parsed.host.toLowerCase() !== configuredHost) {
5958
6433
  throw new UsageError(`URL host '${parsed.host.toLowerCase()}' does not match configured Moodle site '${configuredHost}'.`);
5959
6434
  }
5960
- const path4 = parsed.pathname.replace(/\/$/, "");
6435
+ const path5 = parsed.pathname.replace(/\/$/, "");
5961
6436
  const intParam = (key, label) => {
5962
6437
  const value = parsed.searchParams.get(key);
5963
6438
  if (!value || !/^\d+$/.test(value)) {
@@ -5965,45 +6440,45 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
5965
6440
  }
5966
6441
  return value;
5967
6442
  };
5968
- if (path4.endsWith("/mod/forum/discuss.php")) {
6443
+ if (path5.endsWith("/mod/forum/discuss.php")) {
5969
6444
  return objectMode ? { commandName: "forum_discussion", kwargs: { discussion: intParam("d", "discussion ID"), postId: parsed.hash, asJson: false, asYaml: false } } : { commandName: "forum:discussion", args: [intParam("d", "discussion ID"), parsed.hash] };
5970
6445
  }
5971
- if (path4.endsWith("/mod/forum/view.php")) {
6446
+ if (path5.endsWith("/mod/forum/view.php")) {
5972
6447
  return objectMode ? { commandName: "forum_discussions", kwargs: { forum: intParam("id", "forum module ID"), asJson: false, asYaml: false } } : { commandName: "forum:discussions", args: [intParam("id", "forum module ID")] };
5973
6448
  }
5974
- if (path4.endsWith("/mod/assign/view.php")) {
6449
+ if (path5.endsWith("/mod/assign/view.php")) {
5975
6450
  const id2 = intParam("id", "assignment module ID");
5976
6451
  return objectMode ? { commandName: "assign", kwargs: { assign: id2, asJson: false, asYaml: false } } : { commandName: "assign", args: [id2] };
5977
6452
  }
5978
- if (path4.endsWith("/mod/quiz/view.php")) {
6453
+ if (path5.endsWith("/mod/quiz/view.php")) {
5979
6454
  const id2 = intParam("id", "quiz module ID");
5980
6455
  return objectMode ? { commandName: "quiz", kwargs: { quiz: id2, asJson: false, asYaml: false } } : { commandName: "quiz", args: [id2] };
5981
6456
  }
5982
- if (path4.endsWith("/mod/resource/view.php")) {
6457
+ if (path5.endsWith("/mod/resource/view.php")) {
5983
6458
  const id2 = intParam("id", "resource module ID");
5984
6459
  return objectMode ? { commandName: "resource", kwargs: { resource: id2, asJson: false, asYaml: false } } : { commandName: "resource", args: [id2] };
5985
6460
  }
5986
- if (path4.endsWith("/mod/url/view.php")) {
6461
+ if (path5.endsWith("/mod/url/view.php")) {
5987
6462
  const id2 = intParam("id", "link module ID");
5988
6463
  return objectMode ? { commandName: "link", kwargs: { link: id2, asJson: false, asYaml: false } } : { commandName: "link", args: [id2] };
5989
6464
  }
5990
- if (path4.endsWith("/mod/page/view.php")) {
6465
+ if (path5.endsWith("/mod/page/view.php")) {
5991
6466
  const id2 = intParam("id", "page module ID");
5992
6467
  return objectMode ? { commandName: "page", kwargs: { page: id2, asJson: false, asYaml: false } } : { commandName: "page", args: [id2] };
5993
6468
  }
5994
- if (path4.endsWith("/mod/folder/view.php")) {
6469
+ if (path5.endsWith("/mod/folder/view.php")) {
5995
6470
  const id2 = intParam("id", "folder module ID");
5996
6471
  return objectMode ? { commandName: "folder", kwargs: { folder: id2, asJson: false, asYaml: false } } : { commandName: "folder", args: [id2] };
5997
6472
  }
5998
- if (path4.endsWith("/course/view.php")) {
6473
+ if (path5.endsWith("/course/view.php")) {
5999
6474
  const id2 = intParam("id", "course ID");
6000
6475
  return objectMode ? { commandName: "course", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "course", args: [id2] };
6001
6476
  }
6002
- if (path4.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path4.includes("/grade/report/")) {
6477
+ if (path5.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path5.includes("/grade/report/")) {
6003
6478
  const id2 = intParam("id", "course ID");
6004
6479
  return objectMode ? { commandName: "grades", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "grades", args: [id2] };
6005
6480
  }
6006
- if (path4.includes("/mod/") && path4.endsWith("/view.php")) {
6481
+ if (path5.includes("/mod/") && path5.endsWith("/view.php")) {
6007
6482
  if (!resolveCourseIdForUrl) {
6008
6483
  throw new UsageError("Could not resolve course ID from the activity page.");
6009
6484
  }
@@ -6021,10 +6496,10 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
6021
6496
 
6022
6497
  // src/mcp/cli.ts
6023
6498
  import { createHash as createHash3 } from "crypto";
6024
- import { readFile as readFile8 } from "fs/promises";
6025
- import { homedir as homedir11 } from "os";
6499
+ import { readFile as readFile9 } from "fs/promises";
6500
+ import { homedir as homedir12 } from "os";
6026
6501
  import { join as join11 } from "path";
6027
- import { createInterface as createInterface3 } from "readline/promises";
6502
+ import { createInterface } from "readline/promises";
6028
6503
  import { fileURLToPath } from "url";
6029
6504
 
6030
6505
  // src/mcp/protocol.ts
@@ -6076,7 +6551,7 @@ function parseJsonRpcRequest(input2) {
6076
6551
  return JsonRpcRequestSchema.parse(input2);
6077
6552
  }
6078
6553
  function resolveProtocolVersion(request, context = {}) {
6079
- const meta = isRecord6(request.params?._meta) ? request.params._meta : void 0;
6554
+ const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
6080
6555
  const initializeVersion = request.method === "initialize" && typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : void 0;
6081
6556
  const requested = context.protocolVersion ?? stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]) ?? initializeVersion ?? MODERN_PROTOCOL_VERSION;
6082
6557
  if (!isSupportedProtocolVersion(requested)) {
@@ -6085,7 +6560,7 @@ function resolveProtocolVersion(request, context = {}) {
6085
6560
  return requested;
6086
6561
  }
6087
6562
  function assertRequestMetadata(request, protocolVersion2, context = {}) {
6088
- const meta = isRecord6(request.params?._meta) ? request.params._meta : void 0;
6563
+ const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
6089
6564
  const metaVersion = stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]);
6090
6565
  if (protocolVersion2 === MODERN_PROTOCOL_VERSION) {
6091
6566
  const parsed = ModernClientMetadataSchema.safeParse(meta);
@@ -6116,7 +6591,7 @@ function jsonRpcSuccess(id2, result) {
6116
6591
  function jsonRpcFailure(id2, error) {
6117
6592
  return { jsonrpc: "2.0", id: id2, error };
6118
6593
  }
6119
- function isRecord6(value) {
6594
+ function isRecord7(value) {
6120
6595
  return typeof value === "object" && value !== null && !Array.isArray(value);
6121
6596
  }
6122
6597
  function stringValue3(value) {
@@ -6191,7 +6666,7 @@ async function bridgeRemoteMcp(options) {
6191
6666
  await writeRemoteError(options.output, request.id, response.status);
6192
6667
  return;
6193
6668
  }
6194
- if (request.method === "initialize" && isRecord7(payload) && "result" in payload) {
6669
+ if (request.method === "initialize" && isRecord8(payload) && "result" in payload) {
6195
6670
  negotiatedProtocolVersion = responseProtocolVersion(payload) ?? initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
6196
6671
  }
6197
6672
  await writeJson(options.output, payload);
@@ -6216,7 +6691,7 @@ function normalizeMcpEndpoint(value) {
6216
6691
  return url.toString();
6217
6692
  }
6218
6693
  function protocolVersion(request, negotiatedVersion) {
6219
- const meta = isRecord7(request.params?._meta) ? request.params?._meta : void 0;
6694
+ const meta = isRecord8(request.params?._meta) ? request.params?._meta : void 0;
6220
6695
  const metadataVersion = meta?.["io.modelcontextprotocol/protocolVersion"];
6221
6696
  if (typeof metadataVersion === "string") return metadataVersion;
6222
6697
  return initializedProtocolVersion(request) ?? negotiatedVersion;
@@ -6226,7 +6701,7 @@ function initializedProtocolVersion(request) {
6226
6701
  return typeof initialized === "string" ? initialized : void 0;
6227
6702
  }
6228
6703
  function responseProtocolVersion(payload) {
6229
- const result = isRecord7(payload.result) ? payload.result : void 0;
6704
+ const result = isRecord8(payload.result) ? payload.result : void 0;
6230
6705
  return typeof result?.protocolVersion === "string" ? result.protocolVersion : void 0;
6231
6706
  }
6232
6707
  async function writeSseMessages(output, body, id2) {
@@ -6260,10 +6735,10 @@ async function forwardProtocolNegotiationError(output, response, id2) {
6260
6735
  }
6261
6736
  try {
6262
6737
  const payload = await response.json();
6263
- if (!isRecord7(payload) || payload.jsonrpc !== "2.0" || payload.id !== id2 || !isRecord7(payload.error)) {
6738
+ if (!isRecord8(payload) || payload.jsonrpc !== "2.0" || payload.id !== id2 || !isRecord8(payload.error)) {
6264
6739
  return false;
6265
6740
  }
6266
- const data = isRecord7(payload.error.data) ? payload.error.data : void 0;
6741
+ const data = isRecord8(payload.error.data) ? payload.error.data : void 0;
6267
6742
  const supported = Array.isArray(data?.supported) ? data.supported.filter((version) => typeof version === "string") : [];
6268
6743
  if (payload.error.code !== -32022 || typeof data?.requested !== "string" || supported.length === 0) {
6269
6744
  return false;
@@ -6283,9 +6758,9 @@ async function writeJson(output, value) {
6283
6758
  `);
6284
6759
  }
6285
6760
  function isBridgeRequest(value) {
6286
- return isRecord7(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
6761
+ return isRecord8(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
6287
6762
  }
6288
- function isRecord7(value) {
6763
+ function isRecord8(value) {
6289
6764
  return typeof value === "object" && value !== null && !Array.isArray(value);
6290
6765
  }
6291
6766
 
@@ -6557,13 +7032,13 @@ function resolveConnection(options) {
6557
7032
  }
6558
7033
 
6559
7034
  // src/mcp/connectors/node-connectors.ts
6560
- import { chmod as chmod4, mkdir as mkdir6, readFile as readFile6, rm as rm5, stat as stat2, writeFile as writeFile6 } from "fs/promises";
6561
- import { homedir as homedir8 } from "os";
7035
+ import { chmod as chmod4, mkdir as mkdir6, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
7036
+ import { homedir as homedir9 } from "os";
6562
7037
  import { dirname as dirname6, join as join8 } from "path";
6563
7038
  var NodeConnectorFileSystem = class {
6564
- async exists(path4) {
7039
+ async exists(path5) {
6565
7040
  try {
6566
- await stat2(path4);
7041
+ await stat3(path5);
6567
7042
  return true;
6568
7043
  } catch (error) {
6569
7044
  if (isMissing3(error)) {
@@ -6572,20 +7047,20 @@ var NodeConnectorFileSystem = class {
6572
7047
  throw error;
6573
7048
  }
6574
7049
  }
6575
- async readText(path4) {
6576
- return readFile6(path4, "utf8");
7050
+ async readText(path5) {
7051
+ return readFile7(path5, "utf8");
6577
7052
  }
6578
- async writePrivate(path4, content) {
6579
- await mkdir6(dirname6(path4), { recursive: true, mode: 448 });
6580
- await writeFile6(path4, content, { encoding: "utf8", mode: 384 });
6581
- await chmod4(path4, 384);
7053
+ async writePrivate(path5, content) {
7054
+ await mkdir6(dirname6(path5), { recursive: true, mode: 448 });
7055
+ await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
7056
+ await chmod4(path5, 384);
6582
7057
  }
6583
- async remove(path4) {
6584
- await rm5(path4, { force: true });
7058
+ async remove(path5) {
7059
+ await rm5(path5, { force: true });
6585
7060
  }
6586
7061
  };
6587
7062
  function createDefaultClientConnectors(profile, options = {}) {
6588
- const home = options.homeDirectory ?? homedir8();
7063
+ const home = options.homeDirectory ?? homedir9();
6589
7064
  const platform = options.platform ?? process.platform;
6590
7065
  const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
6591
7066
  const runtime = runtimeCommand(options.command, options.commandArgs);
@@ -7370,10 +7845,10 @@ function asDeploymentError(error) {
7370
7845
  }
7371
7846
 
7372
7847
  // src/mcp/wrangler.ts
7373
- import { createInterface as createInterface2 } from "readline/promises";
7848
+ import { createUi as createUi2 } from "@bunizao/cli-kit";
7374
7849
  import { mkdir as mkdir7 } from "fs/promises";
7375
7850
  import { existsSync } from "fs";
7376
- import { homedir as homedir9 } from "os";
7851
+ import { homedir as homedir10 } from "os";
7377
7852
  import { join as join9 } from "path";
7378
7853
  async function resolveWrangler(runner, options = {}) {
7379
7854
  const env = options.env ?? process.env;
@@ -7385,7 +7860,7 @@ async function resolveWrangler(runner, options = {}) {
7385
7860
  if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
7386
7861
  notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
7387
7862
  }
7388
- const root = join9(options.homeDir ?? homedir9(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
7863
+ const root = join9(options.homeDir ?? homedir10(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
7389
7864
  const script = join9(root, "node_modules", "wrangler", "bin", "wrangler.js");
7390
7865
  const bun = findExecutable("bun", env);
7391
7866
  const node = findExecutable("node", env);
@@ -7395,13 +7870,10 @@ async function resolveWrangler(runner, options = {}) {
7395
7870
  if (!bun && !npm) throw new Error("Install Bun or npm to download the pinned Cloudflare toolchain.");
7396
7871
  const yes = options.yes ?? (process.argv.includes("--yes") || process.argv.includes("-y"));
7397
7872
  if (!yes) {
7398
- if (!process.stdin.isTTY) throw new UsageError("Cloudflare management needs a first-use Wrangler download.", "Rerun with --yes to download and cache the pinned toolchain.");
7399
- const reader = createInterface2({ input: process.stdin, output: process.stderr });
7400
- try {
7401
- const answer = await reader.question(`Download Cloudflare Wrangler ${WRANGLER_VERSION} (cached for next time)? [Y/n] `);
7402
- if (answer.trim() && !/^y(?:es)?$/iu.test(answer.trim())) throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
7403
- } finally {
7404
- reader.close();
7873
+ const ui = createUi2({ input: process.stdin, output: process.stderr });
7874
+ if (!ui.interactive) throw new UsageError("Cloudflare management needs a first-use Wrangler download.", "Rerun with --yes to download and cache the pinned toolchain.");
7875
+ if (!await ui.confirm(`Download Cloudflare Wrangler ${WRANGLER_VERSION} (cached for next time)?`, { initial: true })) {
7876
+ throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
7405
7877
  }
7406
7878
  }
7407
7879
  notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
@@ -7421,8 +7893,8 @@ function sameMajorAtLeast(actual, pinned) {
7421
7893
  import { isDeepStrictEqual } from "util";
7422
7894
  import { spawn as spawn2 } from "child_process";
7423
7895
  import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
7424
- import { chmod as chmod5, mkdir as mkdir8, mkdtemp, readFile as readFile7, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7425
- import { homedir as homedir10, tmpdir } from "os";
7896
+ import { chmod as chmod5, mkdir as mkdir8, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7897
+ import { homedir as homedir11, tmpdir } from "os";
7426
7898
  import { basename, dirname as dirname7, join as join10 } from "path";
7427
7899
  var MODERN_MCP_VERSION = "2026-07-28";
7428
7900
  var WORKER_PROPAGATION_ATTEMPTS = 10;
@@ -7614,7 +8086,7 @@ ${error.stderr}`)) {
7614
8086
  const binding = (value, name) => {
7615
8087
  let result2 = null;
7616
8088
  visit(value, (_key, item) => {
7617
- if (isRecord8(item) && item.name === name && typeof item.text === "string") result2 = item.text;
8089
+ if (isRecord9(item) && item.name === name && typeof item.text === "string") result2 = item.text;
7618
8090
  });
7619
8091
  return result2;
7620
8092
  };
@@ -7660,7 +8132,7 @@ ${error.stderr}`)) {
7660
8132
  }
7661
8133
  };
7662
8134
  async function copyReleaseBundle(source, destination) {
7663
- await writeFile7(destination, await readFile7(source));
8135
+ await writeFile7(destination, await readFile8(source));
7664
8136
  }
7665
8137
  var NodeReleaseMaterializer = class {
7666
8138
  constructor(options) {
@@ -7782,10 +8254,10 @@ var FetchManagedWorkerClient = class {
7782
8254
  })
7783
8255
  }, isRetryableSessionUpload);
7784
8256
  const body = await safeJson(response);
7785
- if (response.ok && isRecord8(body) && typeof body.revision === "number") {
8257
+ if (response.ok && isRecord9(body) && typeof body.revision === "number") {
7786
8258
  return { revision: body.revision };
7787
8259
  }
7788
- const code = isRecord8(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
8260
+ const code = isRecord9(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
7789
8261
  throw new DeploymentApplyError(code, `The Worker rejected the Moodle session update (${code})`);
7790
8262
  }
7791
8263
  async getReadiness(input2) {
@@ -7793,7 +8265,7 @@ var FetchManagedWorkerClient = class {
7793
8265
  headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
7794
8266
  }, isRetryableSessionUpload);
7795
8267
  const body = await safeJson(response);
7796
- if (isRecord8(body) && (body.status === "pass" || body.status === "warn" || body.status === "fail")) {
8268
+ if (isRecord9(body) && (body.status === "pass" || body.status === "warn" || body.status === "fail")) {
7797
8269
  const session = firstHealthCheck(body, "moodle:session");
7798
8270
  const upstream = firstHealthCheck(body, "moodle:upstream");
7799
8271
  return {
@@ -7820,7 +8292,7 @@ var FetchManagedWorkerClient = class {
7820
8292
  isRetryableWorkerPropagation
7821
8293
  );
7822
8294
  const healthBody = await safeJson(health);
7823
- if (!health.ok || !isRecord8(healthBody) || healthBody.status !== "pass") {
8295
+ if (!health.ok || !isRecord9(healthBody) || healthBody.status !== "pass") {
7824
8296
  throw new DeploymentApplyError("HEALTH_CHECK_FAILED", "Worker liveness check failed");
7825
8297
  }
7826
8298
  let readiness = await this.getReadiness(input2);
@@ -7851,11 +8323,11 @@ var FetchManagedWorkerClient = class {
7851
8323
  if (!Array.isArray(courses)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no course list");
7852
8324
  if (courses.length) {
7853
8325
  const first2 = courses[0];
7854
- if (!isRecord8(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
8326
+ if (!isRecord9(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
7855
8327
  throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no usable course ID");
7856
8328
  }
7857
8329
  const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "get_course", arguments: { courseId: first2.id } }, 5));
7858
- if (!isRecord8(detail.course) || !isRecord8(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
8330
+ if (!isRecord9(detail.course) || !isRecord9(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
7859
8331
  throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP course lookup did not match the listed course");
7860
8332
  }
7861
8333
  }
@@ -7879,7 +8351,7 @@ var FetchManagedWorkerClient = class {
7879
8351
  headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
7880
8352
  });
7881
8353
  const body = await safeJson(response);
7882
- if (!response.ok || !isRecord8(body) || typeof body.code !== "string" || typeof body.expiresAt !== "string") {
8354
+ if (!response.ok || !isRecord9(body) || typeof body.code !== "string" || typeof body.expiresAt !== "string") {
7883
8355
  throw new DeploymentApplyError("PAIRING_UNAVAILABLE", "The Worker could not open a pairing window");
7884
8356
  }
7885
8357
  return {
@@ -7916,7 +8388,7 @@ var FetchManagedWorkerClient = class {
7916
8388
  })
7917
8389
  }, isRetryableSessionUpload);
7918
8390
  const body = await safeJson(response);
7919
- if (!response.ok || !isRecord8(body) || body.jsonrpc !== "2.0" || body.id !== id2 || "error" in body || !("result" in body)) {
8391
+ if (!response.ok || !isRecord9(body) || body.jsonrpc !== "2.0" || body.id !== id2 || "error" in body || !("result" in body)) {
7920
8392
  throw new DeploymentApplyError("MCP_SMOKE_FAILED", `MCP ${method} check failed`);
7921
8393
  }
7922
8394
  if (method === "tools/call") readableToolResult(body.result);
@@ -7934,13 +8406,13 @@ var FetchManagedWorkerClient = class {
7934
8406
  }
7935
8407
  };
7936
8408
  var PrivateDeploymentReceiptStore = class {
7937
- constructor(baseDirectory = join10(homedir10(), ".config", "moodle-cli", "mcp", "deployments")) {
8409
+ constructor(baseDirectory = join10(homedir11(), ".config", "moodle-cli", "mcp", "deployments")) {
7938
8410
  this.baseDirectory = baseDirectory;
7939
8411
  }
7940
8412
  baseDirectory;
7941
8413
  async read(profile) {
7942
8414
  try {
7943
- const parsed = JSON.parse(await readFile7(this.path(profile), "utf8"));
8415
+ const parsed = JSON.parse(await readFile8(this.path(profile), "utf8"));
7944
8416
  return isReceipt(parsed) ? parsed : null;
7945
8417
  } catch (error) {
7946
8418
  if (isMissing4(error)) {
@@ -7950,11 +8422,11 @@ var PrivateDeploymentReceiptStore = class {
7950
8422
  }
7951
8423
  }
7952
8424
  async write(receipt) {
7953
- const path4 = this.path(receipt.profile);
7954
- await mkdir8(dirname7(path4), { recursive: true, mode: 448 });
7955
- await writeFile7(path4, `${JSON.stringify(receipt, null, 2)}
8425
+ const path5 = this.path(receipt.profile);
8426
+ await mkdir8(dirname7(path5), { recursive: true, mode: 448 });
8427
+ await writeFile7(path5, `${JSON.stringify(receipt, null, 2)}
7956
8428
  `, { mode: 384 });
7957
- await chmod5(path4, 384);
8429
+ await chmod5(path5, 384);
7958
8430
  }
7959
8431
  async delete(profile) {
7960
8432
  await rm6(this.path(profile), { force: true });
@@ -7967,7 +8439,7 @@ var PrivateDeploymentReceiptStore = class {
7967
8439
  }
7968
8440
  };
7969
8441
  function createDefaultManagedDeployment(options) {
7970
- const homeDirectory = options.homeDirectory ?? homedir10();
8442
+ const homeDirectory = options.homeDirectory ?? homedir11();
7971
8443
  const platform = options.platform ?? process.platform;
7972
8444
  const runtime = runtimeCommand(options.executable, options.executableArgs);
7973
8445
  const defaults = {
@@ -8005,17 +8477,17 @@ function digest(value) {
8005
8477
  function ownershipId(accountId, workerName) {
8006
8478
  return `moodle-cli:${accountId}:${workerName}`;
8007
8479
  }
8008
- function endpointUrl(endpoint, path4) {
8009
- return `${endpoint.replace(/\/$/u, "")}${path4}`;
8480
+ function endpointUrl(endpoint, path5) {
8481
+ return `${endpoint.replace(/\/$/u, "")}${path5}`;
8010
8482
  }
8011
8483
  async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
8012
8484
  let config;
8013
8485
  try {
8014
- config = JSON.parse(await readFile7(configPath, "utf8"));
8486
+ config = JSON.parse(await readFile8(configPath, "utf8"));
8015
8487
  } catch {
8016
8488
  throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
8017
8489
  }
8018
- if (!isRecord8(config) || !isRecord8(config.vars)) {
8490
+ if (!isRecord9(config) || !isRecord9(config.vars)) {
8019
8491
  throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
8020
8492
  }
8021
8493
  const hosts = endpointHosts(workerName, productionEndpoint);
@@ -8055,18 +8527,18 @@ async function safeJson(response) {
8055
8527
  }
8056
8528
  function firstHealthCheck(body, name) {
8057
8529
  const checks = body.checks;
8058
- if (!isRecord8(checks) || !Array.isArray(checks[name])) {
8530
+ if (!isRecord9(checks) || !Array.isArray(checks[name])) {
8059
8531
  return null;
8060
8532
  }
8061
8533
  const check = checks[name][0];
8062
- return isRecord8(check) ? check : null;
8534
+ return isRecord9(check) ? check : null;
8063
8535
  }
8064
8536
  function readableToolResult(result) {
8065
- if (isRecord8(result) && result.isError !== true && Array.isArray(result.content)) {
8537
+ if (isRecord9(result) && result.isError !== true && Array.isArray(result.content)) {
8066
8538
  try {
8067
- const text2 = result.content.filter((block) => isRecord8(block) && block.type === "text").map((block) => block.text).join("\n");
8539
+ const text2 = result.content.filter((block) => isRecord9(block) && block.type === "text").map((block) => block.text).join("\n");
8068
8540
  const parsed = JSON.parse(text2);
8069
- if (isRecord8(parsed) && isDeepStrictEqual(parsed, result.structuredContent)) return parsed;
8541
+ if (isRecord9(parsed) && isDeepStrictEqual(parsed, result.structuredContent)) return parsed;
8070
8542
  } catch {
8071
8543
  }
8072
8544
  }
@@ -8074,7 +8546,7 @@ function readableToolResult(result) {
8074
8546
  }
8075
8547
  function mcpUserFullname(result) {
8076
8548
  const user = readableToolResult(result).user;
8077
- return isRecord8(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
8549
+ return isRecord9(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
8078
8550
  }
8079
8551
  function parseJsonOutput(output) {
8080
8552
  const candidates = [output.indexOf("{"), output.indexOf("[")].filter((index) => index >= 0).sort((a, b) => a - b);
@@ -8090,7 +8562,7 @@ function parseJsonOutput(output) {
8090
8562
  function deploymentHistory(value) {
8091
8563
  const entries = [];
8092
8564
  visit(value, (_key, item) => {
8093
- if (!isRecord8(item) || !Array.isArray(item.versions)) {
8565
+ if (!isRecord9(item) || !Array.isArray(item.versions)) {
8094
8566
  return;
8095
8567
  }
8096
8568
  const versionId = activeVersionId(item.versions);
@@ -8098,7 +8570,7 @@ function deploymentHistory(value) {
8098
8570
  return;
8099
8571
  }
8100
8572
  const createdOn = typeof item.created_on === "string" ? Date.parse(item.created_on) : Number.NaN;
8101
- const message = isRecord8(item.annotations) ? item.annotations["workers/message"] : void 0;
8573
+ const message = isRecord9(item.annotations) ? item.annotations["workers/message"] : void 0;
8102
8574
  entries.push({
8103
8575
  versionId,
8104
8576
  message: typeof message === "string" ? message : null,
@@ -8109,7 +8581,7 @@ function deploymentHistory(value) {
8109
8581
  return entries.sort((a, b) => b.createdOn - a.createdOn || b.index - a.index).map(({ versionId, message }) => ({ versionId, message }));
8110
8582
  }
8111
8583
  function activeVersionId(versions) {
8112
- const records = versions.filter(isRecord8);
8584
+ const records = versions.filter(isRecord9);
8113
8585
  const active = records.find((version) => version.percentage === 100) ?? records[0];
8114
8586
  const id2 = active?.version_id ?? active?.versionId;
8115
8587
  return typeof id2 === "string" ? id2 : null;
@@ -8120,7 +8592,7 @@ function releaseDigestFromMessage(message) {
8120
8592
  function collectAccountObjects(value) {
8121
8593
  const accounts = [];
8122
8594
  visit(value, (_key, item) => {
8123
- if (!isRecord8(item)) {
8595
+ if (!isRecord9(item)) {
8124
8596
  return;
8125
8597
  }
8126
8598
  const id2 = typeof item.id === "string" ? item.id : typeof item.account_id === "string" ? item.account_id : null;
@@ -8149,14 +8621,14 @@ function visit(value, visitor, key = "") {
8149
8621
  for (const item of value) {
8150
8622
  visit(item, visitor);
8151
8623
  }
8152
- } else if (isRecord8(value)) {
8624
+ } else if (isRecord9(value)) {
8153
8625
  for (const [childKey, item] of Object.entries(value)) {
8154
8626
  visit(item, visitor, childKey);
8155
8627
  }
8156
8628
  }
8157
8629
  }
8158
8630
  function isReceipt(value) {
8159
- if (!isRecord8(value)) {
8631
+ if (!isRecord9(value)) {
8160
8632
  return false;
8161
8633
  }
8162
8634
  return [
@@ -8170,7 +8642,7 @@ function isReceipt(value) {
8170
8642
  "releaseDigest"
8171
8643
  ].every((key) => typeof value[key] === "string") && typeof value.sessionRevision === "number";
8172
8644
  }
8173
- function isRecord8(value) {
8645
+ function isRecord9(value) {
8174
8646
  return typeof value === "object" && value !== null && !Array.isArray(value);
8175
8647
  }
8176
8648
  function isMissing4(error) {
@@ -8234,13 +8706,23 @@ var READ_ONLY_ANNOTATIONS = {
8234
8706
  idempotentHint: true,
8235
8707
  openWorldHint: true
8236
8708
  };
8709
+ var WRITE_ANNOTATIONS = {
8710
+ readOnlyHint: false,
8711
+ destructiveHint: true,
8712
+ idempotentHint: false,
8713
+ openWorldHint: true
8714
+ };
8715
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["submit"]);
8237
8716
  var aliases = { get_overview: "home", list_courses: "units", get_course: "unit", get_activity: "item", get_grades: "grades", get_thread: "thread", get_file: "file" };
8238
8717
  var TOOL_CATALOG = Object.entries(intentContracts).map(([name, contract]) => ({
8239
8718
  name,
8240
8719
  description: intentDescription(name),
8241
8720
  inputSchema: compactSchema(z4.toJSONSchema(contract.input, { io: "input" })),
8242
- annotations: READ_ONLY_ANNOTATIONS
8721
+ annotations: WRITE_TOOLS.has(name) ? WRITE_ANNOTATIONS : READ_ONLY_ANNOTATIONS
8243
8722
  }));
8723
+ function toolsFor(gateway) {
8724
+ return gateway.submitAssignment ? TOOL_CATALOG : TOOL_CATALOG.filter((tool) => !WRITE_TOOLS.has(tool.name));
8725
+ }
8244
8726
  var TOOL_OUTPUT_SCHEMAS = Object.fromEntries(Object.entries(intentContracts).map(([name, contract]) => [name, compactSchema(z4.toJSONSchema(contract.output))]));
8245
8727
  function compactSchema(value) {
8246
8728
  if (Array.isArray(value)) return value.map(compactSchema);
@@ -8278,7 +8760,7 @@ function createMoodleMcpServer(gateway, options = {}) {
8278
8760
  protocolVersion: protocolVersion2,
8279
8761
  capabilities: { tools: { listChanged: false } },
8280
8762
  serverInfo,
8281
- instructions: "Read-only access to the authenticated user's Moodle data."
8763
+ 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."
8282
8764
  });
8283
8765
  }
8284
8766
  if (request.method === "ping") {
@@ -8286,7 +8768,7 @@ function createMoodleMcpServer(gateway, options = {}) {
8286
8768
  }
8287
8769
  if (request.method === "tools/list") {
8288
8770
  return jsonRpcSuccess(id2, {
8289
- tools: TOOL_CATALOG,
8771
+ tools: toolsFor(gateway),
8290
8772
  resultType: "complete",
8291
8773
  _meta: RESULT_META
8292
8774
  });
@@ -8342,8 +8824,9 @@ async function callTool(gateway, params) {
8342
8824
  const requested = typeof params?.name === "string" ? params.name : "";
8343
8825
  const name = Object.hasOwn(aliases, requested) ? aliases[requested] : requested;
8344
8826
  if (!Object.hasOwn(intentContracts, name) && !["get_user", "list_activities", "list_forums"].includes(name)) throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${requested || "<missing>"}`);
8827
+ if (WRITE_TOOLS.has(name) && !gateway.submitAssignment) throw new McpCallError("TOOL_NOT_FOUND", `${requested} is only available on a local MCP server with access to the files.`);
8345
8828
  const raw = params?.arguments ?? {};
8346
- if (!isRecord9(raw)) throw new McpCallError("INVALID_TOOL_ARGUMENTS", "Tool arguments must be an object.");
8829
+ if (!isRecord10(raw)) throw new McpCallError("INVALID_TOOL_ARGUMENTS", "Tool arguments must be an object.");
8347
8830
  const args = { ...raw };
8348
8831
  if (Object.hasOwn(aliases, requested)) {
8349
8832
  const renames = { courseId: "unit", activityId: "ref", discussionId: "discussion_id", source: "ref", todoDays: "days", gradedOnly: "graded_only" };
@@ -8391,7 +8874,7 @@ async function callTool(gateway, params) {
8391
8874
  const mapped2 = { type: "MOODLE_RESULT_INVALID", message: `Moodle returned ${name} data in an unexpected shape.`, hint: "Retry once; if it persists, run the same command locally with --verbose and report the tool name.", issues: error.issues.slice(0, 5).map((issue) => ({ path: issue.path.join("."), message: issue.message })) };
8392
8875
  return { content: [{ type: "text", text: JSON.stringify({ error: mapped2 }) }], structuredContent: { error: mapped2 }, isError: true, resultType: "complete", _meta: RESULT_META };
8393
8876
  }
8394
- const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error);
8877
+ const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error, WRITE_TOOLS.has(name));
8395
8878
  return { content: [{ type: "text", text: JSON.stringify({ error: mapped }) }], structuredContent: { error: mapped }, isError: true, resultType: "complete", _meta: RESULT_META };
8396
8879
  }
8397
8880
  }
@@ -8410,9 +8893,11 @@ function toolContent(name, payload, structuredContent) {
8410
8893
  }
8411
8894
  ];
8412
8895
  }
8413
- function mapMoodleError(error) {
8414
- const record2 = isRecord9(error) ? error : {};
8415
- const code = typeof record2.code === "string" ? record2.code : "";
8896
+ function mapMoodleError(error, verbatim = false) {
8897
+ const record3 = isRecord10(error) ? error : {};
8898
+ const code = typeof record3.code === "string" ? record3.code : "";
8899
+ const own = verbatim && typeof record3.message === "string" && record3.message.trim() && code !== "auth" ? record3.message : void 0;
8900
+ const ownHint = verbatim && typeof record3.hint === "string" && record3.hint.trim() ? record3.hint : void 0;
8416
8901
  const typeByCode = {
8417
8902
  auth: "MOODLE_AUTH_REQUIRED",
8418
8903
  not_found: "MOODLE_NOT_FOUND",
@@ -8421,8 +8906,8 @@ function mapMoodleError(error) {
8421
8906
  };
8422
8907
  const type = code.startsWith("MOODLE_") ? code : typeByCode[code] ?? "MOODLE_UPSTREAM_ERROR";
8423
8908
  const message = type === "MOODLE_AUTH_REQUIRED" ? "The Moodle session has expired. Sign in again." : type === "MOODLE_NOT_FOUND" || type === "MOODLE_COURSE_NOT_FOUND" ? "The requested Moodle item was not found." : type === "MOODLE_INVALID_REQUEST" ? "The Moodle request is invalid." : "Moodle could not complete the request.";
8424
- const moodleCode = typeof record2.moodleErrorCode === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(record2.moodleErrorCode) ? record2.moodleErrorCode : void 0;
8425
- return { type, message, hint: type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find.", ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
8909
+ const moodleCode = typeof record3.moodleErrorCode === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(record3.moodleErrorCode) ? record3.moodleErrorCode : void 0;
8910
+ return { type, message: own ?? message, hint: ownHint ?? (type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find."), ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
8426
8911
  }
8427
8912
  var McpCallError = class extends Error {
8428
8913
  type;
@@ -8434,11 +8919,11 @@ var McpCallError = class extends Error {
8434
8919
  this.details = details;
8435
8920
  }
8436
8921
  };
8437
- function isRecord9(value) {
8922
+ function isRecord10(value) {
8438
8923
  return typeof value === "object" && value !== null && !Array.isArray(value);
8439
8924
  }
8440
8925
  function isMoodleFile(value) {
8441
- return isRecord9(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
8926
+ return isRecord10(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
8442
8927
  }
8443
8928
 
8444
8929
  // src/mcp/stdio.ts
@@ -8494,12 +8979,12 @@ async function writeResponse2(output, response) {
8494
8979
  `);
8495
8980
  }
8496
8981
  function initializeProtocolVersion(input2) {
8497
- if (!isRecord10(input2) || input2.method !== "initialize" || !isRecord10(input2.params)) {
8982
+ if (!isRecord11(input2) || input2.method !== "initialize" || !isRecord11(input2.params)) {
8498
8983
  return void 0;
8499
8984
  }
8500
8985
  return typeof input2.params.protocolVersion === "string" ? input2.params.protocolVersion : void 0;
8501
8986
  }
8502
- function isRecord10(value) {
8987
+ function isRecord11(value) {
8503
8988
  return typeof value === "object" && value !== null && !Array.isArray(value);
8504
8989
  }
8505
8990
 
@@ -8520,7 +9005,7 @@ function deriveMcpWorkerName(moodleOrigin) {
8520
9005
  var DefaultMcpCommandService = class {
8521
9006
  constructor(options) {
8522
9007
  this.options = options;
8523
- this.homeDirectory = options.homeDir ?? homedir11();
9008
+ this.homeDirectory = options.homeDir ?? homedir12();
8524
9009
  this.wranglerInstance = options.wrangler;
8525
9010
  this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join11(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
8526
9011
  this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
@@ -9077,7 +9562,7 @@ Selection: `)).trim());
9077
9562
  if (this.options.prompt) return this.options.prompt(question);
9078
9563
  const input2 = this.options.stdin ?? process.stdin;
9079
9564
  const output = this.options.stderr ?? process.stderr;
9080
- const readline = createInterface3({ input: input2, output });
9565
+ const readline = createInterface({ input: input2, output });
9081
9566
  return readline.question(question).finally(() => readline.close());
9082
9567
  }
9083
9568
  workerBundlePath() {
@@ -9088,7 +9573,7 @@ Selection: `)).trim());
9088
9573
  return this.wranglerInstance;
9089
9574
  }
9090
9575
  releaseDigest() {
9091
- return readFile8(this.workerBundlePath()).then((content) => sha256(content));
9576
+ return readFile9(this.workerBundlePath()).then((content) => sha256(content));
9092
9577
  }
9093
9578
  // True when a deployment receipt exists but its recorded release digest no
9094
9579
  // longer matches the Worker bundle shipped with this CLI, i.e. the remote
@@ -9233,12 +9718,12 @@ function sha256(value) {
9233
9718
  // src/cli.ts
9234
9719
  var NOUNS = [
9235
9720
  { name: "units", aliases: ["courses"], verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "show" } },
9236
- { name: "activities", verbs: ["list", "show"], defaultByArity: { 1: "list" }, valueFlags: ["--limit", "--section"] },
9721
+ { name: "activities", verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "list" }, valueFlags: ["--limit", "--section"] },
9237
9722
  { name: "grades", verbs: ["list"], defaultByArity: { 0: "list", 1: "list" } },
9238
9723
  {
9239
9724
  name: "forums",
9240
9725
  verbs: ["list", "show", "search"],
9241
- defaultByArity: { 1: "list" },
9726
+ defaultByArity: { 0: "list", 1: "list" },
9242
9727
  valueFlags: ["--limit", "--course", "--forum", "--limit-forums", "--limit-discussions", "--unit"]
9243
9728
  },
9244
9729
  { name: "threads", verbs: ["show"], defaultByArity: { 1: "show" }, valueFlags: ["--post", "--limit", "--offset"] }
@@ -9246,7 +9731,8 @@ var NOUNS = [
9246
9731
  function buildProgram(io = {}) {
9247
9732
  const stdout = io.stdout ?? process.stdout;
9248
9733
  const stderr = io.stderr ?? process.stderr;
9249
- const program = createProgram({ name: "moodle", version: VERSION, description: "Terminal-first CLI for Moodle LMS." });
9734
+ const program = createProgram({ name: "moodle", version: VERSION, description: MOODLE_TAGLINE });
9735
+ banner(program, MOODLE_WORDMARK);
9250
9736
  program.configureOutput({
9251
9737
  writeOut: (text2) => stdout.write(text2),
9252
9738
  writeErr: (text2) => stderr.write(text2),
@@ -9271,9 +9757,11 @@ function buildProgram(io = {}) {
9271
9757
  width: stdout.columns || void 0,
9272
9758
  color: !process.env.NO_COLOR && program.opts().color !== false && outputFormat({ ...program.opts(), ...options }, stdout) === "table"
9273
9759
  });
9760
+ configureTerminalTables({ color: () => colorEnabled2(stdout, io.env) && program.opts().color !== false });
9274
9761
  const count = (key, local, fallback) => program.opts()[key] ?? local ?? fallback;
9275
9762
  const runtime = {
9276
9763
  client: null,
9764
+ busy: false,
9277
9765
  screen,
9278
9766
  count,
9279
9767
  baseUrl: async () => (await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr, fetch: io.fetchImpl })).baseUrl,
@@ -9283,11 +9771,11 @@ function buildProgram(io = {}) {
9283
9771
  let inflight = 0;
9284
9772
  let displayed = false;
9285
9773
  let timer;
9286
- runtime.client = await createMoodleClient(baseUrl, {
9774
+ const connect = () => createMoodleClient(baseUrl, {
9287
9775
  env: io.env,
9288
9776
  fetchImpl: async (input2, init) => {
9289
9777
  const started2 = Date.now();
9290
- const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json");
9778
+ const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json") && !runtime.busy;
9291
9779
  if (tty && inflight++ === 0) timer = setTimeout(() => {
9292
9780
  displayed = true;
9293
9781
  stderr.write("Loading Moodle\u2026");
@@ -9313,14 +9801,21 @@ function buildProgram(io = {}) {
9313
9801
  homeDir: io.homeDir,
9314
9802
  noCache: Boolean(program.opts().cache === false)
9315
9803
  });
9804
+ try {
9805
+ runtime.client = await connect();
9806
+ } catch (error) {
9807
+ if (!(error instanceof AuthError) || !human()) throw error;
9808
+ await signIn(baseUrl);
9809
+ runtime.client = await connect();
9810
+ }
9316
9811
  }
9317
9812
  return runtime.client;
9318
9813
  },
9319
9814
  output: async (data, formatter, options) => {
9320
9815
  const merged = { ...program.opts(), ...options };
9321
9816
  const format = outputFormat(merged, stdout);
9322
- const human = format === "table" ? formatter() : "";
9323
- const text2 = format === "table" ? `${human}${human.includes("Try ") ? "" : "\n\nTry moodle due \xB7 moodle units \xB7 moodle --help"}
9817
+ const human2 = format === "table" ? formatter() : "";
9818
+ const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : "\n\nTry moodle due \xB7 moodle units \xB7 moodle --help"}
9324
9819
  ` : format === "json" ? `${JSON.stringify(JSON.parse(render(data, { format, fields: parseFields(data, merged.fields) })), null, merged.pretty ? 2 : void 0)}
9325
9820
  ` : render(data, { format, fields: parseFields(data, merged.fields) });
9326
9821
  if (io.stdout && !merged.output) {
@@ -9348,23 +9843,40 @@ function buildProgram(io = {}) {
9348
9843
  const result = await runner.run(name, args);
9349
9844
  await runtime.output(result, () => screen(result, options), options);
9350
9845
  };
9846
+ const human = () => detectAudience({
9847
+ stdin: io.stdin ?? process.stdin,
9848
+ stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
9849
+ env: io.env ?? process.env,
9850
+ format: outputFormat(program.opts(), stdout)
9851
+ }) === "human";
9852
+ const theme = () => createTheme3(colorEnabled2(stderr, io.env) && program.opts().color !== false);
9853
+ const signIn = async (baseUrl) => {
9854
+ const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
9855
+ showWordmark(ui);
9856
+ ui.note(`No Moodle session for ${baseUrl}.
9857
+ Sign in once in your browser; later commands reuse that session.`, "One-time setup");
9858
+ if (!await ui.confirm("Open the browser to sign in?", { initial: true })) {
9859
+ throw new AuthError(`No usable MoodleSession found for ${baseUrl}.`, "Run moodle auth login when you are ready.");
9860
+ }
9861
+ const spin = ui.spinner();
9862
+ spin.start("Opening the browser");
9863
+ try {
9864
+ const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, onBrowserOpened: (url) => spin.message(`Finish signing in at ${url}`) });
9865
+ spin.stop(`Signed in as userid ${session.userid}`);
9866
+ } catch (error) {
9867
+ spin.error("Sign-in did not complete");
9868
+ throw error;
9869
+ }
9870
+ };
9351
9871
  const choose = async (action, retry) => {
9352
9872
  try {
9353
9873
  return await action();
9354
9874
  } catch (error) {
9355
- if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !(io.stdin?.isTTY ?? process.stdin.isTTY) || outputFormat(program.opts(), stdout) !== "table") throw error;
9356
- stderr.write(`${error.message}
9357
- ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9358
- `);
9359
- const reader = createInterface4({ input: io.stdin ?? process.stdin, output: stderr });
9360
- try {
9361
- const answer = await reader.question(`Pick [1-${error.candidates.length}]: `);
9362
- const chosen = error.candidates[Number(answer) - 1];
9363
- if (!chosen) throw error;
9364
- return await retry(chosen.id);
9365
- } finally {
9366
- reader.close();
9367
- }
9875
+ if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !human()) throw error;
9876
+ const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
9877
+ const hint = (c) => c.code ?? c.type;
9878
+ const chosen = await ui.select(error.message, error.candidates.map((c) => ({ value: c.id, label: c.name, ...hint(c) ? { hint: hint(c) } : {} })));
9879
+ return await retry(chosen);
9368
9880
  }
9369
9881
  };
9370
9882
  program.action(async (targets, options) => {
@@ -9429,15 +9941,42 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9429
9941
  if (name === "due") command.option("--days <number>", "Deadline window in days.", parsePositiveInt);
9430
9942
  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));
9431
9943
  }
9432
- addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>").argument("[unit]")).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));
9433
- addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
9944
+ 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));
9945
+ 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) => {
9434
9946
  const client = await runtime.getClient();
9435
9947
  const service = createIntentService(createMoodleGateway(client));
9436
9948
  const source = await choose(() => service.fileSource(ref2), (id2) => Promise.resolve(id2));
9437
- const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ? path3.resolve(io.cwd ?? process.cwd(), options.to) : void 0, force: options.force });
9949
+ const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ? path4.resolve(io.cwd ?? process.cwd(), options.to) : void 0, force: options.force });
9438
9950
  await runtime.output(receipt, () => formatDownloadReceipt(receipt), options);
9439
9951
  });
9440
- addOutputOptions(program.command("open").description("Open a unit or activity reference in the browser.").argument("<ref>")).action(async (ref2, options) => {
9952
+ addOutputOptions(mutating(program.command("submit").description(humanDescription("submit")).summary("Upload files into an assignment").argument("<ref>", "Assignment id, URL, or UNIT TASK phrase").argument("[files...]", "Local files to upload"))).option("--final", "Also submit for grading. Moodle does not allow undoing this.").option("--replace", "Remove the files already in the submission first.").option("--accept-statement", "Agree to the site's submission statement when it requires one.").action(async (ref2, files, options) => {
9953
+ const interactive = human();
9954
+ if (!program.opts().dryRun && !program.opts().yes && !interactive) throw new UsageError("Mutation requires --yes when stdin is not interactive.", "Run with --dry-run to see the plan first.");
9955
+ const client = await runtime.getClient();
9956
+ const service = createIntentService(createMoodleGateway(client));
9957
+ const args = { files: files.map((file2) => resolveSubmissionPath(file2, io.cwd ?? process.cwd())), final: Boolean(options.final), replace: Boolean(options.replace), accept_statement: Boolean(options.acceptStatement) };
9958
+ const plan = await choose(() => service.run("submit", { ref: ref2, ...args, dry_run: true }), (id2) => service.run("submit", { ref: id2, ...args, dry_run: true }));
9959
+ const planned = plan.submission;
9960
+ if (program.opts().dryRun) return runtime.output(plan, () => formatSubmissionReceipt(planned), options);
9961
+ if (!await confirm({ summary: submissionSummary(planned, args.final, theme()) }, { yes: Boolean(program.opts().yes), dryRun: false, interactive })) return;
9962
+ const spin = interactive ? createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true }).spinner() : void 0;
9963
+ runtime.busy = true;
9964
+ spin?.start("Preparing the upload");
9965
+ let result;
9966
+ try {
9967
+ const live = createIntentService(createMoodleGateway(client, { onSubmitProgress: (message) => spin?.message(message) }));
9968
+ result = await live.run("submit", { ref: planned.id, ...args, dry_run: false });
9969
+ const receipt = result.submission;
9970
+ spin?.stop(receipt.uploads.length ? `Uploaded ${receipt.uploads.map((file2) => file2.name).join(", ")} to ${receipt.name}` : `Submitted ${receipt.name}`);
9971
+ } catch (error) {
9972
+ spin?.error("The upload did not complete");
9973
+ throw error;
9974
+ } finally {
9975
+ runtime.busy = false;
9976
+ }
9977
+ await runtime.output(result, () => formatSubmissionReceipt(result.submission), options);
9978
+ });
9979
+ 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) => {
9441
9980
  const client = await runtime.getClient();
9442
9981
  let url;
9443
9982
  if (looksLikeUrl(ref2)) {
@@ -9495,7 +10034,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9495
10034
  const result = stripEmpty({ activities: rows.slice(0, count("limit", options.limit)), total: rows.length });
9496
10035
  await runtime.output(result, () => runtime.screen(result, options), options);
9497
10036
  });
9498
- addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").argument("<id>", "Course-module ID")).action(
10037
+ addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").summary("Show activity details").argument("<id>", "Course-module ID")).action(
9499
10038
  async (id2, options) => {
9500
10039
  await execute("item", { ref: parsePositiveInt(id2) }, options);
9501
10040
  }
@@ -9503,7 +10042,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9503
10042
  addOutputOptions(
9504
10043
  program.command("download").alias("dl").description("Download one authenticated Moodle file.").argument("<source>", "Course-module ID or authenticated Moodle file URL").option("--dest <path>", "Exact downloaded file path").option("--force", "Atomically replace an existing destination")
9505
10044
  ).action(async (source, options) => {
9506
- const destination = options.dest ? path3.resolve(io.cwd ?? process.cwd(), options.dest) : void 0;
10045
+ const destination = options.dest ? path4.resolve(io.cwd ?? process.cwd(), options.dest) : void 0;
9507
10046
  const receipt = await downloadMoodleFile(await runtime.getClient(), {
9508
10047
  source,
9509
10048
  destination,
@@ -9548,12 +10087,12 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9548
10087
  await runtime.output(stripEmpty({ forums: forums2.map((f) => ({ id: f.id, name: f.name, unit_id: f.course_id })), total }), () => formatForumActivities(forums2), options);
9549
10088
  });
9550
10089
  addForumSearchCommand(forums.command("search").description("Search forum discussion titles and post text."), runtime, 20);
9551
- addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.")).action(async (options) => {
10090
+ addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.").summary("Diagnose runtime, session and MCP setup")).action(async (options) => {
9552
10091
  const result = await doctor(io);
9553
10092
  await runtime.output(result, () => result.checks.map((c) => `${c.status.toUpperCase()} ${c.name}: ${c.detail}${c.hint ? `
9554
10093
  ${c.hint}` : ""}`).join("\n") + "\n\nTry moodle auth login \xB7 moodle mcp status", options);
9555
10094
  });
9556
- program.command("completion").description("Print shell completion for zsh, bash or fish.").argument("<shell>").action((shell) => {
10095
+ program.command("completion").description("Print shell completion for zsh, bash or fish.").addArgument(program.createArgument("<shell>", "Shell to target").choices(["zsh", "bash", "fish"])).action((shell) => {
9557
10096
  const names = program.commands.filter((c) => c.name() !== "help").flatMap((c) => [c.name(), ...c.aliases()]);
9558
10097
  if (shell === "bash") stdout.write(`complete -W '${names.join(" ")}' moodle
9559
10098
  `);
@@ -9563,22 +10102,22 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
9563
10102
  else if (shell === "fish") stdout.write(names.map((n2) => `complete -c moodle -f -a '${n2}'`).join("\n") + "\n");
9564
10103
  else throw new UsageError("Choose zsh, bash or fish.");
9565
10104
  });
9566
- addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration."))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
9567
- const home = io.homeDir ?? homedir12();
10105
+ 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) => {
10106
+ const home = io.homeDir ?? homedir13();
9568
10107
  const jobs = await ownedJobs(home);
9569
- const receipts = await readdir3(path3.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
9570
- const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config: path3.join(home, CONFIG_DIR_NAME), cache: path3.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." };
10108
+ const receipts = await readdir3(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10109
+ 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." };
9571
10110
  if (program.opts().dryRun) return runtime.output(result, () => JSON.stringify(result, null, 2), options);
9572
10111
  if (options.purge && receipts.length && !options.remote) throw new UsageError("Managed deployment receipts exist; remove the Worker before purging its recovery information.", "Run moodle mcp remove for each configured site, then moodle uninstall --purge.");
9573
10112
  if (options.purge && receipts.length > 1) throw new UsageError("Multiple managed deployment receipts exist; remove each Worker before purging configuration.");
9574
- if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY) })) return;
10113
+ if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: human() })) return;
9575
10114
  if (options.remote) await getMcpService().remove({ yes: true });
9576
10115
  if (process.platform === "darwin") await uninstallKeepalive({ homeDir: home });
9577
10116
  const renewal2 = new DefaultRenewalIntegration({ homeDirectory: home, executable: process.execPath });
9578
10117
  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)));
9579
10118
  for (const profile of profiles) {
9580
10119
  await renewal2.remove(profile);
9581
- if (options.purge) await rm7(path3.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
10120
+ if (options.purge) await rm7(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
9582
10121
  }
9583
10122
  if (options.purge) {
9584
10123
  await rm7(result.config, { recursive: true, force: true });
@@ -9617,7 +10156,7 @@ ${url}
9617
10156
  }
9618
10157
  );
9619
10158
  const keepalive = addOutputOptions(
9620
- auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
10159
+ auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").summary("Renew the session once").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
9621
10160
  ).action(async (options) => {
9622
10161
  const baseUrl = await runtime.baseUrl();
9623
10162
  const result = await keepAliveOnce(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl, renewOnExpiry: options.renew });
@@ -9629,7 +10168,7 @@ ${url}
9629
10168
  const globals = program.opts();
9630
10169
  if (!await confirm(
9631
10170
  { summary: `Install the Moodle session keepalive agent${options.interval ? ` with a ${options.interval}-minute interval` : ""}.` },
9632
- { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY) }
10171
+ { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
9633
10172
  )) return;
9634
10173
  const baseUrl = await runtime.baseUrl();
9635
10174
  await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true });
@@ -9643,7 +10182,7 @@ Log: ${result.log_path}`, options);
9643
10182
  const globals = program.opts();
9644
10183
  if (!await confirm(
9645
10184
  { summary: "Remove the Moodle session keepalive agent." },
9646
- { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY) }
10185
+ { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
9647
10186
  )) return;
9648
10187
  const result = await uninstallKeepalive({ homeDir: io.homeDir });
9649
10188
  await runtime.output(result, () => `Keepalive removed (${result.plist_path})`, options);
@@ -9655,7 +10194,7 @@ Log: ${result.log_path}`, options);
9655
10194
  await runtime.output(result, () => result.installed ? `Keepalive installed (${result.plist_path})` : "Keepalive not installed", options);
9656
10195
  }
9657
10196
  );
9658
- const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.");
10197
+ const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.").summary("Private MCP server on Cloudflare");
9659
10198
  addOutputOptions(mutating(mcp.command("deploy").description("Deploy or update the managed Moodle MCP server."))).option("--dry-run", "Preview deployment changes without applying them.").option("--repair", "Repair authentication and managed deployment state.").option("--rotate-key", "Rotate the session encryption key and migrate the active session.").option("--rotate-token", "Rotate the MCP access token with an overlap window.").option("--rollback", "Restore the previous healthy Worker release.").action(async (options) => {
9660
10199
  const dryRun = Boolean(options.dryRun || program.opts().dryRun);
9661
10200
  if (!dryRun && !await confirm(
@@ -9663,7 +10202,7 @@ Log: ${result.log_path}`, options);
9663
10202
  {
9664
10203
  yes: Boolean(program.opts().yes),
9665
10204
  dryRun: false,
9666
- interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY)
10205
+ interactive: human()
9667
10206
  }
9668
10207
  )) return;
9669
10208
  const result = await getMcpService().deploy({
@@ -9705,11 +10244,11 @@ Log: ${result.log_path}`, options);
9705
10244
  addOutputOptions(mcp.command("clients").description("List pending and approved OAuth clients.")).action(async (options) => {
9706
10245
  await outputMcpResult(runtime, await getMcpService().manageClients({}), options);
9707
10246
  });
9708
- addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
10247
+ addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]", "OAuth client id; omit with --all"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
9709
10248
  if (Boolean(clientId) === Boolean(options.all)) throw new UsageError("Provide a client ID or --all.");
9710
10249
  await outputMcpResult(runtime, await getMcpService().manageClients({ revoke: true, clientId }), options);
9711
10250
  });
9712
- addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server."))).action(
10251
+ addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server.").summary("Open a pairing window for Claude"))).action(
9713
10252
  async (options) => {
9714
10253
  await outputMcpResult(runtime, await getMcpService().pair(), options);
9715
10254
  }
@@ -9742,7 +10281,7 @@ Log: ${result.log_path}`, options);
9742
10281
  await runtime.output(description, () => JSON.stringify(description, null, 2), options);
9743
10282
  }
9744
10283
  );
9745
- const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.");
10284
+ const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.").summary("Agent skill metadata");
9746
10285
  skills.action(() => {
9747
10286
  stdout.write(`${formatSkillSummary()}
9748
10287
  `);
@@ -9752,18 +10291,40 @@ Log: ${result.log_path}`, options);
9752
10291
  stdout.write("Generated Moodle skill bundle\n");
9753
10292
  });
9754
10293
  skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
10294
+ for (const [title, names] of Object.entries(HELP_SECTIONS)) {
10295
+ for (const name of names) for (const command of program.commands) if (command.name() === name) helpSection(command, title);
10296
+ }
10297
+ examples(program, [
10298
+ "moodle # today: due items, alerts and news",
10299
+ "moodle UNIT grades",
10300
+ 'moodle submit UNIT "Assignment 2" report.pdf'
10301
+ ]);
9755
10302
  return program;
9756
10303
  }
10304
+ var HELP_SECTIONS = {
10305
+ "Core commands": ["due", "news", "find", "get", "open", "submit", "units", "activities", "grades", "threads", "forums"],
10306
+ "Additional commands": ["user", "todo", "alerts", "overview", "download", "auth", "doctor", "completion", "uninstall"],
10307
+ "Agent commands": ["mcp", "commands", "skills"]
10308
+ };
9757
10309
  async function runCli(argv = process.argv, io = {}) {
9758
10310
  const stderr = io.stderr ?? process.stderr;
9759
10311
  const stdout = io.stdout ?? process.stdout;
9760
10312
  const args = insertDefaultVerb(argv.slice(2), NOUNS);
9761
- const program = buildProgram({ ...io, rootArgs: args });
10313
+ const ui = createUi3({
10314
+ input: io.stdin ?? process.stdin,
10315
+ output: stderr,
10316
+ interactive: detectAudience({
10317
+ stdin: io.stdin ?? process.stdin,
10318
+ stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
10319
+ env: io.env ?? process.env,
10320
+ format: errorOutputFormat(args, stdout)
10321
+ }) === "human"
10322
+ });
9762
10323
  try {
9763
- await program.parseAsync(args, { from: "user" });
10324
+ await parseWithPrompts(() => buildProgram({ ...io, rootArgs: args }), args, { ui, fillers: { unit: pickUnit(io, ui) } });
9764
10325
  return 0;
9765
10326
  } catch (error) {
9766
- if (isCommanderCompletion(error)) {
10327
+ if (isInformationalExit(error)) {
9767
10328
  return 0;
9768
10329
  }
9769
10330
  const format = errorOutputFormat(args, stdout);
@@ -9843,11 +10404,26 @@ function addForumSearchCommand(command, runtime, defaultLimit) {
9843
10404
  });
9844
10405
  }
9845
10406
  function addOutputOptions(command) {
9846
- return command.option("--pretty", "Indent JSON output.").option("--json", "Output as JSON.").option("--yaml", "Output as YAML.").option("--table", "Force human output.").option("--fields <fields>", "Keep only listed top-level fields in structured output.");
10407
+ for (const [flags, description] of [
10408
+ ["--pretty", "Indent JSON output."],
10409
+ ["--json", "Output as JSON."],
10410
+ ["--yaml", "Output as YAML."],
10411
+ ["--table", "Force human output."],
10412
+ ["--fields <fields>", "Keep only listed top-level fields in structured output."]
10413
+ ]) command.addOption(command.createOption(flags, description).hideHelp());
10414
+ return command;
9847
10415
  }
9848
10416
  function outputFormat(options, stdout) {
9849
10417
  return resolveFormat(options, Boolean(stdout && "isTTY" in stdout && stdout.isTTY));
9850
10418
  }
10419
+ function submissionSummary(plan, final, theme) {
10420
+ const destination = `${theme.target(plan.name)}${plan.unit_id ? theme.dim(` unit ${plan.unit_id}`) : ""}`;
10421
+ 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`];
10422
+ if (plan.removed.length) lines.push(`${theme.dim("Remove")} ${theme.tone("danger", plan.removed.join(", "))} ${theme.dim("first")}`);
10423
+ if (plan.statement) lines.push(`${theme.dim(" Agree")} "${plan.statement}"`);
10424
+ lines.push(final ? theme.tone("warning", "Then submit for grading. Moodle does not allow undoing this.") : theme.dim("Moodle keeps a draft where the assignment allows drafts; otherwise it submits at once."));
10425
+ return lines.join("\n");
10426
+ }
9851
10427
  function parsePositiveInt(value) {
9852
10428
  const parsed = Number(value);
9853
10429
  if (!Number.isInteger(parsed) || parsed < 1) {
@@ -9888,11 +10464,24 @@ function parseFields(data, value) {
9888
10464
  }
9889
10465
  return fields2;
9890
10466
  }
9891
- function isCommanderCompletion(error) {
9892
- if (!error || typeof error !== "object") return false;
9893
- if ("exitCode" in error && error.exitCode === 0) return true;
9894
- const code = "code" in error ? String(error.code) : "";
9895
- return code.startsWith("commander.help") || code === "commander.version";
10467
+ function pickUnit(io, ui) {
10468
+ return async () => {
10469
+ const spin = ui.spinner();
10470
+ spin.start("Loading your units");
10471
+ try {
10472
+ return await selectUnit(spin);
10473
+ } catch (error) {
10474
+ spin.stop("Could not load your units");
10475
+ throw error;
10476
+ }
10477
+ };
10478
+ async function selectUnit(spin) {
10479
+ const { baseUrl } = await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr: io.stderr ?? process.stderr, fetch: io.fetchImpl });
10480
+ const client = await createMoodleClient(baseUrl, { env: io.env, fetchImpl: io.fetchImpl, homeDir: io.homeDir });
10481
+ const courses = await client.getCourses();
10482
+ spin.stop(`${courses.length} units`);
10483
+ return ui.select("Which unit?", courses.map((course) => ({ value: String(course.id), label: course.fullname, ...course.shortname ? { hint: course.shortname } : {} })));
10484
+ }
9896
10485
  }
9897
10486
  function parseRootOutputOptions(args) {
9898
10487
  const fieldsIndex = args.findIndex((arg) => arg === "--fields" || arg.startsWith("--fields="));