moodle-cli 0.5.5 → 0.6.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command, CommanderError } from "commander";
5
- import { realpathSync } from "fs";
5
+ import { realpathSync as realpathSync2 } from "fs";
6
6
  import { fileURLToPath } from "url";
7
7
 
8
8
  // src/client.ts
@@ -36,11 +36,16 @@ var FUNC_GET_POPUP_NOTIFICATIONS = "message_popup_get_popup_notifications";
36
36
  var FUNC_GET_CONVERSATION_COUNTS = "core_message_get_conversation_counts";
37
37
  var FUNC_GET_UNREAD_CONVERSATION_COUNTS = "core_message_get_unread_conversation_counts";
38
38
  var FUNC_GET_DISCUSSION_POSTS = "mod_forum_get_discussion_posts";
39
+ var FUNC_SESSION_TOUCH = "core_session_touch";
40
+ var FUNC_SESSION_TIME_REMAINING = "core_session_time_remaining";
39
41
  var CONFIG_FILENAME = "config.yaml";
40
42
  var CONFIG_DIR_NAME = ".config/moodle-cli";
41
43
  var CACHE_DIR_NAME = ".cache/moodle-cli";
42
44
  var SESSION_CACHE_FILENAME = "session.json";
43
- var DEFAULT_SESSION_CACHE_TTL_MS = 2 * 60 * 60 * 1e3;
45
+ var DEFAULT_SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
46
+ var KEEPALIVE_LAUNCH_AGENT_LABEL = "com.moodle-cli.keepalive";
47
+ var KEEPALIVE_DEFAULT_INTERVAL_MINUTES = 30;
48
+ var KEEPALIVE_LOG_FILENAME = "keepalive.log";
44
49
  var ENV_MOODLE_SESSION = "MOODLE_SESSION";
45
50
  var ENV_MOODLE_BASE_URL = "MOODLE_BASE_URL";
46
51
  var MOODLE_SESSION_COOKIE_PREFIX = "MoodleSession";
@@ -236,7 +241,7 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
236
241
  await refreshSessionCache(baseUrl, oktaSession.cookie, oktaSession.context, options);
237
242
  return { baseUrl, cookie: oktaSession.cookie, ...oktaSession.context, fromCache: false };
238
243
  }
239
- if (!options.oktaCookieProvider && oktaCookies.length) {
244
+ if (!options.oktaCookieProvider && oktaCookies.length && !options.nonInteractive) {
240
245
  const refreshed = matchingMoodleSessionCookies(
241
246
  await loadSessionsFromOktaCli(baseUrl, { ...options, oktaCookieProvider: void 0, noCache: true }, true),
242
247
  baseUrl
@@ -286,7 +291,7 @@ async function loadSessionsFromOktaCli(baseUrl, options = {}, forceLogin = false
286
291
  return [];
287
292
  }
288
293
  const stored = await readOktaCookies(executable, baseUrl, execFile);
289
- if (stored.length && !forceLogin) {
294
+ if (stored.length && !forceLogin || options.nonInteractive) {
290
295
  return stored;
291
296
  }
292
297
  const login = await runOktaJson(executable, ["login", baseUrl], execFile);
@@ -304,6 +309,9 @@ function authFailureHint(baseUrl) {
304
309
  `okta-auth: ${OKTA_AUTH_URL}`
305
310
  ].join("\n");
306
311
  }
312
+ async function invalidateCachedSession(baseUrl, options = {}) {
313
+ await deleteCachedSession(baseUrl, cacheOptions(options));
314
+ }
307
315
  function parseSessionContext(html) {
308
316
  const sesskey = firstMatch(html, [
309
317
  /"sesskey"\s*:\s*"([^"]+)"/,
@@ -630,6 +638,75 @@ function isRecord2(value) {
630
638
  return !!value && typeof value === "object" && !Array.isArray(value);
631
639
  }
632
640
 
641
+ // src/html-utils.ts
642
+ import { parse } from "node-html-parser";
643
+ function htmlToStructuredContent(html, baseUrl) {
644
+ if (!html) {
645
+ return { text: "", image_urls: [], links: [], tables: [] };
646
+ }
647
+ const root = parse(html);
648
+ const image_urls = [];
649
+ const links = [];
650
+ const tables = [];
651
+ for (const br of root.querySelectorAll("br")) {
652
+ br.replaceWith("\n");
653
+ }
654
+ for (const img of root.querySelectorAll("img")) {
655
+ const src = (img.getAttribute("src") ?? "").trim();
656
+ if (!src) {
657
+ img.replaceWith("[image]");
658
+ continue;
659
+ }
660
+ const absolute = resolveUrl(baseUrl, src);
661
+ image_urls.push(absolute);
662
+ const label = (img.getAttribute("alt") ?? "").trim() || "image";
663
+ img.replaceWith(`[${label}] ${absolute}`);
664
+ }
665
+ for (const link of root.querySelectorAll("a[href]")) {
666
+ const href = (link.getAttribute("href") ?? "").trim();
667
+ if (!href) {
668
+ continue;
669
+ }
670
+ links.push({ text: cleanText(link.textContent), url: resolveUrl(baseUrl, href) });
671
+ }
672
+ for (const table of root.querySelectorAll("table")) {
673
+ const headers = [];
674
+ const rows = [];
675
+ for (const row of table.querySelectorAll("tr")) {
676
+ const headerCells = row.querySelectorAll("th");
677
+ const dataCells = row.querySelectorAll("td");
678
+ const cells = headerCells.length ? headerCells : dataCells;
679
+ if (!cells.length) {
680
+ continue;
681
+ }
682
+ const values = cells.map((cell) => cleanText(cell.textContent));
683
+ if (headerCells.length && !headers.length && !rows.length) {
684
+ headers.push(...values);
685
+ } else {
686
+ rows.push(values);
687
+ }
688
+ }
689
+ if (headers.length || rows.length) {
690
+ tables.push({ headers, rows });
691
+ }
692
+ }
693
+ const text = root.textContent.split(/\r?\n/).map((line) => cleanText(line)).filter(Boolean).join("\n");
694
+ return { text, image_urls, links, tables };
695
+ }
696
+ function cleanText(value) {
697
+ return decodeHtml2(value ?? "").replace(/\s+/g, " ").trim();
698
+ }
699
+ function resolveUrl(baseUrl, href) {
700
+ try {
701
+ return new URL(href, baseUrl).toString();
702
+ } catch {
703
+ return href;
704
+ }
705
+ }
706
+ function decodeHtml2(value) {
707
+ return value.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
708
+ }
709
+
633
710
  // src/parsers.ts
634
711
  function schema(parser) {
635
712
  return { parse: parser };
@@ -767,18 +844,20 @@ function parseForumPostAuthor(value) {
767
844
  profile_image_url: stringValue(urls.profileimage)
768
845
  };
769
846
  }
770
- function parseForumPost(value) {
847
+ function parseForumPost(value, baseUrl = "") {
771
848
  const data = asRecord(value);
772
849
  const urls = asRecord(data.urls);
850
+ const messageHtml = stringValue(data.message);
851
+ const structured = htmlToStructuredContent(messageHtml, stringValue(urls.view || urls.discuss) || baseUrl);
773
852
  return {
774
853
  id: numberValue(data.id),
775
854
  discussion_id: numberValue(data.discussionid),
776
855
  subject: stringValue(data.subject),
777
- message_html: stringValue(data.message),
778
- message_text: stringValue(data.message).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(),
779
- image_urls: [],
780
- links: [],
781
- tables: [],
856
+ message_html: messageHtml,
857
+ message_text: structured.text,
858
+ image_urls: structured.image_urls,
859
+ links: structured.links,
860
+ tables: structured.tables,
782
861
  author: parseForumPostAuthor(data.author),
783
862
  parent_id: numberValue(data.parentid),
784
863
  time_created: numberValue(data.timecreated),
@@ -791,9 +870,9 @@ function parseForumPost(value) {
791
870
  reply_url: stringValue(urls.reply)
792
871
  };
793
872
  }
794
- function parseForumDiscussion(value, discussionId) {
873
+ function parseForumDiscussion(value, discussionId, baseUrl = "") {
795
874
  const data = asRecord(value);
796
- const posts = asArray(data.posts).map((item) => parseForumPost(item));
875
+ const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
797
876
  return {
798
877
  id: discussionId,
799
878
  subject: posts[0]?.subject ?? "",
@@ -832,77 +911,6 @@ function booleanValue(value, defaultValue = false) {
832
911
 
833
912
  // src/scraper.ts
834
913
  import { parse as parse2 } from "node-html-parser";
835
-
836
- // src/html-utils.ts
837
- import { parse } from "node-html-parser";
838
- function htmlToStructuredContent(html, baseUrl) {
839
- if (!html) {
840
- return { text: "", image_urls: [], links: [], tables: [] };
841
- }
842
- const root = parse(html);
843
- const image_urls = [];
844
- const links = [];
845
- const tables = [];
846
- for (const br of root.querySelectorAll("br")) {
847
- br.replaceWith("\n");
848
- }
849
- for (const img of root.querySelectorAll("img")) {
850
- const src = (img.getAttribute("src") ?? "").trim();
851
- if (!src) {
852
- img.replaceWith("[image]");
853
- continue;
854
- }
855
- const absolute = resolveUrl(baseUrl, src);
856
- image_urls.push(absolute);
857
- const label = (img.getAttribute("alt") ?? "").trim() || "image";
858
- img.replaceWith(`[${label}] ${absolute}`);
859
- }
860
- for (const link of root.querySelectorAll("a[href]")) {
861
- const href = (link.getAttribute("href") ?? "").trim();
862
- if (!href) {
863
- continue;
864
- }
865
- links.push({ text: cleanText(link.textContent), url: resolveUrl(baseUrl, href) });
866
- }
867
- for (const table of root.querySelectorAll("table")) {
868
- const headers = [];
869
- const rows = [];
870
- for (const row of table.querySelectorAll("tr")) {
871
- const headerCells = row.querySelectorAll("th");
872
- const dataCells = row.querySelectorAll("td");
873
- const cells = headerCells.length ? headerCells : dataCells;
874
- if (!cells.length) {
875
- continue;
876
- }
877
- const values = cells.map((cell) => cleanText(cell.textContent));
878
- if (headerCells.length && !headers.length && !rows.length) {
879
- headers.push(...values);
880
- } else {
881
- rows.push(values);
882
- }
883
- }
884
- if (headers.length || rows.length) {
885
- tables.push({ headers, rows });
886
- }
887
- }
888
- const text = root.textContent.split(/\r?\n/).map((line) => cleanText(line)).filter(Boolean).join("\n");
889
- return { text, image_urls, links, tables };
890
- }
891
- function cleanText(value) {
892
- return decodeHtml2(value ?? "").replace(/\s+/g, " ").trim();
893
- }
894
- function resolveUrl(baseUrl, href) {
895
- try {
896
- return new URL(href, baseUrl).toString();
897
- } catch {
898
- return href;
899
- }
900
- }
901
- function decodeHtml2(value) {
902
- return value.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
903
- }
904
-
905
- // src/scraper.ts
906
914
  function parsePageContext(html, baseUrl) {
907
915
  const root = parse2(html);
908
916
  const config = parseMoodleConfig(html);
@@ -1422,33 +1430,453 @@ function cleanTableCell(node) {
1422
1430
  if (!node) {
1423
1431
  return "";
1424
1432
  }
1425
- const clone = parse2(node.toString());
1426
- for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
1427
- unwanted.remove();
1433
+ const clone = parse2(node.toString());
1434
+ for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
1435
+ unwanted.remove();
1436
+ }
1437
+ return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
1438
+ }
1439
+ function numberQueryValue(href, key) {
1440
+ try {
1441
+ return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
1442
+ } catch {
1443
+ return null;
1444
+ }
1445
+ }
1446
+ function numberValue2(value) {
1447
+ if (typeof value === "number" && Number.isFinite(value)) {
1448
+ return value;
1449
+ }
1450
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
1451
+ return Number(value);
1452
+ }
1453
+ return 0;
1454
+ }
1455
+ function stringValue2(value) {
1456
+ return typeof value === "string" ? value : value == null ? "" : String(value);
1457
+ }
1458
+ function unique(items) {
1459
+ return [...new Set(items)];
1460
+ }
1461
+
1462
+ // src/forum.ts
1463
+ var ForumModule = class {
1464
+ baseUrl;
1465
+ callMoodle;
1466
+ loadPage;
1467
+ loadCourses;
1468
+ loadCourseContents;
1469
+ forumDiscussionCache = /* @__PURE__ */ new Map();
1470
+ forumDiscussionRefsCache = /* @__PURE__ */ new Map();
1471
+ constructor(options) {
1472
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
1473
+ this.callMoodle = options.call;
1474
+ this.loadPage = options.getPage;
1475
+ this.loadCourses = options.getCourses;
1476
+ this.loadCourseContents = options.getCourseContents;
1477
+ }
1478
+ async getForumDiscussion(discussionId) {
1479
+ const cached = this.forumDiscussionCache.get(discussionId);
1480
+ if (cached) {
1481
+ return cached;
1482
+ }
1483
+ try {
1484
+ const data = await this.callMoodle(FUNC_GET_DISCUSSION_POSTS, {
1485
+ discussionid: discussionId,
1486
+ sortby: "created",
1487
+ sortdirection: "ASC",
1488
+ includeinlineattachments: true
1489
+ });
1490
+ const discussion2 = parseForumDiscussion(data, discussionId, this.baseUrl);
1491
+ if (discussion2.group_id <= 0) {
1492
+ const html2 = await this.loadPage(FORUM_DISCUSS_PATH, { d: discussionId }).catch(() => "");
1493
+ if (html2) {
1494
+ const [groupId, groupName] = parseForumDiscussionGroupHtml(html2);
1495
+ discussion2.group_id = groupId;
1496
+ discussion2.group_name = groupName;
1497
+ }
1498
+ }
1499
+ this.forumDiscussionCache.set(discussionId, discussion2);
1500
+ return discussion2;
1501
+ } catch (error) {
1502
+ if (!shouldFallbackForumAjax(error)) {
1503
+ throw error;
1504
+ }
1505
+ }
1506
+ const html = await this.loadPage(FORUM_DISCUSS_PATH, { d: discussionId });
1507
+ const discussion = parseForumDiscussionHtml(html, this.baseUrl, discussionId);
1508
+ this.forumDiscussionCache.set(discussionId, discussion);
1509
+ return discussion;
1510
+ }
1511
+ async getForumViewCmid(discussionId) {
1512
+ const html = await this.loadPage(FORUM_DISCUSS_PATH, { d: discussionId });
1513
+ return parseForumViewCmidFromDiscussionHtml(html);
1514
+ }
1515
+ async getForumDiscussionRefs(forumCmid) {
1516
+ const cached = this.forumDiscussionRefsCache.get(forumCmid);
1517
+ if (cached) {
1518
+ return cached;
1519
+ }
1520
+ const html = await this.loadPage(FORUM_VIEW_PATH, { id: forumCmid });
1521
+ const groups = parseForumGroupsHtml(html);
1522
+ const refs = groups.length ? [] : parseForumDiscussionRefsHtml(html, this.baseUrl);
1523
+ const seenIds = new Set(refs.map((ref) => ref.id));
1524
+ for (const [groupId, groupName] of groups) {
1525
+ const groupHtml = await this.loadPage(FORUM_VIEW_PATH, { id: forumCmid, group: groupId });
1526
+ for (const ref of parseForumDiscussionRefsHtml(groupHtml, this.baseUrl)) {
1527
+ if (seenIds.has(ref.id)) {
1528
+ continue;
1529
+ }
1530
+ seenIds.add(ref.id);
1531
+ refs.push({ ...ref, group_id: groupId, group_name: groupName });
1532
+ }
1533
+ }
1534
+ this.forumDiscussionRefsCache.set(forumCmid, refs);
1535
+ return refs;
1536
+ }
1537
+ async getCourseForums(courseId, courseName = "") {
1538
+ if (!this.loadCourseContents) {
1539
+ throw new Error("getCourseContents loader is required to list course forums");
1540
+ }
1541
+ const sections = await this.loadCourseContents(courseId);
1542
+ return sections.flatMap(
1543
+ (section) => section.activities.filter((activity) => activity.modname === "forum").map((activity) => ({
1544
+ id: activity.id,
1545
+ name: activity.name,
1546
+ course_id: courseId,
1547
+ course_name: courseName,
1548
+ url: activity.url
1549
+ }))
1550
+ );
1551
+ }
1552
+ async getForums(courseId) {
1553
+ if (courseId !== void 0) {
1554
+ const courseName = await this.courseName(courseId);
1555
+ return this.getCourseForums(courseId, courseName);
1556
+ }
1557
+ if (!this.loadCourses) {
1558
+ throw new Error("getCourses loader is required to list all forums");
1559
+ }
1560
+ const forums = [];
1561
+ for (const course of await this.loadCourses()) {
1562
+ forums.push(...await this.getCourseForums(course.id, course.fullname || course.shortname));
1563
+ }
1564
+ return forums;
1565
+ }
1566
+ async courseName(courseId) {
1567
+ if (!this.loadCourses) {
1568
+ return "";
1569
+ }
1570
+ const course = (await this.loadCourses()).find((item) => item.id === courseId);
1571
+ return course ? course.fullname || course.shortname : "";
1572
+ }
1573
+ };
1574
+ function parseDiscussionReference(value) {
1575
+ const raw = value.trim();
1576
+ if (/^\d+$/.test(raw)) {
1577
+ return { discussionId: Number(raw), postId: null };
1578
+ }
1579
+ let url;
1580
+ try {
1581
+ url = new URL(raw);
1582
+ } catch {
1583
+ throw new UsageError("DISCUSSION must be a numeric ID or a full discuss.php URL.");
1584
+ }
1585
+ const discussionValue = url.searchParams.get("d");
1586
+ if (!discussionValue || !/^\d+$/.test(discussionValue)) {
1587
+ throw new UsageError("Could not find discussion ID in URL query (expected ?d=...).");
1588
+ }
1589
+ const fragment = url.hash.replace(/^#/, "");
1590
+ const postId = /^p\d+$/.test(fragment) ? Number(fragment.slice(1)) : null;
1591
+ return { discussionId: Number(discussionValue), postId };
1592
+ }
1593
+ async function parseForumReference(value, resolveForumCmid) {
1594
+ const raw = value.trim();
1595
+ if (/^\d+$/.test(raw)) {
1596
+ return Number(raw);
1597
+ }
1598
+ let url;
1599
+ try {
1600
+ url = new URL(raw);
1601
+ } catch {
1602
+ throw new UsageError("FORUM must be a numeric ID or a full forum URL.");
1603
+ }
1604
+ if (url.pathname.endsWith("/mod/forum/view.php")) {
1605
+ const forumValue = url.searchParams.get("id");
1606
+ if (!forumValue || !/^\d+$/.test(forumValue)) {
1607
+ throw new UsageError("Could not find forum module ID in view.php URL (expected ?id=...).");
1608
+ }
1609
+ return Number(forumValue);
1610
+ }
1611
+ if (url.pathname.endsWith("/mod/forum/discuss.php")) {
1612
+ const discussionValue = url.searchParams.get("d");
1613
+ if (!discussionValue || !/^\d+$/.test(discussionValue)) {
1614
+ throw new UsageError("Could not find discussion ID in discuss.php URL (expected ?d=...).");
1615
+ }
1616
+ if (!resolveForumCmid) {
1617
+ throw new UsageError("A discuss.php URL needs a resolver to find the forum ID.");
1618
+ }
1619
+ const forumCmid = await resolveForumCmid(Number(discussionValue));
1620
+ if (!forumCmid) {
1621
+ throw new NotFoundError("Could not resolve forum ID from the discussion page.");
1622
+ }
1623
+ return forumCmid;
1624
+ }
1625
+ throw new UsageError("Unsupported forum URL. Use a view.php?id=... or discuss.php?d=... URL.");
1626
+ }
1627
+ function filterDiscussionToPost(discussion, postId) {
1628
+ if (postId == null) {
1629
+ return discussion;
1630
+ }
1631
+ const posts = discussion.posts.filter((post) => post.id === postId);
1632
+ if (!posts.length) {
1633
+ throw new NotFoundError(`Post ${postId} was not found in discussion ${discussion.id}.`);
1634
+ }
1635
+ return { ...discussion, posts };
1636
+ }
1637
+ function shouldFallbackForumAjax(error) {
1638
+ if (!(error instanceof MoodleAPIError)) {
1639
+ return false;
1640
+ }
1641
+ return error.moodleErrorCode === "servicenotavailable" || error.moodleErrorCode === "accessexception" || error.message.includes("Web service is not available");
1642
+ }
1643
+
1644
+ // src/forum-search.ts
1645
+ async function searchForumContent(source, query, options = {}) {
1646
+ const cleanedQuery = query.trim();
1647
+ if (!cleanedQuery) {
1648
+ return [];
1649
+ }
1650
+ const includePostText = options.includePostText ?? true;
1651
+ const unreadOnly = options.unreadOnly ?? false;
1652
+ const sortBy = options.sortBy ?? "relevance";
1653
+ let forumRefs = await source.getForums(options.courseId);
1654
+ if (options.forumCmid !== void 0) {
1655
+ forumRefs = forumRefs.filter((ref) => ref.id === options.forumCmid);
1656
+ if (!forumRefs.length) {
1657
+ forumRefs = [
1658
+ {
1659
+ id: options.forumCmid,
1660
+ name: "",
1661
+ course_id: 0,
1662
+ course_name: "",
1663
+ url: `${(options.baseUrl ?? "").replace(/\/$/, "")}/mod/forum/view.php?id=${options.forumCmid}`
1664
+ }
1665
+ ];
1666
+ }
1667
+ } else if (options.maxForums !== void 0) {
1668
+ forumRefs = forumRefs.slice(0, options.maxForums);
1669
+ }
1670
+ const hits = [];
1671
+ const seen = /* @__PURE__ */ new Set();
1672
+ for (const forumRef of forumRefs) {
1673
+ let refs = await source.getForumDiscussionRefs(forumRef.id);
1674
+ if (options.maxDiscussionsPerForum !== void 0) {
1675
+ refs = refs.slice(0, options.maxDiscussionsPerForum);
1676
+ }
1677
+ for (const ref of refs) {
1678
+ let discussion = null;
1679
+ let latestPost = null;
1680
+ let discussionHasUnread = false;
1681
+ const matchingPostHits = [];
1682
+ if (includePostText || unreadOnly || sortBy === "recent") {
1683
+ discussion = await source.getForumDiscussion(ref.id);
1684
+ if (discussion.posts.length) {
1685
+ latestPost = discussion.posts.reduce((latest, post) => (post.time_created || 0) > (latest.time_created || 0) ? post : latest);
1686
+ discussionHasUnread = discussion.posts.some((post) => post.unread);
1687
+ }
1688
+ }
1689
+ if (!includePostText) {
1690
+ addSubjectHit({
1691
+ hits,
1692
+ seen,
1693
+ score: matchScore(ref.subject, cleanedQuery),
1694
+ query: cleanedQuery,
1695
+ ref,
1696
+ forumRef,
1697
+ discussionHasUnread,
1698
+ unreadOnly,
1699
+ latestPost
1700
+ });
1701
+ continue;
1702
+ }
1703
+ discussion ??= await source.getForumDiscussion(ref.id);
1704
+ for (const post of discussion.posts) {
1705
+ const postSubjectScore = matchScore(post.subject, cleanedQuery);
1706
+ const postBodyScore = matchScore(post.message_text, cleanedQuery);
1707
+ if (postSubjectScore <= 0 && postBodyScore <= 0) {
1708
+ continue;
1709
+ }
1710
+ if (unreadOnly && !post.unread) {
1711
+ continue;
1712
+ }
1713
+ const matchedIn = postSubjectScore >= postBodyScore ? "post_subject" : "post_body";
1714
+ const matchedText = matchedIn === "post_subject" ? post.subject : post.message_text;
1715
+ matchingPostHits.push([
1716
+ 300 + Math.max(postSubjectScore, postBodyScore),
1717
+ {
1718
+ course_id: forumRef.course_id,
1719
+ course_name: forumRef.course_name,
1720
+ forum_id: forumRef.id,
1721
+ forum_name: forumRef.name,
1722
+ group_id: discussion.group_id || ref.group_id,
1723
+ group_name: discussion.group_name || ref.group_name,
1724
+ discussion_id: ref.id,
1725
+ discussion_subject: discussion.subject || ref.subject,
1726
+ post_id: post.id,
1727
+ author_name: post.author.fullname,
1728
+ matched_in: matchedIn,
1729
+ snippet: snippetForText(matchedText, cleanedQuery),
1730
+ unread: post.unread,
1731
+ time_created: post.time_created,
1732
+ url: post.url || ref.url
1733
+ }
1734
+ ]);
1735
+ }
1736
+ if (matchingPostHits.length) {
1737
+ for (const [score, hit] of matchingPostHits) {
1738
+ const key = hitKey(hit.discussion_id, hit.post_id);
1739
+ if (seen.has(key)) {
1740
+ continue;
1741
+ }
1742
+ seen.add(key);
1743
+ hits.push([score, hit]);
1744
+ }
1745
+ continue;
1746
+ }
1747
+ addSubjectHit({
1748
+ hits,
1749
+ seen,
1750
+ score: matchScore(ref.subject, cleanedQuery),
1751
+ query: cleanedQuery,
1752
+ ref,
1753
+ forumRef,
1754
+ discussionHasUnread,
1755
+ unreadOnly,
1756
+ latestPost
1757
+ });
1758
+ }
1759
+ }
1760
+ hits.sort(sortBy === "recent" ? sortRecent : sortRelevant);
1761
+ return hits.slice(0, options.limit ?? 20).map(([, hit]) => hit);
1762
+ }
1763
+ async function checkForumDiscussions(source, forumCmid, limit = 20) {
1764
+ const refs = (await source.getForumDiscussionRefs(forumCmid)).slice(0, limit);
1765
+ const results = [];
1766
+ for (const ref of refs) {
1767
+ try {
1768
+ const discussion = await source.getForumDiscussion(ref.id);
1769
+ results.push({
1770
+ discussion_id: ref.id,
1771
+ subject: ref.subject,
1772
+ ok: true,
1773
+ posts: discussion.posts.length,
1774
+ images: discussion.posts.reduce((count, post) => count + post.image_urls.length, 0)
1775
+ });
1776
+ } catch (error) {
1777
+ results.push({
1778
+ discussion_id: ref.id,
1779
+ subject: ref.subject,
1780
+ ok: false,
1781
+ error: error instanceof Error ? error.message : String(error)
1782
+ });
1783
+ }
1784
+ }
1785
+ return results;
1786
+ }
1787
+ function normalizeQuery(value) {
1788
+ const normalized = value.toLowerCase().split(/\s+/).filter(Boolean).join(" ");
1789
+ return { normalized, tokens: normalized ? normalized.split(/\s+/) : [] };
1790
+ }
1791
+ function matchScore(text, query) {
1792
+ const haystack = text.toLowerCase().split(/\s+/).filter(Boolean).join(" ");
1793
+ if (!haystack) {
1794
+ return 0;
1795
+ }
1796
+ const { normalized, tokens } = normalizeQuery(query);
1797
+ if (!normalized) {
1798
+ return 0;
1799
+ }
1800
+ if (haystack.includes(normalized)) {
1801
+ return 100 + normalized.length;
1802
+ }
1803
+ if (tokens.length && tokens.every((token) => haystack.includes(token))) {
1804
+ return 60 + tokens.length;
1805
+ }
1806
+ return 0;
1807
+ }
1808
+ function snippetForText(text, query, maxLen = 120) {
1809
+ const cleaned = text.split(/\s+/).filter(Boolean).join(" ");
1810
+ if (!cleaned) {
1811
+ return "";
1812
+ }
1813
+ const { normalized, tokens } = normalizeQuery(query);
1814
+ const lower = cleaned.toLowerCase();
1815
+ let start = normalized ? lower.indexOf(normalized) : -1;
1816
+ if (start < 0) {
1817
+ for (const token of tokens) {
1818
+ start = lower.indexOf(token);
1819
+ if (start >= 0) {
1820
+ break;
1821
+ }
1822
+ }
1823
+ }
1824
+ if (start < 0 || cleaned.length <= maxLen) {
1825
+ return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
1826
+ }
1827
+ const half = Math.floor(maxLen / 2);
1828
+ const left = Math.max(0, start - half);
1829
+ const right = Math.min(cleaned.length, left + maxLen);
1830
+ let snippet = cleaned.slice(left, right);
1831
+ if (left > 0) {
1832
+ snippet = `\u2026${snippet}`;
1428
1833
  }
1429
- return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
1430
- }
1431
- function numberQueryValue(href, key) {
1432
- try {
1433
- return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
1434
- } catch {
1435
- return null;
1834
+ if (right < cleaned.length) {
1835
+ snippet = `${snippet}\u2026`;
1436
1836
  }
1837
+ return snippet;
1437
1838
  }
1438
- function numberValue2(value) {
1439
- if (typeof value === "number" && Number.isFinite(value)) {
1440
- return value;
1839
+ function addSubjectHit(args) {
1840
+ if (args.score <= 0 || args.unreadOnly && !args.discussionHasUnread) {
1841
+ return;
1441
1842
  }
1442
- if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
1443
- return Number(value);
1843
+ const key = hitKey(args.ref.id, 0);
1844
+ if (args.seen.has(key)) {
1845
+ return;
1444
1846
  }
1445
- return 0;
1847
+ args.seen.add(key);
1848
+ args.hits.push([
1849
+ 400 + args.score,
1850
+ {
1851
+ course_id: args.forumRef.course_id,
1852
+ course_name: args.forumRef.course_name,
1853
+ forum_id: args.forumRef.id,
1854
+ forum_name: args.forumRef.name,
1855
+ group_id: args.ref.group_id,
1856
+ group_name: args.ref.group_name,
1857
+ discussion_id: args.ref.id,
1858
+ discussion_subject: args.ref.subject,
1859
+ post_id: 0,
1860
+ author_name: "",
1861
+ matched_in: "discussion_subject",
1862
+ snippet: snippetForText(args.ref.subject, args.query),
1863
+ unread: args.discussionHasUnread,
1864
+ time_created: args.latestPost?.time_created ?? 0,
1865
+ url: args.ref.url
1866
+ }
1867
+ ]);
1446
1868
  }
1447
- function stringValue2(value) {
1448
- return typeof value === "string" ? value : value == null ? "" : String(value);
1869
+ function sortRecent(a, b) {
1870
+ return (b[1].time_created || 0) - (a[1].time_created || 0) || b[0] - a[0] || compareText(a[1].course_name, b[1].course_name) || compareText(a[1].forum_name, b[1].forum_name) || a[1].discussion_id - b[1].discussion_id || a[1].post_id - b[1].post_id;
1449
1871
  }
1450
- function unique(items) {
1451
- return [...new Set(items)];
1872
+ function sortRelevant(a, b) {
1873
+ return b[0] - a[0] || compareText(a[1].course_name, b[1].course_name) || compareText(a[1].forum_name, b[1].forum_name) || a[1].discussion_id - b[1].discussion_id || a[1].post_id - b[1].post_id;
1874
+ }
1875
+ function compareText(a, b) {
1876
+ return a.toLowerCase().localeCompare(b.toLowerCase());
1877
+ }
1878
+ function hitKey(discussionId, postId) {
1879
+ return `${discussionId}:${postId}`;
1452
1880
  }
1453
1881
 
1454
1882
  // src/client.ts
@@ -1472,8 +1900,7 @@ var MoodleClient = class {
1472
1900
  cacheOptions;
1473
1901
  onLoginRequired;
1474
1902
  retryingLogin = false;
1475
- forumDiscussions = /* @__PURE__ */ new Map();
1476
- forumRefs = /* @__PURE__ */ new Map();
1903
+ forum;
1477
1904
  constructor(baseUrl, options) {
1478
1905
  this.baseUrl = baseUrl.replace(/\/$/, "");
1479
1906
  const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
@@ -1484,22 +1911,45 @@ var MoodleClient = class {
1484
1911
  this.userInfo = resolvedOptions.pageContext?.user_info ?? null;
1485
1912
  this.cacheOptions = resolvedOptions.cacheOptions;
1486
1913
  this.onLoginRequired = resolvedOptions.onLoginRequired;
1914
+ this.forum = new ForumModule({
1915
+ baseUrl: this.baseUrl,
1916
+ call: async (functionName, args) => {
1917
+ await this.ensureSession();
1918
+ return this.call(functionName, args);
1919
+ },
1920
+ getPage: (path3, params) => this.get(path3, params),
1921
+ getCourses: () => this.getCourses(),
1922
+ getCourseContents: (courseId) => this.getCourseContents(courseId)
1923
+ });
1487
1924
  }
1488
1925
  async getSiteInfo() {
1489
1926
  await this.ensureSession();
1490
- const data = await this.call(FUNC_GET_SITE_INFO);
1491
- if (!isRecord3(data) || !("userid" in data)) {
1492
- if (this.userInfo) {
1493
- return this.userInfo;
1927
+ try {
1928
+ const data = await this.call(FUNC_GET_SITE_INFO);
1929
+ if (isRecord3(data) && "userid" in data) {
1930
+ const info = parseUserInfo(data);
1931
+ this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
1932
+ this.userid = info.userid;
1933
+ this.userInfo = info;
1934
+ await this.writeCache();
1935
+ return info;
1936
+ }
1937
+ } catch (error) {
1938
+ if (!(error instanceof MoodleAPIError) || error.moodleErrorCode !== "servicenotavailable") {
1939
+ throw error;
1494
1940
  }
1495
- throw new NotFoundError("Session appears invalid: could not retrieve user info");
1496
1941
  }
1497
- const info = parseUserInfo(data);
1498
- this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
1499
- this.userid = info.userid;
1500
- this.userInfo = info;
1942
+ if (this.userInfo?.fullname) {
1943
+ return this.userInfo;
1944
+ }
1945
+ const html = await this.get(DASHBOARD_PATH);
1946
+ const context = parsePageContext(html, this.baseUrl);
1947
+ if (!context.user_info.fullname && this.userInfo) {
1948
+ return this.userInfo;
1949
+ }
1950
+ this.applyContext(context);
1501
1951
  await this.writeCache();
1502
- return info;
1952
+ return context.user_info;
1503
1953
  }
1504
1954
  async getCourses() {
1505
1955
  await this.ensureSession();
@@ -1678,174 +2128,23 @@ var MoodleClient = class {
1678
2128
  return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id }), id, this.baseUrl);
1679
2129
  }
1680
2130
  async getForumDiscussion(discussionId) {
1681
- const cached = this.forumDiscussions.get(discussionId);
1682
- if (cached) {
1683
- return cached;
1684
- }
1685
- await this.ensureSession();
1686
- try {
1687
- const data = await this.call(FUNC_GET_DISCUSSION_POSTS, {
1688
- discussionid: discussionId,
1689
- sortby: "created",
1690
- sortdirection: "ASC",
1691
- includeinlineattachments: true
1692
- });
1693
- const discussion2 = parseForumDiscussion(data, discussionId);
1694
- if (discussion2.group_id <= 0) {
1695
- try {
1696
- const html = await this.get(FORUM_DISCUSS_PATH, { d: discussionId });
1697
- [discussion2.group_id, discussion2.group_name] = parseForumDiscussionGroupHtml(html);
1698
- } catch {
1699
- }
1700
- }
1701
- this.forumDiscussions.set(discussionId, discussion2);
1702
- return discussion2;
1703
- } catch (error) {
1704
- if (!(error instanceof MoodleAPIError) || !["servicenotavailable", "accessexception"].includes(error.moodleErrorCode ?? "")) {
1705
- throw error;
1706
- }
1707
- }
1708
- const discussion = parseForumDiscussionHtml(await this.get(FORUM_DISCUSS_PATH, { d: discussionId }), this.baseUrl, discussionId);
1709
- this.forumDiscussions.set(discussionId, discussion);
1710
- return discussion;
2131
+ return this.forum.getForumDiscussion(discussionId);
1711
2132
  }
1712
2133
  async getForumViewCmid(discussionId) {
1713
- return parseForumViewCmidFromDiscussionHtml(await this.get(FORUM_DISCUSS_PATH, { d: discussionId }));
2134
+ return this.forum.getForumViewCmid(discussionId);
1714
2135
  }
1715
2136
  async resolveCourseIdForUrl(url) {
1716
2137
  return parseCourseIdFromPageHtml(await this.getAbsolute(url));
1717
2138
  }
1718
2139
  async getForumDiscussionRefs(forumCmid) {
1719
- const cached = this.forumRefs.get(forumCmid);
1720
- if (cached) {
1721
- return cached;
1722
- }
1723
- const rootHtml = await this.get(FORUM_VIEW_PATH, { id: forumCmid });
1724
- const groups = parseForumGroupsHtml(rootHtml);
1725
- const refs = groups.length ? [] : parseForumDiscussionRefsHtml(rootHtml, this.baseUrl);
1726
- const seen = new Set(refs.map((ref) => ref.id));
1727
- for (const [groupId, groupName] of groups) {
1728
- const html = await this.get(FORUM_VIEW_PATH, { id: forumCmid, group: groupId });
1729
- for (const ref of parseForumDiscussionRefsHtml(html, this.baseUrl)) {
1730
- if (seen.has(ref.id)) {
1731
- continue;
1732
- }
1733
- ref.group_id = groupId;
1734
- ref.group_name = groupName;
1735
- seen.add(ref.id);
1736
- refs.push(ref);
1737
- }
1738
- }
1739
- this.forumRefs.set(forumCmid, refs);
1740
- return refs;
1741
- }
1742
- async getCourseForums(courseId, courseName = "") {
1743
- const sections = await this.getCourseContents(courseId);
1744
- return sections.flatMap(
1745
- (section) => section.activities.filter((activity) => activity.modname === "forum").map((activity) => ({
1746
- id: activity.id,
1747
- name: activity.name,
1748
- course_id: courseId,
1749
- course_name: courseName,
1750
- url: activity.url
1751
- }))
1752
- );
2140
+ return this.forum.getForumDiscussionRefs(forumCmid);
1753
2141
  }
1754
2142
  async getForums(courseId) {
1755
- if (courseId !== void 0) {
1756
- const courseName = (await this.getCourses()).find((course) => course.id === courseId);
1757
- return this.getCourseForums(courseId, courseName?.fullname || courseName?.shortname || "");
1758
- }
1759
- const refs = [];
1760
- for (const course of await this.getCourses()) {
1761
- refs.push(...await this.getCourseForums(course.id, course.fullname || course.shortname));
1762
- }
1763
- return refs;
2143
+ return this.forum.getForums(courseId);
1764
2144
  }
1765
2145
  async searchForumContent(options) {
1766
- const query = options.query.trim();
1767
- if (!query) {
1768
- return [];
1769
- }
1770
- let forums = await this.getForums(options.courseId);
1771
- if (options.forumCmid !== void 0) {
1772
- forums = forums.filter((forum) => forum.id === options.forumCmid);
1773
- if (!forums.length) {
1774
- forums = [{ id: options.forumCmid, name: "", course_id: 0, course_name: "", url: `${this.baseUrl}${FORUM_VIEW_PATH}?id=${options.forumCmid}` }];
1775
- }
1776
- } else if (options.maxForums !== void 0) {
1777
- forums = forums.slice(0, options.maxForums);
1778
- }
1779
- const hits = [];
1780
- const seen = /* @__PURE__ */ new Set();
1781
- for (const forum of forums) {
1782
- let refs = await this.getForumDiscussionRefs(forum.id);
1783
- if (options.maxDiscussionsPerForum !== void 0) {
1784
- refs = refs.slice(0, options.maxDiscussionsPerForum);
1785
- }
1786
- for (const ref of refs) {
1787
- let discussion = null;
1788
- let latest = 0;
1789
- let discussionHasUnread = false;
1790
- if (options.includePostText !== false || options.unreadOnly || options.sortBy === "recent") {
1791
- discussion = await this.getForumDiscussion(ref.id);
1792
- latest = Math.max(0, ...discussion.posts.map((post) => post.time_created));
1793
- discussionHasUnread = discussion.posts.some((post) => post.unread);
1794
- }
1795
- if (options.includePostText === false) {
1796
- const score = matchScore(ref.subject, query);
1797
- if (score > 0 && (!options.unreadOnly || discussionHasUnread)) {
1798
- addHit(hits, seen, 400 + score, makeHit(forum, ref, { matched_in: "discussion_subject", snippet: snippetForText(ref.subject, query), unread: discussionHasUnread, time_created: latest }));
1799
- }
1800
- continue;
1801
- }
1802
- discussion ??= await this.getForumDiscussion(ref.id);
1803
- let postMatched = false;
1804
- for (const post of discussion.posts) {
1805
- const subjectScore = matchScore(post.subject, query);
1806
- const bodyScore = matchScore(post.message_text, query);
1807
- if (subjectScore <= 0 && bodyScore <= 0) {
1808
- continue;
1809
- }
1810
- if (options.unreadOnly && !post.unread) {
1811
- continue;
1812
- }
1813
- postMatched = true;
1814
- const matched_in = subjectScore >= bodyScore ? "post_subject" : "post_body";
1815
- const matchedText = matched_in === "post_subject" ? post.subject : post.message_text;
1816
- addHit(
1817
- hits,
1818
- seen,
1819
- 300 + Math.max(subjectScore, bodyScore),
1820
- makeHit(forum, ref, {
1821
- group_id: discussion.group_id || ref.group_id,
1822
- group_name: discussion.group_name || ref.group_name,
1823
- discussion_subject: discussion.subject || ref.subject,
1824
- post_id: post.id,
1825
- author_name: post.author.fullname,
1826
- matched_in,
1827
- snippet: snippetForText(matchedText, query),
1828
- unread: post.unread,
1829
- time_created: post.time_created,
1830
- url: post.url || ref.url
1831
- })
1832
- );
1833
- }
1834
- if (!postMatched) {
1835
- const score = matchScore(ref.subject, query);
1836
- if (score > 0 && (!options.unreadOnly || discussionHasUnread)) {
1837
- addHit(hits, seen, 400 + score, makeHit(forum, ref, { matched_in: "discussion_subject", snippet: snippetForText(ref.subject, query), unread: discussionHasUnread, time_created: latest }));
1838
- }
1839
- }
1840
- }
1841
- }
1842
- hits.sort((a, b) => {
1843
- if (options.sortBy === "recent") {
1844
- return b[1].time_created - a[1].time_created || b[0] - a[0] || compareHit(a[1], b[1]);
1845
- }
1846
- return b[0] - a[0] || compareHit(a[1], b[1]);
1847
- });
1848
- return hits.slice(0, options.limit ?? 20).map(([, hit]) => hit);
2146
+ const { query, ...searchOptions } = options;
2147
+ return searchForumContent(this.forum, query, { ...searchOptions, baseUrl: this.baseUrl });
1849
2148
  }
1850
2149
  async callBatch(requests) {
1851
2150
  await this.ensureSession();
@@ -2030,85 +2329,11 @@ function authToClientSession(auth) {
2030
2329
  }
2031
2330
  };
2032
2331
  }
2033
- function filterDiscussionToPost(discussion, postId) {
2034
- if (postId === null) {
2035
- return discussion;
2036
- }
2037
- const posts = discussion.posts.filter((post) => post.id === postId);
2038
- if (!posts.length) {
2039
- throw new NotFoundError(`Post ${postId} was not found in discussion ${discussion.id}.`);
2040
- }
2041
- return { ...discussion, posts };
2042
- }
2043
2332
  function queryMatches(text, query) {
2044
2333
  const haystack = text.toLowerCase().split(/\s+/).join(" ");
2045
2334
  const needle = query.toLowerCase().split(/\s+/).join(" ");
2046
2335
  return needle ? haystack.includes(needle) || needle.split(" ").every((token) => haystack.includes(token)) : true;
2047
2336
  }
2048
- function matchScore(text, query) {
2049
- const haystack = text.toLowerCase().split(/\s+/).join(" ");
2050
- const normalized = query.toLowerCase().split(/\s+/).join(" ");
2051
- const tokens = normalized.split(/\s+/).filter(Boolean);
2052
- if (!haystack || !normalized) {
2053
- return 0;
2054
- }
2055
- if (haystack.includes(normalized)) {
2056
- return 100 + normalized.length;
2057
- }
2058
- if (tokens.length && tokens.every((token) => haystack.includes(token))) {
2059
- return 60 + tokens.length;
2060
- }
2061
- return 0;
2062
- }
2063
- function snippetForText(text, query, maxLen = 120) {
2064
- const cleaned = text.split(/\s+/).join(" ").trim();
2065
- if (!cleaned || cleaned.length <= maxLen) {
2066
- return cleaned;
2067
- }
2068
- const normalized = query.toLowerCase().split(/\s+/).join(" ");
2069
- const lower = cleaned.toLowerCase();
2070
- let start = lower.indexOf(normalized);
2071
- if (start < 0) {
2072
- start = normalized.split(/\s+/).map((token) => lower.indexOf(token)).find((index) => index >= 0) ?? -1;
2073
- }
2074
- if (start < 0) {
2075
- return `${cleaned.slice(0, maxLen - 1)}...`;
2076
- }
2077
- const left = Math.max(0, start - Math.floor(maxLen / 2));
2078
- const right = Math.min(cleaned.length, left + maxLen);
2079
- return `${left > 0 ? "..." : ""}${cleaned.slice(left, right)}${right < cleaned.length ? "..." : ""}`;
2080
- }
2081
- function makeHit(forum, ref, override) {
2082
- return {
2083
- course_id: forum.course_id,
2084
- course_name: forum.course_name,
2085
- forum_id: forum.id,
2086
- forum_name: forum.name,
2087
- group_id: ref.group_id,
2088
- group_name: ref.group_name,
2089
- discussion_id: ref.id,
2090
- discussion_subject: ref.subject,
2091
- post_id: 0,
2092
- author_name: "",
2093
- matched_in: "",
2094
- snippet: "",
2095
- unread: false,
2096
- time_created: 0,
2097
- url: ref.url,
2098
- ...override
2099
- };
2100
- }
2101
- function addHit(hits, seen, score, hit) {
2102
- const key = `${hit.discussion_id}:${hit.post_id}`;
2103
- if (seen.has(key)) {
2104
- return;
2105
- }
2106
- seen.add(key);
2107
- hits.push([score, hit]);
2108
- }
2109
- function compareHit(a, b) {
2110
- return a.course_name.localeCompare(b.course_name) || a.forum_name.localeCompare(b.forum_name) || a.discussion_id - b.discussion_id || a.post_id - b.post_id;
2111
- }
2112
2337
  function isRecord3(value) {
2113
2338
  return !!value && typeof value === "object" && !Array.isArray(value);
2114
2339
  }
@@ -2456,6 +2681,43 @@ function formatForumCheckResults(forumCmid, rows) {
2456
2681
  }
2457
2682
  return lines.join("\n");
2458
2683
  }
2684
+ function formatAuthStatus(status) {
2685
+ return formatKeyValues([
2686
+ ["Site", status.base_url],
2687
+ ["Cached session", status.session_cached ? "yes" : "no"],
2688
+ ["Cache age", status.cache_age_minutes === null ? "" : `${status.cache_age_minutes} min`],
2689
+ ["Session alive", status.session_alive === null ? status.session_cached ? "unknown" : "" : status.session_alive ? "yes" : "no"],
2690
+ ["Server timeout in", formatDuration(status.session_time_remaining_seconds)],
2691
+ ["Keepalive agent", status.keepalive_installed ? `installed (${status.keepalive_plist_path})` : "not installed"]
2692
+ ]);
2693
+ }
2694
+ function formatKeepaliveResult(result) {
2695
+ switch (result.status) {
2696
+ case "renewed":
2697
+ return `Session renewed${result.time_remaining_seconds ? `; server timeout in ${formatDuration(result.time_remaining_seconds)}` : ""}`;
2698
+ case "reauthenticated":
2699
+ return "Session was expired; re-authenticated from browser/okta cookies";
2700
+ case "expired":
2701
+ return "Session expired and could not be renewed. Log in to Moodle in your browser or run: moodle auth login";
2702
+ case "no_session":
2703
+ return "No cached session to renew. Run any moodle command once, or: moodle auth login";
2704
+ case "unreachable":
2705
+ return "Could not reach the Moodle site; session state unchanged";
2706
+ }
2707
+ }
2708
+ function formatDuration(seconds) {
2709
+ if (seconds === null) {
2710
+ return "";
2711
+ }
2712
+ if (seconds < 90) {
2713
+ return `${seconds}s`;
2714
+ }
2715
+ const minutes = Math.round(seconds / 60);
2716
+ if (minutes < 90) {
2717
+ return `${minutes} min`;
2718
+ }
2719
+ return `${(minutes / 60).toFixed(1)} h`;
2720
+ }
2459
2721
  function preview(value, maxLen = 100) {
2460
2722
  const cleaned = value.split(/\s+/).filter(Boolean).join(" ");
2461
2723
  return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
@@ -2533,12 +2795,24 @@ function isPlainObject(value) {
2533
2795
 
2534
2796
  // src/skills.ts
2535
2797
  import { spawnSync } from "child_process";
2536
- import { readFileSync, writeFileSync } from "fs";
2798
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
2537
2799
  import path from "path";
2538
2800
  var SKILL_NAME = "moodle-cli";
2539
2801
  var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
2540
2802
  var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
2541
- var SKILL_DESCRIPTION = "Inspect Moodle data from the terminal with the `moodle` CLI. Use when an agent needs courses, deadlines, grades, alerts, activities, or forum discussions. Prefer JSON output for agent workflows.";
2803
+ var SKILL_DESCRIPTION = "Read Moodle data with the `moodle` CLI. Use for authenticated profile, course discovery, deadlines, alerts, sections, activities, grades, assignment or quiz detail, resources, forum search and discussions, supported Moodle URLs, authentication diagnostics, or CLI updates.";
2804
+ var SKILL_BUNDLE_TEMPLATES = [
2805
+ ["SKILL.md", "skill.template.md"],
2806
+ ["references/setup-and-auth.md", "skill-references/setup-and-auth.md"],
2807
+ ["references/profile-and-courses.md", "skill-references/profile-and-courses.md"],
2808
+ ["references/deadlines-and-alerts.md", "skill-references/deadlines-and-alerts.md"],
2809
+ ["references/coursework-and-grades.md", "skill-references/coursework-and-grades.md"],
2810
+ ["references/forums.md", "skill-references/forums.md"],
2811
+ ["references/output-and-errors.md", "skill-references/output-and-errors.md"],
2812
+ ["references/maintenance.md", "skill-references/maintenance.md"],
2813
+ ["references/command-reference.md", "skill-references/command-reference.md"],
2814
+ ["agents/openai.yaml", "skill-agents/openai.yaml"]
2815
+ ];
2542
2816
  function formatSkillSummary() {
2543
2817
  return [
2544
2818
  `Name: ${SKILL_NAME}`,
@@ -2547,7 +2821,7 @@ function formatSkillSummary() {
2547
2821
  `Spec: ${SKILLS_SPEC_URL}`,
2548
2822
  `Install: npx skills add ${SKILL_SOURCE}`,
2549
2823
  "CLI alias: moodle skills add (falls back to npm exec)",
2550
- "Generate: moodle skills generate"
2824
+ "Generate: moodle skills generate (writes SKILL.md, references/, and agents/)"
2551
2825
  ].join("\n");
2552
2826
  }
2553
2827
  function buildSkillsAddCommand(extraArgs = [], launcher = "npx") {
@@ -2585,15 +2859,15 @@ function extractCommanderCommands(program) {
2585
2859
  flags: row.flags
2586
2860
  }));
2587
2861
  }
2588
- function generateSkillMarkdown(input) {
2589
- if (isGenerateOptions(input)) {
2590
- return renderSkillMarkdown(input.commands, input.template);
2591
- }
2592
- const template = readSkillTemplate();
2593
- return renderSkillMarkdown(extractCommanderCommands(input), template);
2594
- }
2595
2862
  function writeGeneratedSkill(program, target = "SKILL.md") {
2596
- writeFileSync(target, generateSkillMarkdown(program), "utf8");
2863
+ const commands = extractCommanderCommands(program);
2864
+ const targetDir = path.dirname(target);
2865
+ for (const [relativeTarget, relativeTemplate] of SKILL_BUNDLE_TEMPLATES) {
2866
+ const outputPath = relativeTarget === "SKILL.md" ? target : path.join(targetDir, relativeTarget);
2867
+ mkdirSync(path.dirname(outputPath), { recursive: true });
2868
+ const template = readSkillTemplate(relativeTemplate);
2869
+ writeFileSync(outputPath, renderSkillMarkdown(commands, template), "utf8");
2870
+ }
2597
2871
  }
2598
2872
  function renderSkillMarkdown(commands, template) {
2599
2873
  const replacements = {
@@ -2667,6 +2941,8 @@ function renderIntentTable() {
2667
2941
  ["Show grades for a course", "moodle grades COURSE_ID --json"],
2668
2942
  ["Find the best forum match", "moodle forum find QUERY --json"],
2669
2943
  ["Open a forum discussion URL or ID", "moodle forum discussion DISCUSSION_OR_URL --json"],
2944
+ ["Check session freshness or authentication state", "moodle auth status --json"],
2945
+ ["Keep the session alive to avoid repeated logins", "moodle auth keepalive install"],
2670
2946
  ["Check whether the CLI has an update", "moodle update --json"],
2671
2947
  ["Install this agent skill", "moodle skills add"]
2672
2948
  ]
@@ -2751,11 +3027,8 @@ function isCommandAvailable(name, runCommand) {
2751
3027
  function isHiddenCommand(command) {
2752
3028
  return Boolean(command.hidden || command._hidden);
2753
3029
  }
2754
- function isGenerateOptions(value) {
2755
- return "template" in value && "commands" in value;
2756
- }
2757
- function readSkillTemplate() {
2758
- return readFileSync(path.join(process.cwd(), "src", "skill.template.md"), "utf8");
3030
+ function readSkillTemplate(relativePath = "skill.template.md") {
3031
+ return readFileSync(path.join(process.cwd(), "src", relativePath), "utf8");
2759
3032
  }
2760
3033
  function findLongFlag(flags) {
2761
3034
  return flags.split(/[,\s]+/).find((part) => part.startsWith("--")) ?? "";
@@ -2916,8 +3189,227 @@ function errorMessage(error) {
2916
3189
  return error instanceof Error ? error.message : String(error);
2917
3190
  }
2918
3191
 
3192
+ // src/keepalive.ts
3193
+ import { spawnSync as spawnSync3 } from "child_process";
3194
+ import { realpathSync } from "fs";
3195
+ import { mkdir as mkdir3, rm as rm3, stat as stat2, writeFile as writeFile3 } from "fs/promises";
3196
+ import { homedir as homedir4 } from "os";
3197
+ import { dirname as dirname3, join as join4 } from "path";
3198
+ async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, extend = true) {
3199
+ const methods = extend ? [FUNC_SESSION_TOUCH, FUNC_SESSION_TIME_REMAINING] : [FUNC_SESSION_TIME_REMAINING];
3200
+ const url = `${baseUrl.replace(/\/$/, "")}${AJAX_SERVICE_PATH}?sesskey=${encodeURIComponent(sesskey)}&info=${methods.join(",")}`;
3201
+ let response;
3202
+ try {
3203
+ response = await fetchImpl(url, {
3204
+ method: "POST",
3205
+ headers: {
3206
+ "content-type": "application/json",
3207
+ cookie: `${cookie.name}=${cookie.value}`
3208
+ },
3209
+ body: JSON.stringify(methods.map((methodname, index) => ({ index, methodname, args: {} })))
3210
+ });
3211
+ } catch {
3212
+ return { alive: null, timeRemainingSeconds: null };
3213
+ }
3214
+ if (!response.ok) {
3215
+ return { alive: null, timeRemainingSeconds: null };
3216
+ }
3217
+ let body;
3218
+ try {
3219
+ body = await response.json();
3220
+ } catch {
3221
+ return { alive: false, timeRemainingSeconds: null };
3222
+ }
3223
+ if (!Array.isArray(body) || !body.length) {
3224
+ return { alive: null, timeRemainingSeconds: null };
3225
+ }
3226
+ const first2 = body[0];
3227
+ if (first2?.error) {
3228
+ const exception = first2.exception;
3229
+ const errorcode = typeof exception?.errorcode === "string" ? exception.errorcode : "";
3230
+ if (errorcode === "servicerequireslogin" || errorcode === "sitepolicynotagreed") {
3231
+ return { alive: false, timeRemainingSeconds: null };
3232
+ }
3233
+ return { alive: null, timeRemainingSeconds: null };
3234
+ }
3235
+ const last = body[body.length - 1];
3236
+ const data = last?.data;
3237
+ const timeRemaining = typeof data?.timeremaining === "number" ? data.timeremaining : null;
3238
+ return { alive: true, timeRemainingSeconds: timeRemaining };
3239
+ }
3240
+ async function keepAliveOnce(baseUrl, options = {}) {
3241
+ const session = await readCachedSession(baseUrl, {
3242
+ homeDir: options.homeDir,
3243
+ ttlMs: Number.MAX_SAFE_INTEGER,
3244
+ now: options.now
3245
+ });
3246
+ if (!session) {
3247
+ return { status: "no_session", time_remaining_seconds: null };
3248
+ }
3249
+ const touch = await touchMoodleSession(
3250
+ baseUrl,
3251
+ { name: session.cookieName, value: session.cookieValue },
3252
+ session.sesskey,
3253
+ options.fetchImpl ?? fetch
3254
+ );
3255
+ if (touch.alive === true) {
3256
+ await writeCachedSession({ ...session, savedAt: (options.now ?? Date.now)() }, { homeDir: options.homeDir });
3257
+ return { status: "renewed", time_remaining_seconds: touch.timeRemainingSeconds };
3258
+ }
3259
+ if (touch.alive === null) {
3260
+ return { status: "unreachable", time_remaining_seconds: null };
3261
+ }
3262
+ if (options.renewOnExpiry === false) {
3263
+ return { status: "expired", time_remaining_seconds: null };
3264
+ }
3265
+ const authenticate = options.authenticate ?? ((url) => getAuthenticatedSession(url, {
3266
+ homeDir: options.homeDir,
3267
+ fetch: options.fetchImpl,
3268
+ noCache: true,
3269
+ now: options.now,
3270
+ // Background runs must never block on an interactive Okta login.
3271
+ nonInteractive: true
3272
+ }));
3273
+ try {
3274
+ await authenticate(baseUrl);
3275
+ return { status: "reauthenticated", time_remaining_seconds: null };
3276
+ } catch {
3277
+ return { status: "expired", time_remaining_seconds: null };
3278
+ }
3279
+ }
3280
+ async function getAuthStatus(baseUrl, options = {}) {
3281
+ const now = options.now ?? Date.now;
3282
+ const keepalive = await keepaliveStatus(options.homeDir);
3283
+ const session = await readCachedSession(baseUrl, {
3284
+ homeDir: options.homeDir,
3285
+ ttlMs: Number.MAX_SAFE_INTEGER,
3286
+ now: options.now
3287
+ });
3288
+ if (!session) {
3289
+ return {
3290
+ base_url: baseUrl,
3291
+ session_cached: false,
3292
+ cache_age_minutes: null,
3293
+ session_alive: null,
3294
+ session_time_remaining_seconds: null,
3295
+ keepalive_installed: keepalive.installed,
3296
+ keepalive_plist_path: keepalive.plist_path
3297
+ };
3298
+ }
3299
+ const touch = await touchMoodleSession(
3300
+ baseUrl,
3301
+ { name: session.cookieName, value: session.cookieValue },
3302
+ session.sesskey,
3303
+ options.fetchImpl ?? fetch,
3304
+ false
3305
+ );
3306
+ return {
3307
+ base_url: baseUrl,
3308
+ session_cached: true,
3309
+ cache_age_minutes: Math.max(0, Math.round((now() - session.savedAt) / 6e4)),
3310
+ session_alive: touch.alive,
3311
+ session_time_remaining_seconds: touch.timeRemainingSeconds,
3312
+ keepalive_installed: keepalive.installed,
3313
+ keepalive_plist_path: keepalive.plist_path
3314
+ };
3315
+ }
3316
+ function keepalivePlistPath(homeDir = homedir4()) {
3317
+ return join4(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
3318
+ }
3319
+ function keepaliveLogPath(homeDir = homedir4()) {
3320
+ return join4(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
3321
+ }
3322
+ function keepaliveProgramArguments(execPath = process.execPath, argv1 = process.argv[1] ?? "") {
3323
+ const resolvedArgv1 = argv1 ? safeRealpath(argv1) : "";
3324
+ const tail = ["auth", "keepalive", "--json"];
3325
+ if (!resolvedArgv1 || resolvedArgv1 === safeRealpath(execPath)) {
3326
+ return [execPath, ...tail];
3327
+ }
3328
+ return [execPath, resolvedArgv1, ...tail];
3329
+ }
3330
+ function buildKeepalivePlist(programArguments, intervalMinutes, logPath) {
3331
+ const args = programArguments.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
3332
+ return [
3333
+ '<?xml version="1.0" encoding="UTF-8"?>',
3334
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
3335
+ '<plist version="1.0">',
3336
+ "<dict>",
3337
+ " <key>Label</key>",
3338
+ ` <string>${KEEPALIVE_LAUNCH_AGENT_LABEL}</string>`,
3339
+ " <key>ProgramArguments</key>",
3340
+ " <array>",
3341
+ args,
3342
+ " </array>",
3343
+ " <key>StartInterval</key>",
3344
+ ` <integer>${Math.round(intervalMinutes * 60)}</integer>`,
3345
+ " <key>RunAtLoad</key>",
3346
+ " <true/>",
3347
+ " <key>StandardOutPath</key>",
3348
+ ` <string>${escapeXml(logPath)}</string>`,
3349
+ " <key>StandardErrPath</key>",
3350
+ ` <string>${escapeXml(logPath)}</string>`,
3351
+ "</dict>",
3352
+ "</plist>",
3353
+ ""
3354
+ ].join("\n");
3355
+ }
3356
+ async function installKeepalive(options = {}) {
3357
+ const platform = options.platform ?? process.platform;
3358
+ if (platform !== "darwin") {
3359
+ throw new Error(
3360
+ "Automatic keepalive install requires macOS launchd. Add a cron entry instead: */30 * * * * moodle auth keepalive --json"
3361
+ );
3362
+ }
3363
+ const homeDir = options.homeDir ?? homedir4();
3364
+ const intervalMinutes = options.intervalMinutes ?? KEEPALIVE_DEFAULT_INTERVAL_MINUTES;
3365
+ const plistPath = keepalivePlistPath(homeDir);
3366
+ const logPath = keepaliveLogPath(homeDir);
3367
+ const command = keepaliveProgramArguments(options.execPath, options.argv1);
3368
+ await mkdir3(dirname3(plistPath), { recursive: true });
3369
+ await mkdir3(dirname3(logPath), { recursive: true, mode: 448 });
3370
+ await writeFile3(plistPath, buildKeepalivePlist(command, intervalMinutes, logPath), "utf8");
3371
+ const runCommand = options.runCommand ?? spawnSync3;
3372
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
3373
+ runCommand("launchctl", ["bootout", `gui/${uid}/${KEEPALIVE_LAUNCH_AGENT_LABEL}`], { stdio: "ignore" });
3374
+ const bootstrap = runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { stdio: "ignore" });
3375
+ if (bootstrap.error || bootstrap.status !== 0) {
3376
+ const legacy = runCommand("launchctl", ["load", "-w", plistPath], { stdio: "ignore" });
3377
+ if (legacy.error || legacy.status !== 0) {
3378
+ throw new Error(`Failed to register the launch agent. Try manually: launchctl bootstrap gui/${uid} ${plistPath}`);
3379
+ }
3380
+ }
3381
+ return { plist_path: plistPath, interval_minutes: intervalMinutes, log_path: logPath, command };
3382
+ }
3383
+ async function uninstallKeepalive(options = {}) {
3384
+ const homeDir = options.homeDir ?? homedir4();
3385
+ const plistPath = keepalivePlistPath(homeDir);
3386
+ const runCommand = options.runCommand ?? spawnSync3;
3387
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
3388
+ runCommand("launchctl", ["bootout", `gui/${uid}/${KEEPALIVE_LAUNCH_AGENT_LABEL}`], { stdio: "ignore" });
3389
+ await rm3(plistPath, { force: true });
3390
+ return { installed: false, plist_path: plistPath };
3391
+ }
3392
+ async function keepaliveStatus(homeDir = homedir4()) {
3393
+ const plistPath = keepalivePlistPath(homeDir);
3394
+ try {
3395
+ return { installed: (await stat2(plistPath)).isFile(), plist_path: plistPath };
3396
+ } catch {
3397
+ return { installed: false, plist_path: plistPath };
3398
+ }
3399
+ }
3400
+ function safeRealpath(path3) {
3401
+ try {
3402
+ return realpathSync(path3);
3403
+ } catch {
3404
+ return path3;
3405
+ }
3406
+ }
3407
+ function escapeXml(value) {
3408
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3409
+ }
3410
+
2919
3411
  // src/version.ts
2920
- var VERSION = "0.5.5";
3412
+ var VERSION = "0.6.0";
2921
3413
 
2922
3414
  // src/url-resolver.ts
2923
3415
  function looksLikeUrl(value) {
@@ -3026,24 +3518,6 @@ function parseActivityReference(value, labelOrOptions, expectedPathValue) {
3026
3518
  }
3027
3519
  return Number(id);
3028
3520
  }
3029
- function parseDiscussionReference(value) {
3030
- const raw = value.trim();
3031
- if (/^\d+$/.test(raw)) {
3032
- return { discussionId: Number(raw), postId: null };
3033
- }
3034
- let parsed;
3035
- try {
3036
- parsed = new URL(raw);
3037
- } catch {
3038
- throw new UsageError("DISCUSSION must be a numeric ID or a full discuss.php URL.");
3039
- }
3040
- const discussion = parsed.searchParams.get("d");
3041
- if (!discussion || !/^\d+$/.test(discussion)) {
3042
- throw new UsageError("Could not find discussion ID in URL query (expected ?d=...).");
3043
- }
3044
- const postId = parsed.hash.startsWith("#p") && /^\d+$/.test(parsed.hash.slice(2)) ? Number(parsed.hash.slice(2)) : null;
3045
- return { discussionId: Number(discussion), postId };
3046
- }
3047
3521
 
3048
3522
  // src/cli.ts
3049
3523
  function buildProgram(io = {}) {
@@ -3147,7 +3621,7 @@ ${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
3147
3621
  });
3148
3622
  addOutputOptions(forum.command("discussions").description("List discussions from a forum.").argument("<forum>", "Forum ID or URL")).option("--limit <number>", "Maximum number of discussions.", parsePositiveInt, 50).option("--query <query>", "Filter discussion titles by query.").action(async (forumRef, options) => {
3149
3623
  const client = await runtime.getClient();
3150
- const forumId = await parseForumReference(client, forumRef);
3624
+ const forumId = await parseForumReference(forumRef, (discussionId) => client.getForumViewCmid(discussionId));
3151
3625
  let refs = await client.getForumDiscussionRefs(forumId);
3152
3626
  if (options.query) {
3153
3627
  refs = refs.filter((ref) => queryMatches2(ref.subject, options.query));
@@ -3169,25 +3643,55 @@ ${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
3169
3643
  addForumSearchCommand(forum.command("find").description("Find the best forum match.").option("--list", "Return a shortlist.").option("--body", "Resolve the target body."), runtime, 5, true);
3170
3644
  addOutputOptions(forum.command("check").description("Validate discussion rendering.").argument("<forum>", "Forum ID or URL")).option("--limit <number>", "Maximum number of discussions.", parsePositiveInt, 20).action(async (forumRef, options) => {
3171
3645
  const client = await runtime.getClient();
3172
- const forumId = await parseForumReference(client, forumRef);
3173
- const refs = (await client.getForumDiscussionRefs(forumId)).slice(0, options.limit);
3174
- const results = [];
3175
- for (const ref of refs) {
3176
- try {
3177
- const discussion = await client.getForumDiscussion(ref.id);
3178
- results.push({
3179
- discussion_id: ref.id,
3180
- subject: ref.subject,
3181
- ok: true,
3182
- posts: discussion.posts.length,
3183
- images: discussion.posts.reduce((total, post) => total + post.image_urls.length, 0)
3184
- });
3185
- } catch (error) {
3186
- results.push({ discussion_id: ref.id, subject: ref.subject, ok: false, error: error instanceof Error ? error.message : String(error) });
3187
- }
3188
- }
3646
+ const forumId = await parseForumReference(forumRef, (discussionId) => client.getForumViewCmid(discussionId));
3647
+ const results = await checkForumDiscussions(client, forumId, options.limit);
3189
3648
  runtime.output(results, () => formatForumCheckResults(forumId, results), options);
3190
3649
  });
3650
+ const auth = program.command("auth").description("Session and keepalive utilities.");
3651
+ addOutputOptions(auth.command("status").description("Show cached session freshness and keepalive state.")).action(
3652
+ async (options) => {
3653
+ const baseUrl = await runtime.baseUrl();
3654
+ const status = await getAuthStatus(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl });
3655
+ runtime.output(status, () => formatAuthStatus(status), options);
3656
+ }
3657
+ );
3658
+ addOutputOptions(auth.command("login").description("Force a fresh login and refresh the session cache.")).action(
3659
+ async (options) => {
3660
+ const baseUrl = await runtime.baseUrl();
3661
+ await invalidateCachedSession(baseUrl, { homeDir: io.homeDir });
3662
+ const session = await getAuthenticatedSession(baseUrl, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, noCache: true });
3663
+ const result = { base_url: baseUrl, userid: session.userid, cookie_source: session.cookie.source ?? "unknown" };
3664
+ runtime.output(result, () => `Authenticated as userid ${result.userid} via ${result.cookie_source}`, options);
3665
+ }
3666
+ );
3667
+ const keepalive = addOutputOptions(
3668
+ 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.")
3669
+ ).action(async (options) => {
3670
+ const baseUrl = await runtime.baseUrl();
3671
+ const result = await keepAliveOnce(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl, renewOnExpiry: options.renew });
3672
+ runtime.output(result, () => formatKeepaliveResult(result), options);
3673
+ });
3674
+ addOutputOptions(
3675
+ keepalive.command("install").description("Install a macOS launch agent that renews the session periodically.").option("--interval <minutes>", "Renewal interval in minutes.", parsePositiveInt)
3676
+ ).action(async (options) => {
3677
+ await runtime.baseUrl();
3678
+ const result = await installKeepalive({ homeDir: io.homeDir, intervalMinutes: options.interval });
3679
+ runtime.output(result, () => `Keepalive installed: renews every ${result.interval_minutes} min
3680
+ Agent: ${result.plist_path}
3681
+ Log: ${result.log_path}`, options);
3682
+ });
3683
+ addOutputOptions(keepalive.command("uninstall").description("Remove the keepalive launch agent.")).action(
3684
+ async (options) => {
3685
+ const result = await uninstallKeepalive({ homeDir: io.homeDir });
3686
+ runtime.output(result, () => `Keepalive removed (${result.plist_path})`, options);
3687
+ }
3688
+ );
3689
+ addOutputOptions(keepalive.command("status").description("Show whether the keepalive launch agent is installed.")).action(
3690
+ async (options) => {
3691
+ const result = await keepaliveStatus(io.homeDir);
3692
+ runtime.output(result, () => result.installed ? `Keepalive installed (${result.plist_path})` : "Keepalive not installed", options);
3693
+ }
3694
+ );
3191
3695
  addOutputOptions(program.command("update").description("Check for updates and upgrade the installed CLI.")).option("--check-only", "Only check for updates; do not install.").action(async (options) => {
3192
3696
  try {
3193
3697
  const info = await checkForUpdates(VERSION, io.fetchImpl);
@@ -3219,9 +3723,9 @@ ${overview.alerts ? formatAlerts(overview.alerts) : ""}`, options);
3219
3723
  stdout.write(`${formatSkillSummary()}
3220
3724
  `);
3221
3725
  });
3222
- skills.command("generate").description("Regenerate SKILL.md from the CLI command tree.").action(() => {
3726
+ skills.command("generate").description("Regenerate the agent skill bundle from the CLI command tree.").action(() => {
3223
3727
  writeGeneratedSkill(program);
3224
- stdout.write("Generated SKILL.md\n");
3728
+ stdout.write("Generated Moodle skill bundle\n");
3225
3729
  });
3226
3730
  skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
3227
3731
  hideCommand(skills.command("install").allowUnknownOption(true)).action((_options, command) => installSkill(command.args));
@@ -3334,7 +3838,7 @@ function addForumSearchCommand(command, runtime, defaultLimit, findMode) {
3334
3838
  addOutputOptions(command.argument("<query>", "Search query")).option("--course <course>", "Restrict to a course ID or unique course name match.").option("--forum <forum>", "Restrict to a forum ID or forum URL.").option("--titles-only", "Only search discussion titles.").option("--unread-only", "Only include unread matches.").option("--recent", "Sort matches by newest activity.").option("--limit-forums <number>", "Maximum number of forums to scan.", parsePositiveInt).option("--limit-discussions <number>", "Maximum number of discussions per forum.", parsePositiveInt).option("--limit <number>", "Maximum number of matches.", parsePositiveInt, defaultLimit).action(async (query, options) => {
3335
3839
  const client = await runtime.getClient();
3336
3840
  const courseId = options.course ? await client.resolveCourseReference(options.course) : void 0;
3337
- const forumCmid = options.forum ? await parseForumReference(client, options.forum) : void 0;
3841
+ const forumCmid = options.forum ? await parseForumReference(options.forum, (discussionId) => client.getForumViewCmid(discussionId)) : void 0;
3338
3842
  const limit = findMode && !options.list ? 1 : options.limit;
3339
3843
  const hits = await client.searchForumContent({
3340
3844
  query,
@@ -3356,29 +3860,6 @@ function addForumSearchCommand(command, runtime, defaultLimit, findMode) {
3356
3860
  runtime.output(output, () => formatForumSearchHits(Array.isArray(output) ? output : output ? [output] : []), options);
3357
3861
  });
3358
3862
  }
3359
- async function parseForumReference(client, value) {
3360
- const raw = value.trim();
3361
- if (/^\d+$/.test(raw)) {
3362
- return Number(raw);
3363
- }
3364
- const parsed = new URL(raw);
3365
- if (parsed.pathname.endsWith("/mod/forum/view.php")) {
3366
- const id = parsed.searchParams.get("id");
3367
- if (id && /^\d+$/.test(id)) {
3368
- return Number(id);
3369
- }
3370
- }
3371
- if (parsed.pathname.endsWith("/mod/forum/discuss.php")) {
3372
- const id = parsed.searchParams.get("d");
3373
- if (id && /^\d+$/.test(id)) {
3374
- const forumId = await client.getForumViewCmid(Number(id));
3375
- if (forumId) {
3376
- return forumId;
3377
- }
3378
- }
3379
- }
3380
- throw new UsageError("Unsupported forum URL. Use a view.php?id=... or discuss.php?d=... URL.");
3381
- }
3382
3863
  function addOutputOptions(command) {
3383
3864
  return command.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.");
3384
3865
  }
@@ -3462,7 +3943,7 @@ function queryMatches2(text, query) {
3462
3943
  const needle = query.toLowerCase().split(/\s+/).join(" ");
3463
3944
  return needle ? haystack.includes(needle) || needle.split(" ").every((token) => haystack.includes(token)) : true;
3464
3945
  }
3465
- var isMain = process.argv[1] ? realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]) : false;
3946
+ var isMain = process.argv[1] ? realpathSync2(fileURLToPath(import.meta.url)) === realpathSync2(process.argv[1]) : false;
3466
3947
  if (isMain) {
3467
3948
  runCli().then((code) => {
3468
3949
  process.exitCode = code;