moodle-cli 0.7.0-alpha.3 → 0.7.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -174,7 +174,7 @@ moodle mcp bridge
174
174
 
175
175
  The default client connection uses `moodle mcp bridge`, which keeps the Bearer token out of client configuration. Use `moodle mcp connect CLIENT --mode remote` for clients that support authenticated remote MCP headers.
176
176
 
177
- Alpha version `0.7.0-alpha.3` supports MCP `2026-07-28` and a stateless compatibility lane for `2025-11-25` clients.
177
+ Alpha version `0.7.0-alpha.4` supports MCP `2026-07-28` and a stateless compatibility lane for `2025-11-25` clients.
178
178
 
179
179
  ### Configuration
180
180
 
package/dist/moodle.js CHANGED
@@ -98,7 +98,7 @@ var MoodleAPIError = class extends CliError {
98
98
  moodleErrorCode;
99
99
  constructor(message, moodleErrorCode) {
100
100
  const auth = isLoginErrorCode(moodleErrorCode);
101
- const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /^HTTP 404\b/.test(message);
101
+ const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /\bHTTP 404\b/.test(message);
102
102
  super(auth ? "auth" : notFound ? "not_found" : "upstream", message, auth ? "Run `moodle auth login`." : void 0);
103
103
  this.moodleErrorCode = moodleErrorCode;
104
104
  }
@@ -898,6 +898,32 @@ function htmlText(value, baseUrl) {
898
898
 
899
899
  // src/scraper.ts
900
900
  import { parse as parse2 } from "node-html-parser";
901
+ function parseMoodleErrorHtml(html) {
902
+ const root = parse2(html);
903
+ const messageNode = first(root, [
904
+ ".errormessage",
905
+ ".alert-danger .alert-message",
906
+ "[data-region='error-message']",
907
+ ".alert-danger[role='alert']",
908
+ ".alert-danger"
909
+ ]);
910
+ if (!messageNode) {
911
+ return null;
912
+ }
913
+ const messageRoot = parse2(messageNode.toString());
914
+ for (const unwanted of messageRoot.querySelectorAll(
915
+ "button, .close, .errorcode, .stacktrace, .debuginfo, .backtrace, a.alert-link, a[href*='/error/']"
916
+ )) {
917
+ unwanted.remove();
918
+ }
919
+ const message = cleanText(messageRoot.textContent);
920
+ if (!message) {
921
+ return null;
922
+ }
923
+ const errorCodeText = cleanNodeText(root.querySelector(".errorcode"));
924
+ const errorCode = errorCodeText.match(/^error\s+code\s*:\s*([a-z][a-z0-9_]*)\s*$/iu)?.[1] ?? moodleDocsErrorCode(root);
925
+ return { message, ...errorCode ? { code: errorCode } : {} };
926
+ }
901
927
  function parsePageContext(html, baseUrl) {
902
928
  const root = parse2(html);
903
929
  const config = parseMoodleConfig(html);
@@ -1329,6 +1355,16 @@ function selectedGroupName(root, groupId) {
1329
1355
  }
1330
1356
  return "";
1331
1357
  }
1358
+ function moodleDocsErrorCode(root) {
1359
+ for (const link2 of root.querySelectorAll("a[href*='/error/']")) {
1360
+ const href = link2.getAttribute("href") ?? "";
1361
+ const code = href.match(/\/error\/[^/]+\/([a-z][a-z0-9_]*)/iu)?.[1];
1362
+ if (code) {
1363
+ return code;
1364
+ }
1365
+ }
1366
+ return void 0;
1367
+ }
1332
1368
  function cleanNodeText(node) {
1333
1369
  return cleanText(node?.textContent ?? "");
1334
1370
  }
@@ -1818,7 +1854,7 @@ var MoodleClientCoreApiError = class extends MoodleClientCoreError {
1818
1854
  moodleErrorCode;
1819
1855
  constructor(message, moodleErrorCode) {
1820
1856
  const auth = isLoginErrorCode2(moodleErrorCode);
1821
- const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /^HTTP 404\b/.test(message);
1857
+ const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /\bHTTP 404\b/.test(message);
1822
1858
  super(
1823
1859
  auth ? "auth" : notFound ? "not_found" : "upstream",
1824
1860
  message,
@@ -2249,7 +2285,15 @@ var MoodleClientCore = class {
2249
2285
  throw this.errors.api("Session expired", "servicerequireslogin");
2250
2286
  }
2251
2287
  if (!response.ok) {
2252
- throw this.errors.api(`HTTP ${response.status} loading ${safeUrl(url)}`);
2288
+ const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
2289
+ const contentType2 = response.headers.get("content-type")?.toLowerCase() ?? "";
2290
+ if (contentType2.includes("text/html") || contentType2.includes("application/xhtml+xml")) {
2291
+ const moodleError = await response.text().then(parseMoodleErrorHtml).catch(() => null);
2292
+ if (moodleError) {
2293
+ throw this.errors.api(`${moodleError.message} (${context})`, moodleError.code);
2294
+ }
2295
+ }
2296
+ throw this.errors.api(context);
2253
2297
  }
2254
2298
  return response;
2255
2299
  }
@@ -2715,84 +2759,163 @@ function isMissingFileError2(error) {
2715
2759
  return isRecord5(error) && error.code === "ENOENT";
2716
2760
  }
2717
2761
 
2762
+ // src/terminal-table.ts
2763
+ import Table from "tty-table";
2764
+ var DEFAULT_WIDTH = 120;
2765
+ var MIN_TABLE_WIDTH = 40;
2766
+ function renderTerminalTable(columns, rows, options = {}) {
2767
+ const tableWidth = Math.max(MIN_TABLE_WIDTH, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
2768
+ const tableColumns = columns.map((column, index) => ({
2769
+ align: "left",
2770
+ alias: sanitizeTerminalText(column.label),
2771
+ headerAlign: "left",
2772
+ value: `column_${index}`
2773
+ }));
2774
+ const data = rows.map((row) => Object.fromEntries(
2775
+ tableColumns.map((column, index) => [column.value, sanitizeTerminalText(row[index] ?? "")])
2776
+ ));
2777
+ const tableOptions = {
2778
+ COLUMNS: tableWidth,
2779
+ compact: true,
2780
+ marginLeft: 0,
2781
+ marginTop: 0,
2782
+ width: String(tableWidth)
2783
+ };
2784
+ const output = Table(tableColumns, data, tableOptions).render();
2785
+ return [options.title ? sanitizeTerminalText(options.title) : "", output].filter(Boolean).join("\n");
2786
+ }
2787
+ function renderKeyValueTable(rows, options = {}) {
2788
+ const present = rows.filter(([, value]) => value !== "");
2789
+ if (present.length === 0) {
2790
+ return options.title ? `${options.title}
2791
+ No details` : "No details";
2792
+ }
2793
+ const columns = [
2794
+ { label: "Field" },
2795
+ { label: "Value" }
2796
+ ];
2797
+ return renderTerminalTable(columns, present, options);
2798
+ }
2799
+ function sanitizeTerminalText(value) {
2800
+ return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, "");
2801
+ }
2802
+
2718
2803
  // src/formatters.ts
2719
2804
  function formatUser(user) {
2720
- return formatKeyValues([
2805
+ return renderKeyValueTable([
2721
2806
  ["User", user.fullname],
2722
2807
  ["Username", user.username],
2723
2808
  ["User ID", String(user.userid)],
2724
2809
  ["Site", user.sitename],
2725
2810
  ["URL", user.siteurl],
2726
2811
  ["Language", user.lang ?? ""]
2727
- ]);
2812
+ ], { title: "User" });
2728
2813
  }
2729
2814
  function formatCourses(courses) {
2730
- return formatColumns([["ID", "Short Name", "Full Name"], ...courses.map((course) => [String(course.id), course.shortname, course.fullname])]);
2815
+ return renderTerminalTable(
2816
+ [
2817
+ { label: "ID" },
2818
+ { label: "Short Name" },
2819
+ { label: "Full Name" }
2820
+ ],
2821
+ courses.map((course) => [String(course.id), course.shortname, course.fullname]),
2822
+ { title: "Enrolled Units" }
2823
+ );
2731
2824
  }
2732
2825
  function formatCourseSections(sections) {
2733
2826
  const lines = ["Course"];
2734
- for (const section of sections) {
2735
- lines.push(` ${section.name || `Section ${section.section}`}${section.visible ? "" : " (hidden)"}`);
2827
+ for (const [sectionIndex, section] of sections.entries()) {
2828
+ const sectionLast = sectionIndex === sections.length - 1;
2829
+ lines.push(`${sectionLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"} ${section.name || `Section ${section.section}`}${section.visible ? "" : " (hidden)"}`);
2736
2830
  if (!section.activities.length) {
2737
- lines.push(" No activities");
2831
+ lines.push(`${sectionLast ? " " : "\u2502 "}\u2514\u2500\u2500 No activities`);
2738
2832
  continue;
2739
2833
  }
2740
- for (const activity of section.activities) {
2741
- lines.push(` ${activity.name}${activity.visible ? "" : " (hidden)"} (${activity.modname})`);
2834
+ for (const [activityIndex, activity] of section.activities.entries()) {
2835
+ const activityPrefix = activityIndex === section.activities.length - 1 ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500";
2836
+ lines.push(`${sectionLast ? " " : "\u2502 "}${activityPrefix} ${activity.name}${activity.visible ? "" : " (hidden)"} (${activity.modname})`);
2742
2837
  }
2743
2838
  }
2744
- return lines.join("\n");
2839
+ return sanitizeTerminalText(lines.join("\n"));
2745
2840
  }
2746
2841
  function formatActivityList(value) {
2747
2842
  const activities = Array.isArray(value) && value[0] && "activities" in value[0] ? value.flatMap((section) => section.activities) : value;
2748
- return formatColumns([["ID", "Type", "Name"], ...activities.map((activity) => [String(activity.id), activity.modname, activity.name])]);
2843
+ return renderTerminalTable(
2844
+ [
2845
+ { label: "ID" },
2846
+ { label: "Type" },
2847
+ { label: "Name" }
2848
+ ],
2849
+ activities.map((activity) => [String(activity.id), activity.modname, activity.name]),
2850
+ { title: "Activities" }
2851
+ );
2749
2852
  }
2750
2853
  function formatTodo(items) {
2751
- if (!items.length) {
2752
- return "No upcoming items";
2753
- }
2754
- return formatColumns([
2755
- ["Due", "Course", "Activity", "Type", "Action"],
2756
- ...items.map((item) => [
2757
- item.due_at ? String(item.due_at) : "-",
2758
- item.course_name,
2759
- item.activity_name || item.name,
2760
- item.modname || item.event_type,
2761
- item.actionable ? item.action_name : ""
2762
- ])
2763
- ]);
2854
+ const columns = [
2855
+ { label: "Due" },
2856
+ { label: "Unit" },
2857
+ { label: "Activity" },
2858
+ { label: "Type" },
2859
+ { label: "Action" }
2860
+ ];
2861
+ const rows = items.length ? items.map((item) => [
2862
+ `${item.overdue ? "Overdue \xB7 " : ""}${formatTimestamp(item.due_at)}`,
2863
+ `${item.course_name}${item.course_progress === void 0 ? "" : ` (${item.course_progress}%)`}`,
2864
+ item.activity_name || item.name,
2865
+ item.modname || item.event_type,
2866
+ item.actionable ? item.action_name : ""
2867
+ ]) : [["No upcoming items", "", "", "", ""]];
2868
+ return renderTerminalTable(columns, rows, { title: "Todo" });
2764
2869
  }
2765
2870
  function formatAlerts(alerts) {
2766
- const lines = [
2767
- `Notifications: ${alerts.notification_count}`,
2768
- `Unread notifications: ${alerts.unread_notification_count}`,
2769
- `Direct messages: ${alerts.direct_message_count}`,
2770
- `Unread direct messages: ${alerts.unread_direct_message_count}`
2771
- ];
2772
- for (const notification of alerts.notifications) {
2773
- lines.push(`${notification.created_pretty || notification.created_at} ${notification.short_subject || notification.subject}`);
2774
- }
2775
- return lines.join("\n");
2871
+ const summary = renderKeyValueTable([
2872
+ ["Notifications", String(alerts.notification_count)],
2873
+ ["Unread notifications", String(alerts.unread_notification_count)],
2874
+ ["Direct messages", String(alerts.direct_message_count)],
2875
+ ["Unread direct messages", String(alerts.unread_direct_message_count)]
2876
+ ], { title: "Alerts" });
2877
+ if (!alerts.notifications.length) return summary;
2878
+ const notifications = renderTerminalTable(
2879
+ [
2880
+ { label: "When" },
2881
+ { label: "Subject" }
2882
+ ],
2883
+ alerts.notifications.map((notification) => [
2884
+ notification.created_pretty || formatTimestamp(notification.created_at),
2885
+ notification.short_subject || notification.subject
2886
+ ]),
2887
+ { title: "Notifications" }
2888
+ );
2889
+ return `${summary}
2890
+
2891
+ ${notifications}`;
2776
2892
  }
2777
2893
  function formatGrades(grades) {
2778
- return formatColumns([
2779
- ["Item", "Grade", "Range", "Percent", "Feedback"],
2780
- ...grades.items.map((item) => [item.name, item.grade, item.range, item.percentage, item.feedback])
2781
- ]);
2894
+ return renderTerminalTable(
2895
+ [
2896
+ { label: "Item" },
2897
+ { label: "Grade" },
2898
+ { label: "Range" },
2899
+ { label: "Percent" },
2900
+ { label: "Feedback" }
2901
+ ],
2902
+ grades.items.map((item) => [item.name, item.grade, item.range, item.percentage, item.feedback]),
2903
+ { title: grades.course_name ? `Grades \xB7 ${grades.course_name}` : "Grades" }
2904
+ );
2782
2905
  }
2783
2906
  function formatActivityDetail(activity) {
2784
2907
  const rows = Object.entries(activity).filter(([, value]) => value !== "" && value !== void 0 && !(Array.isArray(value) && value.length === 0)).map(([key, value]) => [key, Array.isArray(value) ? value.join("\n") : String(value)]);
2785
- return formatKeyValues(rows);
2908
+ return renderKeyValueTable(rows, { title: "Activity" });
2786
2909
  }
2787
2910
  function formatDownloadReceipt(receipt) {
2788
- return formatKeyValues([
2911
+ return renderKeyValueTable([
2789
2912
  ["File", receipt.file_path],
2790
2913
  ["Filename", receipt.filename],
2791
2914
  ["Bytes", String(receipt.bytes_written)],
2792
2915
  ["Content type", receipt.content_type],
2793
2916
  ["Source", receipt.source_url],
2794
2917
  ["Final URL", receipt.final_url]
2795
- ]);
2918
+ ], { title: "Download" });
2796
2919
  }
2797
2920
  function formatForumDiscussion(discussion, options = {}) {
2798
2921
  const lines = [`Discussion: ${discussion.id}`];
@@ -2810,13 +2933,13 @@ function formatForumDiscussion(discussion, options = {}) {
2810
2933
  }
2811
2934
  if (!discussion.posts.length) {
2812
2935
  lines.push("", "No posts");
2813
- return lines.join("\n");
2936
+ return sanitizeTerminalText(lines.join("\n"));
2814
2937
  }
2815
2938
  for (const post of discussion.posts) {
2816
2939
  const marker = options.highlightPostId === post.id ? "*" : "-";
2817
2940
  lines.push("", `${marker} Post ${post.id}`);
2818
2941
  lines.push(` Author: ${post.author.fullname || "-"}`);
2819
- lines.push(` When: ${post.created_pretty || (post.time_created ? String(post.time_created) : "-")}`);
2942
+ lines.push(` When: ${post.created_pretty || formatTimestamp(post.time_created)}`);
2820
2943
  if (post.subject) {
2821
2944
  lines.push(` Subject: ${post.subject}`);
2822
2945
  }
@@ -2835,58 +2958,68 @@ function formatForumDiscussion(discussion, options = {}) {
2835
2958
  lines.push(` Images: ${post.image_urls.length}`);
2836
2959
  }
2837
2960
  }
2838
- return lines.join("\n");
2961
+ return sanitizeTerminalText(lines.join("\n"));
2839
2962
  }
2840
2963
  function formatForumDiscussionRefs(forumCmid, refs) {
2841
- const lines = [`Forum ${forumCmid}: Discussions`];
2842
- if (!refs.length) {
2843
- return `${lines[0]}
2844
- No discussions`;
2845
- }
2846
- for (const ref of refs) {
2847
- lines.push([ref.id, ref.subject, ref.group_name, ref.url].filter(Boolean).join(" | "));
2848
- }
2849
- return lines.join("\n");
2964
+ return renderTerminalTable(
2965
+ [
2966
+ { label: "ID" },
2967
+ { label: "Subject" },
2968
+ { label: "Group" },
2969
+ { label: "URL" }
2970
+ ],
2971
+ refs.length ? refs.map((ref) => [String(ref.id), ref.subject, ref.group_name, ref.url]) : [["No discussions", "", "", ""]],
2972
+ { title: `Forum ${forumCmid} \xB7 Discussions` }
2973
+ );
2850
2974
  }
2851
2975
  function formatForumActivities(forums) {
2852
- if (!forums.length) {
2853
- return "Forums\nNo forums";
2854
- }
2855
- return [
2856
- "Forums",
2857
- ...forums.map((forum) => [forum.id, forum.name, forum.course_name, forum.course_id || "", forum.url].filter(Boolean).join(" | "))
2858
- ].join("\n");
2976
+ return renderTerminalTable(
2977
+ [
2978
+ { label: "ID" },
2979
+ { label: "Forum" },
2980
+ { label: "Unit" },
2981
+ { label: "URL" }
2982
+ ],
2983
+ forums.length ? forums.map((forum) => [String(forum.id), forum.name, forum.course_name, forum.url]) : [["No forums", "", "", ""]],
2984
+ { title: "Forums" }
2985
+ );
2859
2986
  }
2860
2987
  function formatForumSearchHits(hits) {
2861
- if (!hits.length) {
2862
- return "Forum Search\nNo matches";
2863
- }
2864
- return [
2865
- "Forum Search",
2866
- ...hits.map(
2867
- (hit) => [
2868
- hit.course_name,
2869
- hit.forum_name,
2870
- hit.discussion_subject,
2871
- hit.discussion_id || "",
2872
- hit.post_id || "",
2873
- hit.matched_in,
2874
- hit.author_name,
2875
- hit.snippet || hit.discussion_subject,
2876
- hit.url
2877
- ].filter(Boolean).join(" | ")
2878
- )
2879
- ].join("\n");
2988
+ return renderTerminalTable(
2989
+ [
2990
+ { label: "Discussion" },
2991
+ { label: "Post" },
2992
+ { label: "Unit" },
2993
+ { label: "Forum" },
2994
+ { label: "Subject" },
2995
+ { label: "Author" },
2996
+ { label: "Match" },
2997
+ { label: "Snippet" },
2998
+ { label: "URL" }
2999
+ ],
3000
+ hits.length ? hits.map((hit) => [
3001
+ String(hit.discussion_id),
3002
+ String(hit.post_id),
3003
+ hit.course_name,
3004
+ hit.forum_name,
3005
+ hit.discussion_subject,
3006
+ hit.author_name,
3007
+ hit.matched_in,
3008
+ hit.snippet || hit.discussion_subject,
3009
+ hit.url
3010
+ ]) : [["No matches", "", "", "", "", "", "", "", ""]],
3011
+ { title: "Forum Search" }
3012
+ );
2880
3013
  }
2881
3014
  function formatAuthStatus(status) {
2882
- return formatKeyValues([
3015
+ return renderKeyValueTable([
2883
3016
  ["Site", status.base_url],
2884
3017
  ["Cached session", status.session_cached ? "yes" : "no"],
2885
3018
  ["Cache age", status.cache_age_minutes === null ? "" : `${status.cache_age_minutes} min`],
2886
3019
  ["Session alive", status.session_alive === null ? status.session_cached ? "unknown" : "" : status.session_alive ? "yes" : "no"],
2887
3020
  ["Server timeout in", formatDuration(status.session_time_remaining_seconds)],
2888
3021
  ["Keepalive agent", status.keepalive_installed ? `installed (${status.keepalive_plist_path})` : "not installed"]
2889
- ]);
3022
+ ], { title: "Authentication" });
2890
3023
  }
2891
3024
  function formatKeepaliveResult(result) {
2892
3025
  switch (result.status) {
@@ -2919,17 +3052,11 @@ function preview(value, maxLen = 100) {
2919
3052
  const cleaned = value.split(/\s+/).filter(Boolean).join(" ");
2920
3053
  return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
2921
3054
  }
2922
- function formatKeyValues(rows) {
2923
- const present = rows.filter(([, value]) => value);
2924
- const width = Math.max(0, ...present.map(([key]) => key.length));
2925
- return present.map(([key, value]) => `${key.padEnd(width)} ${value}`).join("\n");
2926
- }
2927
- function formatColumns(rows) {
2928
- if (!rows.length) {
2929
- return "";
2930
- }
2931
- const widths = rows[0].map((_, index) => Math.max(...rows.map((row) => (row[index] ?? "").length)));
2932
- return rows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()).join("\n");
3055
+ function formatTimestamp(value) {
3056
+ if (value <= 0) return "-";
3057
+ const date = new Date(value * 1e3);
3058
+ const pad = (part) => String(part).padStart(2, "0");
3059
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
2933
3060
  }
2934
3061
 
2935
3062
  // src/download.ts
@@ -3700,7 +3827,7 @@ function escapeXml(value) {
3700
3827
  }
3701
3828
 
3702
3829
  // src/version.ts
3703
- var VERSION = "0.7.0-alpha.3";
3830
+ var VERSION = "0.7.0-alpha.4";
3704
3831
 
3705
3832
  // src/forum.ts
3706
3833
  function parseDiscussionReference(value) {
@@ -5996,7 +5996,7 @@ function problemResponse(status, code, title, detail, headers) {
5996
5996
  }
5997
5997
 
5998
5998
  // src/version.ts
5999
- var VERSION = "0.7.0-alpha.3";
5999
+ var VERSION = "0.7.0-alpha.4";
6000
6000
 
6001
6001
  // src/worker/http.ts
6002
6002
  var HEALTH_PATH = "/healthz";
@@ -21154,6 +21154,32 @@ function htmlText(value, baseUrl) {
21154
21154
 
21155
21155
  // src/scraper.ts
21156
21156
  var import_node_html_parser2 = __toESM(require_dist(), 1);
21157
+ function parseMoodleErrorHtml(html) {
21158
+ const root = (0, import_node_html_parser2.parse)(html);
21159
+ const messageNode = first(root, [
21160
+ ".errormessage",
21161
+ ".alert-danger .alert-message",
21162
+ "[data-region='error-message']",
21163
+ ".alert-danger[role='alert']",
21164
+ ".alert-danger"
21165
+ ]);
21166
+ if (!messageNode) {
21167
+ return null;
21168
+ }
21169
+ const messageRoot = (0, import_node_html_parser2.parse)(messageNode.toString());
21170
+ for (const unwanted of messageRoot.querySelectorAll(
21171
+ "button, .close, .errorcode, .stacktrace, .debuginfo, .backtrace, a.alert-link, a[href*='/error/']"
21172
+ )) {
21173
+ unwanted.remove();
21174
+ }
21175
+ const message = cleanText(messageRoot.textContent);
21176
+ if (!message) {
21177
+ return null;
21178
+ }
21179
+ const errorCodeText = cleanNodeText(root.querySelector(".errorcode"));
21180
+ const errorCode = errorCodeText.match(/^error\s+code\s*:\s*([a-z][a-z0-9_]*)\s*$/iu)?.[1] ?? moodleDocsErrorCode(root);
21181
+ return { message, ...errorCode ? { code: errorCode } : {} };
21182
+ }
21157
21183
  function parsePageContext(html, baseUrl) {
21158
21184
  const root = (0, import_node_html_parser2.parse)(html);
21159
21185
  const config2 = parseMoodleConfig(html);
@@ -21585,6 +21611,16 @@ function selectedGroupName(root, groupId) {
21585
21611
  }
21586
21612
  return "";
21587
21613
  }
21614
+ function moodleDocsErrorCode(root) {
21615
+ for (const link of root.querySelectorAll("a[href*='/error/']")) {
21616
+ const href = link.getAttribute("href") ?? "";
21617
+ const code = href.match(/\/error\/[^/]+\/([a-z][a-z0-9_]*)/iu)?.[1];
21618
+ if (code) {
21619
+ return code;
21620
+ }
21621
+ }
21622
+ return void 0;
21623
+ }
21588
21624
  function cleanNodeText(node) {
21589
21625
  return cleanText(node?.textContent ?? "");
21590
21626
  }
@@ -22074,7 +22110,7 @@ var MoodleClientCoreApiError = class extends MoodleClientCoreError {
22074
22110
  moodleErrorCode;
22075
22111
  constructor(message, moodleErrorCode) {
22076
22112
  const auth = isLoginErrorCode(moodleErrorCode);
22077
- const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /^HTTP 404\b/.test(message);
22113
+ const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /\bHTTP 404\b/.test(message);
22078
22114
  super(
22079
22115
  auth ? "auth" : notFound ? "not_found" : "upstream",
22080
22116
  message,
@@ -22505,7 +22541,15 @@ var MoodleClientCore = class {
22505
22541
  throw this.errors.api("Session expired", "servicerequireslogin");
22506
22542
  }
22507
22543
  if (!response.ok) {
22508
- throw this.errors.api(`HTTP ${response.status} loading ${safeUrl(url2)}`);
22544
+ const context = `HTTP ${response.status} loading ${safeUrl(url2)}`;
22545
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
22546
+ if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {
22547
+ const moodleError = await response.text().then(parseMoodleErrorHtml).catch(() => null);
22548
+ if (moodleError) {
22549
+ throw this.errors.api(`${moodleError.message} (${context})`, moodleError.code);
22550
+ }
22551
+ }
22552
+ throw this.errors.api(context);
22509
22553
  }
22510
22554
  return response;
22511
22555
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moodle-cli",
3
- "version": "0.7.0-alpha.3",
3
+ "version": "0.7.0-alpha.4",
4
4
  "description": "Terminal-first CLI for Moodle LMS",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,6 +35,7 @@
35
35
  "@steipete/sweet-cookie": "^0.4.0",
36
36
  "commander": "^13.1.0",
37
37
  "node-html-parser": "^7.0.1",
38
+ "tty-table": "^5.0.0",
38
39
  "wrangler": "^4.120.0",
39
40
  "yaml": "^2.8.0",
40
41
  "zod": "^4.0.5"