moodle-cli 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/moodle.js CHANGED
@@ -20,6 +20,8 @@ var counts = z.object(Object.fromEntries(["notification_count", "unread_notifica
20
20
  var postSchema = z.object({ id, parent_id: n, name: s, author: z.object({ id, name: s }), time_created: id, created: s, message_text: s, links: z.array(z.object({ text: s, url: s })).optional() });
21
21
  var gradeSchema = z.object({ unit_id: id, code: s, graded: id, total: id, total_grade: s, total_range: s, total_percentage: s, items: z.array(z.object({ name: s, type: s, grade: s, range: s, percentage: s, weight: s, contribution: s, feedback: s, status: s, due: s, due_at: n })).optional() });
22
22
  var searchSchema = z.object({ unit_id: id, forum_id: id, discussion_id: id, name: s, post_id: id, snippet: s, time_created: n, created: s, url: s });
23
+ var submissionFile = z.object({ name: z.string(), bytes: n, url: s });
24
+ var receiptSchema = z.object({ id, name: s, unit_id: n, url: s, action: z.enum(["planned", "saved", "submitted"]), submission_status: s, grading_status: s, due: s, time_remaining: s, last_modified: s, statement: s, statement_accepted: z.boolean().optional(), files: z.array(submissionFile).optional(), uploads: z.array(z.object({ name: z.string(), bytes: id, path: s })).optional(), removed: z.array(z.string()).optional(), limits: z.object({ max_bytes: n, max_files: n, area_max_bytes: n, accepted_types: z.array(z.string()).optional() }).optional(), checked_at: z.string() });
23
25
  var input = (shape) => z.object(shape).strict();
24
26
  var list = (key, value) => z.object({ [key]: z.array(value).optional(), total: id });
25
27
  var intentContracts = {
@@ -33,6 +35,7 @@ var intentContracts = {
33
35
  news: { when: "announcements", command: "moodle news [UNIT]", what: "Latest announcement threads with first-post text.", instead: "search_forums for other discussions", refs: "optional unit code, name, id or URL", then: "thread with discussion id", cost: "up to 5 announcements by default", input: input({ unit: ref.optional(), limit: limit.default(5) }), output: list("news", z.object({ id, name: s, unit_id: id, unit_code: s, forum_id: id, post: postSchema.optional() })) },
34
36
  thread: { when: "discussion posts", command: "moodle threads show ID", what: "A discussion and a page of compact posts, with attachment links.", instead: "news for announcements", refs: "discussion_id; offset and limit", then: "increase offset while posts_total exceeds returned", cost: "up to 20 posts by default", input: input({ discussion_id: z.number().int().positive(), limit, offset: z.number().int().nonnegative().default(0) }), output: z.object({ thread: z.object({ id, name: s, unit_id: id, forum_id: id, url: s, posts: z.array(postSchema).optional(), posts_total: id, offset: id }) }) },
35
37
  search_forums: { when: "forum post text", command: 'moodle forums search "QUERY" --unit UNIT', what: "Matching posts with unit and forum name maps.", instead: "find for activity names", refs: "query, optional unit or courseId and forumId", then: "thread with discussion_id", cost: "bounded forum scan; total covers scanned scope", input: input({ query: z.string().trim().min(1), unit: ref.optional(), courseId: z.number().int().positive().optional(), forumId: z.number().int().positive().optional(), limit, includePostText: z.boolean().default(true), titlesOnly: z.boolean().default(false), unreadOnly: z.boolean().default(false), sortBy: z.enum(["relevance", "recent"]).default("relevance"), maxForums: limit.default(20), maxDiscussionsPerForum: limit.default(50) }), output: z.object({ results: z.array(searchSchema).optional(), total: id, forums: z.record(z.string(), z.string()).optional(), units: z.record(z.string(), z.string()).optional(), scope: z.object({ max_forums: id, max_discussions_per_forum: id }) }) },
38
+ submit: { when: "upload assignment files", command: 'moodle submit "UNIT TASK" FILE... [--final]', what: "Upload local files into an assignment; returns the receipt Moodle shows afterwards.", instead: "item for status only", refs: "assignment id, same-site URL or UNIT TASK phrase; local file paths", then: "dry_run (default) only plans; show the plan to the person, then rerun with dry_run false; final submits for grading and cannot be undone", cost: "writes to Moodle", input: input({ ref, files: z.array(z.string().trim().min(1)).max(20).default([]), final: z.boolean().default(false), replace: z.boolean().default(false), accept_statement: z.boolean().default(false), dry_run: z.boolean().default(true) }), output: z.object({ submission: receiptSchema }) },
36
39
  file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file as embedded content, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned resource", cost: "binary content up to 16 MiB", input: input({ ref }), output: z.object({ file: z.object({ name: z.string(), mime_type: z.string(), bytes: id, uri: z.string() }) }) }
37
40
  };
38
41
  function humanDescription(name) {
@@ -95,10 +98,10 @@ function postRow(p, subject, tz = "UTC") {
95
98
  }
96
99
 
97
100
  // src/doctor.ts
98
- import { access, readdir as readdir2, readFile as readFile4 } from "fs/promises";
99
- import { constants as constants2 } from "fs";
100
- import { homedir as homedir6 } from "os";
101
- import { join as join7 } from "path";
101
+ import { access as access3, readdir as readdir3, readFile as readFile4 } from "fs/promises";
102
+ import { constants as constants3 } from "fs";
103
+ import { homedir as homedir8 } from "os";
104
+ import { join as join9 } from "path";
102
105
 
103
106
  // src/session-fetch.ts
104
107
  var MAX_REDIRECTS = 5;
@@ -124,7 +127,12 @@ async function fetchWithSession(input2, init, moodleOrigin, cookie, fetchImpl =
124
127
  headers.delete("authorization");
125
128
  headers.delete("proxy-authorization");
126
129
  }
127
- const response = await fetchImpl(url.toString(), { ...init, method, body, headers: Object.fromEntries(headers), signal, redirect: "manual" });
130
+ let response;
131
+ try {
132
+ response = await fetchImpl(url.toString(), { ...init, method, body, headers: Object.fromEntries(headers), signal, redirect: "manual" });
133
+ } catch (error) {
134
+ throw requestFailure(error, url, deadline);
135
+ }
128
136
  if (!REDIRECT_STATUSES.has(response.status)) return response;
129
137
  const location = response.headers.get("location");
130
138
  if (!location) return response;
@@ -145,13 +153,31 @@ async function fetchWithSession(input2, init, moodleOrigin, cookie, fetchImpl =
145
153
  url = next;
146
154
  }
147
155
  }
156
+ var RequestFailed = class extends Error {
157
+ host;
158
+ timedOut;
159
+ constructor(host, timedOut, cause) {
160
+ super(timedOut ? `${host} did not respond within ${REQUEST_TIMEOUT_MS / 1e3}s.` : `Could not reach ${host}: ${cause}`);
161
+ this.name = "RequestFailed";
162
+ this.host = host;
163
+ this.timedOut = timedOut;
164
+ }
165
+ };
166
+ function requestFailure(error, url, deadline) {
167
+ if (deadline.aborted) {
168
+ return new RequestFailed(url.host, true);
169
+ }
170
+ if (!(error instanceof Error)) {
171
+ return error;
172
+ }
173
+ return new RequestFailed(url.host, false, error.cause instanceof Error ? error.cause.message : error.message);
174
+ }
148
175
 
149
176
  // src/auth.ts
150
177
  import { ALL_PROFILES, getCookies } from "@steipete/sweet-cookie";
151
- import { execFile as execFileCallback } from "child_process";
152
- import { readdir } from "fs/promises";
153
- import { homedir as homedir3 } from "os";
154
- import { join as join3 } from "path";
178
+ import { readdir as readdir2 } from "fs/promises";
179
+ import { homedir as homedir5 } from "os";
180
+ import { join as join5 } from "path";
155
181
 
156
182
  // src/constants.ts
157
183
  var PACKAGE_NAME = "moodle-cli";
@@ -171,6 +197,15 @@ var GRADE_REPORT_INDEX_PATH = "/grade/report/index.php";
171
197
  var GRADE_REPORT_OVERVIEW_PATH = "/grade/report/overview/index.php";
172
198
  var GRADE_REPORT_PATH = "/grade/report/user/index.php";
173
199
  var LOGIN_PATH = "/login/index.php";
200
+ var MOBILE_LAUNCH_PATH = "/admin/tool/mobile/launch.php";
201
+ var MOBILE_AUTOLOGIN_PATH = "/admin/tool/mobile/autologin.php";
202
+ var WEBSERVICE_REST_PATH = "/webservice/rest/server.php";
203
+ var SERVICE_NOLOGIN_PATH = "/lib/ajax/service-nologin.php";
204
+ var MOBILE_SERVICE_SHORTNAME = "moodle_mobile_app";
205
+ var MOBILE_URL_SCHEME = "moodlecli";
206
+ var MOBILE_USER_AGENT = "MoodleMobile 4.5.0 (moodle-cli)";
207
+ var FUNC_MOBILE_PUBLIC_CONFIG = "tool_mobile_get_public_config";
208
+ var FUNC_MOBILE_AUTOLOGIN_KEY = "tool_mobile_get_autologin_key";
174
209
  var FUNC_GET_SITE_INFO = "core_webservice_get_site_info";
175
210
  var FUNC_GET_COURSES = "core_enrol_get_users_courses";
176
211
  var FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification";
@@ -189,6 +224,7 @@ var CONFIG_FILENAME = "config.yaml";
189
224
  var CONFIG_DIR_NAME = ".config/moodle-cli";
190
225
  var CACHE_DIR_NAME = ".cache/moodle-cli";
191
226
  var SESSION_CACHE_FILENAME = "session.json";
227
+ var CDP_PROFILE_DIR_NAME = ".cache/moodle-cli/browser-profile";
192
228
  var DEFAULT_SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
193
229
  var KEEPALIVE_LAUNCH_AGENT_LABEL = "com.moodle-cli.keepalive";
194
230
  var KEEPALIVE_DEFAULT_INTERVAL_MINUTES = 30;
@@ -201,6 +237,466 @@ var ENV_MOODLE_TOKEN = "MOODLE_TOKEN";
201
237
  var MOODLE_SESSION_COOKIE_PREFIX = "MoodleSession";
202
238
  var WRANGLER_VERSION = "4.131.0";
203
239
 
240
+ // src/cookie-stores.ts
241
+ import { constants } from "fs";
242
+ import { access, readdir } from "fs/promises";
243
+ import { homedir } from "os";
244
+ import { join } from "path";
245
+ var CHROMIUM_ROOTS = [
246
+ ["Chrome", "Google/Chrome"],
247
+ ["Edge", "Microsoft Edge"],
248
+ ["Brave", "BraveSoftware/Brave-Browser"]
249
+ ];
250
+ var CHROMIUM_FILES = ["Cookies", "Network/Cookies"];
251
+ var SAFARI_FILES = [
252
+ "Library/Cookies/Cookies.binarycookies",
253
+ "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"
254
+ ];
255
+ function probe(path5, mode) {
256
+ return access(path5, mode).then(() => true, () => false);
257
+ }
258
+ async function chromiumProfiles(root) {
259
+ const entries = await readdir(root).catch(() => []);
260
+ const profiles = entries.filter((entry) => entry === "Default" || entry.startsWith("Profile "));
261
+ return profiles.length ? profiles : ["Default"];
262
+ }
263
+ async function browserCookieStores(options = {}) {
264
+ if ((options.platform ?? process.platform) !== "darwin") {
265
+ return [];
266
+ }
267
+ const home = options.homeDir ?? homedir();
268
+ const stores = [];
269
+ const add = async (browser, path5) => {
270
+ if (await probe(path5, constants.F_OK)) {
271
+ stores.push({ browser, path: path5, readable: await probe(path5, constants.R_OK) });
272
+ }
273
+ };
274
+ for (const [browser, directory] of CHROMIUM_ROOTS) {
275
+ const root = join(home, "Library/Application Support", directory);
276
+ for (const profile of await chromiumProfiles(root)) {
277
+ for (const file2 of CHROMIUM_FILES) {
278
+ await add(browser, join(root, profile, file2));
279
+ }
280
+ }
281
+ }
282
+ const firefoxRoot = join(home, "Library/Application Support/Firefox/Profiles");
283
+ for (const profile of await readdir(firefoxRoot).catch(() => [])) {
284
+ await add("Firefox", join(firefoxRoot, profile, "cookies.sqlite"));
285
+ }
286
+ for (const file2 of SAFARI_FILES) {
287
+ await add("Safari", join(home, file2));
288
+ }
289
+ return stores;
290
+ }
291
+ function unreadableCookieStores(stores) {
292
+ return stores.filter((store) => !store.readable);
293
+ }
294
+ function cookieStoresBlocked(stores) {
295
+ return stores.length > 0 && stores.every((store) => !store.readable);
296
+ }
297
+
298
+ // src/cdp-login.ts
299
+ import { spawn } from "child_process";
300
+ import { access as access2, mkdir } from "fs/promises";
301
+ import { accessSync, constants as fsConstants } from "fs";
302
+ import { homedir as homedir2 } from "os";
303
+ import { join as join2 } from "path";
304
+ var CdpError = class extends Error {
305
+ constructor(message, hint) {
306
+ super(message);
307
+ this.hint = hint;
308
+ this.name = "CdpError";
309
+ }
310
+ hint;
311
+ };
312
+ var NO_CHROMIUM_HINT = "Install Google Chrome, Microsoft Edge, Brave, or Chromium, or run `moodle auth login --paste` to hand over the cookie yourself.";
313
+ var DEFAULT_INTERACTIVE_TIMEOUT_MS = 3e5;
314
+ var DEFAULT_HEADLESS_TIMEOUT_MS = 45e3;
315
+ var HANDSHAKE_TIMEOUT_MS = 15e3;
316
+ var RPC_TIMEOUT_MS = 1e4;
317
+ var CLOSE_TIMEOUT_MS = 2e3;
318
+ var KILL_GRACE_MS = 1e3;
319
+ function withTimeout(promise, ms, onTimeout) {
320
+ return new Promise((resolve, reject) => {
321
+ const timer = setTimeout(() => resolve(onTimeout()), ms);
322
+ timer.unref?.();
323
+ promise.then(
324
+ (value) => {
325
+ clearTimeout(timer);
326
+ resolve(value);
327
+ },
328
+ (error) => {
329
+ clearTimeout(timer);
330
+ reject(error instanceof Error ? error : new Error(String(error)));
331
+ }
332
+ );
333
+ });
334
+ }
335
+ function cdpProfileDir(homeDir = homedir2()) {
336
+ return join2(homeDir, CDP_PROFILE_DIR_NAME);
337
+ }
338
+ function launchFlags(profileDir, url, headless) {
339
+ return [
340
+ `--user-data-dir=${profileDir}`,
341
+ "--remote-debugging-pipe",
342
+ "--no-first-run",
343
+ "--no-default-browser-check",
344
+ "--no-service-autorun",
345
+ "--disable-sync",
346
+ "--disable-background-networking",
347
+ "--disable-features=Translate,MediaRouter,OptimizationHints",
348
+ // macOS: use an in-memory key so decrypting cookies never prompts Keychain.
349
+ "--use-mock-keychain",
350
+ // Linux: avoid a gnome-keyring/kwallet unlock prompt for the same reason.
351
+ "--password-store=basic",
352
+ ...headless ? ["--headless=new"] : [],
353
+ url
354
+ ];
355
+ }
356
+ var MAC_BROWSERS = [
357
+ { name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
358
+ { name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
359
+ { name: "Brave", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
360
+ { name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" }
361
+ ];
362
+ var LINUX_BROWSERS = [
363
+ { name: "Google Chrome", path: "google-chrome" },
364
+ { name: "Google Chrome", path: "google-chrome-stable" },
365
+ { name: "Chromium", path: "chromium" },
366
+ { name: "Chromium", path: "chromium-browser" },
367
+ { name: "Microsoft Edge", path: "microsoft-edge" },
368
+ { name: "Brave", path: "brave-browser" }
369
+ ];
370
+ function windowsBrowsers(env) {
371
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
372
+ const relative = [
373
+ ["Google Chrome", "Google/Chrome/Application/chrome.exe"],
374
+ ["Microsoft Edge", "Microsoft/Edge/Application/msedge.exe"],
375
+ ["Brave", "BraveSoftware/Brave-Browser/Application/brave.exe"],
376
+ ["Chromium", "Chromium/Application/chrome.exe"]
377
+ ];
378
+ return roots.flatMap((root) => relative.map(([name, tail]) => ({ name, path: join2(root, tail) })));
379
+ }
380
+ async function findChromiumBrowser(options = {}) {
381
+ if (options.browserPath) {
382
+ return { name: "Chromium", path: options.browserPath };
383
+ }
384
+ const platform = options.platform ?? process.platform;
385
+ const env = options.env ?? process.env;
386
+ if (platform === "linux") {
387
+ for (const candidate of LINUX_BROWSERS) {
388
+ const resolved = resolveFromPath(candidate.path, env, platform);
389
+ if (resolved) return { name: candidate.name, path: resolved };
390
+ }
391
+ return null;
392
+ }
393
+ const candidates = platform === "win32" ? windowsBrowsers(env) : MAC_BROWSERS;
394
+ for (const candidate of candidates) {
395
+ if (await isExecutable(candidate.path)) return candidate;
396
+ }
397
+ return null;
398
+ }
399
+ async function loginWithCdp(options) {
400
+ const browser = await findChromiumBrowser(options);
401
+ if (!browser) {
402
+ throw new CdpError("No Chromium-family browser is installed.", NO_CHROMIUM_HINT);
403
+ }
404
+ const profileDir = options.profileDir ?? cdpProfileDir(options.homeDir);
405
+ await mkdir(profileDir, { recursive: true, mode: 448 });
406
+ const headless = options.headless ?? false;
407
+ const spawnImpl = options.spawn ?? spawn;
408
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
409
+ const now = options.now ?? Date.now;
410
+ const timeoutMs = options.timeoutMs ?? (headless ? DEFAULT_HEADLESS_TIMEOUT_MS : DEFAULT_INTERACTIVE_TIMEOUT_MS);
411
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
412
+ const child = spawnImpl(browser.path, launchFlags(profileDir, options.url, headless), {
413
+ stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"]
414
+ });
415
+ const connection = new CdpConnection(child, { rpc: options.rpcTimeoutMs, close: options.closeTimeoutMs });
416
+ try {
417
+ await connection.handshake(options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
418
+ options.onOpened?.();
419
+ const deadline = now() + timeoutMs;
420
+ while (now() < deadline) {
421
+ if (connection.closed) {
422
+ throw new CdpError(
423
+ "The browser closed before sign-in completed.",
424
+ "Another moodle login or renewal may be using the same browser profile. Wait for it to finish, then retry."
425
+ );
426
+ }
427
+ const cookies = await connection.getCookies();
428
+ if (await options.isDone(cookies)) {
429
+ return { cookies, browserName: browser.name };
430
+ }
431
+ await sleep(pollIntervalMs);
432
+ }
433
+ throw new CdpError(
434
+ headless ? "Timed out renewing the session in the background browser." : "Timed out waiting for sign-in.",
435
+ headless ? "Run `moodle auth login` to sign in again." : "Complete the sign-in in the browser window, then retry."
436
+ );
437
+ } finally {
438
+ await connection.close();
439
+ }
440
+ }
441
+ var CdpConnection = class {
442
+ constructor(child, timeouts = {}) {
443
+ this.child = child;
444
+ this.timeouts = timeouts;
445
+ child.on("exit", () => {
446
+ this.exited = true;
447
+ clearTimeout(this.hardKill);
448
+ this.markClosed();
449
+ });
450
+ child.on("error", () => this.markClosed());
451
+ const toBrowser = child.stdio[3];
452
+ toBrowser?.on("error", () => this.markClosed());
453
+ const fromBrowser = child.stdio[4];
454
+ fromBrowser?.on("error", () => this.markClosed());
455
+ fromBrowser?.on("data", (chunk) => this.consume(chunk));
456
+ }
457
+ child;
458
+ timeouts;
459
+ nextId = 1;
460
+ buffer = "";
461
+ pending = /* @__PURE__ */ new Map();
462
+ closed = false;
463
+ exited = false;
464
+ hardKill;
465
+ markClosed() {
466
+ if (this.closed) return;
467
+ this.closed = true;
468
+ for (const resolve of this.pending.values()) resolve({ error: { message: "browser closed" } });
469
+ this.pending.clear();
470
+ }
471
+ consume(chunk) {
472
+ this.buffer += chunk.toString("utf8");
473
+ let index;
474
+ while ((index = this.buffer.indexOf("\0")) !== -1) {
475
+ const raw = this.buffer.slice(0, index);
476
+ this.buffer = this.buffer.slice(index + 1);
477
+ let message;
478
+ try {
479
+ message = JSON.parse(raw);
480
+ } catch {
481
+ continue;
482
+ }
483
+ if (typeof message.id === "number") {
484
+ this.pending.get(message.id)?.(message);
485
+ this.pending.delete(message.id);
486
+ }
487
+ }
488
+ }
489
+ call(method, params = {}, timeoutMs = this.timeouts.rpc ?? RPC_TIMEOUT_MS) {
490
+ if (this.closed) return Promise.resolve({ error: { message: "browser closed" } });
491
+ const id2 = this.nextId++;
492
+ const toBrowser = this.child.stdio[3];
493
+ const request = new Promise((resolve) => {
494
+ this.pending.set(id2, resolve);
495
+ try {
496
+ toBrowser?.write(`${JSON.stringify({ id: id2, method, params })}\0`);
497
+ } catch {
498
+ this.pending.delete(id2);
499
+ resolve({ error: { message: "pipe write failed" } });
500
+ }
501
+ });
502
+ return withTimeout(request, timeoutMs, () => {
503
+ this.pending.delete(id2);
504
+ return { error: { message: "timeout" } };
505
+ });
506
+ }
507
+ async handshake(timeoutMs) {
508
+ const version = await this.call("Browser.getVersion", {}, timeoutMs);
509
+ if (version.error || this.closed) {
510
+ throw new CdpError(
511
+ "Could not talk to the browser over remote debugging.",
512
+ "Update the browser, or run `moodle auth login --paste` instead."
513
+ );
514
+ }
515
+ }
516
+ async getCookies() {
517
+ const response = await this.call("Storage.getCookies");
518
+ const cookies = response.result?.cookies ?? [];
519
+ return cookies.map((cookie) => ({
520
+ name: String(cookie.name ?? ""),
521
+ value: String(cookie.value ?? ""),
522
+ domain: String(cookie.domain ?? ""),
523
+ path: typeof cookie.path === "string" ? cookie.path : void 0,
524
+ secure: Boolean(cookie.secure),
525
+ httpOnly: Boolean(cookie.httpOnly)
526
+ }));
527
+ }
528
+ async close() {
529
+ if (!this.closed) {
530
+ await this.call("Browser.close", {}, this.timeouts.close ?? CLOSE_TIMEOUT_MS);
531
+ }
532
+ if (this.exited) return;
533
+ if (!this.child.killed) this.child.kill();
534
+ if (!this.exited) {
535
+ this.hardKill = setTimeout(() => {
536
+ if (!this.exited) this.child.kill("SIGKILL");
537
+ }, KILL_GRACE_MS);
538
+ this.hardKill.unref?.();
539
+ }
540
+ }
541
+ };
542
+ async function isExecutable(path5) {
543
+ try {
544
+ await access2(path5, fsConstants.X_OK);
545
+ return true;
546
+ } catch {
547
+ return false;
548
+ }
549
+ }
550
+ function resolveFromPath(name, env, platform) {
551
+ if (name.includes("/")) return null;
552
+ const separator = platform === "win32" ? ";" : ":";
553
+ for (const dir of (env.PATH ?? "").split(separator).filter(Boolean)) {
554
+ const candidate = join2(dir, name);
555
+ try {
556
+ accessSync(candidate, fsConstants.X_OK);
557
+ return candidate;
558
+ } catch {
559
+ continue;
560
+ }
561
+ }
562
+ return null;
563
+ }
564
+
565
+ // src/mobile-login-core.ts
566
+ function parseLaunchToken(location) {
567
+ const match = /token=([^&#]+)/.exec(location);
568
+ if (!match) return null;
569
+ let decoded;
570
+ try {
571
+ decoded = Buffer.from(decodeURIComponent(match[1]), "base64").toString("utf8");
572
+ } catch {
573
+ return null;
574
+ }
575
+ const parts = decoded.split(":::");
576
+ if (parts.length < 2 || !parts[1]) return null;
577
+ return { wstoken: parts[1], privatetoken: parts[2] || void 0 };
578
+ }
579
+ async function readMobilePublicConfig(baseUrl, fetchImpl = fetch) {
580
+ const url = new URL(SERVICE_NOLOGIN_PATH, ensureTrailingSlash(baseUrl));
581
+ url.searchParams.set("info", FUNC_MOBILE_PUBLIC_CONFIG);
582
+ const body = JSON.stringify([{ index: 0, methodname: FUNC_MOBILE_PUBLIC_CONFIG, args: {} }]);
583
+ let response;
584
+ try {
585
+ response = await fetchImpl(url.toString(), {
586
+ method: "POST",
587
+ headers: { "content-type": "application/json" },
588
+ body
589
+ });
590
+ } catch {
591
+ return null;
592
+ }
593
+ if (!response.ok) return null;
594
+ let payload;
595
+ try {
596
+ payload = await response.json();
597
+ } catch {
598
+ return null;
599
+ }
600
+ const data = Array.isArray(payload) ? payload[0] : void 0;
601
+ if (!data || data.error) return null;
602
+ const config = data.data;
603
+ if (!config) return null;
604
+ return {
605
+ webserviceEnabled: config.enablewebservices === 1 || config.enablewebservices === true,
606
+ mobileServiceEnabled: config.enablemobilewebservice === 1 || config.enablemobilewebservice === true
607
+ };
608
+ }
609
+ async function fetchMobileToken(baseUrl, cookie, fetchImpl = fetch) {
610
+ const url = new URL(MOBILE_LAUNCH_PATH, ensureTrailingSlash(baseUrl));
611
+ url.searchParams.set("service", MOBILE_SERVICE_SHORTNAME);
612
+ url.searchParams.set("passport", randomPassport());
613
+ url.searchParams.set("urlscheme", MOBILE_URL_SCHEME);
614
+ let response;
615
+ try {
616
+ response = await fetchImpl(url.toString(), {
617
+ method: "GET",
618
+ headers: { "user-agent": MOBILE_USER_AGENT, cookie: `${cookie.name}=${cookie.value}` },
619
+ redirect: "manual"
620
+ });
621
+ } catch {
622
+ return null;
623
+ }
624
+ const location = response.headers.get("location");
625
+ if (location) {
626
+ await response.body?.cancel();
627
+ return parseLaunchToken(location);
628
+ }
629
+ const html = await response.text().catch(() => "");
630
+ const inline = new RegExp(`${MOBILE_URL_SCHEME}://[^"'\\s]*token=[^"'\\s]+`).exec(html);
631
+ return inline ? parseLaunchToken(inline[0]) : null;
632
+ }
633
+ async function mintSessionFromMobileToken(baseUrl, userid, token, fetchImpl = fetch) {
634
+ if (!token.privatetoken) return null;
635
+ const key = await requestAutologinKey(baseUrl, token, fetchImpl);
636
+ if (!key) return null;
637
+ return exchangeAutologinKey(baseUrl, userid, key, fetchImpl);
638
+ }
639
+ async function requestAutologinKey(baseUrl, token, fetchImpl) {
640
+ const url = new URL(WEBSERVICE_REST_PATH, ensureTrailingSlash(baseUrl));
641
+ url.searchParams.set("moodlewsrestformat", "json");
642
+ url.searchParams.set("wsfunction", FUNC_MOBILE_AUTOLOGIN_KEY);
643
+ url.searchParams.set("wstoken", token.wstoken);
644
+ const form = new URLSearchParams({ privatetoken: token.privatetoken ?? "" });
645
+ let response;
646
+ try {
647
+ response = await fetchImpl(url.toString(), {
648
+ method: "POST",
649
+ headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": MOBILE_USER_AGENT },
650
+ body: form.toString()
651
+ });
652
+ } catch {
653
+ return null;
654
+ }
655
+ if (!response.ok) return null;
656
+ let payload;
657
+ try {
658
+ payload = await response.json();
659
+ } catch {
660
+ return null;
661
+ }
662
+ if (!payload || typeof payload !== "object" || "exception" in payload) return null;
663
+ const key = payload.key;
664
+ return typeof key === "string" && key ? key : null;
665
+ }
666
+ async function exchangeAutologinKey(baseUrl, userid, key, fetchImpl) {
667
+ const url = new URL(MOBILE_AUTOLOGIN_PATH, ensureTrailingSlash(baseUrl));
668
+ url.searchParams.set("userid", String(userid));
669
+ url.searchParams.set("key", key);
670
+ let response;
671
+ try {
672
+ response = await fetchImpl(url.toString(), {
673
+ method: "GET",
674
+ headers: { "user-agent": MOBILE_USER_AGENT },
675
+ redirect: "manual"
676
+ });
677
+ } catch {
678
+ return null;
679
+ }
680
+ const value = extractSessionCookie(response);
681
+ return value ? { cookie: { name: value.name, value: value.value, source: "mobile-token" } } : null;
682
+ }
683
+ function extractSessionCookie(response) {
684
+ const headers = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [response.headers.get("set-cookie") ?? ""].filter(Boolean);
685
+ for (const header of headers) {
686
+ const match = new RegExp(`(${MOODLE_SESSION_COOKIE_PREFIX}\\w*)=([^;\\s]+)`).exec(header);
687
+ if (match && match[2] && match[2] !== "deleted") {
688
+ return { name: match[1], value: match[2] };
689
+ }
690
+ }
691
+ return null;
692
+ }
693
+ function ensureTrailingSlash(baseUrl) {
694
+ return `${baseUrl.replace(/\/+$/, "")}/`;
695
+ }
696
+ function randomPassport() {
697
+ return (Math.random() * 1e3).toFixed(10);
698
+ }
699
+
204
700
  // src/errors.ts
205
701
  import { CliError } from "@bunizao/cli-kit";
206
702
  import { CliError as CliError2 } from "@bunizao/cli-kit";
@@ -239,15 +735,18 @@ function isLoginRequiredError(error) {
239
735
  function isLoginErrorCode(code) {
240
736
  return ["servicerequireslogin", "sitepolicynotagreed"].includes(code ?? "");
241
737
  }
738
+ function asNetworkError(error) {
739
+ return error instanceof RequestFailed ? new CliError("network", error.message, "Check the connection or VPN, then retry.") : null;
740
+ }
242
741
 
243
742
  // src/session-cache.ts
244
743
  import { createHash, randomBytes } from "crypto";
245
744
 
246
745
  // src/mcp/credentials/node-store.ts
247
- import { spawn } from "child_process";
248
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
249
- import { homedir } from "os";
250
- import { dirname, join, win32 as windowsPath } from "path";
746
+ import { spawn as spawn2 } from "child_process";
747
+ import { chmod, mkdir as mkdir2, readFile, rename, rm, writeFile } from "fs/promises";
748
+ import { homedir as homedir3 } from "os";
749
+ import { dirname, join as join3, win32 as windowsPath } from "path";
251
750
 
252
751
  // src/mcp/credentials/store.ts
253
752
  var TOKEN_OVERLAP_MS = 10 * 60 * 1e3;
@@ -472,7 +971,7 @@ if ([IO.File]::Exists($payload.path)) { Remove-Item -LiteralPath $payload.path -
472
971
  var NodeCredentialCommandRunner = class {
473
972
  async run(command, args, input2) {
474
973
  return new Promise((resolve, reject) => {
475
- const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
974
+ const child = spawn2(command, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
476
975
  let stdout = "";
477
976
  let stderr = "";
478
977
  child.stdout.setEncoding("utf8").on("data", (chunk) => {
@@ -717,15 +1216,15 @@ var UnavailableCredentialBackend = class {
717
1216
  }
718
1217
  };
719
1218
  var PrivateFileCredentialBackend = class {
720
- constructor(baseDirectory = join(homedir(), ".config", "moodle-cli", "mcp", "credentials")) {
1219
+ constructor(baseDirectory = join3(homedir3(), ".config", "moodle-cli", "mcp", "credentials")) {
721
1220
  this.baseDirectory = baseDirectory;
722
1221
  }
723
1222
  baseDirectory;
724
1223
  name = "private credential file";
725
1224
  async read(profile) {
726
- const path4 = this.path(profile);
1225
+ const path5 = this.path(profile);
727
1226
  try {
728
- return parseCredentials(await readFile(path4, "utf8"));
1227
+ return parseCredentials(await readFile(path5, "utf8"));
729
1228
  } catch (error) {
730
1229
  if (isMissing(error)) {
731
1230
  return null;
@@ -734,30 +1233,30 @@ var PrivateFileCredentialBackend = class {
734
1233
  }
735
1234
  }
736
1235
  async write(profile, credentials) {
737
- const path4 = this.path(profile);
738
- const temporary = `${path4}.${process.pid}.tmp`;
739
- await mkdir(dirname(path4), { recursive: true, mode: 448 });
740
- await chmod(dirname(path4), 448);
1236
+ const path5 = this.path(profile);
1237
+ const temporary = `${path5}.${process.pid}.tmp`;
1238
+ await mkdir2(dirname(path5), { recursive: true, mode: 448 });
1239
+ await chmod(dirname(path5), 448);
741
1240
  await writeFile(temporary, `${JSON.stringify(credentials)}
742
1241
  `, { encoding: "utf8", mode: 384 });
743
1242
  await chmod(temporary, 384);
744
- await rename(temporary, path4);
745
- await chmod(path4, 384);
1243
+ await rename(temporary, path5);
1244
+ await chmod(path5, 384);
746
1245
  }
747
1246
  async delete(profile) {
748
1247
  await rm(this.path(profile), { force: true });
749
1248
  }
750
1249
  path(profile) {
751
1250
  validateProfile(profile);
752
- return join(this.baseDirectory, `${profile}.json`);
1251
+ return join3(this.baseDirectory, `${profile}.json`);
753
1252
  }
754
1253
  };
755
1254
  function createDefaultCredentialStore(options = {}) {
756
1255
  const platform = options.platform ?? process.platform;
757
1256
  const runner = options.runner ?? new NodeCredentialCommandRunner();
758
1257
  const preferred = platform === "darwin" ? new MacOSKeychainCredentialBackend(runner) : platform === "linux" ? new LinuxSecretServiceCredentialBackend(runner) : platform === "win32" ? new WindowsCredentialManagerBackend(runner) : new UnavailableCredentialBackend(`${platform} credential store`);
759
- const home = options.homeDirectory ?? homedir();
760
- const fallbackDirectory = platform === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join(home, ".config", "moodle-cli", "mcp", "credentials");
1258
+ const home = options.homeDirectory ?? homedir3();
1259
+ const fallbackDirectory = platform === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join3(home, ".config", "moodle-cli", "mcp", "credentials");
761
1260
  const fallback = platform === "win32" ? new WindowsDpapiFileCredentialBackend(fallbackDirectory, runner) : new PrivateFileCredentialBackend(fallbackDirectory);
762
1261
  return new SafeCredentialStore(preferred, fallback, platform !== "win32");
763
1262
  }
@@ -798,9 +1297,9 @@ function windowsCredentialInput(profile, credentials) {
798
1297
  ...credentials ? { credentials: JSON.stringify(credentials) } : {}
799
1298
  });
800
1299
  }
801
- function windowsDpapiInput(path4, credentials) {
1300
+ function windowsDpapiInput(path5, credentials) {
802
1301
  return JSON.stringify({
803
- path: path4,
1302
+ path: path5,
804
1303
  ...credentials ? { credentials: JSON.stringify(credentials) } : {}
805
1304
  });
806
1305
  }
@@ -894,26 +1393,26 @@ function isRecord(value) {
894
1393
  }
895
1394
 
896
1395
  // src/session-cache.ts
897
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
898
- import { homedir as homedir2 } from "os";
899
- import { dirname as dirname2, join as join2 } from "path";
900
- var nodeFs = { readFile: readFile2, writeFile: writeFile2, mkdir: mkdir2, rm: rm2, chmod: chmod2 };
901
- function sessionCachePath(homeDir = homedir2()) {
902
- return join2(homeDir, CACHE_DIR_NAME, SESSION_CACHE_FILENAME);
1396
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1397
+ import { homedir as homedir4 } from "os";
1398
+ import { dirname as dirname2, join as join4 } from "path";
1399
+ var nodeFs = { readFile: readFile2, writeFile: writeFile2, mkdir: mkdir3, rm: rm2, chmod: chmod2 };
1400
+ function sessionCachePath(homeDir = homedir4()) {
1401
+ return join4(homeDir, CACHE_DIR_NAME, SESSION_CACHE_FILENAME);
903
1402
  }
904
1403
  function isCachedSessionFresh(session, ttlMs = DEFAULT_SESSION_CACHE_TTL_MS, now = Date.now) {
905
1404
  const age = now() - session.savedAt;
906
- return age >= 0 && age <= ttlMs;
1405
+ return !session.cookieInvalidated && age >= 0 && age <= ttlMs;
907
1406
  }
908
1407
  async function readCachedSession(baseUrl, options = {}) {
909
1408
  if (options.noCache) {
910
1409
  return null;
911
1410
  }
912
1411
  const fs = options.fs ?? nodeFs;
913
- const path4 = sessionCachePath(options.homeDir);
1412
+ const path5 = sessionCachePath(options.homeDir);
914
1413
  let raw;
915
1414
  try {
916
- raw = await fs.readFile(path4, "utf8");
1415
+ raw = await fs.readFile(path5, "utf8");
917
1416
  } catch (error) {
918
1417
  if (isMissingFileError(error)) {
919
1418
  return null;
@@ -931,28 +1430,28 @@ async function readCachedSession(baseUrl, options = {}) {
931
1430
  if (session) await writeCachedSession(session, { ...options, noCache: false });
932
1431
  }
933
1432
  } catch {
934
- if (parseCachedSession(raw)) await fs.rm(path4, { force: true });
1433
+ if (parseCachedSession(raw)) await fs.rm(path5, { force: true });
935
1434
  return null;
936
1435
  }
937
1436
  if (!session || !sameBaseUrl(session.baseUrl, baseUrl)) {
938
1437
  return null;
939
1438
  }
940
1439
  const ttlMs = options.ttlMs ?? DEFAULT_SESSION_CACHE_TTL_MS;
941
- return isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
1440
+ return options.allowExpired || isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
942
1441
  }
943
1442
  async function writeCachedSession(session, options = {}) {
944
1443
  if (options.noCache) return;
945
1444
  const fs = options.fs ?? nodeFs;
946
- const path4 = sessionCachePath(options.homeDir);
1445
+ const path5 = sessionCachePath(options.homeDir);
947
1446
  const keyring = await createEncryptionKeyring(await cacheEncryptionKey(options));
948
1447
  const encrypted = { version: 2, encrypted_session: await encryptValue(JSON.stringify(session), keyring) };
949
- await fs.mkdir(dirname2(path4), { recursive: true, mode: 448 });
950
- await fs.writeFile(path4, `${JSON.stringify(encrypted)}
1448
+ await fs.mkdir(dirname2(path5), { recursive: true, mode: 448 });
1449
+ await fs.writeFile(path5, `${JSON.stringify(encrypted)}
951
1450
  `, { encoding: "utf8", mode: 384 });
952
- await fs.chmod(path4, 384);
1451
+ await fs.chmod(path5, 384);
953
1452
  }
954
1453
  async function deleteCachedSession(baseUrl, options = {}) {
955
- const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, ttlMs: Number.MAX_SAFE_INTEGER });
1454
+ const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, allowExpired: true });
956
1455
  if (!current2) {
957
1456
  return;
958
1457
  }
@@ -986,9 +1485,12 @@ function parseCachedSession(raw) {
986
1485
  sesskey: session.sesskey,
987
1486
  userid: session.userid,
988
1487
  savedAt: session.savedAt,
1488
+ ...session.cookieInvalidated === true ? { cookieInvalidated: true } : {},
989
1489
  ...typeof session.cookieSource === "string" ? { cookieSource: session.cookieSource } : {},
990
1490
  ...Array.isArray(session.unavailable) && session.unavailable.every((name) => typeof name === "string") ? { unavailable: session.unavailable } : {},
991
- ...isRecord2(session.user) && typeof session.user.fullname === "string" && typeof session.user.userid === "number" ? { user: session.user } : {}
1491
+ ...isRecord2(session.user) && typeof session.user.fullname === "string" && typeof session.user.userid === "number" ? { user: session.user } : {},
1492
+ ...isRecord2(session.mobileToken) && typeof session.mobileToken.wstoken === "string" ? { mobileToken: { wstoken: session.mobileToken.wstoken, ...typeof session.mobileToken.privatetoken === "string" ? { privatetoken: session.mobileToken.privatetoken } : {} } } : {},
1493
+ ...typeof session.mobileServiceEnabled === "boolean" ? { mobileServiceEnabled: session.mobileServiceEnabled } : {}
992
1494
  };
993
1495
  }
994
1496
  function sameBaseUrl(left, right) {
@@ -1007,7 +1509,7 @@ function isMissingFileError(error) {
1007
1509
  var pendingCacheKeys = /* @__PURE__ */ new Map();
1008
1510
  async function cacheEncryptionKey(options) {
1009
1511
  if (options.encryptionKey) return options.encryptionKey();
1010
- const homeDirectory = options.homeDir ?? homedir2();
1512
+ const homeDirectory = options.homeDir ?? homedir4();
1011
1513
  let pending = pendingCacheKeys.get(homeDirectory);
1012
1514
  if (!pending) {
1013
1515
  pending = (async () => {
@@ -1026,6 +1528,23 @@ async function cacheEncryptionKey(options) {
1026
1528
  }
1027
1529
 
1028
1530
  // src/auth.ts
1531
+ var COOKIE_STORE_TIMEOUT_MS = 8e3;
1532
+ function withTimeoutValue(promise, ms, onTimeout) {
1533
+ return new Promise((resolve, reject) => {
1534
+ const timer = setTimeout(() => resolve(onTimeout()), ms);
1535
+ timer.unref?.();
1536
+ promise.then(
1537
+ (value) => {
1538
+ clearTimeout(timer);
1539
+ resolve(value);
1540
+ },
1541
+ (error) => {
1542
+ clearTimeout(timer);
1543
+ reject(error instanceof Error ? error : new Error(String(error)));
1544
+ }
1545
+ );
1546
+ });
1547
+ }
1029
1548
  async function getAuthenticatedSession(baseUrl, options = {}) {
1030
1549
  const envSession = loadSessionFromEnv(options.env);
1031
1550
  const validate = options.validateSession ?? validateSessionWithFetch(options);
@@ -1044,6 +1563,10 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
1044
1563
  if (cached) {
1045
1564
  return cached;
1046
1565
  }
1566
+ const minted = await mintFromStoredToken(baseUrl, options, validate);
1567
+ if (minted) {
1568
+ return minted;
1569
+ }
1047
1570
  const cookieWarnings = [];
1048
1571
  const providerOptions = {
1049
1572
  ...options,
@@ -1059,17 +1582,25 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
1059
1582
  await refreshSessionCache(baseUrl, browserSession.cookie, browserSession.context, options);
1060
1583
  return { baseUrl, cookie: browserSession.cookie, ...browserSession.context, fromCache: false };
1061
1584
  }
1585
+ const stores = await browserCookieStores({ homeDir: options.homeDir, platform: options.platform });
1062
1586
  throw new AuthError(
1063
1587
  `No usable MoodleSession found for ${baseUrl}.`,
1064
- authFailureHint(baseUrl, cookieWarnings, options.platform)
1588
+ authFailureHint(baseUrl, cookieWarnings, options.platform, unreadableCookieStores(stores), options.env)
1065
1589
  );
1066
1590
  }
1067
1591
  async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {}) {
1068
1592
  const cookieWarnings = [];
1593
+ const rawProvider = options.browserCookieProvider ?? defaultBrowserCookieProvider;
1594
+ const cookieStoreTimeoutMs = options.cookieStoreTimeoutMs ?? COOKIE_STORE_TIMEOUT_MS;
1595
+ const boundedProvider = (url, opts) => withTimeoutValue(rawProvider(url, opts), cookieStoreTimeoutMs, () => {
1596
+ opts.onCookieWarnings?.(["Reading the browser cookie store timed out; opening a browser to sign in."]);
1597
+ return [];
1598
+ });
1069
1599
  const authOptions = {
1070
1600
  ...options,
1071
1601
  noCache: true,
1072
1602
  nonInteractive: true,
1603
+ browserCookieProvider: boundedProvider,
1073
1604
  onCookieWarnings: (warnings) => {
1074
1605
  cookieWarnings.push(...warnings);
1075
1606
  options.onCookieWarnings?.(warnings);
@@ -1095,34 +1626,92 @@ async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {})
1095
1626
  }
1096
1627
  }
1097
1628
  }
1098
- if (cookieAccessBlocked(cookieWarnings)) {
1099
- throw new AuthError(
1100
- `Cannot read browser cookies for ${baseUrl}.`,
1101
- cookieAccessHint(cookieWarnings, options.platform)
1102
- );
1629
+ const probeBrowser = options.findBrowser ?? findChromiumBrowser;
1630
+ const hasBrowser = Boolean(await probeBrowser({ ...options }));
1631
+ if (!hasBrowser) {
1632
+ const stores = await browserCookieStores({ homeDir: options.homeDir, platform: options.platform });
1633
+ if (cookieAccessBlocked(cookieWarnings) || cookieStoresBlocked(stores)) {
1634
+ throw new AuthError(
1635
+ `Cannot read browser cookies for ${baseUrl}.`,
1636
+ cookieAccessHint(cookieWarnings, options.platform, unreadableCookieStores(stores), options.env)
1637
+ );
1638
+ }
1103
1639
  }
1640
+ return loginViaCdp(baseUrl, { ...options, ...browserAuthOptions });
1641
+ }
1642
+ function toSessionCookie(cookie) {
1643
+ return { name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, source: "cdp" };
1644
+ }
1645
+ async function loginViaCdp(baseUrl, options) {
1646
+ const validate = options.validateSession ?? validateSessionWithFetch(options);
1647
+ const runCdp = options.cdpLogin ?? loginWithCdp;
1104
1648
  const url = loginUrl(baseUrl);
1105
- await (options.openBrowser ?? ((target) => openSystemBrowser(target, options)))(url);
1106
- options.onBrowserOpened?.(url);
1107
- const pollIntervalMs = options.browserLoginPollIntervalMs ?? 1e3;
1108
- const timeoutMs = options.browserLoginTimeoutMs ?? 12e4;
1109
- const attempts = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs));
1110
- const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1111
- const pollOptions = browserAuthOptions;
1112
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1113
- await sleep(pollIntervalMs);
1114
- try {
1115
- return await getAuthenticatedSession(baseUrl, pollOptions);
1116
- } catch (error) {
1117
- if (!(error instanceof AuthError)) {
1118
- throw error;
1649
+ let resolved = null;
1650
+ let lastChecked = "";
1651
+ try {
1652
+ await runCdp({
1653
+ url,
1654
+ headless: options.headlessCdp ?? false,
1655
+ homeDir: options.homeDir,
1656
+ platform: options.platform,
1657
+ env: options.env,
1658
+ onOpened: () => options.onBrowserOpened?.(url),
1659
+ isDone: async (cookies) => {
1660
+ if (resolved) return true;
1661
+ const top = matchingMoodleSessionCookies(cookies.map(toSessionCookie), baseUrl)[0];
1662
+ if (!top || top.value === lastChecked) return false;
1663
+ lastChecked = top.value;
1664
+ const context = await validate(baseUrl, top);
1665
+ if (context) {
1666
+ resolved = { cookie: top, context };
1667
+ return true;
1668
+ }
1669
+ return false;
1119
1670
  }
1671
+ });
1672
+ } catch (error) {
1673
+ if (error instanceof CdpError) {
1674
+ throw new AuthError(error.message, error.hint ?? authFailureHint(baseUrl, [], options.platform, [], options.env));
1120
1675
  }
1676
+ throw error;
1121
1677
  }
1122
- throw new AuthError(
1123
- `Timed out waiting for browser login at ${baseUrl}.`,
1124
- `Complete the login in your browser, then rerun: moodle auth login`
1125
- );
1678
+ const session = resolved;
1679
+ if (!session) {
1680
+ throw new AuthError(`Sign-in did not complete for ${baseUrl}.`, `Run: moodle auth login`);
1681
+ }
1682
+ await refreshSessionCache(baseUrl, session.cookie, session.context, { ...options, noCache: false });
1683
+ return { baseUrl, cookie: session.cookie, ...session.context, fromCache: false };
1684
+ }
1685
+ function parsePastedSessionCookie(raw) {
1686
+ const text2 = raw.trim();
1687
+ if (!text2) {
1688
+ return null;
1689
+ }
1690
+ const pair = /\b(MoodleSession\w*)=([^;\s'"\\]+)/.exec(text2);
1691
+ if (pair) {
1692
+ return { name: pair[1], value: pair[2], source: "paste" };
1693
+ }
1694
+ return /[\s=;]/.test(text2) ? null : { name: MOODLE_SESSION_COOKIE_PREFIX, value: text2, source: "paste" };
1695
+ }
1696
+ async function authenticateWithPastedCookie(baseUrl, raw, options = {}) {
1697
+ const cookie = parsePastedSessionCookie(raw);
1698
+ if (!cookie) {
1699
+ throw new AuthError(`That is not a ${MOODLE_SESSION_COOKIE_PREFIX} cookie value.`, pastedCookieHint(baseUrl));
1700
+ }
1701
+ const validate = options.validateSession ?? validateSessionWithFetch(options);
1702
+ const context = await validate(baseUrl, cookie);
1703
+ if (!context) {
1704
+ throw new AuthError(`The pasted cookie did not authenticate for ${baseUrl}.`, pastedCookieHint(baseUrl));
1705
+ }
1706
+ await refreshSessionCache(baseUrl, cookie, context, { ...options, noCache: false });
1707
+ return { baseUrl, cookie, ...context, fromCache: false };
1708
+ }
1709
+ function pastedCookieHint(baseUrl) {
1710
+ return [
1711
+ `Sign in at ${loginUrl(baseUrl)}, then open the browser developer tools.`,
1712
+ 'In the Network tab, right-click any request to the site and choose "Copy as cURL", then paste the whole command.',
1713
+ `Copying the ${MOODLE_SESSION_COOKIE_PREFIX} value from Application (or Storage) > Cookies works too.`
1714
+ ].join("\n");
1126
1715
  }
1127
1716
  function loadSessionFromEnv(env = process.env) {
1128
1717
  const source = env[ENV_MOODLE_TOKEN] ? ENV_MOODLE_TOKEN : ENV_MOODLE_SESSION;
@@ -1174,17 +1763,17 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
1174
1763
  }));
1175
1764
  }
1176
1765
  async function braveProfilePaths(options = {}) {
1177
- const home = options.homeDir ?? homedir3();
1766
+ const home = options.homeDir ?? homedir5();
1178
1767
  const platform = options.platform ?? process.platform;
1179
1768
  const roots = platform === "linux" ? [
1180
- join3(home, ".config/BraveSoftware/Brave-Browser"),
1181
- join3(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
1182
- ] : platform === "win32" ? [join3(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join3(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
1769
+ join5(home, ".config/BraveSoftware/Brave-Browser"),
1770
+ join5(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
1771
+ ] : platform === "win32" ? [join5(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join5(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
1183
1772
  const profiles = [];
1184
1773
  for (const root of roots) {
1185
1774
  try {
1186
1775
  profiles.push(
1187
- ...(await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => name === "Default" || name === "Guest Profile" || name.startsWith("Profile ")).sort().map((name) => join3(root, name))
1776
+ ...(await readdir2(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => name === "Default" || name === "Guest Profile" || name.startsWith("Profile ")).sort().map((name) => join5(root, name))
1188
1777
  );
1189
1778
  } catch {
1190
1779
  continue;
@@ -1192,46 +1781,70 @@ async function braveProfilePaths(options = {}) {
1192
1781
  }
1193
1782
  return profiles;
1194
1783
  }
1784
+ var FULL_DISK_ACCESS_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
1785
+ var TERMINAL_APPLICATIONS = {
1786
+ Apple_Terminal: "Terminal",
1787
+ ghostty: "Ghostty",
1788
+ Hyper: "Hyper",
1789
+ "iTerm.app": "iTerm",
1790
+ Tabby: "Tabby",
1791
+ vscode: "Visual Studio Code",
1792
+ WarpTerminal: "Warp",
1793
+ WezTerm: "WezTerm"
1794
+ };
1795
+ function hostApplicationName(env = process.env) {
1796
+ const program = env.TERM_PROGRAM?.trim();
1797
+ return program ? TERMINAL_APPLICATIONS[program] ?? program : null;
1798
+ }
1195
1799
  var COOKIE_ACCESS_DENIED = /EPERM|EACCES|operation not permitted|permission denied/i;
1196
1800
  var COOKIE_SQLITE_UNAVAILABLE = /No such built-in module: node:sqlite/i;
1197
1801
  var MINIMUM_NODE_FOR_BROWSER_COOKIES = "22.13.0";
1198
1802
  function cookieAccessBlocked(warnings) {
1199
1803
  return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning));
1200
1804
  }
1201
- function cookieAccessHint(warnings, platform = process.platform) {
1202
- const grant = platform === "darwin" ? "Grant Full Disk Access to the application running this command (System Settings > Privacy & Security > Full Disk Access), then restart it." : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
1203
- const remedy = warnings.some((warning) => COOKIE_SQLITE_UNAVAILABLE.test(warning)) ? [
1204
- `This Node.js runtime has no node:sqlite, which is needed to read browser cookies. Use Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or run the CLI with Bun (bunx --bun moodle-cli).`
1205
- ] : [
1206
- "If this runs inside a sandboxed app (an IDE or agent terminal), rerun it from a regular terminal first.",
1207
- grant
1208
- ];
1805
+ function cookieAccessHint(warnings, platform = process.platform, unreadable = [], env = process.env) {
1806
+ const grant = platform === "darwin" ? [
1807
+ `Grant Full Disk Access to ${hostApplicationName(env) ?? "the application running this command"}, then restart it:`,
1808
+ ` open "${FULL_DISK_ACCESS_PANE}"`
1809
+ ].join("\n") : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
1810
+ const remedy = [];
1811
+ if (warnings.some((warning) => COOKIE_SQLITE_UNAVAILABLE.test(warning))) {
1812
+ remedy.push(
1813
+ `This Node.js runtime has no node:sqlite, which is needed to read browser cookies. Use Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or run the CLI with Bun (bunx --bun moodle-cli).`
1814
+ );
1815
+ }
1816
+ if (unreadable.length || !remedy.length) {
1817
+ remedy.push(
1818
+ "If this runs inside a sandboxed app (an IDE or agent terminal), rerun it from a regular terminal first.",
1819
+ grant
1820
+ );
1821
+ }
1209
1822
  return [
1210
1823
  "The browser cookie store could not be read, so the session could not be detected.",
1211
1824
  ...remedy,
1825
+ "Or skip the store entirely: `moodle auth login --paste` takes the cookie by hand and caches it.",
1212
1826
  "Run moodle doctor for runtime and browser diagnostics.",
1213
- `Alternatively set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
1214
- "",
1215
- "Cookie store diagnostics:",
1216
- ...warnings.map((warning) => ` - ${warning}`)
1827
+ ...cookieDiagnostics(warnings, unreadable)
1217
1828
  ].join("\n");
1218
1829
  }
1219
- function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform) {
1220
- if (cookieAccessBlocked(cookieWarnings)) {
1221
- return cookieAccessHint(cookieWarnings, platform);
1222
- }
1830
+ function cookieDiagnostics(warnings, unreadable) {
1831
+ const probed = unreadable.map((store) => store.path);
1223
1832
  const lines = [
1224
- `Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
1225
- "Or run `moodle auth login` to sign in through a browser window this command controls.",
1226
- `Or set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`
1833
+ ...unreadable.map((store) => ` - ${store.browser} cookie store exists but cannot be opened: ${store.path}`),
1834
+ ...warnings.filter((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning)).filter((warning) => !probed.some((path5) => warning.includes(path5))).map((warning) => ` - ${warning}`)
1227
1835
  ];
1228
- if (cookieWarnings.length) {
1229
- lines.push("", "Cookie store diagnostics:", ...cookieWarnings.map((warning) => ` - ${warning}`));
1230
- }
1231
- return lines.join("\n");
1836
+ return lines.length ? ["", "Cookie store diagnostics:", ...lines] : [];
1232
1837
  }
1233
- async function invalidateCachedSession(baseUrl, options = {}) {
1234
- await deleteCachedSession(baseUrl, cacheOptions(options));
1838
+ function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform, unreadable = [], env = process.env) {
1839
+ if (cookieAccessBlocked(cookieWarnings) || unreadable.length) {
1840
+ return cookieAccessHint(cookieWarnings, platform, unreadable, env);
1841
+ }
1842
+ return [
1843
+ `Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
1844
+ "Or run `moodle auth login` to sign in through a browser window this command controls.",
1845
+ "Or run `moodle auth login --paste` to hand over the cookie yourself.",
1846
+ ...cookieDiagnostics(cookieWarnings, unreadable)
1847
+ ].join("\n");
1235
1848
  }
1236
1849
  function parseSessionContext(html) {
1237
1850
  const sesskey = firstMatch(html, [
@@ -1259,7 +1872,11 @@ function validateSessionWithFetch(options) {
1259
1872
  let response;
1260
1873
  try {
1261
1874
  response = await fetchWithSession(`${baseUrl}${DASHBOARD_PATH}`, {}, baseUrl, cookie, fetcher);
1262
- } catch {
1875
+ } catch (error) {
1876
+ const network = asNetworkError(error);
1877
+ if (network) {
1878
+ throw network;
1879
+ }
1263
1880
  return null;
1264
1881
  }
1265
1882
  if (response.status >= 400 || isLoginRedirect(response.url, baseUrl)) {
@@ -1300,16 +1917,59 @@ async function refreshSessionCache(baseUrl, cookie, context, options) {
1300
1917
  savedAt: (options.now ?? Date.now)()
1301
1918
  };
1302
1919
  try {
1303
- const previous = await readCachedSession(baseUrl, { ...cacheOptions(options), ttlMs: Number.MAX_SAFE_INTEGER });
1920
+ const previous = await readCachedSession(baseUrl, { ...cacheOptions(options), allowExpired: true });
1921
+ if (typeof previous?.mobileServiceEnabled === "boolean") session.mobileServiceEnabled = previous.mobileServiceEnabled;
1304
1922
  if (previous?.userid === context.userid) {
1305
1923
  if (previous.unavailable?.length) session.unavailable = previous.unavailable;
1306
1924
  if (previous.user) session.user = previous.user;
1925
+ if (previous.mobileToken?.privatetoken) session.mobileToken = previous.mobileToken;
1926
+ }
1927
+ const newCookie = !previous || previous.cookieValue !== cookie.value;
1928
+ if (!session.mobileToken && newCookie && options.captureMobileToken && session.mobileServiceEnabled !== false) {
1929
+ const captured = await captureMobileToken(baseUrl, cookie, options);
1930
+ session.mobileServiceEnabled = captured.supported;
1931
+ if (captured.token) session.mobileToken = captured.token;
1307
1932
  }
1308
1933
  await writeCachedSession(session, cacheOptions(options));
1309
1934
  } catch {
1310
1935
  return;
1311
1936
  }
1312
1937
  }
1938
+ async function captureMobileToken(baseUrl, cookie, options) {
1939
+ const fetchImpl = options.fetch ?? globalThis.fetch;
1940
+ try {
1941
+ const config = await readMobilePublicConfig(baseUrl, fetchImpl);
1942
+ if (config && !config.mobileServiceEnabled) return { supported: false };
1943
+ const token = await fetchMobileToken(baseUrl, cookie, fetchImpl) ?? void 0;
1944
+ return { supported: config?.mobileServiceEnabled ?? (token ? true : void 0), token };
1945
+ } catch {
1946
+ return {};
1947
+ }
1948
+ }
1949
+ async function mintValidatedSession(baseUrl, stored, options = {}) {
1950
+ if (!stored.mobileToken?.privatetoken) return null;
1951
+ const fetchImpl = options.fetch ?? globalThis.fetch;
1952
+ const minted = await mintSessionFromMobileToken(baseUrl, stored.userid, stored.mobileToken, fetchImpl);
1953
+ if (!minted) return null;
1954
+ const cookie = { name: minted.cookie.name, value: minted.cookie.value, source: minted.cookie.source };
1955
+ const validate = options.validateSession ?? validateSessionWithFetch(options);
1956
+ const context = await validate(baseUrl, cookie);
1957
+ if (!context || context.userid === 0 || context.userid !== stored.userid) return null;
1958
+ return { cookie, context };
1959
+ }
1960
+ async function mintFromStoredToken(baseUrl, options, validate) {
1961
+ let stored;
1962
+ try {
1963
+ stored = await readCachedSession(baseUrl, { ...cacheOptions(options), allowExpired: true });
1964
+ } catch {
1965
+ return null;
1966
+ }
1967
+ if (!stored?.mobileToken?.privatetoken) return null;
1968
+ const result = await mintValidatedSession(baseUrl, stored, { ...options, validateSession: validate });
1969
+ if (!result) return null;
1970
+ await refreshSessionCache(baseUrl, result.cookie, result.context, options);
1971
+ return { baseUrl, cookie: result.cookie, ...result.context, fromCache: false };
1972
+ }
1313
1973
  function cachedSessionToAuth(baseUrl, cached) {
1314
1974
  return {
1315
1975
  baseUrl,
@@ -1347,8 +2007,8 @@ function isLoginRedirect(responseUrl, baseUrl) {
1347
2007
  if (!responseUrl) {
1348
2008
  return false;
1349
2009
  }
1350
- const path4 = new URL(responseUrl, baseUrl).pathname;
1351
- return path4 === LOGIN_PATH || path4.startsWith("/login/");
2010
+ const path5 = new URL(responseUrl, baseUrl).pathname;
2011
+ return path5 === LOGIN_PATH || path5.startsWith("/login/");
1352
2012
  }
1353
2013
  function looksLikeLoginPage(html) {
1354
2014
  return /name=["']username["']/i.test(html) && /name=["']password["']/i.test(html);
@@ -1365,40 +2025,36 @@ function firstMatch(value, patterns) {
1365
2025
  function decodeHtml(value) {
1366
2026
  return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
1367
2027
  }
1368
- async function openSystemBrowser(url, options) {
1369
- const platform = options.platform ?? process.platform;
1370
- const command = platform === "darwin" ? { file: "open", args: [url] } : platform === "win32" ? { file: "cmd", args: ["/c", "start", "", url] } : { file: "xdg-open", args: [url] };
1371
- const result = await (options.execFile ?? defaultExecFile)(command.file, command.args);
1372
- if (result.exitCode !== 0) {
1373
- throw new AuthError(
1374
- `Could not open the browser for Moodle login.`,
1375
- `Open ${url} manually, then rerun: moodle auth login`
1376
- );
1377
- }
1378
- }
1379
- var defaultExecFile = (file2, args) => new Promise((resolve) => {
1380
- execFileCallback(file2, args, { encoding: "utf8" }, (error, stdout, stderr) => {
1381
- const errorWithCode = error;
1382
- resolve({
1383
- stdout: String(stdout ?? ""),
1384
- stderr: String(stderr ?? ""),
1385
- exitCode: errorWithCode ? Number(errorWithCode.code) || 1 : 0
1386
- });
1387
- });
1388
- });
1389
2028
 
1390
2029
  // src/config.ts
1391
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1392
- import { homedir as homedir4 } from "os";
1393
- import { dirname as dirname3, join as join4 } from "path";
1394
- import { createInterface } from "readline/promises";
2030
+ import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
2031
+ import { homedir as homedir6 } from "os";
2032
+ import { dirname as dirname3, join as join6 } from "path";
2033
+ import { createUi, isAgentEnvironment } from "@bunizao/cli-kit";
2034
+
2035
+ // src/wordmark.ts
2036
+ var MOODLE_TAGLINE = "Read Moodle and submit work from the command line.";
2037
+ var MOODLE_WORDMARK = [
2038
+ " _ _",
2039
+ " _ __ ___ ___ __| | |___",
2040
+ " | ' \\/ _ \\/ _ \\/ _` | / -_)",
2041
+ " |_|_|_\\___/\\___/\\__,_|_\\___|"
2042
+ ].join("\n");
2043
+ var shown = false;
2044
+ function showWordmark(ui) {
2045
+ if (shown) return;
2046
+ shown = true;
2047
+ ui.banner(MOODLE_WORDMARK, MOODLE_TAGLINE);
2048
+ }
2049
+
2050
+ // src/config.ts
1395
2051
  import YAML from "yaml";
1396
- var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir3 };
2052
+ var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir4 };
1397
2053
  function cwdConfigPath(cwd = process.cwd()) {
1398
- return join4(cwd, CONFIG_FILENAME);
2054
+ return join6(cwd, CONFIG_FILENAME);
1399
2055
  }
1400
- function userConfigPath(homeDir = homedir4()) {
1401
- return join4(homeDir, CONFIG_DIR_NAME, CONFIG_FILENAME);
2056
+ function userConfigPath(homeDir = homedir6()) {
2057
+ return join6(homeDir, CONFIG_DIR_NAME, CONFIG_FILENAME);
1402
2058
  }
1403
2059
  function normalizeBaseUrl(value) {
1404
2060
  const raw = value.trim();
@@ -1453,29 +2109,32 @@ async function loadConfig(options = {}) {
1453
2109
  return toMoodleConfig(loaded.config, baseUrl);
1454
2110
  }
1455
2111
  async function promptForBaseUrl(options = {}) {
1456
- const prompt = options.prompt ?? defaultPrompt(options);
1457
- const output = options.stderr ?? process.stderr;
1458
- output.write("Configuration required\n");
1459
- output.write("Moodle base URL is not configured yet.\n");
1460
- output.write(`Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`}. Browser SQLite needs Bun or Node 22.13+. Run moodle doctor for diagnostics.
1461
- `);
1462
- output.write("Required format: https://school.example.edu\n");
1463
- output.write("Use the site root only. Do not include paths like /login/index.php or /my/.\n");
2112
+ const ui = createUi({ input: process.stdin, output: options.stderr ?? process.stderr, ...options.prompt ? { interactive: false } : {} });
2113
+ const prompt = options.prompt ?? defaultPrompt(ui);
2114
+ showWordmark(ui);
2115
+ ui.intro("Moodle setup");
2116
+ ui.note([
2117
+ "Moodle base URL is not configured yet.",
2118
+ `Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`}. Browser SQLite needs Bun or Node 22.13+. Run moodle doctor for diagnostics.`,
2119
+ "Use the site root only, for example https://school.example.edu.",
2120
+ "Do not include paths like /login/index.php or /my/."
2121
+ ].join("\n"), "Configuration required");
1464
2122
  while (true) {
1465
2123
  let baseUrl;
1466
2124
  try {
1467
2125
  baseUrl = normalizeBaseUrl(await prompt("Moodle base URL"));
1468
2126
  } catch (error) {
1469
- output.write(`Invalid URL: ${error instanceof Error ? error.message : String(error)}
1470
- `);
2127
+ ui.warn(`Invalid URL: ${error instanceof Error ? error.message : String(error)}`);
1471
2128
  continue;
1472
2129
  }
1473
- const probe = await probeBaseUrl(baseUrl, options);
1474
- if (probe.ok) {
2130
+ const spin = ui.spinner();
2131
+ spin.start(`Checking ${baseUrl}`);
2132
+ const probe2 = await probeBaseUrl(baseUrl, options);
2133
+ if (probe2.ok) {
2134
+ spin.stop(`${baseUrl} looks like Moodle`);
1475
2135
  return baseUrl;
1476
2136
  }
1477
- output.write(`Validation failed: ${probe.message ?? "site did not look like Moodle"}
1478
- `);
2137
+ spin.error(`Validation failed: ${probe2.message ?? "site did not look like Moodle"}`);
1479
2138
  }
1480
2139
  }
1481
2140
  async function probeBaseUrl(baseUrl, options = {}) {
@@ -1523,19 +2182,19 @@ async function loadExistingConfig(options) {
1523
2182
  const paths = [explicitPath, cwdConfigPath(options.cwd), userConfigPath(options.homeDir)].filter(
1524
2183
  (value) => Boolean(value)
1525
2184
  );
1526
- for (const path4 of paths) {
1527
- const config = await readConfigFile(path4, options);
2185
+ for (const path5 of paths) {
2186
+ const config = await readConfigFile(path5, options);
1528
2187
  if (config) {
1529
- return { config, path: path4 };
2188
+ return { config, path: path5 };
1530
2189
  }
1531
2190
  }
1532
2191
  return { config: {}, path: explicitPath ?? null };
1533
2192
  }
1534
- async function readConfigFile(path4, options) {
2193
+ async function readConfigFile(path5, options) {
1535
2194
  const fs = options.fs ?? nodeFs2;
1536
2195
  let raw;
1537
2196
  try {
1538
- raw = await fs.readFile(path4, "utf8");
2197
+ raw = await fs.readFile(path5, "utf8");
1539
2198
  } catch (error) {
1540
2199
  if (isMissingFileError2(error)) {
1541
2200
  return null;
@@ -1544,34 +2203,24 @@ async function readConfigFile(path4, options) {
1544
2203
  }
1545
2204
  const parsed = YAML.parse(raw) ?? {};
1546
2205
  if (!isRecord3(parsed)) {
1547
- throw new ConfigError(`${path4} must contain a YAML object.`);
2206
+ throw new ConfigError(`${path5} must contain a YAML object.`);
1548
2207
  }
1549
2208
  return parsed;
1550
2209
  }
1551
- async function saveConfigFile(path4, config, options) {
2210
+ async function saveConfigFile(path5, config, options) {
1552
2211
  const fs = options.fs ?? nodeFs2;
1553
- await fs.mkdir(dirname3(path4), { recursive: true });
1554
- await fs.writeFile(path4, YAML.stringify(config, { sortMapEntries: true }), "utf8");
2212
+ await fs.mkdir(dirname3(path5), { recursive: true });
2213
+ await fs.writeFile(path5, YAML.stringify(config, { sortMapEntries: true }), "utf8");
1555
2214
  }
1556
2215
  function toMoodleConfig(config, baseUrl) {
1557
2216
  const { base_url: _baseUrl, ...rest } = config;
1558
2217
  return { ...rest, baseUrl };
1559
2218
  }
1560
- function defaultPrompt(options) {
1561
- return async (label) => {
1562
- const rl = createInterface({
1563
- input: process.stdin,
1564
- output: options.stdout ?? process.stdout
1565
- });
1566
- try {
1567
- return await rl.question(`${label} > `);
1568
- } finally {
1569
- rl.close();
1570
- }
1571
- };
2219
+ function defaultPrompt(ui) {
2220
+ return (label) => ui.text(label, { placeholder: "https://school.example.edu" });
1572
2221
  }
1573
2222
  function isInteractive(options) {
1574
- return Boolean((options.stdin ?? process.stdin).isTTY);
2223
+ return Boolean((options.stdin ?? process.stdin).isTTY) && !isAgentEnvironment(options.env ?? process.env);
1575
2224
  }
1576
2225
  function isRecord3(value) {
1577
2226
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -1581,15 +2230,15 @@ function isMissingFileError2(error) {
1581
2230
  }
1582
2231
 
1583
2232
  // src/mcp/self-command.ts
1584
- import { accessSync, constants, realpathSync } from "fs";
1585
- import { delimiter, join as join5 } from "path";
2233
+ import { accessSync as accessSync2, constants as constants2, realpathSync } from "fs";
2234
+ import { delimiter, join as join7 } from "path";
1586
2235
  import { spawnSync } from "child_process";
1587
2236
  function findExecutable(name, env = process.env) {
1588
2237
  for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
1589
2238
  for (const suffix of process.platform === "win32" ? [".exe", ".cmd", ""] : [""]) {
1590
- const file2 = join5(dir, name + suffix);
2239
+ const file2 = join7(dir, name + suffix);
1591
2240
  try {
1592
- accessSync(file2, constants.X_OK);
2241
+ accessSync2(file2, constants2.X_OK);
1593
2242
  return realpathSync(file2);
1594
2243
  } catch {
1595
2244
  }
@@ -1619,9 +2268,9 @@ function runtimeCommand(command, args) {
1619
2268
  // src/keepalive.ts
1620
2269
  import { spawnSync as spawnSync2 } from "child_process";
1621
2270
  import { realpathSync as realpathSync2 } from "fs";
1622
- import { mkdir as mkdir4, rm as rm3, stat, writeFile as writeFile4 } from "fs/promises";
1623
- import { homedir as homedir5 } from "os";
1624
- import { dirname as dirname4, join as join6 } from "path";
2271
+ import { mkdir as mkdir5, rm as rm3, stat, writeFile as writeFile4 } from "fs/promises";
2272
+ import { homedir as homedir7 } from "os";
2273
+ import { dirname as dirname4, join as join8 } from "path";
1625
2274
  async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, extend = true) {
1626
2275
  const methods = extend ? [FUNC_SESSION_TOUCH, FUNC_SESSION_TIME_REMAINING] : [FUNC_SESSION_TIME_REMAINING];
1627
2276
  const url = `${baseUrl.replace(/\/$/, "")}${AJAX_SERVICE_PATH}?sesskey=${encodeURIComponent(sesskey)}&info=${methods.join(",")}`;
@@ -1667,13 +2316,13 @@ async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, e
1667
2316
  async function keepAliveOnce(baseUrl, options = {}) {
1668
2317
  const session = await readCachedSession(baseUrl, {
1669
2318
  homeDir: options.homeDir,
1670
- ttlMs: Number.MAX_SAFE_INTEGER,
2319
+ allowExpired: true,
1671
2320
  now: options.now
1672
2321
  });
1673
2322
  if (!session) {
1674
2323
  return { status: "no_session", time_remaining_seconds: null };
1675
2324
  }
1676
- const touch = await touchMoodleSession(
2325
+ const touch = session.cookieInvalidated ? { alive: false, timeRemainingSeconds: null } : await touchMoodleSession(
1677
2326
  baseUrl,
1678
2327
  { name: session.cookieName, value: session.cookieValue },
1679
2328
  session.sesskey,
@@ -1689,6 +2338,10 @@ async function keepAliveOnce(baseUrl, options = {}) {
1689
2338
  if (options.renewOnExpiry === false) {
1690
2339
  return { status: "expired", time_remaining_seconds: null };
1691
2340
  }
2341
+ if (session.mobileToken) {
2342
+ const renewed = await renewViaMobileToken(baseUrl, session, options);
2343
+ if (renewed) return renewed;
2344
+ }
1692
2345
  const authenticate = options.authenticate ?? ((url) => getAuthenticatedSession(url, {
1693
2346
  homeDir: options.homeDir,
1694
2347
  fetch: options.fetchImpl,
@@ -1704,12 +2357,29 @@ async function keepAliveOnce(baseUrl, options = {}) {
1704
2357
  return { status: "expired", time_remaining_seconds: null };
1705
2358
  }
1706
2359
  }
2360
+ async function renewViaMobileToken(baseUrl, session, options) {
2361
+ const result = await mintValidatedSession(baseUrl, session, { fetch: options.fetchImpl }).catch(() => null);
2362
+ if (!result) return null;
2363
+ await writeCachedSession(
2364
+ {
2365
+ ...session,
2366
+ cookieInvalidated: false,
2367
+ cookieName: result.cookie.name,
2368
+ cookieValue: result.cookie.value,
2369
+ cookieSource: result.cookie.source,
2370
+ sesskey: result.context.sesskey,
2371
+ savedAt: (options.now ?? Date.now)()
2372
+ },
2373
+ { homeDir: options.homeDir }
2374
+ );
2375
+ return { status: "reauthenticated", time_remaining_seconds: null };
2376
+ }
1707
2377
  async function getAuthStatus(baseUrl, options = {}) {
1708
2378
  const now = options.now ?? Date.now;
1709
2379
  const keepalive = await keepaliveStatus(options.homeDir);
1710
2380
  const session = await readCachedSession(baseUrl, {
1711
2381
  homeDir: options.homeDir,
1712
- ttlMs: Number.MAX_SAFE_INTEGER,
2382
+ allowExpired: true,
1713
2383
  now: options.now
1714
2384
  });
1715
2385
  if (!session) {
@@ -1723,7 +2393,7 @@ async function getAuthStatus(baseUrl, options = {}) {
1723
2393
  keepalive_plist_path: keepalive.plist_path
1724
2394
  };
1725
2395
  }
1726
- const touch = await touchMoodleSession(
2396
+ const touch = session.cookieInvalidated ? { alive: false, timeRemainingSeconds: null } : await touchMoodleSession(
1727
2397
  baseUrl,
1728
2398
  { name: session.cookieName, value: session.cookieValue },
1729
2399
  session.sesskey,
@@ -1741,11 +2411,11 @@ async function getAuthStatus(baseUrl, options = {}) {
1741
2411
  keepalive_plist_path: keepalive.plist_path
1742
2412
  };
1743
2413
  }
1744
- function keepalivePlistPath(homeDir = homedir5()) {
1745
- return join6(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
2414
+ function keepalivePlistPath(homeDir = homedir7()) {
2415
+ return join8(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
1746
2416
  }
1747
- function keepaliveLogPath(homeDir = homedir5()) {
1748
- return join6(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
2417
+ function keepaliveLogPath(homeDir = homedir7()) {
2418
+ return join8(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
1749
2419
  }
1750
2420
  function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
1751
2421
  const args = programArguments2.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
@@ -1787,13 +2457,13 @@ async function installKeepalive(options = {}) {
1787
2457
  `This runtime (${process.version}) cannot read browser cookies, and the launch agent would be pinned to it. Reinstall with Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or with Bun.`
1788
2458
  );
1789
2459
  }
1790
- const homeDir = options.homeDir ?? homedir5();
2460
+ const homeDir = options.homeDir ?? homedir7();
1791
2461
  const intervalMinutes = options.intervalMinutes ?? KEEPALIVE_DEFAULT_INTERVAL_MINUTES;
1792
2462
  const plistPath = keepalivePlistPath(homeDir);
1793
2463
  const logPath = keepaliveLogPath(homeDir);
1794
2464
  const command = [selectedRuntime.command, ...selectedRuntime.args, "auth", "keepalive", "--json"];
1795
- await mkdir4(dirname4(plistPath), { recursive: true });
1796
- await mkdir4(dirname4(logPath), { recursive: true, mode: 448 });
2465
+ await mkdir5(dirname4(plistPath), { recursive: true });
2466
+ await mkdir5(dirname4(logPath), { recursive: true, mode: 448 });
1797
2467
  await writeFile4(plistPath, buildKeepalivePlist(command, intervalMinutes, logPath), "utf8");
1798
2468
  const runCommand = options.runCommand ?? spawnSync2;
1799
2469
  const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
@@ -1808,7 +2478,7 @@ async function installKeepalive(options = {}) {
1808
2478
  return { plist_path: plistPath, interval_minutes: intervalMinutes, log_path: logPath, command };
1809
2479
  }
1810
2480
  async function uninstallKeepalive(options = {}) {
1811
- const homeDir = options.homeDir ?? homedir5();
2481
+ const homeDir = options.homeDir ?? homedir7();
1812
2482
  const plistPath = keepalivePlistPath(homeDir);
1813
2483
  const runCommand = options.runCommand ?? spawnSync2;
1814
2484
  const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
@@ -1816,7 +2486,7 @@ async function uninstallKeepalive(options = {}) {
1816
2486
  await rm3(plistPath, { force: true });
1817
2487
  return { installed: false, plist_path: plistPath };
1818
2488
  }
1819
- async function keepaliveStatus(homeDir = homedir5()) {
2489
+ async function keepaliveStatus(homeDir = homedir7()) {
1820
2490
  const plistPath = keepalivePlistPath(homeDir);
1821
2491
  try {
1822
2492
  return { installed: (await stat(plistPath)).isFile(), plist_path: plistPath };
@@ -1829,20 +2499,21 @@ function escapeXml(value) {
1829
2499
  }
1830
2500
 
1831
2501
  // src/doctor.ts
1832
- async function ownedJobs(homeDir = homedir6()) {
1833
- const root = join7(homeDir, "Library", "LaunchAgents");
1834
- const files = await readdir2(root).catch(() => []);
2502
+ async function ownedJobs(homeDir = homedir8()) {
2503
+ const root = join9(homeDir, "Library", "LaunchAgents");
2504
+ const files = await readdir3(root).catch(() => []);
1835
2505
  const jobs = [];
1836
2506
  for (const name of files.filter((n2) => n2 === "com.moodle-cli.keepalive.plist" || /^com\.moodle-cli\.mcp-renewal\.[a-z0-9_-]+\.plist$/u.test(n2))) {
1837
- const path4 = join7(root, name);
1838
- const content = await readFile4(path4, "utf8");
1839
- jobs.push({ path: path4, profile: name.match(/mcp-renewal\.(.+)\.plist$/u)?.[1], interpreter: content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/u)?.[1] });
2507
+ const path5 = join9(root, name);
2508
+ const content = await readFile4(path5, "utf8");
2509
+ jobs.push({ path: path5, profile: name.match(/mcp-renewal\.(.+)\.plist$/u)?.[1], interpreter: content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/u)?.[1] });
1840
2510
  }
1841
2511
  return jobs;
1842
2512
  }
1843
2513
  async function doctor(options = {}) {
1844
- const home = options.homeDir ?? homedir6();
2514
+ const home = options.homeDir ?? homedir8();
1845
2515
  const checks = [];
2516
+ const stores = await browserCookieStores({ homeDir: home });
1846
2517
  const cookies = runtimeSupportsCookies();
1847
2518
  checks.push({ name: "sqlite", status: cookies ? "pass" : "fail", detail: `${process.versions.bun ? "bun" : "node"} ${process.versions.bun ?? process.versions.node}`, ...cookies ? {} : { hint: "Install Bun or Node 22.13+; Safari cookie reads do not require SQLite." } });
1848
2519
  let baseUrl;
@@ -1862,43 +2533,19 @@ async function doctor(options = {}) {
1862
2533
  const warnings = [];
1863
2534
  try {
1864
2535
  const found = await defaultBrowserCookieProvider(baseUrl, { homeDir: home, onCookieWarnings: (items) => warnings.push(...items) });
1865
- const blocked = warnings.some((w) => /EPERM|EACCES|permission denied|operation not permitted/iu.test(w));
1866
- checks.push({ name: "browser", status: blocked ? "fail" : found.length ? "pass" : "warn", detail: blocked ? "Browser store access was denied." : found.length ? `Cookie sources: ${[...new Set(found.map((c) => c.source || "browser"))].join(", ")}` : "No browser session found.", ...blocked ? { hint: "System Settings > Privacy & Security > Full Disk Access: enable the app running this command, then restart it." } : !found.length ? { hint: "Sign in to Moodle in a supported browser, then run moodle auth login." } : {} });
2536
+ const blocked = !found.length && (cookieStoresBlocked(stores) || warnings.some((w) => /EPERM|EACCES|permission denied|operation not permitted/iu.test(w)));
2537
+ checks.push({ name: "browser", status: blocked ? "fail" : found.length ? "pass" : "warn", detail: blocked ? `Browser store access was denied (${unreadableCookieStores(stores).length} unreadable store(s)).` : found.length ? `Cookie sources: ${[...new Set(found.map((c) => c.source || "browser"))].join(", ")}` : "No browser session found.", ...blocked ? { hint: `System Settings > Privacy & Security > Full Disk Access: enable ${hostApplicationName(options.env) ?? "the app running this command"}, then restart it.` } : !found.length ? { hint: "Sign in to Moodle in a supported browser, then run moodle auth login." } : {} });
1867
2538
  } catch {
1868
2539
  checks.push({ name: "browser", status: "warn", detail: "Could not inspect browser stores.", hint: "Run moodle auth login from a regular terminal." });
1869
2540
  }
1870
2541
  }
1871
- const stores = [];
1872
- if (process.platform === "darwin") {
1873
- for (const [browser, directory] of [["Chrome", "Google/Chrome"], ["Edge", "Microsoft Edge"], ["Brave", "BraveSoftware/Brave-Browser"], ["Firefox", "Firefox/Profiles"]]) {
1874
- const root = join7(home, "Library/Application Support", directory);
1875
- for (const profile of await readdir2(root).catch(() => [])) {
1876
- if (browser !== "Firefox" && profile !== "Default" && !profile.startsWith("Profile ")) continue;
1877
- for (const name of browser === "Firefox" ? ["cookies.sqlite"] : ["Cookies", "Network/Cookies"]) {
1878
- const file2 = join7(root, profile, name);
1879
- try {
1880
- await access(file2);
1881
- stores.push({ browser, path: file2, readable: await access(file2, constants2.R_OK).then(() => true, () => false) });
1882
- } catch {
1883
- }
1884
- }
1885
- }
1886
- }
1887
- for (const file2 of [join7(home, "Library/Cookies/Cookies.binarycookies"), join7(home, "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies")]) {
1888
- try {
1889
- await access(file2);
1890
- stores.push({ browser: "Safari", path: file2, readable: await access(file2, constants2.R_OK).then(() => true, () => false) });
1891
- } catch {
1892
- }
1893
- }
1894
- }
1895
2542
  const jobs = await ownedJobs(home);
1896
2543
  for (const job of jobs) {
1897
- const present = job.interpreter ? await access(job.interpreter, constants2.X_OK).then(() => true, () => false) : false;
2544
+ const present = job.interpreter ? await access3(job.interpreter, constants3.X_OK).then(() => true, () => false) : false;
1898
2545
  const supported = present && (job.interpreter === process.execPath && !runtimeCommand().args.length || runtimeSupportsCookies(job.interpreter));
1899
2546
  checks.push({ name: "job", status: supported ? "pass" : "warn", detail: `${job.path}: ${job.interpreter ?? "missing interpreter"}`, ...!supported ? { hint: job.profile ? "Run moodle mcp deploy to repair renewal." : "Run moodle auth keepalive install." } : {} });
1900
2547
  }
1901
- const profiles = await readdir2(join7(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
2548
+ const profiles = await readdir3(join9(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
1902
2549
  checks.push({ name: "mcp", status: profiles.length ? "pass" : "warn", detail: profiles.length ? `${profiles.length} local deployment receipts. Run moodle mcp status for remote readiness.` : "No managed MCP deployment; optional for CLI use." });
1903
2550
  let pin;
1904
2551
  try {
@@ -1909,8 +2556,8 @@ async function doctor(options = {}) {
1909
2556
  }
1910
2557
 
1911
2558
  // src/cli.ts
1912
- import { rm as rm7, readdir as readdir3 } from "fs/promises";
1913
- import { homedir as homedir12 } from "os";
2559
+ import { rm as rm7, readdir as readdir4 } from "fs/promises";
2560
+ import { homedir as homedir15 } from "os";
1914
2561
 
1915
2562
  // src/mcp/renewal/decision.ts
1916
2563
  function decideRenewal(snapshot) {
@@ -2047,7 +2694,7 @@ function macOSPlan(options, intervalMinutes) {
2047
2694
  throw new Error("macOS renewal installation requires the current user ID");
2048
2695
  }
2049
2696
  const label = `com.moodle-cli.mcp-renewal.${options.profile}`;
2050
- const path4 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
2697
+ const path5 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
2051
2698
  const target = `gui/${options.uid}`;
2052
2699
  const logPath = `${trimEnd(options.homeDirectory, "/")}/Library/Logs/${label}.log`;
2053
2700
  const plist = [
@@ -2069,12 +2716,12 @@ function macOSPlan(options, intervalMinutes) {
2069
2716
  platform: "darwin",
2070
2717
  profile: options.profile,
2071
2718
  label,
2072
- files: [{ path: path4, content: plist, mode: 384 }],
2719
+ files: [{ path: path5, content: plist, mode: 384 }],
2073
2720
  installCommands: [
2074
- { command: "launchctl", args: ["bootout", target, path4], ignoreFailure: true },
2075
- { command: "launchctl", args: ["bootstrap", target, path4] }
2721
+ { command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true },
2722
+ { command: "launchctl", args: ["bootstrap", target, path5] }
2076
2723
  ],
2077
- removeCommands: [{ command: "launchctl", args: ["bootout", target, path4], ignoreFailure: true }]
2724
+ removeCommands: [{ command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true }]
2078
2725
  };
2079
2726
  }
2080
2727
  function linuxPlan(options, intervalMinutes) {
@@ -2125,7 +2772,7 @@ function linuxPlan(options, intervalMinutes) {
2125
2772
  }
2126
2773
  function windowsPlan(options, intervalMinutes) {
2127
2774
  const label = `Moodle CLI MCP Renewal (${options.profile})`;
2128
- const path4 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
2775
+ const path5 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
2129
2776
  const argumentsText = [...options.executableArgs ?? [], ...renewalArgs(options.profile)].map(windowsArgument).join(" ");
2130
2777
  const task = [
2131
2778
  '<?xml version="1.0" encoding="UTF-8"?>',
@@ -2145,8 +2792,8 @@ function windowsPlan(options, intervalMinutes) {
2145
2792
  platform: "win32",
2146
2793
  profile: options.profile,
2147
2794
  label,
2148
- files: [{ path: path4, content: task, mode: 384 }],
2149
- installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML", path4, "/F"] }],
2795
+ files: [{ path: path5, content: task, mode: 384 }],
2796
+ installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML", path5, "/F"] }],
2150
2797
  removeCommands: [{ command: "schtasks.exe", args: ["/Delete", "/TN", label, "/F"], ignoreFailure: true }]
2151
2798
  };
2152
2799
  }
@@ -2199,24 +2846,24 @@ async function sendRenewalNotification(kind, sender) {
2199
2846
  }
2200
2847
 
2201
2848
  // src/mcp/renewal/node-renewal.ts
2202
- import { execFile as execFileCallback2 } from "child_process";
2203
- import { chmod as chmod3, mkdir as mkdir5, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "fs/promises";
2204
- import { homedir as homedir7 } from "os";
2849
+ import { execFile as execFileCallback } from "child_process";
2850
+ import { chmod as chmod3, mkdir as mkdir6, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "fs/promises";
2851
+ import { homedir as homedir9 } from "os";
2205
2852
  import { dirname as dirname5 } from "path";
2206
2853
  import { promisify } from "util";
2207
- var execFile = promisify(execFileCallback2);
2854
+ var execFile = promisify(execFileCallback);
2208
2855
  var NodeRenewalInstallerIO = class {
2209
- async writePrivate(path4, content, mode) {
2210
- await mkdir5(dirname5(path4), { recursive: true, mode: 448 });
2211
- await writeFile5(path4, content, { encoding: "utf8", mode });
2212
- await chmod3(path4, mode);
2856
+ async writePrivate(path5, content, mode) {
2857
+ await mkdir6(dirname5(path5), { recursive: true, mode: 448 });
2858
+ await writeFile5(path5, content, { encoding: "utf8", mode });
2859
+ await chmod3(path5, mode);
2213
2860
  }
2214
- async removeFile(path4) {
2215
- await rm4(path4, { force: true });
2861
+ async removeFile(path5) {
2862
+ await rm4(path5, { force: true });
2216
2863
  }
2217
- async exists(path4) {
2864
+ async exists(path5) {
2218
2865
  try {
2219
- await readFile5(path4);
2866
+ await readFile5(path5);
2220
2867
  return true;
2221
2868
  } catch (error) {
2222
2869
  if (isMissing2(error)) {
@@ -2246,7 +2893,7 @@ function createDefaultRenewalInstaller(profile, options = {}) {
2246
2893
  profile,
2247
2894
  executable: runtime.command,
2248
2895
  executableArgs: runtime.args,
2249
- homeDirectory: options.homeDirectory ?? homedir7(),
2896
+ homeDirectory: options.homeDirectory ?? homedir9(),
2250
2897
  uid: options.uid ?? (typeof process.getuid === "function" ? process.getuid() : void 0),
2251
2898
  intervalMinutes: options.intervalMinutes
2252
2899
  });
@@ -2334,13 +2981,14 @@ var MoodleGatewayError = class extends Error {
2334
2981
  this.code = code;
2335
2982
  }
2336
2983
  };
2337
- function createMoodleGateway(client) {
2984
+ function createMoodleGateway(client, hooks = {}) {
2338
2985
  return {
2339
2986
  getUser: () => client.getSiteInfo(),
2340
2987
  listThreads: (id2) => client.getForumDiscussionRefs ? client.getForumDiscussionRefs(id2) : Promise.resolve([]),
2341
2988
  listNewsForums: (id2) => client.getNewsForums ? client.getNewsForums(id2) : Promise.resolve([]),
2342
2989
  getOverview: (input2) => client.getOverview(input2.todoLimit, input2.todoDays, input2.alertsLimit),
2343
2990
  ...client.getTodo ? { getDue: (days, courseId) => client.getTodo(Number.MAX_SAFE_INTEGER, days, courseId) } : {},
2991
+ ...client.submitAssignmentFiles ? { submitAssignment: (input2) => client.submitAssignmentFiles({ ...input2, ...hooks.onSubmitProgress ? { onProgress: hooks.onSubmitProgress } : {} }) } : {},
2344
2992
  listCourses: () => client.getCourses(),
2345
2993
  async getCourse({ courseId }) {
2346
2994
  const [courses, sections] = await Promise.all([
@@ -2658,9 +3306,9 @@ function splitUnitPhrase(phrase, courses) {
2658
3306
  }
2659
3307
 
2660
3308
  // src/intents.ts
2661
- async function inParallel(items, size, run) {
3309
+ async function inParallel(items, size2, run) {
2662
3310
  const results = [];
2663
- for (let index = 0; index < items.length; index += size) results.push(...await Promise.all(items.slice(index, index + size).map(run)));
3311
+ for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
2664
3312
  return results;
2665
3313
  }
2666
3314
  function createIntentService(gateway, now = () => Date.now()) {
@@ -2851,6 +3499,12 @@ function createIntentService(gateway, now = () => Date.now()) {
2851
3499
  result = { file: { name: file2.name, mime_type: file2.mimeType, bytes: file2.bytes, uri: file2.uri } };
2852
3500
  break;
2853
3501
  }
3502
+ case "submit": {
3503
+ if (!gateway.submitAssignment) throw new MoodleGatewayError("MOODLE_TOOL_UNAVAILABLE", "Submitting needs local files; run moodle submit or the local MCP server on the machine that holds them.");
3504
+ const activityId = await resolveItem(input2.ref);
3505
+ result = { submission: await gateway.submitAssignment({ activityId, files: input2.files, final: Boolean(input2.final), replace: Boolean(input2.replace), acceptStatement: Boolean(input2.accept_statement), dryRun: Boolean(input2.dry_run) }) };
3506
+ break;
3507
+ }
2854
3508
  }
2855
3509
  return intentContracts[name].output.parse(stripEmpty(result));
2856
3510
  }
@@ -2861,16 +3515,31 @@ function createIntentService(gateway, now = () => Date.now()) {
2861
3515
  return { run, resolveItem, fileSource, find, sections };
2862
3516
  }
2863
3517
 
3518
+ // src/screens.ts
3519
+ import { createTheme as createTheme2 } from "@bunizao/cli-kit";
3520
+
2864
3521
  // src/terminal-table.ts
3522
+ import { createTheme } from "@bunizao/cli-kit";
2865
3523
  var DEFAULT_WIDTH = 120;
3524
+ var TONED_LABEL = /status|state|type|grade|due|ok|action|grading/iu;
3525
+ var colorEnabled = () => false;
3526
+ function configureTerminalTables(options) {
3527
+ colorEnabled = options.color;
3528
+ }
2866
3529
  function renderTerminalTable(columns, rows, options = {}) {
3530
+ const theme = createTheme(colorEnabled());
2867
3531
  const width = Math.max(20, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
2868
- const clean = rows.map((row) => columns.map((_, i) => sanitizeTerminalText(row[i] ?? "").replace(/\s+/gu, " ")));
3532
+ const cellText = (cell) => typeof cell === "string" ? cell : cell?.text ?? "";
3533
+ const tones = rows.map((row) => columns.map((_, i) => {
3534
+ const cell = row[i];
3535
+ return typeof cell === "object" ? cell.tone : void 0;
3536
+ }));
3537
+ const clean = rows.map((row) => columns.map((_, i) => sanitizeTerminalText(cellText(row[i])).replace(/\s+/gu, " ")));
2869
3538
  const lengths = columns.map((c, i) => Math.max(c.label.length, ...clean.map((r) => Array.from(r[i]).length)));
2870
3539
  const flexible = columns.map((c, i) => c.flex || /name|title|subject|feedback|description|value|course|unit/iu.test(c.label) ? i : -1).filter((i) => i >= 0);
2871
3540
  const widths = columns.map((c, i) => c.width ?? (flexible.includes(i) ? Math.min(lengths[i], 36) : lengths[i]));
2872
3541
  const minimum = widths.reduce((n2, w, i) => n2 + (flexible.includes(i) ? 8 : w), 0) + columns.length * 3 + 1;
2873
- const title = options.title ? [sanitizeTerminalText(options.title)] : [];
3542
+ const title = options.title ? [theme.subject(sanitizeTerminalText(options.title))] : [];
2874
3543
  if (width < 60 || minimum > width) {
2875
3544
  const wrap = (line2) => Array.from(line2).reduce((lines, char) => {
2876
3545
  if (!lines.length || Array.from(lines.at(-1)).length >= width) lines.push("");
@@ -2889,9 +3558,22 @@ function renderTerminalTable(columns, rows, options = {}) {
2889
3558
  const trimmed = chars.length > w ? `${chars.slice(0, w - 1).join("")}\u2026` : v;
2890
3559
  return trimmed + " ".repeat(Math.max(0, w - Array.from(trimmed).length));
2891
3560
  };
2892
- const border = (l, m, r) => l + widths.map((w) => "\u2500".repeat(w + 2)).join(m) + r;
2893
- const line = (row) => "\u2502" + widths.map((w, i) => ` ${fit(row[i] ?? "", w)} `).join("\u2502") + "\u2502";
2894
- return [...title, border("\u250C", "\u252C", "\u2510"), line(columns.map((c) => c.label)), border("\u251C", "\u253C", "\u2524"), ...clean.map(line), border("\u2514", "\u2534", "\u2518")].join("\n");
3561
+ const paint = (cell, i, row, tone) => {
3562
+ if (tone) return theme.tone(tone, cell);
3563
+ if (options.keyValue) return i === 0 ? theme.dim(cell) : /status|grading|action/iu.test(row[0] ?? "") ? theme.status(cell) : cell;
3564
+ return i === 0 ? theme.key(cell) : TONED_LABEL.test(columns[i]?.label ?? "") ? theme.status(cell) : cell;
3565
+ };
3566
+ const border = (l, m, r) => theme.dim(l + widths.map((w) => "\u2500".repeat(w + 2)).join(m) + r);
3567
+ const bar = theme.dim("\u2502");
3568
+ const line = (row, paintCell) => bar + widths.map((w, i) => ` ${paintCell(fit(row[i] ?? "", w), i)} `).join(bar) + bar;
3569
+ return [
3570
+ ...title,
3571
+ border("\u250C", "\u252C", "\u2510"),
3572
+ line(columns.map((c) => c.label), (cell) => theme.dim(cell)),
3573
+ border("\u251C", "\u253C", "\u2524"),
3574
+ ...clean.map((row, r) => line(row, (cell, i) => paint(cell, i, row, tones[r][i]))),
3575
+ border("\u2514", "\u2534", "\u2518")
3576
+ ].join("\n");
2895
3577
  }
2896
3578
  function renderKeyValueTable(rows, options = {}) {
2897
3579
  const present = rows.filter(([, value]) => value !== "");
@@ -2903,10 +3585,10 @@ No details` : "No details";
2903
3585
  { label: "Field" },
2904
3586
  { label: "Value" }
2905
3587
  ];
2906
- return renderTerminalTable(columns, present, options);
3588
+ return renderTerminalTable(columns, present, { ...options, keyValue: true });
2907
3589
  }
2908
3590
  function sanitizeTerminalText(value) {
2909
- return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, "");
3591
+ return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[- --Ÿ]/gu, "");
2910
3592
  }
2911
3593
 
2912
3594
  // src/screens.ts
@@ -2924,21 +3606,29 @@ function moment(value, now) {
2924
3606
  const sameYear = new Date(now).getFullYear() === Number(year);
2925
3607
  return `${weekday} ${Number(day)} ${MONTHS[Number(month) - 1]}${sameYear ? "" : ` ${year}`}${hour ? `, ${hour}:${minute}` : ""}`;
2926
3608
  }
3609
+ function tryLines(commands) {
3610
+ return commands.map((command, index) => `${index ? " " : "Try "}${command}`).join("\n");
3611
+ }
2927
3612
  function renderScreen(data, options = {}) {
2928
3613
  const lines = [];
2929
3614
  const now = options.now ?? Date.now();
2930
- const dueText = (row) => {
2931
- if (!row.due_at) return text(row.status || row.submission_status);
3615
+ const theme = createTheme2(Boolean(options.color));
3616
+ const due = (row) => {
2932
3617
  const days = Math.ceil((Number(row.due_at) * 1e3 - now) / 864e5);
2933
3618
  const value = `${days < 0 ? `${-days} days overdue` : days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`} \xB7 ${moment(row.due, now)}`;
2934
- return options.color && days <= 2 ? `\x1B[${days < 0 ? 31 : 33}m${value}\x1B[0m` : value;
3619
+ return { text: value, tone: days < 0 ? "danger" : days <= 2 ? "warning" : "muted" };
3620
+ };
3621
+ const dueText = (row) => {
3622
+ if (!row.due_at) return theme.status(text(row.status || row.submission_status));
3623
+ const { text: value, tone } = due(row);
3624
+ return theme.tone(tone, value);
2935
3625
  };
2936
3626
  const rows = (items, title) => {
2937
- lines.push(title);
2938
- if (!items.length) lines.push(" None");
2939
- for (const r of items) lines.push(` ${text(r.unit_code || r.type)} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? ` #${r.id}` : ""}`);
3627
+ lines.push(theme.subject(title));
3628
+ if (!items.length) lines.push(theme.dim(" None"));
3629
+ for (const r of items) lines.push(` ${theme.key(text(r.unit_code || r.type))} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? ` ${theme.dim(`#${r.id}`)}` : ""}`);
2940
3630
  };
2941
- let next = "moodle due --days 30 \xB7 moodle grades";
3631
+ let next = ["moodle due --days 30", "moodle grades"];
2942
3632
  if (data.home) {
2943
3633
  const h = record(data.home);
2944
3634
  lines.push(`${text(h.name)} \xB7 ${moment(h.today, now)} \xB7 ${text(h.timezone)}${h.timezone_source === "site" ? "" : ` (${text(h.timezone_source)})`}`, text(h.siteurl), "");
@@ -2969,11 +3659,12 @@ function renderScreen(data, options = {}) {
2969
3659
  rows(array(data.news), "Latest news");
2970
3660
  }
2971
3661
  const unit = JSON.stringify(u.code || u.name);
2972
- next = array(data.sections).some((s2) => s2.activities) ? `moodle ${unit} "TASK" \xB7 moodle get "UNIT TASK" --to .` : `moodle ${unit} SECTION \xB7 moodle ${unit} grades`;
3662
+ next = array(data.sections).some((s2) => s2.activities) ? [`moodle ${unit} "TASK"`, `moodle get "UNIT TASK" --to .`] : [`moodle ${unit} SECTION`, `moodle ${unit} grades`];
2973
3663
  } else if (data.grades) {
2974
3664
  for (const g of array(data.grades)) {
2975
3665
  lines.push(`${text(g.code)} \xB7 ${g.graded} of ${g.total} graded`);
2976
- lines.push(renderTerminalTable([{ label: "Name", flex: true }, { label: "Grade" }, { label: "Range" }, { label: "Feedback", flex: true }], array(g.items).map((i) => [text(i.name), text(i.grade), text(i.range), text(i.feedback || (i.due ? dueText(i) : ""))]), { width: options.width }));
3666
+ const feedback = (i) => i.feedback ? text(i.feedback) : i.due_at ? due(i) : "";
3667
+ lines.push(renderTerminalTable([{ label: "Name", flex: true }, { label: "Grade" }, { label: "Range" }, { label: "Feedback", flex: true }], array(g.items).map((i) => [text(i.name), text(i.grade), text(i.range), feedback(i)]), { width: options.width }));
2977
3668
  }
2978
3669
  } else if (data.item) {
2979
3670
  const i = record(data.item);
@@ -2981,13 +3672,13 @@ function renderScreen(data, options = {}) {
2981
3672
  for (const [k, v] of Object.entries(i)) if (!["id", "name", "type", "files"].includes(k) && !k.endsWith("_at") && typeof v !== "object") lines.push(`${k.replaceAll("_", " ")}: ${moment(v, now)}`);
2982
3673
  for (const f of array(i.files)) lines.push(`File ${text(f.name)} ${text(f.url)}`);
2983
3674
  if (data.threads) rows(array(data.threads), "Threads");
2984
- next = `moodle get ${i.id} --to DIR`;
3675
+ next = [`moodle get ${i.id} --to DIR`];
2985
3676
  } else if (data.thread) {
2986
3677
  const t = record(data.thread);
2987
3678
  lines.push(text(t.name));
2988
3679
  for (const p of array(t.posts)) lines.push("", `${text(record(p.author).name)} \xB7 ${moment(p.created, now)}`, text(p.message_text), ...array(p.links).map((l) => `${text(l.text)} ${text(l.url)}`));
2989
3680
  lines.push(`Posts ${Number(t.offset) + array(t.posts).length} of ${t.posts_total}`);
2990
- next = `moodle threads show ${t.id} --offset ${Number(t.offset) + array(t.posts).length}`;
3681
+ next = [`moodle threads show ${t.id} --offset ${Number(t.offset) + array(t.posts).length}`];
2991
3682
  } else if (data.news) {
2992
3683
  for (const n2 of array(data.news)) {
2993
3684
  const p = record(n2.post);
@@ -2995,13 +3686,13 @@ function renderScreen(data, options = {}) {
2995
3686
  }
2996
3687
  } else if (data.units) {
2997
3688
  lines.push(renderTerminalTable([{ label: "ID" }, { label: "Code" }, { label: "Name", flex: true }], array(data.units).map((u) => [text(u.id), text(u.code), text(u.name)]), { width: options.width }));
2998
- next = "moodle UNIT \xB7 moodle find QUERY";
3689
+ next = ["moodle UNIT", "moodle find QUERY"];
2999
3690
  } else {
3000
3691
  const key = ["due", "results", "activities", "forums"].find((k) => k in data);
3001
3692
  rows(array(key ? data[key] : []), key === "due" ? "Due" : "Matches");
3002
3693
  if (data.total !== void 0) lines.push(`${data.total} total`);
3003
3694
  }
3004
- lines.push("", `Try ${next}`);
3695
+ lines.push("", ...tryLines(next).split("\n").map((line) => theme.dim(line)));
3005
3696
  const width = Math.max(40, options.width || 80);
3006
3697
  return lines.flatMap((line) => {
3007
3698
  if (line.includes("\x1B[") || line.startsWith("\u2502") || /^[┌└├]/u.test(line)) return [line];
@@ -3022,12 +3713,20 @@ function renderScreen(data, options = {}) {
3022
3713
  }
3023
3714
 
3024
3715
  // src/cli.ts
3025
- import { createInterface as createInterface4 } from "readline/promises";
3026
- import { spawn as spawn3 } from "child_process";
3716
+ import { spawn as spawn4 } from "child_process";
3027
3717
  import {
3718
+ banner,
3719
+ colorEnabled as colorEnabled2,
3028
3720
  confirm,
3029
3721
  createProgram,
3722
+ createTheme as createTheme3,
3723
+ createUi as createUi3,
3724
+ detectAudience,
3725
+ examples,
3726
+ helpSection,
3030
3727
  insertDefaultVerb,
3728
+ isInformationalExit,
3729
+ parseWithPrompts,
3031
3730
  render,
3032
3731
  reportError,
3033
3732
  normalizeError,
@@ -3036,12 +3735,15 @@ import {
3036
3735
  writeOutput
3037
3736
  } from "@bunizao/cli-kit";
3038
3737
  import { realpathSync as realpathSync3 } from "fs";
3039
- import path3 from "path";
3738
+ import path4 from "path";
3040
3739
  import { fileURLToPath as fileURLToPath2 } from "url";
3041
3740
 
3042
3741
  // src/moodle-client-core.ts
3043
3742
  import { z as z2 } from "zod";
3044
3743
 
3744
+ // src/moodle-assign-core.ts
3745
+ import { parse as parse4 } from "node-html-parser";
3746
+
3045
3747
  // src/html-utils.ts
3046
3748
  import { parse as parse2 } from "node-html-parser";
3047
3749
  function htmlToStructuredContent(html, baseUrl) {
@@ -3111,262 +3813,6 @@ function decodeHtml2(value) {
3111
3813
  return value.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
3112
3814
  }
3113
3815
 
3114
- // src/parsers.ts
3115
- function schema(parser) {
3116
- return { parse: parser };
3117
- }
3118
- var UserInfoSchema = schema(parseUserInfo);
3119
- var CourseSchema = schema(parseCourse);
3120
- var CoursesSchema = schema(parseCourses);
3121
- var ActivitySchema = schema(parseActivity);
3122
- var SectionSchema = schema(parseSection);
3123
- var CourseContentsSchema = schema(parseCourseContents);
3124
- var TodoItemSchema = schema(parseTodoItem);
3125
- function parseUserInfo(value) {
3126
- const data = asRecord(value);
3127
- return {
3128
- userid: numberValue(data.userid),
3129
- username: stringValue(data.username),
3130
- fullname: stringValue(data.fullname),
3131
- sitename: stringValue(data.sitename),
3132
- siteurl: stringValue(data.siteurl),
3133
- lang: stringValue(data.lang),
3134
- ...data.timezone ? { timezone: String(data.timezone) } : {}
3135
- };
3136
- }
3137
- function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
3138
- const data = asRecord(value);
3139
- const course = {
3140
- id: numberValue(data.id),
3141
- shortname: stringValue(data.shortname),
3142
- fullname: stringValue(data.fullname),
3143
- category: numberValue(data.category),
3144
- visible: booleanValue(data.visible, true),
3145
- startdate: numberValue(data.startdate)
3146
- };
3147
- const enddate = numberValue(data.enddate);
3148
- if (enddate > 0) {
3149
- course.enddate = enddate;
3150
- }
3151
- return course;
3152
- }
3153
- function parseCourses(value) {
3154
- return asArray(value).map((item) => parseCourse(item));
3155
- }
3156
- function parseActivity(value) {
3157
- const data = asRecord(value);
3158
- return {
3159
- id: numberValue(data.id),
3160
- name: stringValue(data.name),
3161
- modname: stringValue(data.modname),
3162
- url: stringValue(data.url),
3163
- visible: booleanValue(data.visible, true),
3164
- description: stringValue(data.description),
3165
- ...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue(asRecord(data.completiondata).state) } : {},
3166
- ...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue(asRecord(f).filename), url: stringValue(asRecord(f).fileurl), requires_authentication: true })) } : {}
3167
- };
3168
- }
3169
- function parseSection(value) {
3170
- const data = asRecord(value);
3171
- return {
3172
- id: numberValue(data.id),
3173
- name: stringValue(data.name),
3174
- section: numberValue(data.section),
3175
- visible: booleanValue(data.visible, true),
3176
- summary: stringValue(data.summary),
3177
- ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
3178
- activities: asArray(data.modules).map((item) => parseActivity(item))
3179
- };
3180
- }
3181
- function parseCourseContents(value) {
3182
- return asArray(value).map((item) => parseSection(item));
3183
- }
3184
- function parseCourseFormatState(value, baseUrl) {
3185
- const state = asRecord(parseJsonValue(value));
3186
- const activities = /* @__PURE__ */ new Map();
3187
- const activitiesBySection = /* @__PURE__ */ new Map();
3188
- for (const item of asArray(state.cm)) {
3189
- const data = asRecord(item);
3190
- const id2 = numberValue(data.id);
3191
- const sectionId = stringValue(data.sectionid);
3192
- const module = stringValue(data.module) || stringValue(data.plugin).replace(/^mod_/u, "") || stringValue(data.modname).toLowerCase();
3193
- const activity = {
3194
- id: id2,
3195
- name: htmlText(data.name, baseUrl),
3196
- modname: module.toLowerCase(),
3197
- url: stringValue(data.url) ? resolveUrl(baseUrl, stringValue(data.url)) : "",
3198
- visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
3199
- description: htmlText(data.content ?? data.description, baseUrl),
3200
- ...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue(data.completionstate) } : {}
3201
- };
3202
- activities.set(String(id2), activity);
3203
- const sectionActivities = activitiesBySection.get(sectionId) ?? [];
3204
- sectionActivities.push(activity);
3205
- activitiesBySection.set(sectionId, sectionActivities);
3206
- }
3207
- return asArray(state.section).map((item) => {
3208
- const data = asRecord(item);
3209
- const id2 = numberValue(data.id);
3210
- const hasActivityList = Array.isArray(data.cmlist);
3211
- const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue(activityId))).filter((activity) => activity !== void 0);
3212
- return {
3213
- id: id2,
3214
- name: htmlText(data.title || data.rawtitle, baseUrl),
3215
- section: numberValue(data.section ?? data.number),
3216
- visible: booleanValue(data.visible, true),
3217
- summary: htmlText(data.summary, baseUrl),
3218
- ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
3219
- activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
3220
- };
3221
- });
3222
- }
3223
- function parseTodoItem(value) {
3224
- const data = asRecord(value);
3225
- const course = asRecord(data.course);
3226
- const action = asRecord(data.action);
3227
- const progress = course.progress;
3228
- return {
3229
- id: numberValue(data.id),
3230
- name: stringValue(data.name),
3231
- activity_name: stringValue(data.activityname),
3232
- modname: stringValue(data.modulename),
3233
- course_id: numberValue(course.id),
3234
- course_name: stringValue(course.fullname),
3235
- due_at: numberValue(data.timesort) || numberValue(data.timestart),
3236
- overdue: booleanValue(data.overdue),
3237
- actionable: booleanValue(action.actionable),
3238
- action_name: stringValue(action.name),
3239
- action_url: stringValue(action.url),
3240
- url: stringValue(data.url),
3241
- event_type: stringValue(data.eventtype),
3242
- course_progress: typeof progress === "number" ? progress : void 0
3243
- };
3244
- }
3245
- function parseTodoItems(value) {
3246
- return asArray(value).map((item) => parseTodoItem(item));
3247
- }
3248
- function parseAlertNotification(value) {
3249
- const data = asRecord(value);
3250
- return {
3251
- id: numberValue(data.id),
3252
- subject: stringValue(data.subject),
3253
- short_subject: stringValue(data.shortenedsubject),
3254
- event_type: stringValue(data.eventtype),
3255
- component: stringValue(data.component),
3256
- created_at: numberValue(data.timecreated),
3257
- created_pretty: stringValue(data.timecreatedpretty),
3258
- read: booleanValue(data.read),
3259
- context_url: stringValue(data.contexturl),
3260
- context_name: stringValue(data.contexturlname)
3261
- };
3262
- }
3263
- function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
3264
- const notificationsRecord = asRecord(notificationsData);
3265
- const counts2 = asRecord(countsData);
3266
- const unreadCounts = asRecord(unreadCountsData);
3267
- const types = asRecord(counts2.types);
3268
- const unreadTypes = asRecord(unreadCounts.types);
3269
- const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
3270
- return {
3271
- notifications,
3272
- notification_count: notifications.length,
3273
- unread_notification_count: notifications.filter((notification) => !notification.read).length,
3274
- starred_message_count: numberValue(counts2.favourites),
3275
- direct_message_count: numberValue(types["1"]),
3276
- group_message_count: numberValue(types["2"]),
3277
- self_message_count: numberValue(types["3"]),
3278
- unread_starred_message_count: numberValue(unreadCounts.favourites),
3279
- unread_direct_message_count: numberValue(unreadTypes["1"]),
3280
- unread_group_message_count: numberValue(unreadTypes["2"]),
3281
- unread_self_message_count: numberValue(unreadTypes["3"])
3282
- };
3283
- }
3284
- function parseForumPostAuthor(value) {
3285
- const data = asRecord(value);
3286
- const urls = asRecord(data.urls);
3287
- return {
3288
- id: numberValue(data.id),
3289
- fullname: stringValue(data.fullname),
3290
- profile_url: stringValue(urls.profile),
3291
- profile_image_url: stringValue(urls.profileimage)
3292
- };
3293
- }
3294
- function parseForumPost(value, baseUrl = "") {
3295
- const data = asRecord(value);
3296
- const urls = asRecord(data.urls);
3297
- const messageHtml = stringValue(data.message);
3298
- const structured = htmlToStructuredContent(messageHtml, stringValue(urls.view || urls.discuss) || baseUrl);
3299
- return {
3300
- id: numberValue(data.id),
3301
- discussion_id: numberValue(data.discussionid),
3302
- subject: stringValue(data.subject),
3303
- message_html: messageHtml,
3304
- message_text: structured.text,
3305
- image_urls: structured.image_urls,
3306
- links: structured.links,
3307
- tables: structured.tables,
3308
- author: parseForumPostAuthor(data.author),
3309
- parent_id: numberValue(data.parentid),
3310
- time_created: numberValue(data.timecreated),
3311
- time_modified: numberValue(data.timemodified),
3312
- created_pretty: "",
3313
- unread: booleanValue(data.unread),
3314
- is_deleted: booleanValue(data.isdeleted),
3315
- is_private_reply: booleanValue(data.isprivatereply),
3316
- url: stringValue(urls.view || urls.viewisolated),
3317
- reply_url: stringValue(urls.reply)
3318
- };
3319
- }
3320
- function parseForumDiscussion(value, discussionId, baseUrl = "") {
3321
- const data = asRecord(value);
3322
- const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
3323
- return {
3324
- id: discussionId,
3325
- subject: posts[0]?.subject ?? "",
3326
- course_id: numberValue(data.courseid),
3327
- forum_id: numberValue(data.forumid),
3328
- group_id: numberValue(data.groupid),
3329
- group_name: stringValue(data.groupname),
3330
- url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
3331
- posts
3332
- };
3333
- }
3334
- function asRecord(value) {
3335
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
3336
- }
3337
- function asArray(value) {
3338
- return Array.isArray(value) ? value : [];
3339
- }
3340
- function stringValue(value) {
3341
- return typeof value === "string" ? value : value == null ? "" : String(value);
3342
- }
3343
- function numberValue(value) {
3344
- if (typeof value === "number" && Number.isFinite(value)) {
3345
- return value;
3346
- }
3347
- if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
3348
- return Number(value);
3349
- }
3350
- return 0;
3351
- }
3352
- function booleanValue(value, defaultValue = false) {
3353
- if (value === void 0 || value === null) {
3354
- return defaultValue;
3355
- }
3356
- return Boolean(value);
3357
- }
3358
- function parseJsonValue(value) {
3359
- if (typeof value !== "string") return value;
3360
- try {
3361
- return JSON.parse(value);
3362
- } catch {
3363
- return {};
3364
- }
3365
- }
3366
- function htmlText(value, baseUrl) {
3367
- return htmlToStructuredContent(stringValue(value), baseUrl).text;
3368
- }
3369
-
3370
3816
  // src/scraper.ts
3371
3817
  import { parse as parse3 } from "node-html-parser";
3372
3818
  function parseMoodleErrorHtml(html) {
@@ -3398,8 +3844,8 @@ function parseMoodleErrorHtml(html) {
3398
3844
  function parsePageContext(html, baseUrl) {
3399
3845
  const root = parse3(html);
3400
3846
  const config = parseMoodleConfig(html);
3401
- const sesskey = stringValue2(config.sesskey).trim();
3402
- const userid = numberValue2(config.userId) || numberValue2(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
3847
+ const sesskey = stringValue(config.sesskey).trim();
3848
+ const userid = numberValue(config.userId) || numberValue(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
3403
3849
  if (!sesskey || !userid) {
3404
3850
  throw new Error("Session appears invalid: could not load authenticated Moodle context");
3405
3851
  }
@@ -3411,8 +3857,8 @@ function parsePageContext(html, baseUrl) {
3411
3857
  fullname: cleanNodeText(root.querySelector(".userfullname")),
3412
3858
  sitename: extractSitename(root),
3413
3859
  siteurl: baseUrl,
3414
- ...config.timezone ? { timezone: stringValue2(config.timezone) } : {},
3415
- lang: stringValue2(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
3860
+ ...config.timezone ? { timezone: stringValue(config.timezone) } : {},
3861
+ lang: stringValue(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
3416
3862
  }
3417
3863
  };
3418
3864
  }
@@ -3895,62 +4341,671 @@ function extractSitename(root) {
3895
4341
  function pageTitle(html) {
3896
4342
  return cleanNodeText(parse3(html).querySelector("h1"));
3897
4343
  }
3898
- function activityContext(html) {
3899
- const root = parse3(html);
3900
- const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
3901
- const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
3902
- const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
3903
- for (const link2 of links) {
3904
- const href = link2.getAttribute("href") ?? "";
3905
- const courseId = numberQueryValue(href, "id");
3906
- if (courseId !== null) {
3907
- context.course_id = courseId;
3908
- }
3909
- if (numberQueryValue(href, "section") === null) {
3910
- context.course_name ||= cleanNodeText(link2);
3911
- } else {
3912
- context.section_name = cleanNodeText(link2);
3913
- }
3914
- }
3915
- return context;
4344
+ function activityContext(html) {
4345
+ const root = parse3(html);
4346
+ const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
4347
+ const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
4348
+ const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
4349
+ for (const link2 of links) {
4350
+ const href = link2.getAttribute("href") ?? "";
4351
+ const courseId = numberQueryValue(href, "id");
4352
+ if (courseId !== null) {
4353
+ context.course_id = courseId;
4354
+ }
4355
+ if (numberQueryValue(href, "section") === null) {
4356
+ context.course_name ||= cleanNodeText(link2);
4357
+ } else {
4358
+ context.section_name = cleanNodeText(link2);
4359
+ }
4360
+ }
4361
+ return context;
4362
+ }
4363
+ function extractLabeledText(html, label) {
4364
+ const root = parse3(html);
4365
+ for (const node of root.querySelectorAll("strong, b")) {
4366
+ if (cleanNodeText(node) !== label) {
4367
+ continue;
4368
+ }
4369
+ const parent = node.parentNode;
4370
+ return cleanText(parent?.textContent.replace(label, "") ?? "");
4371
+ }
4372
+ return "";
4373
+ }
4374
+ function findTableValue(html, label) {
4375
+ const root = parse3(html);
4376
+ for (const row of root.querySelectorAll("tr")) {
4377
+ const cells = row.querySelectorAll("th, td");
4378
+ if (cleanNodeText(cells[0]) === label) {
4379
+ return cleanTableCell(cells[1]);
4380
+ }
4381
+ }
4382
+ return "";
4383
+ }
4384
+ function cleanTableCell(node) {
4385
+ if (!node) {
4386
+ return "";
4387
+ }
4388
+ const clone = parse3(node.toString());
4389
+ for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
4390
+ unwanted.remove();
4391
+ }
4392
+ return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
4393
+ }
4394
+ function numberQueryValue(href, key) {
4395
+ try {
4396
+ return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
4397
+ } catch {
4398
+ return null;
4399
+ }
4400
+ }
4401
+ function numberValue(value) {
4402
+ if (typeof value === "number" && Number.isFinite(value)) {
4403
+ return value;
4404
+ }
4405
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
4406
+ return Number(value);
4407
+ }
4408
+ return 0;
4409
+ }
4410
+ function stringValue(value) {
4411
+ return typeof value === "string" ? value : value == null ? "" : String(value);
4412
+ }
4413
+ function fileEntry(name, url, baseUrl) {
4414
+ return {
4415
+ name,
4416
+ url,
4417
+ requires_authentication: new URL(url).origin === new URL(baseUrl).origin
4418
+ };
4419
+ }
4420
+ function unique(items) {
4421
+ return [...new Set(items)];
4422
+ }
4423
+
4424
+ // src/moodle-assign-core.ts
4425
+ async function submitAssignmentFiles(deps, request) {
4426
+ const id2 = request.activityId;
4427
+ if (!Number.isSafeInteger(id2) || id2 <= 0) throw deps.usage("The assignment id must be a positive integer.");
4428
+ if (!request.files.length && !request.final) throw deps.usage("Give at least one file to upload, or use --final to submit the existing draft.");
4429
+ const seen = /* @__PURE__ */ new Set();
4430
+ for (const file2 of request.files) {
4431
+ if (!file2.name || /[\\/]/u.test(file2.name)) throw deps.usage(`'${file2.name}' is not a plain file name.`);
4432
+ if (seen.has(file2.name.toLowerCase())) throw deps.usage(`'${file2.name}' is given twice; Moodle keeps one file per name.`);
4433
+ seen.add(file2.name.toLowerCase());
4434
+ }
4435
+ const viewUrl = `${deps.baseUrl}${ASSIGN_VIEW_PATH}?id=${id2}`;
4436
+ request.onProgress?.("Reading the assignment");
4437
+ const before = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
4438
+ const form = parseSubmissionForm(await pageText(deps, `${viewUrl}&action=editsubmission`), deps);
4439
+ const draft = await listDraftFiles(deps, form);
4440
+ const removed = request.replace ? draft.map((file2) => file2.name) : [];
4441
+ const kept = request.replace ? [] : draft.filter((file2) => !seen.has(file2.name.toLowerCase()));
4442
+ checkLimits(deps, form, kept, request.files);
4443
+ let statement = form.statement;
4444
+ let confirm2;
4445
+ if (request.final && !statement) {
4446
+ confirm2 = parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
4447
+ statement = confirm2.statement;
4448
+ }
4449
+ if (statement && !request.acceptStatement) {
4450
+ throw deps.usage(`Moodle requires you to accept this statement: "${statement}"`, "Re-run with --accept-statement once you agree.");
4451
+ }
4452
+ const limits = describeLimits(form);
4453
+ const uploads = request.files.map((file2) => ({ name: file2.name, bytes: file2.bytes.byteLength, ...file2.path ? { path: file2.path } : {} }));
4454
+ if (request.dryRun) {
4455
+ return {
4456
+ ...before,
4457
+ action: "planned",
4458
+ files: draft.map((file2) => ({ name: file2.name, bytes: file2.bytes })),
4459
+ uploads,
4460
+ removed,
4461
+ limits,
4462
+ ...statement ? { statement, statement_accepted: true } : {},
4463
+ checked_at: timestamp(deps)
4464
+ };
4465
+ }
4466
+ if (removed.length) {
4467
+ request.onProgress?.(`Removing ${removed.join(", ")}`);
4468
+ await deleteDraftFiles(deps, form, draft);
4469
+ }
4470
+ const storedNames = [];
4471
+ for (const [index, file2] of request.files.entries()) {
4472
+ request.onProgress?.(`Uploading ${file2.name} (${index + 1}/${request.files.length})`);
4473
+ storedNames.push(await uploadDraftFile(deps, form, file2));
4474
+ }
4475
+ if (storedNames.length || removed.length) {
4476
+ request.onProgress?.("Saving the submission");
4477
+ const savedHtml = await postForm(deps, form.action, [...form.fields, ...form.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Save changes"]]);
4478
+ if (savedHtml !== null) throw deps.fail(`Moodle did not save the submission: ${noticesOf(savedHtml) || "it returned the edit form again without a reason"}`);
4479
+ }
4480
+ request.onProgress?.("Reading the receipt");
4481
+ let receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
4482
+ const listed = new Set(receipt.files.map((file2) => file2.name.toLowerCase()));
4483
+ const missing = storedNames.filter((name) => !listed.has(name.toLowerCase()));
4484
+ if (missing.length) throw deps.fail(`Moodle saved the submission but its page does not list ${missing.join(", ")}; check the assignment in a browser before submitting.`);
4485
+ let action = "saved";
4486
+ if (isSubmitted(receipt.submission_status)) action = "submitted";
4487
+ else if (request.final) {
4488
+ request.onProgress?.("Submitting for grading");
4489
+ confirm2 ??= parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
4490
+ const errorHtml = await postForm(deps, confirm2.action, [...confirm2.fields, ...confirm2.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Continue"]]);
4491
+ if (errorHtml !== null) throw deps.fail(`Moodle did not submit the assignment for grading: ${noticesOf(errorHtml) || "it returned the confirmation page again without a reason"}`);
4492
+ receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
4493
+ if (!isSubmitted(receipt.submission_status)) throw deps.fail(`Moodle accepted the confirmation but still reports "${receipt.submission_status || "no status"}"; check the assignment in a browser.`);
4494
+ action = "submitted";
4495
+ }
4496
+ return {
4497
+ ...receipt,
4498
+ action,
4499
+ uploads,
4500
+ removed,
4501
+ limits,
4502
+ ...statement ? { statement, statement_accepted: true } : {},
4503
+ checked_at: timestamp(deps)
4504
+ };
4505
+ }
4506
+ function parseSubmissionForm(html, deps) {
4507
+ const root = parse4(html);
4508
+ const form = formWithAction(root, "savesubmission");
4509
+ if (!form) {
4510
+ const notice = noticesOf(html);
4511
+ if (root.querySelectorAll("input[type=submit], button").some((button) => /begin assignment/iu.test(cleanText(button.getAttribute("value") ?? button.textContent)))) {
4512
+ throw deps.fail("This is a timed assignment; start it in a browser before uploading files.");
4513
+ }
4514
+ throw deps.fail(notice ? `Moodle is not accepting a submission: ${notice}` : "Moodle did not show a submission form for this assignment.");
4515
+ }
4516
+ const itemid = form.querySelector("input[name=files_filemanager]")?.getAttribute("value")?.trim() ?? "";
4517
+ if (!itemid) throw deps.fail("This assignment does not accept file uploads.");
4518
+ const options = filemanagerOptions(html, itemid);
4519
+ if (!options) throw deps.fail("Moodle did not describe the file upload area for this assignment.");
4520
+ const fields2 = formFields(form);
4521
+ const sesskey = fields2.find(([name]) => name === "sesskey")?.[1] ?? "";
4522
+ if (!sesskey) throw deps.fail("The submission form has no session key.");
4523
+ const picker = record2(options.filepicker);
4524
+ const repositories = (Array.isArray(picker.repositories) ? picker.repositories : Object.values(record2(picker.repositories))).map(record2);
4525
+ const upload = repositories.find((repo) => repo.type === "upload");
4526
+ if (!upload || upload.id === void 0) throw deps.fail("The site does not allow direct file uploads for this assignment.");
4527
+ const accepted = options.accepted_types;
4528
+ const acceptedTypes = Array.isArray(accepted) ? accepted.map(String).filter(Boolean) : accepted === void 0 || accepted === "*" ? "*" : [String(accepted)];
4529
+ return {
4530
+ action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`),
4531
+ fields: fields2,
4532
+ sesskey,
4533
+ itemid,
4534
+ clientId: String(options.client_id ?? ""),
4535
+ contextId: String(record2(options.context).id ?? ""),
4536
+ repoId: String(upload.id),
4537
+ author: String(picker.author ?? ""),
4538
+ license: String(picker.defaultlicense ?? ""),
4539
+ maxBytes: integer(options.maxbytes),
4540
+ areaMaxBytes: integer(options.areamaxbytes),
4541
+ maxFiles: integer(options.maxfiles),
4542
+ acceptedTypes: acceptedTypes.length === 1 && acceptedTypes[0] === "*" ? "*" : acceptedTypes,
4543
+ ...statementOf(form)
4544
+ };
4545
+ }
4546
+ function parseConfirmForm(html, deps) {
4547
+ const root = parse4(html);
4548
+ const form = formWithAction(root, "confirmsubmit");
4549
+ if (!form) {
4550
+ const notice = noticesOf(html);
4551
+ throw deps.fail(notice ? `Moodle is not accepting a submission for grading: ${notice}` : "Moodle did not show the submit-for-grading confirmation.");
4552
+ }
4553
+ return { action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`), fields: formFields(form), ...statementOf(form) };
4554
+ }
4555
+ function parseReceiptPage(html, activityId, baseUrl) {
4556
+ const page = parseAssignmentHtml(html, activityId, baseUrl);
4557
+ const root = parse4(html);
4558
+ const files = /* @__PURE__ */ new Map();
4559
+ const add = (link2) => {
4560
+ const name = cleanText(link2.textContent);
4561
+ const href = link2.getAttribute("href") ?? "";
4562
+ if (name && !files.has(name.toLowerCase())) files.set(name.toLowerCase(), { name, ...href ? { url: resolveUrl(baseUrl, href) } : {} });
4563
+ };
4564
+ for (const link2 of root.querySelectorAll(".fileuploadsubmission a[href]")) add(link2);
4565
+ for (const link2 of tableCell(root, "File submissions")?.querySelectorAll("a[href]") ?? []) add(link2);
4566
+ return {
4567
+ id: activityId,
4568
+ name: page.name,
4569
+ ...page.course_id ? { unit_id: page.course_id } : {},
4570
+ url: page.url,
4571
+ submission_status: page.submission_status,
4572
+ grading_status: page.grading_status,
4573
+ due: page.due_pretty || cleanText(tableCell(root, "Due date")?.textContent),
4574
+ time_remaining: page.time_remaining,
4575
+ last_modified: cleanText(tableCell(root, "Last modified")?.textContent),
4576
+ files: [...files.values()]
4577
+ };
4578
+ }
4579
+ function noticesOf(html) {
4580
+ const root = parse4(html);
4581
+ const texts = [];
4582
+ for (const node of root.querySelectorAll(".alert, .invalid-feedback, .form-control-feedback, .error, [data-fieldtype] .text-danger")) {
4583
+ for (const junk of node.querySelectorAll("button, .close")) junk.remove();
4584
+ const text2 = cleanText(node.textContent);
4585
+ if (text2 && !texts.includes(text2)) texts.push(text2);
4586
+ }
4587
+ return texts.join(" ");
4588
+ }
4589
+ function formWithAction(root, action) {
4590
+ for (const form of root.querySelectorAll("form")) {
4591
+ if (form.querySelectorAll("input[name=action]").some((input2) => input2.getAttribute("value") === action)) return form;
4592
+ }
4593
+ return null;
4594
+ }
4595
+ function formFields(form) {
4596
+ const fields2 = [];
4597
+ for (const element of form.querySelectorAll("input, textarea, select")) {
4598
+ const name = element.getAttribute("name");
4599
+ if (!name) continue;
4600
+ const tag = element.tagName.toLowerCase();
4601
+ if (tag === "textarea") {
4602
+ fields2.push([name, element.textContent]);
4603
+ continue;
4604
+ }
4605
+ if (tag === "select") {
4606
+ const options = element.querySelectorAll("option");
4607
+ const chosen = options.find((option) => option.hasAttribute("selected")) ?? options[0];
4608
+ if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
4609
+ continue;
4610
+ }
4611
+ const type = (element.getAttribute("type") ?? "text").toLowerCase();
4612
+ if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
4613
+ if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
4614
+ fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
4615
+ }
4616
+ return fields2;
4617
+ }
4618
+ function statementOf(form) {
4619
+ const box = form.querySelector("input[name=submissionstatement]");
4620
+ if (!box) return {};
4621
+ const id2 = box.getAttribute("id");
4622
+ const label = (id2 ? form.querySelector(`label[for="${id2}"]`) : null) ?? box.closest("label") ?? box.parentNode?.querySelector("label") ?? null;
4623
+ const text2 = cleanText(label?.textContent).replace(/\s*Required\s*$/u, "").trim();
4624
+ return { statement: text2 || "Submission statement" };
4625
+ }
4626
+ function filemanagerOptions(html, itemid) {
4627
+ const pattern = /M\.form_filemanager\.init\(\s*Y\s*,\s*/gu;
4628
+ let match;
4629
+ while (match = pattern.exec(html)) {
4630
+ const json = balancedObject(html, match.index + match[0].length);
4631
+ if (!json) continue;
4632
+ try {
4633
+ const options = JSON.parse(json);
4634
+ if (isRecord4(options) && String(options.itemid) === itemid) return options;
4635
+ } catch {
4636
+ }
4637
+ }
4638
+ return null;
4639
+ }
4640
+ function balancedObject(text2, start) {
4641
+ if (text2[start] !== "{") return null;
4642
+ let depth = 0;
4643
+ let quoted = false;
4644
+ for (let index = start; index < text2.length; index += 1) {
4645
+ const char = text2[index];
4646
+ if (quoted) {
4647
+ if (char === "\\") index += 1;
4648
+ else if (char === '"') quoted = false;
4649
+ } else if (char === '"') quoted = true;
4650
+ else if (char === "{") depth += 1;
4651
+ else if (char === "}") {
4652
+ depth -= 1;
4653
+ if (depth === 0) return text2.slice(start, index + 1);
4654
+ }
4655
+ }
4656
+ return null;
4657
+ }
4658
+ function tableCell(root, label) {
4659
+ for (const row of root.querySelectorAll("tr")) {
4660
+ const cells = row.querySelectorAll("th, td");
4661
+ if (cells.length > 1 && cleanText(cells[0].textContent) === label) return cells[1];
4662
+ }
4663
+ return null;
4664
+ }
4665
+ async function pageText(deps, url) {
4666
+ return (await deps.request(url)).text();
4667
+ }
4668
+ async function postForm(deps, action, fields2) {
4669
+ const response = await deps.request(action, {
4670
+ method: "POST",
4671
+ headers: { "content-type": "application/x-www-form-urlencoded" },
4672
+ body: new URLSearchParams(fields2).toString()
4673
+ });
4674
+ const html = await response.text();
4675
+ return landedOnView(response.url) ? null : html;
4676
+ }
4677
+ function landedOnView(url) {
4678
+ try {
4679
+ const parsed = new URL(url);
4680
+ return parsed.pathname.endsWith(ASSIGN_VIEW_PATH) && (parsed.searchParams.get("action") ?? "view") === "view";
4681
+ } catch {
4682
+ return false;
4683
+ }
4684
+ }
4685
+ async function draftAjax(deps, form, action, params) {
4686
+ const body = new URLSearchParams({ sesskey: form.sesskey, client_id: form.clientId, itemid: form.itemid, ...params });
4687
+ const response = await deps.request(`${deps.baseUrl}/repository/draftfiles_ajax.php?action=${action}`, {
4688
+ method: "POST",
4689
+ headers: { "content-type": "application/x-www-form-urlencoded" },
4690
+ body: body.toString()
4691
+ }, { allowErrorStatus: true });
4692
+ return jsonOf(deps, response, `draft file ${action}`);
4693
+ }
4694
+ async function listDraftFiles(deps, form) {
4695
+ const data = record2(await draftAjax(deps, form, "list", { filepath: "/" }));
4696
+ const list2 = Array.isArray(data.list) ? data.list.map(record2) : [];
4697
+ return list2.filter((item) => item.type !== "folder").map((item) => ({ name: String(item.filename ?? item.fullname ?? ""), path: String(item.filepath ?? "/"), bytes: integer(item.size) })).filter((item) => item.name);
4698
+ }
4699
+ async function deleteDraftFiles(deps, form, files) {
4700
+ const selected = JSON.stringify(files.map((file2) => ({ filename: file2.name, filepath: file2.path })));
4701
+ const result = await draftAjax(deps, form, "deleteselected", { selected });
4702
+ if (result === false) throw deps.fail("Moodle did not remove the existing submission files.");
4703
+ }
4704
+ async function uploadDraftFile(deps, form, file2) {
4705
+ const body = new FormData();
4706
+ body.set("sesskey", form.sesskey);
4707
+ body.set("client_id", form.clientId);
4708
+ body.set("repo_id", form.repoId);
4709
+ body.set("itemid", form.itemid);
4710
+ body.set("env", "filemanager");
4711
+ body.set("ctx_id", form.contextId);
4712
+ body.set("title", file2.name);
4713
+ body.set("author", form.author);
4714
+ body.set("license", form.license);
4715
+ body.set("savepath", "/");
4716
+ body.set("maxbytes", String(form.maxBytes));
4717
+ body.set("areamaxbytes", String(form.areaMaxBytes));
4718
+ for (const type of form.acceptedTypes === "*" ? ["*"] : form.acceptedTypes) body.append("accepted_types[]", type);
4719
+ body.set("overwrite", "1");
4720
+ body.set("repo_upload_file", new Blob([Uint8Array.from(file2.bytes)]), file2.name);
4721
+ const response = await deps.request(`${deps.baseUrl}/repository/repository_ajax.php?action=upload`, { method: "POST", body }, { allowErrorStatus: true });
4722
+ const data = record2(await jsonOf(deps, response, `upload of ${file2.name}`));
4723
+ if (typeof data.error === "string" && data.error) throw deps.fail(`Moodle refused ${file2.name}: ${data.error}`, typeof data.errorcode === "string" ? data.errorcode : void 0);
4724
+ if (data.event === "fileexists") throw deps.fail(`Moodle reports ${file2.name} already exists and did not overwrite it.`);
4725
+ const stored = typeof data.file === "string" && data.file ? data.file : file2.name;
4726
+ if (!data.url && !data.id && !data.file) throw deps.fail(`Moodle did not confirm the upload of ${file2.name}.`);
4727
+ return stored;
4728
+ }
4729
+ async function jsonOf(deps, response, step) {
4730
+ const text2 = await response.text();
4731
+ try {
4732
+ return JSON.parse(text2);
4733
+ } catch {
4734
+ const notice = noticesOf(text2);
4735
+ throw deps.fail(`Moodle did not answer the ${step} with JSON (HTTP ${response.status})${notice ? `: ${notice}` : ""}`);
4736
+ }
4737
+ }
4738
+ function checkLimits(deps, form, kept, files) {
4739
+ if (form.maxFiles > 0 && kept.length + files.length > form.maxFiles) {
4740
+ throw deps.usage(`This assignment allows ${form.maxFiles} file${form.maxFiles === 1 ? "" : "s"}; the submission would hold ${kept.length + files.length}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
4741
+ }
4742
+ for (const file2 of files) {
4743
+ if (form.maxBytes > 0 && file2.bytes.byteLength > form.maxBytes) throw deps.usage(`${file2.name} is ${size(file2.bytes.byteLength)}; the limit is ${size(form.maxBytes)}.`);
4744
+ if (form.acceptedTypes !== "*" && form.acceptedTypes.every((type) => type.startsWith(".")) && !form.acceptedTypes.some((type) => file2.name.toLowerCase().endsWith(type.toLowerCase()))) {
4745
+ throw deps.usage(`${file2.name} is not an accepted type; allowed: ${form.acceptedTypes.join(", ")}.`);
4746
+ }
4747
+ }
4748
+ const total = kept.reduce((sum, file2) => sum + file2.bytes, 0) + files.reduce((sum, file2) => sum + file2.bytes.byteLength, 0);
4749
+ if (form.areaMaxBytes > 0 && total > form.areaMaxBytes) throw deps.usage(`The submission would total ${size(total)}; the limit is ${size(form.areaMaxBytes)}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
4750
+ }
4751
+ function describeLimits(form) {
4752
+ return {
4753
+ ...form.maxBytes > 0 ? { max_bytes: form.maxBytes } : {},
4754
+ ...form.maxFiles > 0 ? { max_files: form.maxFiles } : {},
4755
+ ...form.areaMaxBytes > 0 ? { area_max_bytes: form.areaMaxBytes } : {},
4756
+ ...form.acceptedTypes !== "*" ? { accepted_types: form.acceptedTypes } : {}
4757
+ };
4758
+ }
4759
+ function isSubmitted(status) {
4760
+ return /\bsubmitted\b/iu.test(status) && !/\bnot submitted\b/iu.test(status);
4761
+ }
4762
+ function size(bytes) {
4763
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
4764
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
4765
+ return `${bytes} B`;
4766
+ }
4767
+ function timestamp(deps) {
4768
+ return (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
4769
+ }
4770
+ function integer(value) {
4771
+ const number = Number(value);
4772
+ return Number.isSafeInteger(number) ? number : 0;
4773
+ }
4774
+ function isRecord4(value) {
4775
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4776
+ }
4777
+ function record2(value) {
4778
+ return isRecord4(value) ? value : {};
4779
+ }
4780
+
4781
+ // src/parsers.ts
4782
+ function schema(parser) {
4783
+ return { parse: parser };
4784
+ }
4785
+ var UserInfoSchema = schema(parseUserInfo);
4786
+ var CourseSchema = schema(parseCourse);
4787
+ var CoursesSchema = schema(parseCourses);
4788
+ var ActivitySchema = schema(parseActivity);
4789
+ var SectionSchema = schema(parseSection);
4790
+ var CourseContentsSchema = schema(parseCourseContents);
4791
+ var TodoItemSchema = schema(parseTodoItem);
4792
+ function parseUserInfo(value) {
4793
+ const data = asRecord(value);
4794
+ return {
4795
+ userid: numberValue2(data.userid),
4796
+ username: stringValue2(data.username),
4797
+ fullname: stringValue2(data.fullname),
4798
+ sitename: stringValue2(data.sitename),
4799
+ siteurl: stringValue2(data.siteurl),
4800
+ lang: stringValue2(data.lang),
4801
+ ...data.timezone ? { timezone: String(data.timezone) } : {}
4802
+ };
4803
+ }
4804
+ function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
4805
+ const data = asRecord(value);
4806
+ const course = {
4807
+ id: numberValue2(data.id),
4808
+ shortname: stringValue2(data.shortname),
4809
+ fullname: stringValue2(data.fullname),
4810
+ category: numberValue2(data.category),
4811
+ visible: booleanValue(data.visible, true),
4812
+ startdate: numberValue2(data.startdate)
4813
+ };
4814
+ const enddate = numberValue2(data.enddate);
4815
+ if (enddate > 0) {
4816
+ course.enddate = enddate;
4817
+ }
4818
+ return course;
4819
+ }
4820
+ function parseCourses(value) {
4821
+ return asArray(value).map((item) => parseCourse(item));
4822
+ }
4823
+ function parseActivity(value) {
4824
+ const data = asRecord(value);
4825
+ return {
4826
+ id: numberValue2(data.id),
4827
+ name: stringValue2(data.name),
4828
+ modname: stringValue2(data.modname),
4829
+ url: stringValue2(data.url),
4830
+ visible: booleanValue(data.visible, true),
4831
+ description: stringValue2(data.description),
4832
+ ...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue2(asRecord(data.completiondata).state) } : {},
4833
+ ...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue2(asRecord(f).filename), url: stringValue2(asRecord(f).fileurl), requires_authentication: true })) } : {}
4834
+ };
4835
+ }
4836
+ function parseSection(value) {
4837
+ const data = asRecord(value);
4838
+ return {
4839
+ id: numberValue2(data.id),
4840
+ name: stringValue2(data.name),
4841
+ section: numberValue2(data.section),
4842
+ visible: booleanValue(data.visible, true),
4843
+ summary: stringValue2(data.summary),
4844
+ ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
4845
+ activities: asArray(data.modules).map((item) => parseActivity(item))
4846
+ };
4847
+ }
4848
+ function parseCourseContents(value) {
4849
+ return asArray(value).map((item) => parseSection(item));
4850
+ }
4851
+ function parseCourseFormatState(value, baseUrl) {
4852
+ const state = asRecord(parseJsonValue(value));
4853
+ const activities = /* @__PURE__ */ new Map();
4854
+ const activitiesBySection = /* @__PURE__ */ new Map();
4855
+ for (const item of asArray(state.cm)) {
4856
+ const data = asRecord(item);
4857
+ const id2 = numberValue2(data.id);
4858
+ const sectionId = stringValue2(data.sectionid);
4859
+ const module = stringValue2(data.module) || stringValue2(data.plugin).replace(/^mod_/u, "") || stringValue2(data.modname).toLowerCase();
4860
+ const activity = {
4861
+ id: id2,
4862
+ name: htmlText(data.name, baseUrl),
4863
+ modname: module.toLowerCase(),
4864
+ url: stringValue2(data.url) ? resolveUrl(baseUrl, stringValue2(data.url)) : "",
4865
+ visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
4866
+ description: htmlText(data.content ?? data.description, baseUrl),
4867
+ ...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue2(data.completionstate) } : {}
4868
+ };
4869
+ activities.set(String(id2), activity);
4870
+ const sectionActivities = activitiesBySection.get(sectionId) ?? [];
4871
+ sectionActivities.push(activity);
4872
+ activitiesBySection.set(sectionId, sectionActivities);
4873
+ }
4874
+ return asArray(state.section).map((item) => {
4875
+ const data = asRecord(item);
4876
+ const id2 = numberValue2(data.id);
4877
+ const hasActivityList = Array.isArray(data.cmlist);
4878
+ const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue2(activityId))).filter((activity) => activity !== void 0);
4879
+ return {
4880
+ id: id2,
4881
+ name: htmlText(data.title || data.rawtitle, baseUrl),
4882
+ section: numberValue2(data.section ?? data.number),
4883
+ visible: booleanValue(data.visible, true),
4884
+ summary: htmlText(data.summary, baseUrl),
4885
+ ...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
4886
+ activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
4887
+ };
4888
+ });
4889
+ }
4890
+ function parseTodoItem(value) {
4891
+ const data = asRecord(value);
4892
+ const course = asRecord(data.course);
4893
+ const action = asRecord(data.action);
4894
+ const progress = course.progress;
4895
+ return {
4896
+ id: numberValue2(data.id),
4897
+ name: stringValue2(data.name),
4898
+ activity_name: stringValue2(data.activityname),
4899
+ modname: stringValue2(data.modulename),
4900
+ course_id: numberValue2(course.id),
4901
+ course_name: stringValue2(course.fullname),
4902
+ due_at: numberValue2(data.timesort) || numberValue2(data.timestart),
4903
+ overdue: booleanValue(data.overdue),
4904
+ actionable: booleanValue(action.actionable),
4905
+ action_name: stringValue2(action.name),
4906
+ action_url: stringValue2(action.url),
4907
+ url: stringValue2(data.url),
4908
+ event_type: stringValue2(data.eventtype),
4909
+ course_progress: typeof progress === "number" ? progress : void 0
4910
+ };
4911
+ }
4912
+ function parseTodoItems(value) {
4913
+ return asArray(value).map((item) => parseTodoItem(item));
4914
+ }
4915
+ function parseAlertNotification(value) {
4916
+ const data = asRecord(value);
4917
+ return {
4918
+ id: numberValue2(data.id),
4919
+ subject: stringValue2(data.subject),
4920
+ short_subject: stringValue2(data.shortenedsubject),
4921
+ event_type: stringValue2(data.eventtype),
4922
+ component: stringValue2(data.component),
4923
+ created_at: numberValue2(data.timecreated),
4924
+ created_pretty: stringValue2(data.timecreatedpretty),
4925
+ read: booleanValue(data.read),
4926
+ context_url: stringValue2(data.contexturl),
4927
+ context_name: stringValue2(data.contexturlname)
4928
+ };
4929
+ }
4930
+ function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
4931
+ const notificationsRecord = asRecord(notificationsData);
4932
+ const counts2 = asRecord(countsData);
4933
+ const unreadCounts = asRecord(unreadCountsData);
4934
+ const types = asRecord(counts2.types);
4935
+ const unreadTypes = asRecord(unreadCounts.types);
4936
+ const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
4937
+ return {
4938
+ notifications,
4939
+ notification_count: notifications.length,
4940
+ unread_notification_count: notifications.filter((notification) => !notification.read).length,
4941
+ starred_message_count: numberValue2(counts2.favourites),
4942
+ direct_message_count: numberValue2(types["1"]),
4943
+ group_message_count: numberValue2(types["2"]),
4944
+ self_message_count: numberValue2(types["3"]),
4945
+ unread_starred_message_count: numberValue2(unreadCounts.favourites),
4946
+ unread_direct_message_count: numberValue2(unreadTypes["1"]),
4947
+ unread_group_message_count: numberValue2(unreadTypes["2"]),
4948
+ unread_self_message_count: numberValue2(unreadTypes["3"])
4949
+ };
4950
+ }
4951
+ function parseForumPostAuthor(value) {
4952
+ const data = asRecord(value);
4953
+ const urls = asRecord(data.urls);
4954
+ return {
4955
+ id: numberValue2(data.id),
4956
+ fullname: stringValue2(data.fullname),
4957
+ profile_url: stringValue2(urls.profile),
4958
+ profile_image_url: stringValue2(urls.profileimage)
4959
+ };
4960
+ }
4961
+ function parseForumPost(value, baseUrl = "") {
4962
+ const data = asRecord(value);
4963
+ const urls = asRecord(data.urls);
4964
+ const messageHtml = stringValue2(data.message);
4965
+ const structured = htmlToStructuredContent(messageHtml, stringValue2(urls.view || urls.discuss) || baseUrl);
4966
+ return {
4967
+ id: numberValue2(data.id),
4968
+ discussion_id: numberValue2(data.discussionid),
4969
+ subject: stringValue2(data.subject),
4970
+ message_html: messageHtml,
4971
+ message_text: structured.text,
4972
+ image_urls: structured.image_urls,
4973
+ links: structured.links,
4974
+ tables: structured.tables,
4975
+ author: parseForumPostAuthor(data.author),
4976
+ parent_id: numberValue2(data.parentid),
4977
+ time_created: numberValue2(data.timecreated),
4978
+ time_modified: numberValue2(data.timemodified),
4979
+ created_pretty: "",
4980
+ unread: booleanValue(data.unread),
4981
+ is_deleted: booleanValue(data.isdeleted),
4982
+ is_private_reply: booleanValue(data.isprivatereply),
4983
+ url: stringValue2(urls.view || urls.viewisolated),
4984
+ reply_url: stringValue2(urls.reply)
4985
+ };
3916
4986
  }
3917
- function extractLabeledText(html, label) {
3918
- const root = parse3(html);
3919
- for (const node of root.querySelectorAll("strong, b")) {
3920
- if (cleanNodeText(node) !== label) {
3921
- continue;
3922
- }
3923
- const parent = node.parentNode;
3924
- return cleanText(parent?.textContent.replace(label, "") ?? "");
3925
- }
3926
- return "";
4987
+ function parseForumDiscussion(value, discussionId, baseUrl = "") {
4988
+ const data = asRecord(value);
4989
+ const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
4990
+ return {
4991
+ id: discussionId,
4992
+ subject: posts[0]?.subject ?? "",
4993
+ course_id: numberValue2(data.courseid),
4994
+ forum_id: numberValue2(data.forumid),
4995
+ group_id: numberValue2(data.groupid),
4996
+ group_name: stringValue2(data.groupname),
4997
+ url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
4998
+ posts
4999
+ };
3927
5000
  }
3928
- function findTableValue(html, label) {
3929
- const root = parse3(html);
3930
- for (const row of root.querySelectorAll("tr")) {
3931
- const cells = row.querySelectorAll("th, td");
3932
- if (cleanNodeText(cells[0]) === label) {
3933
- return cleanTableCell(cells[1]);
3934
- }
3935
- }
3936
- return "";
5001
+ function asRecord(value) {
5002
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
3937
5003
  }
3938
- function cleanTableCell(node) {
3939
- if (!node) {
3940
- return "";
3941
- }
3942
- const clone = parse3(node.toString());
3943
- for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
3944
- unwanted.remove();
3945
- }
3946
- return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
5004
+ function asArray(value) {
5005
+ return Array.isArray(value) ? value : [];
3947
5006
  }
3948
- function numberQueryValue(href, key) {
3949
- try {
3950
- return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
3951
- } catch {
3952
- return null;
3953
- }
5007
+ function stringValue2(value) {
5008
+ return typeof value === "string" ? value : value == null ? "" : String(value);
3954
5009
  }
3955
5010
  function numberValue2(value) {
3956
5011
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -3961,18 +5016,22 @@ function numberValue2(value) {
3961
5016
  }
3962
5017
  return 0;
3963
5018
  }
3964
- function stringValue2(value) {
3965
- return typeof value === "string" ? value : value == null ? "" : String(value);
5019
+ function booleanValue(value, defaultValue = false) {
5020
+ if (value === void 0 || value === null) {
5021
+ return defaultValue;
5022
+ }
5023
+ return Boolean(value);
3966
5024
  }
3967
- function fileEntry(name, url, baseUrl) {
3968
- return {
3969
- name,
3970
- url,
3971
- requires_authentication: new URL(url).origin === new URL(baseUrl).origin
3972
- };
5025
+ function parseJsonValue(value) {
5026
+ if (typeof value !== "string") return value;
5027
+ try {
5028
+ return JSON.parse(value);
5029
+ } catch {
5030
+ return {};
5031
+ }
3973
5032
  }
3974
- function unique(items) {
3975
- return [...new Set(items)];
5033
+ function htmlText(value, baseUrl) {
5034
+ return htmlToStructuredContent(stringValue2(value), baseUrl).text;
3976
5035
  }
3977
5036
 
3978
5037
  // src/moodle-forum-core.ts
@@ -4109,13 +5168,13 @@ var ForumModule = class {
4109
5168
  }
4110
5169
  };
4111
5170
  function shouldFallbackForumAjax(error) {
4112
- if (!isRecord4(error)) {
5171
+ if (!isRecord5(error)) {
4113
5172
  return false;
4114
5173
  }
4115
5174
  const code = typeof error.moodleErrorCode === "string" ? error.moodleErrorCode : "";
4116
5175
  return code === "servicenotavailable" || code === "accessexception" || error instanceof Error && error.message.includes("Web service is not available");
4117
5176
  }
4118
- function isRecord4(value) {
5177
+ function isRecord5(value) {
4119
5178
  return typeof value === "object" && value !== null && !Array.isArray(value);
4120
5179
  }
4121
5180
 
@@ -4247,9 +5306,9 @@ async function searchForumContent(source, query, options = {}) {
4247
5306
  hits.sort(sortBy === "recent" ? sortRecent : sortRelevant);
4248
5307
  return hits.slice(0, options.limit ?? 20).map(([, hit]) => includePostText ? hit : { ...hit, snippet: "" });
4249
5308
  }
4250
- async function inParallel2(items, size, run) {
5309
+ async function inParallel2(items, size2, run) {
4251
5310
  const results = [];
4252
- for (let index = 0; index < items.length; index += size) results.push(...await Promise.all(items.slice(index, index + size).map(run)));
5311
+ for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
4253
5312
  return results;
4254
5313
  }
4255
5314
  function normalizeQuery(value) {
@@ -4425,7 +5484,7 @@ var MoodleClientCore = class {
4425
5484
  await this.ensureSession();
4426
5485
  return this.call(functionName, args);
4427
5486
  },
4428
- getPage: (path4, params) => this.get(path4, params),
5487
+ getPage: (path5, params) => this.get(path5, params),
4429
5488
  getCourses: () => this.getCourses(),
4430
5489
  getCourseContents: (courseId) => this.getCourseContents(courseId)
4431
5490
  });
@@ -4435,7 +5494,7 @@ var MoodleClientCore = class {
4435
5494
  try {
4436
5495
  if (this.unavailable.has(FUNC_GET_SITE_INFO) && this.userInfo?.fullname) return this.userInfo;
4437
5496
  const data = await this.call(FUNC_GET_SITE_INFO);
4438
- if (isRecord5(data) && "userid" in data) {
5497
+ if (isRecord6(data) && "userid" in data) {
4439
5498
  const info = parseUserInfo(data);
4440
5499
  this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
4441
5500
  this.userid = info.userid;
@@ -4534,9 +5593,9 @@ var MoodleClientCore = class {
4534
5593
  let type = "";
4535
5594
  try {
4536
5595
  const data = await this.call(FUNC_GET_COURSE_MODULE, { cmid: id2 });
4537
- const module = isRecord5(data) && isRecord5(data.cm) ? data.cm : data;
4538
- type = isRecord5(module) && typeof module.modname === "string" ? module.modname : "";
4539
- courseId = isRecord5(module) && typeof module.course === "number" ? module.course : void 0;
5596
+ const module = isRecord6(data) && isRecord6(data.cm) ? data.cm : data;
5597
+ type = isRecord6(module) && typeof module.modname === "string" ? module.modname : "";
5598
+ courseId = isRecord6(module) && typeof module.course === "number" ? module.course : void 0;
4540
5599
  } catch (error) {
4541
5600
  if (!this.errors.isApi(error) || error.moodleErrorCode !== "servicenotavailable") {
4542
5601
  throw error;
@@ -4580,14 +5639,14 @@ var MoodleClientCore = class {
4580
5639
  } else {
4581
5640
  data = await this.call(FUNC_GET_ACTION_EVENTS, { ...window, limittononsuspendedevents: true });
4582
5641
  }
4583
- const events = isRecord5(data) && Array.isArray(data.events) ? data.events : [];
5642
+ const events = isRecord6(data) && Array.isArray(data.events) ? data.events : [];
4584
5643
  for (const item of parseTodoItems(events)) if (!seen.has(item.id)) {
4585
5644
  seen.add(item.id);
4586
5645
  items.push(item);
4587
5646
  }
4588
5647
  if (events.length < batchSize) break;
4589
5648
  const last = events.at(-1);
4590
- const next = isRecord5(last) && typeof last.id === "number" ? last.id : void 0;
5649
+ const next = isRecord6(last) && typeof last.id === "number" ? last.id : void 0;
4591
5650
  if (!next || next === aftereventid) throw this.errors.api("Moodle repeated a calendar page; refine the date window.");
4592
5651
  aftereventid = next;
4593
5652
  }
@@ -4754,8 +5813,18 @@ var MoodleClientCore = class {
4754
5813
  async getFolder(id2) {
4755
5814
  return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
4756
5815
  }
4757
- async requestAbsolute(url, init = {}) {
4758
- return this.requestAbsoluteInternal(url, init, true);
5816
+ async requestAbsolute(url, init = {}, options = {}) {
5817
+ return this.requestAbsoluteInternal(url, init, true, Boolean(options.allowErrorStatus));
5818
+ }
5819
+ /** Uploads files into an assignment and reads the receipt back from the site. */
5820
+ async submitAssignment(request) {
5821
+ await this.ensureSession();
5822
+ return submitAssignmentFiles({
5823
+ baseUrl: this.baseUrl,
5824
+ request: (url, init, options) => this.requestAbsolute(url, init, options),
5825
+ fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
5826
+ usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
5827
+ }, request);
4759
5828
  }
4760
5829
  async getNewsForums(courseId) {
4761
5830
  const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
@@ -4765,7 +5834,7 @@ var MoodleClientCore = class {
4765
5834
  await this.ensureSession();
4766
5835
  const data = await this.call("mod_forum_get_forums_by_courses", { courseids: units.map((c) => c.id) });
4767
5836
  for (const f of Array.isArray(data) ? data : []) {
4768
- if (!isRecord5(f) || f.type !== "news" || typeof f.cmid !== "number") continue;
5837
+ if (!isRecord6(f) || f.type !== "news" || typeof f.cmid !== "number") continue;
4769
5838
  const c = units.find((c2) => c2.id === f.course);
4770
5839
  forums.push({ id: f.cmid, name: String(f.name || ""), course_id: Number(f.course), course_name: c?.fullname || "", url: `${this.baseUrl}/mod/forum/view.php?id=${f.cmid}` });
4771
5840
  }
@@ -4893,16 +5962,16 @@ var MoodleClientCore = class {
4893
5962
  async getAbsolute(url) {
4894
5963
  return (await this.requestAbsolute(url)).text();
4895
5964
  }
4896
- async requestAbsoluteInternal(url, init, allowRetry) {
5965
+ async requestAbsoluteInternal(url, init, allowRetry, allowErrorStatus = false) {
4897
5966
  const response = await fetchWithSession(url, init, this.baseUrl, this.cookie, this.fetchImpl);
4898
5967
  if (response.url.includes("/login/")) {
4899
5968
  if (this.onLoginRequired && allowRetry && !this.retryingLogin) {
4900
5969
  await this.reauthenticate();
4901
- return this.requestAbsoluteInternal(url, init, false);
5970
+ return this.requestAbsoluteInternal(url, init, false, allowErrorStatus);
4902
5971
  }
4903
5972
  throw this.errors.api("Session expired", "servicerequireslogin");
4904
5973
  }
4905
- if (!response.ok) {
5974
+ if (!response.ok && !allowErrorStatus) {
4906
5975
  const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
4907
5976
  const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
4908
5977
  if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
@@ -4920,7 +5989,7 @@ var MoodleClientCore = class {
4920
5989
  let offset = 0;
4921
5990
  while (true) {
4922
5991
  const data = await this.call(FUNC_GET_COURSES_BY_TIMELINE, { classification: "all", limit: 100, offset });
4923
- if (!isRecord5(data) || !Array.isArray(data.courses) || !data.courses.length) {
5992
+ if (!isRecord6(data) || !Array.isArray(data.courses) || !data.courses.length) {
4924
5993
  break;
4925
5994
  }
4926
5995
  courses.push(...data.courses);
@@ -5080,19 +6149,19 @@ function placeholderUserInfo(baseUrl, userid) {
5080
6149
  };
5081
6150
  }
5082
6151
  function parseTodoPayload(value) {
5083
- return parseTodoItems(isRecord5(value) && Array.isArray(value.events) ? value.events : []);
6152
+ return parseTodoItems(isRecord6(value) && Array.isArray(value.events) ? value.events : []);
5084
6153
  }
5085
6154
  function errorMessage(value) {
5086
6155
  return value instanceof Error ? value.message : "Unknown Moodle error";
5087
6156
  }
5088
- function chunks(values, size) {
6157
+ function chunks(values, size2) {
5089
6158
  const result = [];
5090
- for (let index = 0; index < values.length; index += size) {
5091
- result.push(values.slice(index, index + size));
6159
+ for (let index = 0; index < values.length; index += size2) {
6160
+ result.push(values.slice(index, index + size2));
5092
6161
  }
5093
6162
  return result;
5094
6163
  }
5095
- function isRecord5(value) {
6164
+ function isRecord6(value) {
5096
6165
  return !!value && typeof value === "object" && !Array.isArray(value);
5097
6166
  }
5098
6167
  function isLoginErrorCode2(code) {
@@ -5114,10 +6183,30 @@ function safeUrl(value) {
5114
6183
  }
5115
6184
  }
5116
6185
 
6186
+ // src/submit.ts
6187
+ import { readFile as readFile6, stat as stat2 } from "fs/promises";
6188
+ import { homedir as homedir10 } from "os";
6189
+ import path from "path";
6190
+ function resolveSubmissionPath(given, cwd = process.cwd()) {
6191
+ return path.resolve(cwd, given.startsWith("~/") ? path.join(homedir10(), given.slice(2)) : given);
6192
+ }
6193
+ async function readSubmissionFiles(paths, cwd = process.cwd()) {
6194
+ const files = [];
6195
+ for (const given of paths) {
6196
+ const resolved = resolveSubmissionPath(given, cwd);
6197
+ const info = await stat2(resolved).catch(() => null);
6198
+ if (!info) throw new UsageError(`File not found: ${given}`);
6199
+ if (!info.isFile()) throw new UsageError(`Not a file: ${given}`, "Zip a folder before uploading it.");
6200
+ files.push({ name: path.basename(resolved), bytes: await readFile6(resolved), path: resolved });
6201
+ }
6202
+ return files;
6203
+ }
6204
+
5117
6205
  // src/client.ts
5118
6206
  var NODE_ERROR_ADAPTER = {
5119
6207
  api: (message, moodleErrorCode) => new MoodleAPIError(message, moodleErrorCode),
5120
6208
  notFound: (message) => new NotFoundError(message),
6209
+ usage: (message, hint) => new UsageError(message, hint),
5121
6210
  isApi: (error) => error instanceof MoodleAPIError,
5122
6211
  isLoginRequired: isLoginRequiredError
5123
6212
  };
@@ -5126,6 +6215,11 @@ var MoodleClient = class extends MoodleClientCore {
5126
6215
  const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
5127
6216
  super(baseUrl, { ...resolvedOptions, errorAdapter: NODE_ERROR_ADAPTER });
5128
6217
  }
6218
+ /** Reads local files, then uploads them into the assignment. Only the Node client has a filesystem. */
6219
+ async submitAssignmentFiles(request) {
6220
+ const { files, cwd, ...rest } = request;
6221
+ return this.submitAssignment({ ...rest, files: await readSubmissionFiles(files, cwd) });
6222
+ }
5129
6223
  };
5130
6224
  async function createMoodleClient(baseUrl, options = {}) {
5131
6225
  const cacheOptions2 = {
@@ -5153,7 +6247,7 @@ async function createMoodleClient(baseUrl, options = {}) {
5153
6247
  });
5154
6248
  }
5155
6249
  }
5156
- const stale = options.noCache ? null : await readCachedSession(baseUrl, { ...cacheOptions2, ttlMs: Number.MAX_SAFE_INTEGER }).catch(() => null);
6250
+ const stale = options.noCache ? null : await readCachedSession(baseUrl, { ...cacheOptions2, allowExpired: true }).catch(() => null);
5157
6251
  const session = authToClientSession(await getAuthenticatedSession(baseUrl, authOptions));
5158
6252
  return new MoodleClient(baseUrl, {
5159
6253
  fetchImpl: options.fetchImpl,
@@ -5166,11 +6260,21 @@ async function createMoodleClient(baseUrl, options = {}) {
5166
6260
  }
5167
6261
  function persistenceCallbacks(baseUrl, options) {
5168
6262
  return {
5169
- clearSessionCache: () => deleteCachedSession(baseUrl, options),
5170
- writeSessionCache: (session) => writeCachedSession({
5171
- ...session,
5172
- savedAt: (options.now ?? Date.now)()
5173
- }, options)
6263
+ clearSessionCache: async () => {
6264
+ const cached = await readCachedSession(baseUrl, { ...options, allowExpired: true });
6265
+ if (cached) await writeCachedSession({ ...cached, cookieInvalidated: true }, options);
6266
+ },
6267
+ writeSessionCache: async (session) => {
6268
+ const previous = await readCachedSession(baseUrl, { ...options, allowExpired: true });
6269
+ await writeCachedSession({
6270
+ ...session,
6271
+ // Client snapshots contain cookie state only. Keep renewal credentials
6272
+ // for the same account without carrying them across an account switch.
6273
+ ...previous?.userid === session.userid ? { mobileToken: previous.mobileToken } : {},
6274
+ mobileServiceEnabled: previous?.mobileServiceEnabled,
6275
+ savedAt: (options.now ?? Date.now)()
6276
+ }, options);
6277
+ }
5174
6278
  };
5175
6279
  }
5176
6280
  function authToClientSession(auth) {
@@ -5234,6 +6338,35 @@ function formatDownloadReceipt(receipt) {
5234
6338
  ["Final URL", receipt.final_url]
5235
6339
  ], { title: "Download" });
5236
6340
  }
6341
+ function formatSubmissionReceipt(receipt) {
6342
+ const size2 = (bytes) => bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MiB` : bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${bytes} B`;
6343
+ const limits = [
6344
+ receipt.limits.max_files ? `${receipt.limits.max_files} files` : "",
6345
+ receipt.limits.max_bytes ? `${size2(receipt.limits.max_bytes)} each` : "",
6346
+ receipt.limits.area_max_bytes ? `${size2(receipt.limits.area_max_bytes)} total` : "",
6347
+ receipt.limits.accepted_types?.length ? receipt.limits.accepted_types.join(" ") : ""
6348
+ ].filter(Boolean).join(", ");
6349
+ const table = renderKeyValueTable([
6350
+ ["Assignment", receipt.name],
6351
+ ["Unit id", receipt.unit_id ? String(receipt.unit_id) : ""],
6352
+ ["URL", receipt.url],
6353
+ ["Action", receipt.action],
6354
+ ["Status", receipt.submission_status],
6355
+ ["Grading", receipt.grading_status],
6356
+ ["Due", receipt.due],
6357
+ ["Time remaining", receipt.time_remaining],
6358
+ ["Last modified", receipt.last_modified],
6359
+ [receipt.action === "planned" ? "Files now" : "Files", receipt.files.map((file2) => file2.bytes ? `${file2.name} (${size2(file2.bytes)})` : file2.name).join(", ")],
6360
+ ["Uploads", receipt.uploads.map((file2) => `${file2.name} (${size2(file2.bytes)})`).join(", ")],
6361
+ ["Removed", receipt.removed.join(", ")],
6362
+ ["Statement", receipt.statement ? `${receipt.statement_accepted ? "accepted" : "not accepted"}: ${receipt.statement}` : ""],
6363
+ ["Limits", limits],
6364
+ ["Checked", receipt.checked_at]
6365
+ ], { title: receipt.action === "planned" ? "Submission plan" : "Submission" });
6366
+ const note = receipt.action === "planned" ? "Plan only; nothing was uploaded. Re-run without --dry-run to upload." : receipt.action === "saved" && /draft|not submitted/iu.test(receipt.submission_status) ? "Saved as a draft. Re-run with --final to submit it for grading." : "";
6367
+ return note ? `${table}
6368
+ ${note}` : table;
6369
+ }
5237
6370
  function formatForumDiscussion(discussion, options = {}) {
5238
6371
  const lines = [`Discussion: ${discussion.id}`];
5239
6372
  if (discussion.subject) {
@@ -5351,10 +6484,10 @@ function formatTimestamp(value) {
5351
6484
  import { createWriteStream } from "fs";
5352
6485
  import { link, lstat, rename as rename2, unlink } from "fs/promises";
5353
6486
  import { randomUUID } from "crypto";
5354
- import path from "path";
6487
+ import path2 from "path";
5355
6488
  import { Readable, Transform } from "stream";
5356
6489
  import { pipeline } from "stream/promises";
5357
- import { parse as parse4 } from "node-html-parser";
6490
+ import { parse as parse5 } from "node-html-parser";
5358
6491
  var ACCEPTED_SOURCE_HINT = "Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL.";
5359
6492
  var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
5360
6493
  "EACCES",
@@ -5374,14 +6507,14 @@ var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
5374
6507
  ]);
5375
6508
  async function downloadMoodleFile(client, request, signal) {
5376
6509
  throwIfCancelled(signal);
5377
- const explicitDestination = request.destination ? path.resolve(request.destination) : void 0;
6510
+ const explicitDestination = request.destination ? path2.resolve(request.destination) : void 0;
5378
6511
  if (explicitDestination && !request.force) {
5379
6512
  await ensureDestinationAvailable(explicitDestination);
5380
6513
  }
5381
6514
  const resolved = await resolveDownload(client, request.source, signal);
5382
6515
  throwIfCancelled(signal);
5383
- const filename = explicitDestination ? path.basename(explicitDestination) : chooseUpstreamFilename(resolved);
5384
- const destination = explicitDestination ?? path.resolve(request.directory ?? process.cwd(), filename);
6516
+ const filename = explicitDestination ? path2.basename(explicitDestination) : chooseUpstreamFilename(resolved);
6517
+ const destination = explicitDestination ?? path2.resolve(request.directory ?? process.cwd(), filename);
5385
6518
  if (!explicitDestination && !request.force) {
5386
6519
  await ensureDestinationAvailable(destination);
5387
6520
  }
@@ -5476,7 +6609,7 @@ async function responseOrWrapper(client, requestUrl, sourceUrl, targetName, sign
5476
6609
  };
5477
6610
  }
5478
6611
  function resourceLinks2(html, baseUrl) {
5479
- const root = parse4(html);
6612
+ const root = parse5(html);
5480
6613
  const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((linkNode) => ({
5481
6614
  name: linkNode.textContent.trim(),
5482
6615
  url: new URL(linkNode.getAttribute("href") ?? "", baseUrl).toString()
@@ -5492,7 +6625,7 @@ function isHtmlWrapper(response) {
5492
6625
  return type.includes("text/html") || type.includes("application/xhtml+xml");
5493
6626
  }
5494
6627
  function looksLikeLoginPage3(html) {
5495
- const root = parse4(html);
6628
+ const root = parse5(html);
5496
6629
  return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
5497
6630
  }
5498
6631
  function chooseUpstreamFilename(resolved) {
@@ -5553,7 +6686,7 @@ async function ensureDestinationAvailable(destination) {
5553
6686
  throw new UsageError(`Destination already exists: ${destination}`, "Choose another --dest path or pass --force to replace this exact file.");
5554
6687
  }
5555
6688
  async function writeResponse(response, destination, force, signal) {
5556
- const temporaryPath = path.join(path.dirname(destination), `.${path.basename(destination)}.moodle-${randomUUID()}.tmp`);
6689
+ const temporaryPath = path2.join(path2.dirname(destination), `.${path2.basename(destination)}.moodle-${randomUUID()}.tmp`);
5557
6690
  let bytesWritten = 0;
5558
6691
  const counter = new Transform({
5559
6692
  transform(chunk, _encoding, callback) {
@@ -5625,7 +6758,7 @@ function isFileSystemError(error) {
5625
6758
  // src/skills.ts
5626
6759
  import { spawnSync as spawnSync3 } from "child_process";
5627
6760
  import { mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
5628
- import path2 from "path";
6761
+ import path3 from "path";
5629
6762
 
5630
6763
  // src/command-contract.ts
5631
6764
  import { VERBS } from "@bunizao/cli-kit";
@@ -5682,7 +6815,7 @@ function isMutating(command) {
5682
6815
  var SKILL_NAME = "moodle-cli";
5683
6816
  var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
5684
6817
  var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
5685
- var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; diagnose sign-in and manage a private MCP server.";
6818
+ var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; submit assignment files; diagnose sign-in and manage a private MCP server.";
5686
6819
  var SKILL_BUNDLE_TEMPLATES = [
5687
6820
  ["SKILL.md", "skill.template.md"],
5688
6821
  ["references/setup-and-auth.md", "skill-references/setup-and-auth.md"],
@@ -5729,10 +6862,10 @@ function extractCommanderCommands(program) {
5729
6862
  return describeProgram(program).commands.flatMap((command) => commandDescriptionRows(command));
5730
6863
  }
5731
6864
  function commandDescriptionRows(command, parentPath = []) {
5732
- const path4 = [...parentPath, command.name];
6865
+ const path5 = [...parentPath, command.name];
5733
6866
  const row = {
5734
6867
  name: command.name,
5735
- path: path4,
6868
+ path: path5,
5736
6869
  description: command.description,
5737
6870
  arguments: command.positionals.map((argument) => ({
5738
6871
  name: argument.name,
@@ -5746,15 +6879,15 @@ function commandDescriptionRows(command, parentPath = []) {
5746
6879
  return { name, alias, description: option.description, required: option.required };
5747
6880
  })
5748
6881
  };
5749
- return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path4))];
6882
+ return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path5))];
5750
6883
  }
5751
6884
  function writeGeneratedSkill(program, target = "SKILL.md") {
5752
6885
  const commands = extractCommanderCommands(program);
5753
- const targetDir = path2.dirname(target);
5754
- for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(path2.join(targetDir, "references", `${obsolete}.md`), { force: true });
6886
+ const targetDir = path3.dirname(target);
6887
+ for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(path3.join(targetDir, "references", `${obsolete}.md`), { force: true });
5755
6888
  for (const [relativeTarget, relativeTemplate] of SKILL_BUNDLE_TEMPLATES) {
5756
- const outputPath = relativeTarget === "SKILL.md" ? target : path2.join(targetDir, relativeTarget);
5757
- mkdirSync(path2.dirname(outputPath), { recursive: true });
6889
+ const outputPath = relativeTarget === "SKILL.md" ? target : path3.join(targetDir, relativeTarget);
6890
+ mkdirSync(path3.dirname(outputPath), { recursive: true });
5758
6891
  const template = readSkillTemplate(relativeTemplate);
5759
6892
  writeFileSync(outputPath, renderSkillMarkdown(commands, template), "utf8");
5760
6893
  }
@@ -5862,11 +6995,168 @@ function isCommandAvailable(name, runCommand) {
5862
6995
  return !result.error && result.status === 0;
5863
6996
  }
5864
6997
  function readSkillTemplate(relativePath = "skill.template.md") {
5865
- return readFileSync(path2.join(process.cwd(), "src", relativePath), "utf8");
6998
+ return readFileSync(path3.join(process.cwd(), "src", relativePath), "utf8");
6999
+ }
7000
+
7001
+ // src/onboarding.ts
7002
+ async function signInInteractively(ui, deps) {
7003
+ deps.showWordmark(ui);
7004
+ const login = loginUrl(deps.baseUrl);
7005
+ let blocked = await deps.storesBlocked();
7006
+ ui.note([
7007
+ `No Moodle session for ${deps.baseUrl}.`,
7008
+ "Sign in once; later commands reuse that session.",
7009
+ ...blocked ? ["", "This terminal cannot read your browser's cookies, so the sign-in happens in a browser window the CLI opens."] : []
7010
+ ].join("\n"), "One-time setup");
7011
+ let session;
7012
+ for (let attempt = 0; !session; attempt++) {
7013
+ const method = await ui.select(attempt ? "Try another way?" : "How do you want to sign in?", [
7014
+ ...blocked ? [] : [{ value: "own-browser", label: "Sign in in my own browser", hint: "the login page opens; press Enter here when done" }],
7015
+ { value: "cli-browser", label: "Open a browser window from here", hint: "a Chrome, Edge or Brave the CLI controls" },
7016
+ { value: "paste", label: "Paste the MoodleSession cookie", hint: "from the browser's developer tools" },
7017
+ { value: "stop", label: "Not now", hint: "moodle auth login works any time" }
7018
+ ]);
7019
+ if (method === "stop") throw new AuthError(`No usable MoodleSession found for ${deps.baseUrl}.`, "Run moodle auth login when you are ready.");
7020
+ if (method === "own-browser") {
7021
+ await deps.openInBrowser(login).catch(() => void 0);
7022
+ if (!await ui.confirm(`Signed in at ${login}? Enter reads the session from your browser`, { initial: true })) continue;
7023
+ const spin = ui.spinner();
7024
+ spin.start("Reading the session from your browser");
7025
+ try {
7026
+ session = await deps.readBrowserSession();
7027
+ spin.stop(`Signed in as userid ${session.userid}`);
7028
+ } catch (error) {
7029
+ if (!(error instanceof AuthError)) {
7030
+ spin.error("Could not read the browser session");
7031
+ throw error;
7032
+ }
7033
+ spin.error(error.message);
7034
+ blocked = await deps.storesBlocked();
7035
+ if (blocked) ui.warn(error.hint ?? "The browser cookie store could not be read.");
7036
+ else ui.info(`Finish signing in at ${login} in the browser you use, then choose the first option again.`);
7037
+ }
7038
+ } else if (method === "cli-browser") {
7039
+ const spin = ui.spinner();
7040
+ spin.start("Opening a browser window");
7041
+ try {
7042
+ session = await deps.browserLogin((url) => spin.message(`Finish signing in at ${url}`));
7043
+ spin.stop(`Signed in as userid ${session.userid}`);
7044
+ } catch (error) {
7045
+ if (!(error instanceof AuthError)) {
7046
+ spin.error("Sign-in did not complete");
7047
+ throw error;
7048
+ }
7049
+ spin.error(error.message);
7050
+ if (error.hint) ui.info(error.hint);
7051
+ }
7052
+ } else {
7053
+ try {
7054
+ session = await deps.pasteLogin();
7055
+ ui.step(`Signed in as userid ${session.userid}`);
7056
+ } catch (error) {
7057
+ if (!(error instanceof AuthError)) throw error;
7058
+ ui.warn([error.message, error.hint].filter(Boolean).join("\n"));
7059
+ }
7060
+ }
7061
+ }
7062
+ await offerRenewal(ui, deps);
7063
+ return session;
7064
+ }
7065
+ async function offerRenewal(ui, deps) {
7066
+ if (deps.platform !== "darwin" || await deps.keepaliveInstalled()) return;
7067
+ ui.note([
7068
+ `A background job can renew this session every ${KEEPALIVE_DEFAULT_INTERVAL_MINUTES} minutes, so it rarely expires.`,
7069
+ "It runs moodle auth keepalive; moodle auth keepalive uninstall removes it."
7070
+ ].join("\n"), "Stay signed in");
7071
+ const wanted = await ui.confirm("Renew the session automatically?", { initial: true }).catch((error) => {
7072
+ if (error instanceof CliError2 && error.code === "cancelled") return false;
7073
+ throw error;
7074
+ });
7075
+ if (!wanted) {
7076
+ ui.info("Later: moodle auth keepalive install");
7077
+ return;
7078
+ }
7079
+ try {
7080
+ const result = await deps.installKeepalive();
7081
+ ui.step(`Renewing every ${result.interval_minutes} min; agent at ${result.plist_path}`);
7082
+ } catch (error) {
7083
+ ui.warn(`${error instanceof Error ? error.message : String(error)}
7084
+ Later: moodle auth keepalive install`);
7085
+ }
7086
+ }
7087
+
7088
+ // src/secret-input.ts
7089
+ var PASTE_MODE_ON = "\x1B[?2004h";
7090
+ var PASTE_MODE_OFF = "\x1B[?2004l";
7091
+ var PASTE_START = "\x1B[200~";
7092
+ var PASTE_END = "\x1B[201~";
7093
+ async function readSecretLine(input2, output, prompt) {
7094
+ if (!input2.isTTY) {
7095
+ return (await readAll(input2)).trim();
7096
+ }
7097
+ const wasRaw = input2.isRaw;
7098
+ input2.setRawMode(true);
7099
+ input2.resume();
7100
+ output.write(prompt);
7101
+ output.write(PASTE_MODE_ON);
7102
+ try {
7103
+ return await new Promise((resolve) => {
7104
+ let content = "";
7105
+ let pasting = false;
7106
+ let esc = "";
7107
+ const finish = (value) => {
7108
+ input2.off("data", onData);
7109
+ resolve(value);
7110
+ };
7111
+ const onData = (chunk) => {
7112
+ for (const ch of chunk.toString("utf8")) {
7113
+ if (esc) {
7114
+ esc += ch;
7115
+ if (esc === PASTE_START) {
7116
+ pasting = true;
7117
+ esc = "";
7118
+ } else if (esc === PASTE_END) {
7119
+ esc = "";
7120
+ return finish(content.trim());
7121
+ } else if (!PASTE_START.startsWith(esc) && !PASTE_END.startsWith(esc)) {
7122
+ esc = "";
7123
+ }
7124
+ continue;
7125
+ }
7126
+ const code = ch.charCodeAt(0);
7127
+ if (code === 27) {
7128
+ esc = "\x1B";
7129
+ continue;
7130
+ }
7131
+ if (pasting) {
7132
+ content += ch;
7133
+ continue;
7134
+ }
7135
+ if (code === 3 || code === 4) return finish(null);
7136
+ if (code === 13 || code === 10) return finish(content.trim());
7137
+ if (code === 8 || code === 127) content = content.slice(0, -1);
7138
+ else if (code >= 32) content += ch;
7139
+ }
7140
+ };
7141
+ input2.on("data", onData);
7142
+ });
7143
+ } finally {
7144
+ output.write(PASTE_MODE_OFF);
7145
+ input2.setRawMode(wasRaw);
7146
+ input2.pause();
7147
+ output.write("\n");
7148
+ }
7149
+ }
7150
+ async function readAll(input2) {
7151
+ const chunks2 = [];
7152
+ for await (const chunk of input2) {
7153
+ chunks2.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
7154
+ }
7155
+ return chunks2.join("");
5866
7156
  }
5867
7157
 
5868
7158
  // src/version.ts
5869
- var VERSION = "0.8.0";
7159
+ var VERSION = "0.9.1";
5870
7160
 
5871
7161
  // src/forum.ts
5872
7162
  function parseDiscussionReference(value) {
@@ -5957,7 +7247,7 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
5957
7247
  if (parsed.host.toLowerCase() !== configuredHost) {
5958
7248
  throw new UsageError(`URL host '${parsed.host.toLowerCase()}' does not match configured Moodle site '${configuredHost}'.`);
5959
7249
  }
5960
- const path4 = parsed.pathname.replace(/\/$/, "");
7250
+ const path5 = parsed.pathname.replace(/\/$/, "");
5961
7251
  const intParam = (key, label) => {
5962
7252
  const value = parsed.searchParams.get(key);
5963
7253
  if (!value || !/^\d+$/.test(value)) {
@@ -5965,45 +7255,45 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
5965
7255
  }
5966
7256
  return value;
5967
7257
  };
5968
- if (path4.endsWith("/mod/forum/discuss.php")) {
7258
+ if (path5.endsWith("/mod/forum/discuss.php")) {
5969
7259
  return objectMode ? { commandName: "forum_discussion", kwargs: { discussion: intParam("d", "discussion ID"), postId: parsed.hash, asJson: false, asYaml: false } } : { commandName: "forum:discussion", args: [intParam("d", "discussion ID"), parsed.hash] };
5970
7260
  }
5971
- if (path4.endsWith("/mod/forum/view.php")) {
7261
+ if (path5.endsWith("/mod/forum/view.php")) {
5972
7262
  return objectMode ? { commandName: "forum_discussions", kwargs: { forum: intParam("id", "forum module ID"), asJson: false, asYaml: false } } : { commandName: "forum:discussions", args: [intParam("id", "forum module ID")] };
5973
7263
  }
5974
- if (path4.endsWith("/mod/assign/view.php")) {
7264
+ if (path5.endsWith("/mod/assign/view.php")) {
5975
7265
  const id2 = intParam("id", "assignment module ID");
5976
7266
  return objectMode ? { commandName: "assign", kwargs: { assign: id2, asJson: false, asYaml: false } } : { commandName: "assign", args: [id2] };
5977
7267
  }
5978
- if (path4.endsWith("/mod/quiz/view.php")) {
7268
+ if (path5.endsWith("/mod/quiz/view.php")) {
5979
7269
  const id2 = intParam("id", "quiz module ID");
5980
7270
  return objectMode ? { commandName: "quiz", kwargs: { quiz: id2, asJson: false, asYaml: false } } : { commandName: "quiz", args: [id2] };
5981
7271
  }
5982
- if (path4.endsWith("/mod/resource/view.php")) {
7272
+ if (path5.endsWith("/mod/resource/view.php")) {
5983
7273
  const id2 = intParam("id", "resource module ID");
5984
7274
  return objectMode ? { commandName: "resource", kwargs: { resource: id2, asJson: false, asYaml: false } } : { commandName: "resource", args: [id2] };
5985
7275
  }
5986
- if (path4.endsWith("/mod/url/view.php")) {
7276
+ if (path5.endsWith("/mod/url/view.php")) {
5987
7277
  const id2 = intParam("id", "link module ID");
5988
7278
  return objectMode ? { commandName: "link", kwargs: { link: id2, asJson: false, asYaml: false } } : { commandName: "link", args: [id2] };
5989
7279
  }
5990
- if (path4.endsWith("/mod/page/view.php")) {
7280
+ if (path5.endsWith("/mod/page/view.php")) {
5991
7281
  const id2 = intParam("id", "page module ID");
5992
7282
  return objectMode ? { commandName: "page", kwargs: { page: id2, asJson: false, asYaml: false } } : { commandName: "page", args: [id2] };
5993
7283
  }
5994
- if (path4.endsWith("/mod/folder/view.php")) {
7284
+ if (path5.endsWith("/mod/folder/view.php")) {
5995
7285
  const id2 = intParam("id", "folder module ID");
5996
7286
  return objectMode ? { commandName: "folder", kwargs: { folder: id2, asJson: false, asYaml: false } } : { commandName: "folder", args: [id2] };
5997
7287
  }
5998
- if (path4.endsWith("/course/view.php")) {
7288
+ if (path5.endsWith("/course/view.php")) {
5999
7289
  const id2 = intParam("id", "course ID");
6000
7290
  return objectMode ? { commandName: "course", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "course", args: [id2] };
6001
7291
  }
6002
- if (path4.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path4.includes("/grade/report/")) {
7292
+ if (path5.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path5.includes("/grade/report/")) {
6003
7293
  const id2 = intParam("id", "course ID");
6004
7294
  return objectMode ? { commandName: "grades", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "grades", args: [id2] };
6005
7295
  }
6006
- if (path4.includes("/mod/") && path4.endsWith("/view.php")) {
7296
+ if (path5.includes("/mod/") && path5.endsWith("/view.php")) {
6007
7297
  if (!resolveCourseIdForUrl) {
6008
7298
  throw new UsageError("Could not resolve course ID from the activity page.");
6009
7299
  }
@@ -6021,10 +7311,10 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
6021
7311
 
6022
7312
  // src/mcp/cli.ts
6023
7313
  import { createHash as createHash3 } from "crypto";
6024
- import { readFile as readFile8 } from "fs/promises";
6025
- import { homedir as homedir11 } from "os";
6026
- import { join as join11 } from "path";
6027
- import { createInterface as createInterface3 } from "readline/promises";
7314
+ import { readFile as readFile9 } from "fs/promises";
7315
+ import { homedir as homedir14 } from "os";
7316
+ import { join as join13 } from "path";
7317
+ import { createInterface } from "readline/promises";
6028
7318
  import { fileURLToPath } from "url";
6029
7319
 
6030
7320
  // src/mcp/protocol.ts
@@ -6076,7 +7366,7 @@ function parseJsonRpcRequest(input2) {
6076
7366
  return JsonRpcRequestSchema.parse(input2);
6077
7367
  }
6078
7368
  function resolveProtocolVersion(request, context = {}) {
6079
- const meta = isRecord6(request.params?._meta) ? request.params._meta : void 0;
7369
+ const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
6080
7370
  const initializeVersion = request.method === "initialize" && typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : void 0;
6081
7371
  const requested = context.protocolVersion ?? stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]) ?? initializeVersion ?? MODERN_PROTOCOL_VERSION;
6082
7372
  if (!isSupportedProtocolVersion(requested)) {
@@ -6085,7 +7375,7 @@ function resolveProtocolVersion(request, context = {}) {
6085
7375
  return requested;
6086
7376
  }
6087
7377
  function assertRequestMetadata(request, protocolVersion2, context = {}) {
6088
- const meta = isRecord6(request.params?._meta) ? request.params._meta : void 0;
7378
+ const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
6089
7379
  const metaVersion = stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]);
6090
7380
  if (protocolVersion2 === MODERN_PROTOCOL_VERSION) {
6091
7381
  const parsed = ModernClientMetadataSchema.safeParse(meta);
@@ -6116,7 +7406,7 @@ function jsonRpcSuccess(id2, result) {
6116
7406
  function jsonRpcFailure(id2, error) {
6117
7407
  return { jsonrpc: "2.0", id: id2, error };
6118
7408
  }
6119
- function isRecord6(value) {
7409
+ function isRecord7(value) {
6120
7410
  return typeof value === "object" && value !== null && !Array.isArray(value);
6121
7411
  }
6122
7412
  function stringValue3(value) {
@@ -6191,7 +7481,7 @@ async function bridgeRemoteMcp(options) {
6191
7481
  await writeRemoteError(options.output, request.id, response.status);
6192
7482
  return;
6193
7483
  }
6194
- if (request.method === "initialize" && isRecord7(payload) && "result" in payload) {
7484
+ if (request.method === "initialize" && isRecord8(payload) && "result" in payload) {
6195
7485
  negotiatedProtocolVersion = responseProtocolVersion(payload) ?? initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
6196
7486
  }
6197
7487
  await writeJson(options.output, payload);
@@ -6216,7 +7506,7 @@ function normalizeMcpEndpoint(value) {
6216
7506
  return url.toString();
6217
7507
  }
6218
7508
  function protocolVersion(request, negotiatedVersion) {
6219
- const meta = isRecord7(request.params?._meta) ? request.params?._meta : void 0;
7509
+ const meta = isRecord8(request.params?._meta) ? request.params?._meta : void 0;
6220
7510
  const metadataVersion = meta?.["io.modelcontextprotocol/protocolVersion"];
6221
7511
  if (typeof metadataVersion === "string") return metadataVersion;
6222
7512
  return initializedProtocolVersion(request) ?? negotiatedVersion;
@@ -6226,7 +7516,7 @@ function initializedProtocolVersion(request) {
6226
7516
  return typeof initialized === "string" ? initialized : void 0;
6227
7517
  }
6228
7518
  function responseProtocolVersion(payload) {
6229
- const result = isRecord7(payload.result) ? payload.result : void 0;
7519
+ const result = isRecord8(payload.result) ? payload.result : void 0;
6230
7520
  return typeof result?.protocolVersion === "string" ? result.protocolVersion : void 0;
6231
7521
  }
6232
7522
  async function writeSseMessages(output, body, id2) {
@@ -6260,10 +7550,10 @@ async function forwardProtocolNegotiationError(output, response, id2) {
6260
7550
  }
6261
7551
  try {
6262
7552
  const payload = await response.json();
6263
- if (!isRecord7(payload) || payload.jsonrpc !== "2.0" || payload.id !== id2 || !isRecord7(payload.error)) {
7553
+ if (!isRecord8(payload) || payload.jsonrpc !== "2.0" || payload.id !== id2 || !isRecord8(payload.error)) {
6264
7554
  return false;
6265
7555
  }
6266
- const data = isRecord7(payload.error.data) ? payload.error.data : void 0;
7556
+ const data = isRecord8(payload.error.data) ? payload.error.data : void 0;
6267
7557
  const supported = Array.isArray(data?.supported) ? data.supported.filter((version) => typeof version === "string") : [];
6268
7558
  if (payload.error.code !== -32022 || typeof data?.requested !== "string" || supported.length === 0) {
6269
7559
  return false;
@@ -6283,9 +7573,9 @@ async function writeJson(output, value) {
6283
7573
  `);
6284
7574
  }
6285
7575
  function isBridgeRequest(value) {
6286
- return isRecord7(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
7576
+ return isRecord8(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
6287
7577
  }
6288
- function isRecord7(value) {
7578
+ function isRecord8(value) {
6289
7579
  return typeof value === "object" && value !== null && !Array.isArray(value);
6290
7580
  }
6291
7581
 
@@ -6557,13 +7847,13 @@ function resolveConnection(options) {
6557
7847
  }
6558
7848
 
6559
7849
  // src/mcp/connectors/node-connectors.ts
6560
- import { chmod as chmod4, mkdir as mkdir6, readFile as readFile6, rm as rm5, stat as stat2, writeFile as writeFile6 } from "fs/promises";
6561
- import { homedir as homedir8 } from "os";
6562
- import { dirname as dirname6, join as join8 } from "path";
7850
+ import { chmod as chmod4, mkdir as mkdir7, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
7851
+ import { homedir as homedir11 } from "os";
7852
+ import { dirname as dirname6, join as join10 } from "path";
6563
7853
  var NodeConnectorFileSystem = class {
6564
- async exists(path4) {
7854
+ async exists(path5) {
6565
7855
  try {
6566
- await stat2(path4);
7856
+ await stat3(path5);
6567
7857
  return true;
6568
7858
  } catch (error) {
6569
7859
  if (isMissing3(error)) {
@@ -6572,20 +7862,20 @@ var NodeConnectorFileSystem = class {
6572
7862
  throw error;
6573
7863
  }
6574
7864
  }
6575
- async readText(path4) {
6576
- return readFile6(path4, "utf8");
7865
+ async readText(path5) {
7866
+ return readFile7(path5, "utf8");
6577
7867
  }
6578
- async writePrivate(path4, content) {
6579
- await mkdir6(dirname6(path4), { recursive: true, mode: 448 });
6580
- await writeFile6(path4, content, { encoding: "utf8", mode: 384 });
6581
- await chmod4(path4, 384);
7868
+ async writePrivate(path5, content) {
7869
+ await mkdir7(dirname6(path5), { recursive: true, mode: 448 });
7870
+ await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
7871
+ await chmod4(path5, 384);
6582
7872
  }
6583
- async remove(path4) {
6584
- await rm5(path4, { force: true });
7873
+ async remove(path5) {
7874
+ await rm5(path5, { force: true });
6585
7875
  }
6586
7876
  };
6587
7877
  function createDefaultClientConnectors(profile, options = {}) {
6588
- const home = options.homeDirectory ?? homedir8();
7878
+ const home = options.homeDirectory ?? homedir11();
6589
7879
  const platform = options.platform ?? process.platform;
6590
7880
  const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
6591
7881
  const runtime = runtimeCommand(options.command, options.commandArgs);
@@ -6597,14 +7887,14 @@ function createDefaultClientConnectors(profile, options = {}) {
6597
7887
  endpoint: options.endpoint,
6598
7888
  accessToken: options.accessToken
6599
7889
  };
6600
- const claudeDesktop = platform === "darwin" ? join8(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform === "win32" ? join8(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join8(home, ".config", "Claude", "claude_desktop_config.json");
6601
- const vscodeUser = platform === "darwin" ? join8(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform === "win32" ? join8(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join8(home, ".config", "Code", "User", "mcp.json");
7890
+ const claudeDesktop = platform === "darwin" ? join10(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform === "win32" ? join10(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join10(home, ".config", "Claude", "claude_desktop_config.json");
7891
+ const vscodeUser = platform === "darwin" ? join10(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform === "win32" ? join10(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join10(home, ".config", "Code", "User", "mcp.json");
6602
7892
  return [
6603
- createCodexConnector({ ...shared, configPath: join8(home, ".codex", "config.toml"), detectionPath: join8(home, ".codex") }, fileSystem),
7893
+ createCodexConnector({ ...shared, configPath: join10(home, ".codex", "config.toml"), detectionPath: join10(home, ".codex") }, fileSystem),
6604
7894
  createClaudeDesktopConnector({ ...shared, configPath: claudeDesktop, detectionPath: dirname6(claudeDesktop) }, fileSystem),
6605
- createClaudeCodeConnector({ ...shared, configPath: join8(home, ".claude.json"), detectionPath: join8(home, ".claude") }, fileSystem),
7895
+ createClaudeCodeConnector({ ...shared, configPath: join10(home, ".claude.json"), detectionPath: join10(home, ".claude") }, fileSystem),
6606
7896
  createVsCodeConnector({ ...shared, configPath: vscodeUser, detectionPath: dirname6(vscodeUser) }, fileSystem),
6607
- createCursorConnector({ ...shared, configPath: join8(home, ".cursor", "mcp.json"), detectionPath: join8(home, ".cursor") }, fileSystem)
7897
+ createCursorConnector({ ...shared, configPath: join10(home, ".cursor", "mcp.json"), detectionPath: join10(home, ".cursor") }, fileSystem)
6608
7898
  ];
6609
7899
  }
6610
7900
  var DefaultClientIntegration = class {
@@ -7365,16 +8655,19 @@ function asDeploymentError(error) {
7365
8655
  if (error instanceof DeploymentApplyError) {
7366
8656
  return error;
7367
8657
  }
8658
+ if (error instanceof CliError2) {
8659
+ return error;
8660
+ }
7368
8661
  const detail = error instanceof Error && error.message ? `: ${error.message}` : "";
7369
8662
  return new DeploymentApplyError("DEPLOYMENT_FAILED", `The managed Moodle MCP deployment failed${detail}`, { cause: error });
7370
8663
  }
7371
8664
 
7372
8665
  // src/mcp/wrangler.ts
7373
- import { createInterface as createInterface2 } from "readline/promises";
7374
- import { mkdir as mkdir7 } from "fs/promises";
8666
+ import { createUi as createUi2 } from "@bunizao/cli-kit";
8667
+ import { mkdir as mkdir8, writeFile as writeFile7 } from "fs/promises";
7375
8668
  import { existsSync } from "fs";
7376
- import { homedir as homedir9 } from "os";
7377
- import { join as join9 } from "path";
8669
+ import { homedir as homedir12 } from "os";
8670
+ import { join as join11 } from "path";
7378
8671
  async function resolveWrangler(runner, options = {}) {
7379
8672
  const env = options.env ?? process.env;
7380
8673
  const notice = options.notice ?? ((text2) => process.stderr.write(`${text2}
@@ -7385,8 +8678,8 @@ async function resolveWrangler(runner, options = {}) {
7385
8678
  if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
7386
8679
  notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
7387
8680
  }
7388
- const root = join9(options.homeDir ?? homedir9(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
7389
- const script = join9(root, "node_modules", "wrangler", "bin", "wrangler.js");
8681
+ const root = join11(options.homeDir ?? homedir12(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
8682
+ const script = join11(root, "node_modules", "wrangler", "bin", "wrangler.js");
7390
8683
  const bun = findExecutable("bun", env);
7391
8684
  const node = findExecutable("node", env);
7392
8685
  if (!bun && !node) throw new Error("Cloudflare management needs Bun or Node 22.13+. Install either, then retry moodle mcp deploy.");
@@ -7395,19 +8688,23 @@ async function resolveWrangler(runner, options = {}) {
7395
8688
  if (!bun && !npm) throw new Error("Install Bun or npm to download the pinned Cloudflare toolchain.");
7396
8689
  const yes = options.yes ?? (process.argv.includes("--yes") || process.argv.includes("-y"));
7397
8690
  if (!yes) {
7398
- if (!process.stdin.isTTY) throw new UsageError("Cloudflare management needs a first-use Wrangler download.", "Rerun with --yes to download and cache the pinned toolchain.");
7399
- const reader = createInterface2({ input: process.stdin, output: process.stderr });
7400
- try {
7401
- const answer = await reader.question(`Download Cloudflare Wrangler ${WRANGLER_VERSION} (cached for next time)? [Y/n] `);
7402
- if (answer.trim() && !/^y(?:es)?$/iu.test(answer.trim())) throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
7403
- } finally {
7404
- reader.close();
8691
+ const ui = createUi2({ input: process.stdin, output: process.stderr });
8692
+ if (!ui.interactive) throw new UsageError("Cloudflare management needs a first-use Wrangler download.", "Rerun with --yes to download and cache the pinned toolchain.");
8693
+ if (!await ui.confirm(`Download Cloudflare Wrangler ${WRANGLER_VERSION} (cached for next time)?`, { initial: true })) {
8694
+ throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
7405
8695
  }
7406
8696
  }
7407
8697
  notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
7408
- await mkdir7(root, { recursive: true, mode: 448 });
7409
- await runner.run(bun ?? npm, bun ? ["install", "--cwd", root, "--no-save", `wrangler@${WRANGLER_VERSION}`] : ["install", "--prefix", root, "--no-save", "--package-lock=false", "--no-audit", "--no-fund", `wrangler@${WRANGLER_VERSION}`]);
7410
- if (!existsSync(script)) throw new Error("Wrangler installation did not create the expected executable. Retry moodle mcp deploy.");
8698
+ await mkdir8(root, { recursive: true, mode: 448 });
8699
+ await writeFile7(join11(root, "package.json"), '{ "private": true }\n');
8700
+ const result = await runner.run(bun ?? npm, bun ? ["install", "--cwd", root, "--no-save", `wrangler@${WRANGLER_VERSION}`] : ["install", "--prefix", root, "--no-save", "--package-lock=false", "--no-audit", "--no-fund", `wrangler@${WRANGLER_VERSION}`]);
8701
+ if (!existsSync(script)) {
8702
+ const output = `${result.stderr}
8703
+ ${result.stdout}`.trim().split(/\r?\n/u).slice(-5).join("\n");
8704
+ throw new Error(`Wrangler installation did not create ${script}.${output ? `
8705
+ ${output}` : ""}
8706
+ Remove ${root} and retry moodle mcp deploy.`);
8707
+ }
7411
8708
  }
7412
8709
  return { command: node ?? bun, args: [script] };
7413
8710
  }
@@ -7419,18 +8716,18 @@ function sameMajorAtLeast(actual, pinned) {
7419
8716
 
7420
8717
  // src/mcp/deployment/node-adapters.ts
7421
8718
  import { isDeepStrictEqual } from "util";
7422
- import { spawn as spawn2 } from "child_process";
8719
+ import { spawn as spawn3 } from "child_process";
7423
8720
  import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
7424
- import { chmod as chmod5, mkdir as mkdir8, mkdtemp, readFile as readFile7, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7425
- import { homedir as homedir10, tmpdir } from "os";
7426
- import { basename, dirname as dirname7, join as join10 } from "path";
8721
+ import { chmod as chmod5, mkdir as mkdir9, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile8 } from "fs/promises";
8722
+ import { homedir as homedir13, tmpdir } from "os";
8723
+ import { basename, dirname as dirname7, join as join12 } from "path";
7427
8724
  var MODERN_MCP_VERSION = "2026-07-28";
7428
8725
  var WORKER_PROPAGATION_ATTEMPTS = 10;
7429
8726
  var WORKER_PROPAGATION_MAX_DELAY_MS = 4e3;
7430
8727
  var NodeDeploymentCommandRunner = class {
7431
8728
  async run(command, args, environment = {}) {
7432
8729
  return new Promise((resolve, reject) => {
7433
- const child = spawn2(command, args, {
8730
+ const child = spawn3(command, args, {
7434
8731
  env: { ...process.env, ...environment },
7435
8732
  stdio: ["ignore", "pipe", "pipe"],
7436
8733
  windowsHide: true
@@ -7614,7 +8911,7 @@ ${error.stderr}`)) {
7614
8911
  const binding = (value, name) => {
7615
8912
  let result2 = null;
7616
8913
  visit(value, (_key, item) => {
7617
- if (isRecord8(item) && item.name === name && typeof item.text === "string") result2 = item.text;
8914
+ if (isRecord9(item) && item.name === name && typeof item.text === "string") result2 = item.text;
7618
8915
  });
7619
8916
  return result2;
7620
8917
  };
@@ -7646,6 +8943,10 @@ ${error.stderr}`)) {
7646
8943
  }
7647
8944
  await this.wrangler(["delete", input2.workerName, "--force"], input2.accountId);
7648
8945
  }
8946
+ /** Locate or download Wrangler now, so a first-use prompt is not drawn under a spinner later. */
8947
+ async prepare(options = {}) {
8948
+ if (!this.wranglerBinPath) await resolveWrangler(this.runner, options);
8949
+ }
7649
8950
  async wrangler(args, accountId, environmentOverrides = {}) {
7650
8951
  const environment = { ...environmentOverrides };
7651
8952
  if (accountId) {
@@ -7660,7 +8961,7 @@ ${error.stderr}`)) {
7660
8961
  }
7661
8962
  };
7662
8963
  async function copyReleaseBundle(source, destination) {
7663
- await writeFile7(destination, await readFile7(source));
8964
+ await writeFile8(destination, await readFile8(source));
7664
8965
  }
7665
8966
  var NodeReleaseMaterializer = class {
7666
8967
  constructor(options) {
@@ -7669,13 +8970,13 @@ var NodeReleaseMaterializer = class {
7669
8970
  options;
7670
8971
  async prepare(plan, credentials) {
7671
8972
  const temporaryRoot = this.options.temporaryRoot ?? tmpdir();
7672
- await mkdir8(temporaryRoot, { recursive: true });
7673
- const artifactDirectory = await mkdtemp(join10(temporaryRoot, "moodle-mcp-"));
8973
+ await mkdir9(temporaryRoot, { recursive: true });
8974
+ const artifactDirectory = await mkdtemp(join12(temporaryRoot, "moodle-mcp-"));
7674
8975
  await chmod5(artifactDirectory, 448);
7675
- const workerFile = join10(artifactDirectory, basename(this.options.workerBundlePath));
8976
+ const workerFile = join12(artifactDirectory, basename(this.options.workerBundlePath));
7676
8977
  await copyReleaseBundle(this.options.workerBundlePath, workerFile);
7677
- const wranglerConfigPath = join10(artifactDirectory, "wrangler.json");
7678
- const secretsFilePath = join10(artifactDirectory, "secrets.json");
8978
+ const wranglerConfigPath = join12(artifactDirectory, "wrangler.json");
8979
+ const secretsFilePath = join12(artifactDirectory, "secrets.json");
7679
8980
  const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
7680
8981
  const config = {
7681
8982
  $schema: "node_modules/wrangler/config-schema.json",
@@ -7715,18 +9016,18 @@ var NodeReleaseMaterializer = class {
7715
9016
  if (credentials.previousTokensExpireAt !== void 0 && Number.isFinite(credentials.previousTokensExpireAt)) {
7716
9017
  secrets.TOKEN_OVERLAP_EXPIRES_AT = String(credentials.previousTokensExpireAt);
7717
9018
  }
7718
- await writeFile7(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
9019
+ await writeFile8(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
7719
9020
  `, { mode: 384 });
7720
- await writeFile7(secretsFilePath, `${JSON.stringify(secrets)}
9021
+ await writeFile8(secretsFilePath, `${JSON.stringify(secrets)}
7721
9022
  `, { mode: 384 });
7722
9023
  await chmod5(wranglerConfigPath, 384);
7723
9024
  await chmod5(secretsFilePath, 384);
7724
9025
  let recoveryConfigPath;
7725
9026
  try {
7726
- const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join10(dirname7(this.options.workerBundlePath), "recovery.js");
7727
- await copyReleaseBundle(recoveryBundle, join10(artifactDirectory, "recovery.js"));
7728
- recoveryConfigPath = join10(artifactDirectory, "wrangler-recovery.json");
7729
- await writeFile7(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
9027
+ const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join12(dirname7(this.options.workerBundlePath), "recovery.js");
9028
+ await copyReleaseBundle(recoveryBundle, join12(artifactDirectory, "recovery.js"));
9029
+ recoveryConfigPath = join12(artifactDirectory, "wrangler-recovery.json");
9030
+ await writeFile8(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
7730
9031
  `, { mode: 384 });
7731
9032
  } catch (error) {
7732
9033
  if (!isMissing4(error) || plan.existing) throw error;
@@ -7782,10 +9083,10 @@ var FetchManagedWorkerClient = class {
7782
9083
  })
7783
9084
  }, isRetryableSessionUpload);
7784
9085
  const body = await safeJson(response);
7785
- if (response.ok && isRecord8(body) && typeof body.revision === "number") {
9086
+ if (response.ok && isRecord9(body) && typeof body.revision === "number") {
7786
9087
  return { revision: body.revision };
7787
9088
  }
7788
- const code = isRecord8(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
9089
+ const code = isRecord9(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
7789
9090
  throw new DeploymentApplyError(code, `The Worker rejected the Moodle session update (${code})`);
7790
9091
  }
7791
9092
  async getReadiness(input2) {
@@ -7793,7 +9094,7 @@ var FetchManagedWorkerClient = class {
7793
9094
  headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
7794
9095
  }, isRetryableSessionUpload);
7795
9096
  const body = await safeJson(response);
7796
- if (isRecord8(body) && (body.status === "pass" || body.status === "warn" || body.status === "fail")) {
9097
+ if (isRecord9(body) && (body.status === "pass" || body.status === "warn" || body.status === "fail")) {
7797
9098
  const session = firstHealthCheck(body, "moodle:session");
7798
9099
  const upstream = firstHealthCheck(body, "moodle:upstream");
7799
9100
  return {
@@ -7820,7 +9121,7 @@ var FetchManagedWorkerClient = class {
7820
9121
  isRetryableWorkerPropagation
7821
9122
  );
7822
9123
  const healthBody = await safeJson(health);
7823
- if (!health.ok || !isRecord8(healthBody) || healthBody.status !== "pass") {
9124
+ if (!health.ok || !isRecord9(healthBody) || healthBody.status !== "pass") {
7824
9125
  throw new DeploymentApplyError("HEALTH_CHECK_FAILED", "Worker liveness check failed");
7825
9126
  }
7826
9127
  let readiness = await this.getReadiness(input2);
@@ -7851,11 +9152,11 @@ var FetchManagedWorkerClient = class {
7851
9152
  if (!Array.isArray(courses)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no course list");
7852
9153
  if (courses.length) {
7853
9154
  const first2 = courses[0];
7854
- if (!isRecord8(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
9155
+ if (!isRecord9(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
7855
9156
  throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no usable course ID");
7856
9157
  }
7857
9158
  const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "get_course", arguments: { courseId: first2.id } }, 5));
7858
- if (!isRecord8(detail.course) || !isRecord8(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
9159
+ if (!isRecord9(detail.course) || !isRecord9(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
7859
9160
  throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP course lookup did not match the listed course");
7860
9161
  }
7861
9162
  }
@@ -7879,7 +9180,7 @@ var FetchManagedWorkerClient = class {
7879
9180
  headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
7880
9181
  });
7881
9182
  const body = await safeJson(response);
7882
- if (!response.ok || !isRecord8(body) || typeof body.code !== "string" || typeof body.expiresAt !== "string") {
9183
+ if (!response.ok || !isRecord9(body) || typeof body.code !== "string" || typeof body.expiresAt !== "string") {
7883
9184
  throw new DeploymentApplyError("PAIRING_UNAVAILABLE", "The Worker could not open a pairing window");
7884
9185
  }
7885
9186
  return {
@@ -7916,7 +9217,7 @@ var FetchManagedWorkerClient = class {
7916
9217
  })
7917
9218
  }, isRetryableSessionUpload);
7918
9219
  const body = await safeJson(response);
7919
- if (!response.ok || !isRecord8(body) || body.jsonrpc !== "2.0" || body.id !== id2 || "error" in body || !("result" in body)) {
9220
+ if (!response.ok || !isRecord9(body) || body.jsonrpc !== "2.0" || body.id !== id2 || "error" in body || !("result" in body)) {
7920
9221
  throw new DeploymentApplyError("MCP_SMOKE_FAILED", `MCP ${method} check failed`);
7921
9222
  }
7922
9223
  if (method === "tools/call") readableToolResult(body.result);
@@ -7934,13 +9235,13 @@ var FetchManagedWorkerClient = class {
7934
9235
  }
7935
9236
  };
7936
9237
  var PrivateDeploymentReceiptStore = class {
7937
- constructor(baseDirectory = join10(homedir10(), ".config", "moodle-cli", "mcp", "deployments")) {
9238
+ constructor(baseDirectory = join12(homedir13(), ".config", "moodle-cli", "mcp", "deployments")) {
7938
9239
  this.baseDirectory = baseDirectory;
7939
9240
  }
7940
9241
  baseDirectory;
7941
9242
  async read(profile) {
7942
9243
  try {
7943
- const parsed = JSON.parse(await readFile7(this.path(profile), "utf8"));
9244
+ const parsed = JSON.parse(await readFile8(this.path(profile), "utf8"));
7944
9245
  return isReceipt(parsed) ? parsed : null;
7945
9246
  } catch (error) {
7946
9247
  if (isMissing4(error)) {
@@ -7950,11 +9251,11 @@ var PrivateDeploymentReceiptStore = class {
7950
9251
  }
7951
9252
  }
7952
9253
  async write(receipt) {
7953
- const path4 = this.path(receipt.profile);
7954
- await mkdir8(dirname7(path4), { recursive: true, mode: 448 });
7955
- await writeFile7(path4, `${JSON.stringify(receipt, null, 2)}
9254
+ const path5 = this.path(receipt.profile);
9255
+ await mkdir9(dirname7(path5), { recursive: true, mode: 448 });
9256
+ await writeFile8(path5, `${JSON.stringify(receipt, null, 2)}
7956
9257
  `, { mode: 384 });
7957
- await chmod5(path4, 384);
9258
+ await chmod5(path5, 384);
7958
9259
  }
7959
9260
  async delete(profile) {
7960
9261
  await rm6(this.path(profile), { force: true });
@@ -7963,11 +9264,11 @@ var PrivateDeploymentReceiptStore = class {
7963
9264
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) {
7964
9265
  throw new Error("Invalid Moodle MCP profile name");
7965
9266
  }
7966
- return join10(this.baseDirectory, `${profile}.json`);
9267
+ return join12(this.baseDirectory, `${profile}.json`);
7967
9268
  }
7968
9269
  };
7969
9270
  function createDefaultManagedDeployment(options) {
7970
- const homeDirectory = options.homeDirectory ?? homedir10();
9271
+ const homeDirectory = options.homeDirectory ?? homedir13();
7971
9272
  const platform = options.platform ?? process.platform;
7972
9273
  const runtime = runtimeCommand(options.executable, options.executableArgs);
7973
9274
  const defaults = {
@@ -7994,7 +9295,7 @@ function createDefaultManagedDeployment(options) {
7994
9295
  command: runtime.command,
7995
9296
  commandArgs: runtime.args
7996
9297
  }),
7997
- receipts: new PrivateDeploymentReceiptStore(join10(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
9298
+ receipts: new PrivateDeploymentReceiptStore(join12(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
7998
9299
  createToken: () => randomBytes2(32).toString("base64url")
7999
9300
  };
8000
9301
  return new ManagedMcpDeployment({ ...defaults, ...options.dependencies });
@@ -8005,17 +9306,17 @@ function digest(value) {
8005
9306
  function ownershipId(accountId, workerName) {
8006
9307
  return `moodle-cli:${accountId}:${workerName}`;
8007
9308
  }
8008
- function endpointUrl(endpoint, path4) {
8009
- return `${endpoint.replace(/\/$/u, "")}${path4}`;
9309
+ function endpointUrl(endpoint, path5) {
9310
+ return `${endpoint.replace(/\/$/u, "")}${path5}`;
8010
9311
  }
8011
9312
  async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
8012
9313
  let config;
8013
9314
  try {
8014
- config = JSON.parse(await readFile7(configPath, "utf8"));
9315
+ config = JSON.parse(await readFile8(configPath, "utf8"));
8015
9316
  } catch {
8016
9317
  throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
8017
9318
  }
8018
- if (!isRecord8(config) || !isRecord8(config.vars)) {
9319
+ if (!isRecord9(config) || !isRecord9(config.vars)) {
8019
9320
  throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
8020
9321
  }
8021
9322
  const hosts = endpointHosts(workerName, productionEndpoint);
@@ -8023,7 +9324,7 @@ async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
8023
9324
  throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
8024
9325
  }
8025
9326
  config.vars.EXPECTED_HOSTS = hosts.join(",");
8026
- await writeFile7(configPath, `${JSON.stringify(config, null, 2)}
9327
+ await writeFile8(configPath, `${JSON.stringify(config, null, 2)}
8027
9328
  `, { mode: 384 });
8028
9329
  }
8029
9330
  function endpointHosts(workerName, endpoint) {
@@ -8055,18 +9356,18 @@ async function safeJson(response) {
8055
9356
  }
8056
9357
  function firstHealthCheck(body, name) {
8057
9358
  const checks = body.checks;
8058
- if (!isRecord8(checks) || !Array.isArray(checks[name])) {
9359
+ if (!isRecord9(checks) || !Array.isArray(checks[name])) {
8059
9360
  return null;
8060
9361
  }
8061
9362
  const check = checks[name][0];
8062
- return isRecord8(check) ? check : null;
9363
+ return isRecord9(check) ? check : null;
8063
9364
  }
8064
9365
  function readableToolResult(result) {
8065
- if (isRecord8(result) && result.isError !== true && Array.isArray(result.content)) {
9366
+ if (isRecord9(result) && result.isError !== true && Array.isArray(result.content)) {
8066
9367
  try {
8067
- const text2 = result.content.filter((block) => isRecord8(block) && block.type === "text").map((block) => block.text).join("\n");
9368
+ const text2 = result.content.filter((block) => isRecord9(block) && block.type === "text").map((block) => block.text).join("\n");
8068
9369
  const parsed = JSON.parse(text2);
8069
- if (isRecord8(parsed) && isDeepStrictEqual(parsed, result.structuredContent)) return parsed;
9370
+ if (isRecord9(parsed) && isDeepStrictEqual(parsed, result.structuredContent)) return parsed;
8070
9371
  } catch {
8071
9372
  }
8072
9373
  }
@@ -8074,7 +9375,7 @@ function readableToolResult(result) {
8074
9375
  }
8075
9376
  function mcpUserFullname(result) {
8076
9377
  const user = readableToolResult(result).user;
8077
- return isRecord8(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
9378
+ return isRecord9(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
8078
9379
  }
8079
9380
  function parseJsonOutput(output) {
8080
9381
  const candidates = [output.indexOf("{"), output.indexOf("[")].filter((index) => index >= 0).sort((a, b) => a - b);
@@ -8090,7 +9391,7 @@ function parseJsonOutput(output) {
8090
9391
  function deploymentHistory(value) {
8091
9392
  const entries = [];
8092
9393
  visit(value, (_key, item) => {
8093
- if (!isRecord8(item) || !Array.isArray(item.versions)) {
9394
+ if (!isRecord9(item) || !Array.isArray(item.versions)) {
8094
9395
  return;
8095
9396
  }
8096
9397
  const versionId = activeVersionId(item.versions);
@@ -8098,7 +9399,7 @@ function deploymentHistory(value) {
8098
9399
  return;
8099
9400
  }
8100
9401
  const createdOn = typeof item.created_on === "string" ? Date.parse(item.created_on) : Number.NaN;
8101
- const message = isRecord8(item.annotations) ? item.annotations["workers/message"] : void 0;
9402
+ const message = isRecord9(item.annotations) ? item.annotations["workers/message"] : void 0;
8102
9403
  entries.push({
8103
9404
  versionId,
8104
9405
  message: typeof message === "string" ? message : null,
@@ -8109,7 +9410,7 @@ function deploymentHistory(value) {
8109
9410
  return entries.sort((a, b) => b.createdOn - a.createdOn || b.index - a.index).map(({ versionId, message }) => ({ versionId, message }));
8110
9411
  }
8111
9412
  function activeVersionId(versions) {
8112
- const records = versions.filter(isRecord8);
9413
+ const records = versions.filter(isRecord9);
8113
9414
  const active = records.find((version) => version.percentage === 100) ?? records[0];
8114
9415
  const id2 = active?.version_id ?? active?.versionId;
8115
9416
  return typeof id2 === "string" ? id2 : null;
@@ -8120,7 +9421,7 @@ function releaseDigestFromMessage(message) {
8120
9421
  function collectAccountObjects(value) {
8121
9422
  const accounts = [];
8122
9423
  visit(value, (_key, item) => {
8123
- if (!isRecord8(item)) {
9424
+ if (!isRecord9(item)) {
8124
9425
  return;
8125
9426
  }
8126
9427
  const id2 = typeof item.id === "string" ? item.id : typeof item.account_id === "string" ? item.account_id : null;
@@ -8149,14 +9450,14 @@ function visit(value, visitor, key = "") {
8149
9450
  for (const item of value) {
8150
9451
  visit(item, visitor);
8151
9452
  }
8152
- } else if (isRecord8(value)) {
9453
+ } else if (isRecord9(value)) {
8153
9454
  for (const [childKey, item] of Object.entries(value)) {
8154
9455
  visit(item, visitor, childKey);
8155
9456
  }
8156
9457
  }
8157
9458
  }
8158
9459
  function isReceipt(value) {
8159
- if (!isRecord8(value)) {
9460
+ if (!isRecord9(value)) {
8160
9461
  return false;
8161
9462
  }
8162
9463
  return [
@@ -8170,7 +9471,7 @@ function isReceipt(value) {
8170
9471
  "releaseDigest"
8171
9472
  ].every((key) => typeof value[key] === "string") && typeof value.sessionRevision === "number";
8172
9473
  }
8173
- function isRecord8(value) {
9474
+ function isRecord9(value) {
8174
9475
  return typeof value === "object" && value !== null && !Array.isArray(value);
8175
9476
  }
8176
9477
  function isMissing4(error) {
@@ -8234,13 +9535,23 @@ var READ_ONLY_ANNOTATIONS = {
8234
9535
  idempotentHint: true,
8235
9536
  openWorldHint: true
8236
9537
  };
9538
+ var WRITE_ANNOTATIONS = {
9539
+ readOnlyHint: false,
9540
+ destructiveHint: true,
9541
+ idempotentHint: false,
9542
+ openWorldHint: true
9543
+ };
9544
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["submit"]);
8237
9545
  var aliases = { get_overview: "home", list_courses: "units", get_course: "unit", get_activity: "item", get_grades: "grades", get_thread: "thread", get_file: "file" };
8238
9546
  var TOOL_CATALOG = Object.entries(intentContracts).map(([name, contract]) => ({
8239
9547
  name,
8240
9548
  description: intentDescription(name),
8241
9549
  inputSchema: compactSchema(z4.toJSONSchema(contract.input, { io: "input" })),
8242
- annotations: READ_ONLY_ANNOTATIONS
9550
+ annotations: WRITE_TOOLS.has(name) ? WRITE_ANNOTATIONS : READ_ONLY_ANNOTATIONS
8243
9551
  }));
9552
+ function toolsFor(gateway) {
9553
+ return gateway.submitAssignment ? TOOL_CATALOG : TOOL_CATALOG.filter((tool) => !WRITE_TOOLS.has(tool.name));
9554
+ }
8244
9555
  var TOOL_OUTPUT_SCHEMAS = Object.fromEntries(Object.entries(intentContracts).map(([name, contract]) => [name, compactSchema(z4.toJSONSchema(contract.output))]));
8245
9556
  function compactSchema(value) {
8246
9557
  if (Array.isArray(value)) return value.map(compactSchema);
@@ -8278,7 +9589,7 @@ function createMoodleMcpServer(gateway, options = {}) {
8278
9589
  protocolVersion: protocolVersion2,
8279
9590
  capabilities: { tools: { listChanged: false } },
8280
9591
  serverInfo,
8281
- instructions: "Read-only access to the authenticated user's Moodle data."
9592
+ instructions: gateway.submitAssignment ? "Access to the authenticated user's Moodle data. Only submit writes; it defaults to a dry run." : "Read-only access to the authenticated user's Moodle data."
8282
9593
  });
8283
9594
  }
8284
9595
  if (request.method === "ping") {
@@ -8286,7 +9597,7 @@ function createMoodleMcpServer(gateway, options = {}) {
8286
9597
  }
8287
9598
  if (request.method === "tools/list") {
8288
9599
  return jsonRpcSuccess(id2, {
8289
- tools: TOOL_CATALOG,
9600
+ tools: toolsFor(gateway),
8290
9601
  resultType: "complete",
8291
9602
  _meta: RESULT_META
8292
9603
  });
@@ -8342,8 +9653,9 @@ async function callTool(gateway, params) {
8342
9653
  const requested = typeof params?.name === "string" ? params.name : "";
8343
9654
  const name = Object.hasOwn(aliases, requested) ? aliases[requested] : requested;
8344
9655
  if (!Object.hasOwn(intentContracts, name) && !["get_user", "list_activities", "list_forums"].includes(name)) throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${requested || "<missing>"}`);
9656
+ if (WRITE_TOOLS.has(name) && !gateway.submitAssignment) throw new McpCallError("TOOL_NOT_FOUND", `${requested} is only available on a local MCP server with access to the files.`);
8345
9657
  const raw = params?.arguments ?? {};
8346
- if (!isRecord9(raw)) throw new McpCallError("INVALID_TOOL_ARGUMENTS", "Tool arguments must be an object.");
9658
+ if (!isRecord10(raw)) throw new McpCallError("INVALID_TOOL_ARGUMENTS", "Tool arguments must be an object.");
8347
9659
  const args = { ...raw };
8348
9660
  if (Object.hasOwn(aliases, requested)) {
8349
9661
  const renames = { courseId: "unit", activityId: "ref", discussionId: "discussion_id", source: "ref", todoDays: "days", gradedOnly: "graded_only" };
@@ -8391,7 +9703,7 @@ async function callTool(gateway, params) {
8391
9703
  const mapped2 = { type: "MOODLE_RESULT_INVALID", message: `Moodle returned ${name} data in an unexpected shape.`, hint: "Retry once; if it persists, run the same command locally with --verbose and report the tool name.", issues: error.issues.slice(0, 5).map((issue) => ({ path: issue.path.join("."), message: issue.message })) };
8392
9704
  return { content: [{ type: "text", text: JSON.stringify({ error: mapped2 }) }], structuredContent: { error: mapped2 }, isError: true, resultType: "complete", _meta: RESULT_META };
8393
9705
  }
8394
- const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error);
9706
+ const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error, WRITE_TOOLS.has(name));
8395
9707
  return { content: [{ type: "text", text: JSON.stringify({ error: mapped }) }], structuredContent: { error: mapped }, isError: true, resultType: "complete", _meta: RESULT_META };
8396
9708
  }
8397
9709
  }
@@ -8410,9 +9722,11 @@ function toolContent(name, payload, structuredContent) {
8410
9722
  }
8411
9723
  ];
8412
9724
  }
8413
- function mapMoodleError(error) {
8414
- const record2 = isRecord9(error) ? error : {};
8415
- const code = typeof record2.code === "string" ? record2.code : "";
9725
+ function mapMoodleError(error, verbatim = false) {
9726
+ const record3 = isRecord10(error) ? error : {};
9727
+ const code = typeof record3.code === "string" ? record3.code : "";
9728
+ const own = verbatim && typeof record3.message === "string" && record3.message.trim() && code !== "auth" ? record3.message : void 0;
9729
+ const ownHint = verbatim && typeof record3.hint === "string" && record3.hint.trim() ? record3.hint : void 0;
8416
9730
  const typeByCode = {
8417
9731
  auth: "MOODLE_AUTH_REQUIRED",
8418
9732
  not_found: "MOODLE_NOT_FOUND",
@@ -8421,8 +9735,8 @@ function mapMoodleError(error) {
8421
9735
  };
8422
9736
  const type = code.startsWith("MOODLE_") ? code : typeByCode[code] ?? "MOODLE_UPSTREAM_ERROR";
8423
9737
  const message = type === "MOODLE_AUTH_REQUIRED" ? "The Moodle session has expired. Sign in again." : type === "MOODLE_NOT_FOUND" || type === "MOODLE_COURSE_NOT_FOUND" ? "The requested Moodle item was not found." : type === "MOODLE_INVALID_REQUEST" ? "The Moodle request is invalid." : "Moodle could not complete the request.";
8424
- const moodleCode = typeof record2.moodleErrorCode === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(record2.moodleErrorCode) ? record2.moodleErrorCode : void 0;
8425
- return { type, message, hint: type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find.", ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
9738
+ const moodleCode = typeof record3.moodleErrorCode === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(record3.moodleErrorCode) ? record3.moodleErrorCode : void 0;
9739
+ return { type, message: own ?? message, hint: ownHint ?? (type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find."), ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
8426
9740
  }
8427
9741
  var McpCallError = class extends Error {
8428
9742
  type;
@@ -8434,11 +9748,11 @@ var McpCallError = class extends Error {
8434
9748
  this.details = details;
8435
9749
  }
8436
9750
  };
8437
- function isRecord9(value) {
9751
+ function isRecord10(value) {
8438
9752
  return typeof value === "object" && value !== null && !Array.isArray(value);
8439
9753
  }
8440
9754
  function isMoodleFile(value) {
8441
- return isRecord9(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
9755
+ return isRecord10(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
8442
9756
  }
8443
9757
 
8444
9758
  // src/mcp/stdio.ts
@@ -8494,12 +9808,12 @@ async function writeResponse2(output, response) {
8494
9808
  `);
8495
9809
  }
8496
9810
  function initializeProtocolVersion(input2) {
8497
- if (!isRecord10(input2) || input2.method !== "initialize" || !isRecord10(input2.params)) {
9811
+ if (!isRecord11(input2) || input2.method !== "initialize" || !isRecord11(input2.params)) {
8498
9812
  return void 0;
8499
9813
  }
8500
9814
  return typeof input2.params.protocolVersion === "string" ? input2.params.protocolVersion : void 0;
8501
9815
  }
8502
- function isRecord10(value) {
9816
+ function isRecord11(value) {
8503
9817
  return typeof value === "object" && value !== null && !Array.isArray(value);
8504
9818
  }
8505
9819
 
@@ -8520,9 +9834,9 @@ function deriveMcpWorkerName(moodleOrigin) {
8520
9834
  var DefaultMcpCommandService = class {
8521
9835
  constructor(options) {
8522
9836
  this.options = options;
8523
- this.homeDirectory = options.homeDir ?? homedir11();
9837
+ this.homeDirectory = options.homeDir ?? homedir14();
8524
9838
  this.wranglerInstance = options.wrangler;
8525
- this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join11(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
9839
+ this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join13(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
8526
9840
  this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
8527
9841
  this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
8528
9842
  this.renewal = options.renewal ?? new DefaultRenewalIntegration({
@@ -8547,6 +9861,7 @@ var DefaultMcpCommandService = class {
8547
9861
  notifyRenewalSignIn;
8548
9862
  progressReporter;
8549
9863
  async deploy(input2) {
9864
+ await this.prepareToolchain(input2.yes);
8550
9865
  const progress = this.progress();
8551
9866
  try {
8552
9867
  progress.begin("Reading Cloudflare account and deployment state");
@@ -8657,6 +9972,7 @@ var DefaultMcpCommandService = class {
8657
9972
  async status(input2) {
8658
9973
  const config = await this.config();
8659
9974
  const profile = deriveMcpProfile(config.baseUrl);
9975
+ await this.prepareToolchain();
8660
9976
  const progress = this.progress();
8661
9977
  let managed;
8662
9978
  try {
@@ -8699,6 +10015,7 @@ var DefaultMcpCommandService = class {
8699
10015
  }
8700
10016
  async login() {
8701
10017
  const profile = deriveMcpProfile((await this.config()).baseUrl);
10018
+ await this.prepareToolchain();
8702
10019
  const progress = this.progress();
8703
10020
  try {
8704
10021
  progress.begin("Reading your Moodle session (a browser sign-in may be required)");
@@ -8932,7 +10249,7 @@ var DefaultMcpCommandService = class {
8932
10249
  async pushSessionFromStdin() {
8933
10250
  const config = await this.config();
8934
10251
  const profile = deriveMcpProfile(config.baseUrl);
8935
- const raw = (await readAll(this.options.stdin ?? process.stdin)).trim();
10252
+ const raw = (await readAll2(this.options.stdin ?? process.stdin)).trim();
8936
10253
  const cookieValue = raw.startsWith("MoodleSession=") ? raw.slice("MoodleSession=".length).trim() : raw;
8937
10254
  if (!cookieValue || /[\r\n]/u.test(cookieValue)) throw new UsageError("Standard input did not contain one Moodle session cookie.");
8938
10255
  const session = await getAuthenticatedSession(config.baseUrl, {
@@ -9077,7 +10394,7 @@ Selection: `)).trim());
9077
10394
  if (this.options.prompt) return this.options.prompt(question);
9078
10395
  const input2 = this.options.stdin ?? process.stdin;
9079
10396
  const output = this.options.stderr ?? process.stderr;
9080
- const readline = createInterface3({ input: input2, output });
10397
+ const readline = createInterface({ input: input2, output });
9081
10398
  return readline.question(question).finally(() => readline.close());
9082
10399
  }
9083
10400
  workerBundlePath() {
@@ -9087,8 +10404,14 @@ Selection: `)).trim());
9087
10404
  this.wranglerInstance ??= new NodeWranglerDeploymentAdapter();
9088
10405
  return this.wranglerInstance;
9089
10406
  }
10407
+ // The first-use Wrangler download asks a question, so it runs before a spinner
10408
+ // owns the terminal rather than drawing its prompt underneath one.
10409
+ async prepareToolchain(yes = false) {
10410
+ if (this.options.createDeployment || this.options.wrangler) return;
10411
+ await this.wrangler().prepare({ yes });
10412
+ }
9090
10413
  releaseDigest() {
9091
- return readFile8(this.workerBundlePath()).then((content) => sha256(content));
10414
+ return readFile9(this.workerBundlePath()).then((content) => sha256(content));
9092
10415
  }
9093
10416
  // True when a deployment receipt exists but its recorded release digest no
9094
10417
  // longer matches the Worker bundle shipped with this CLI, i.e. the remote
@@ -9212,7 +10535,7 @@ function displayClientName(client) {
9212
10535
  };
9213
10536
  return names[client];
9214
10537
  }
9215
- async function readAll(input2) {
10538
+ async function readAll2(input2) {
9216
10539
  const decoder = new TextDecoder();
9217
10540
  let value = "";
9218
10541
  for await (const chunk of input2) value += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
@@ -9233,12 +10556,12 @@ function sha256(value) {
9233
10556
  // src/cli.ts
9234
10557
  var NOUNS = [
9235
10558
  { name: "units", aliases: ["courses"], verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "show" } },
9236
- { name: "activities", verbs: ["list", "show"], defaultByArity: { 1: "list" }, valueFlags: ["--limit", "--section"] },
10559
+ { name: "activities", verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "list" }, valueFlags: ["--limit", "--section"] },
9237
10560
  { name: "grades", verbs: ["list"], defaultByArity: { 0: "list", 1: "list" } },
9238
10561
  {
9239
10562
  name: "forums",
9240
10563
  verbs: ["list", "show", "search"],
9241
- defaultByArity: { 1: "list" },
10564
+ defaultByArity: { 0: "list", 1: "list" },
9242
10565
  valueFlags: ["--limit", "--course", "--forum", "--limit-forums", "--limit-discussions", "--unit"]
9243
10566
  },
9244
10567
  { name: "threads", verbs: ["show"], defaultByArity: { 1: "show" }, valueFlags: ["--post", "--limit", "--offset"] }
@@ -9246,7 +10569,8 @@ var NOUNS = [
9246
10569
  function buildProgram(io = {}) {
9247
10570
  const stdout = io.stdout ?? process.stdout;
9248
10571
  const stderr = io.stderr ?? process.stderr;
9249
- const program = createProgram({ name: "moodle", version: VERSION, description: "Terminal-first CLI for Moodle LMS." });
10572
+ const program = createProgram({ name: "moodle", version: VERSION, description: MOODLE_TAGLINE });
10573
+ banner(program, MOODLE_WORDMARK);
9250
10574
  program.configureOutput({
9251
10575
  writeOut: (text2) => stdout.write(text2),
9252
10576
  writeErr: (text2) => stderr.write(text2),
@@ -9271,9 +10595,11 @@ function buildProgram(io = {}) {
9271
10595
  width: stdout.columns || void 0,
9272
10596
  color: !process.env.NO_COLOR && program.opts().color !== false && outputFormat({ ...program.opts(), ...options }, stdout) === "table"
9273
10597
  });
10598
+ configureTerminalTables({ color: () => colorEnabled2(stdout, io.env) && program.opts().color !== false });
9274
10599
  const count = (key, local, fallback) => program.opts()[key] ?? local ?? fallback;
9275
10600
  const runtime = {
9276
10601
  client: null,
10602
+ busy: false,
9277
10603
  screen,
9278
10604
  count,
9279
10605
  baseUrl: async () => (await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr, fetch: io.fetchImpl })).baseUrl,
@@ -9283,11 +10609,11 @@ function buildProgram(io = {}) {
9283
10609
  let inflight = 0;
9284
10610
  let displayed = false;
9285
10611
  let timer;
9286
- runtime.client = await createMoodleClient(baseUrl, {
10612
+ const connect = () => createMoodleClient(baseUrl, {
9287
10613
  env: io.env,
9288
10614
  fetchImpl: async (input2, init) => {
9289
10615
  const started2 = Date.now();
9290
- const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json");
10616
+ const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json") && !runtime.busy;
9291
10617
  if (tty && inflight++ === 0) timer = setTimeout(() => {
9292
10618
  displayed = true;
9293
10619
  stderr.write("Loading Moodle\u2026");
@@ -9313,14 +10639,23 @@ function buildProgram(io = {}) {
9313
10639
  homeDir: io.homeDir,
9314
10640
  noCache: Boolean(program.opts().cache === false)
9315
10641
  });
10642
+ try {
10643
+ runtime.client = await connect();
10644
+ } catch (error) {
10645
+ if (!(error instanceof AuthError) || !human()) throw error;
10646
+ await signIn(baseUrl);
10647
+ runtime.client = await connect();
10648
+ }
9316
10649
  }
9317
10650
  return runtime.client;
9318
10651
  },
9319
10652
  output: async (data, formatter, options) => {
9320
10653
  const merged = { ...program.opts(), ...options };
9321
10654
  const format = outputFormat(merged, stdout);
9322
- const human = format === "table" ? formatter() : "";
9323
- const text2 = format === "table" ? `${human}${human.includes("Try ") ? "" : "\n\nTry moodle due \xB7 moodle units \xB7 moodle --help"}
10655
+ const human2 = format === "table" ? formatter() : "";
10656
+ const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : `
10657
+
10658
+ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
9324
10659
  ` : format === "json" ? `${JSON.stringify(JSON.parse(render(data, { format, fields: parseFields(data, merged.fields) })), null, merged.pretty ? 2 : void 0)}
9325
10660
  ` : render(data, { format, fields: parseFields(data, merged.fields) });
9326
10661
  if (io.stdout && !merged.output) {
@@ -9348,23 +10683,43 @@ function buildProgram(io = {}) {
9348
10683
  const result = await runner.run(name, args);
9349
10684
  await runtime.output(result, () => screen(result, options), options);
9350
10685
  };
10686
+ const human = () => detectAudience({
10687
+ stdin: io.stdin ?? process.stdin,
10688
+ stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
10689
+ env: io.env ?? process.env,
10690
+ format: outputFormat(program.opts(), stdout)
10691
+ }) === "human";
10692
+ const theme = () => createTheme3(colorEnabled2(stderr, io.env) && program.opts().color !== false);
10693
+ const signIn = async (baseUrl) => {
10694
+ const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
10695
+ const auth2 = { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true };
10696
+ runtime.busy = true;
10697
+ try {
10698
+ await signInInteractively(ui, {
10699
+ baseUrl,
10700
+ platform: process.platform,
10701
+ showWordmark,
10702
+ storesBlocked: async () => cookieStoresBlocked(await browserCookieStores({ homeDir: io.homeDir })),
10703
+ openInBrowser,
10704
+ readBrowserSession: () => getAuthenticatedSession(baseUrl, { ...auth2, noCache: true, nonInteractive: true }),
10705
+ browserLogin: (onBrowserOpened) => getAuthenticatedSessionWithBrowserFallback(baseUrl, { ...auth2, onBrowserOpened }),
10706
+ pasteLogin: () => pasteLogin(baseUrl, true),
10707
+ keepaliveInstalled: async () => (await keepaliveStatus(io.homeDir)).installed,
10708
+ installKeepalive: () => installKeepalive({ homeDir: io.homeDir })
10709
+ });
10710
+ } finally {
10711
+ runtime.busy = false;
10712
+ }
10713
+ };
9351
10714
  const choose = async (action, retry) => {
9352
10715
  try {
9353
10716
  return await action();
9354
10717
  } catch (error) {
9355
- if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !(io.stdin?.isTTY ?? process.stdin.isTTY) || outputFormat(program.opts(), stdout) !== "table") throw error;
9356
- stderr.write(`${error.message}
9357
- ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9358
- `);
9359
- const reader = createInterface4({ input: io.stdin ?? process.stdin, output: stderr });
9360
- try {
9361
- const answer = await reader.question(`Pick [1-${error.candidates.length}]: `);
9362
- const chosen = error.candidates[Number(answer) - 1];
9363
- if (!chosen) throw error;
9364
- return await retry(chosen.id);
9365
- } finally {
9366
- reader.close();
9367
- }
10718
+ if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !human()) throw error;
10719
+ const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
10720
+ const hint = (c) => c.code ?? c.type;
10721
+ const chosen = await ui.select(error.message, error.candidates.map((c) => ({ value: c.id, label: c.name, ...hint(c) ? { hint: hint(c) } : {} })));
10722
+ return await retry(chosen);
9368
10723
  }
9369
10724
  };
9370
10725
  program.action(async (targets, options) => {
@@ -9429,15 +10784,42 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9429
10784
  if (name === "due") command.option("--days <number>", "Deadline window in days.", parsePositiveInt);
9430
10785
  command.option("--limit <number>", "Maximum returned rows.", parsePositiveInt).action(async (unit, options) => execute(name, { unit, limit: count("limit", options.limit), ...name === "due" ? { days: count("days", options.days) } : {} }, options));
9431
10786
  }
9432
- addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>").argument("[unit]")).option("--limit <number>", "Maximum returned rows.", parsePositiveInt).option("--types <types>", "Comma-separated activity types.").action(async (query, unit, options) => execute("find", { query, unit, limit: count("limit", options.limit), types: options.types?.split(",") }, options));
9433
- addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
10787
+ addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>", "Words to look for").argument("[unit]", "Unit code, name, id or URL")).option("--limit <number>", "Maximum returned rows.", parsePositiveInt).option("--types <types>", "Comma-separated activity types.").action(async (query, unit, options) => execute("find", { query, unit, limit: count("limit", options.limit), types: options.types?.split(",") }, options));
10788
+ addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>", "Resource id, URL, or UNIT TASK phrase")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
9434
10789
  const client = await runtime.getClient();
9435
10790
  const service = createIntentService(createMoodleGateway(client));
9436
10791
  const source = await choose(() => service.fileSource(ref2), (id2) => Promise.resolve(id2));
9437
- const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ? path3.resolve(io.cwd ?? process.cwd(), options.to) : void 0, force: options.force });
10792
+ const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ? path4.resolve(io.cwd ?? process.cwd(), options.to) : void 0, force: options.force });
9438
10793
  await runtime.output(receipt, () => formatDownloadReceipt(receipt), options);
9439
10794
  });
9440
- addOutputOptions(program.command("open").description("Open a unit or activity reference in the browser.").argument("<ref>")).action(async (ref2, options) => {
10795
+ addOutputOptions(mutating(program.command("submit").description(humanDescription("submit")).summary("Upload files into an assignment").argument("<ref>", "Assignment id, URL, or UNIT TASK phrase").argument("[files...]", "Local files to upload"))).option("--final", "Also submit for grading. Moodle does not allow undoing this.").option("--replace", "Remove the files already in the submission first.").option("--accept-statement", "Agree to the site's submission statement when it requires one.").action(async (ref2, files, options) => {
10796
+ const interactive = human();
10797
+ if (!program.opts().dryRun && !program.opts().yes && !interactive) throw new UsageError("Mutation requires --yes when stdin is not interactive.", "Run with --dry-run to see the plan first.");
10798
+ const client = await runtime.getClient();
10799
+ const service = createIntentService(createMoodleGateway(client));
10800
+ const args = { files: files.map((file2) => resolveSubmissionPath(file2, io.cwd ?? process.cwd())), final: Boolean(options.final), replace: Boolean(options.replace), accept_statement: Boolean(options.acceptStatement) };
10801
+ const plan = await choose(() => service.run("submit", { ref: ref2, ...args, dry_run: true }), (id2) => service.run("submit", { ref: id2, ...args, dry_run: true }));
10802
+ const planned = plan.submission;
10803
+ if (program.opts().dryRun) return runtime.output(plan, () => formatSubmissionReceipt(planned), options);
10804
+ if (!await confirm({ summary: submissionSummary(planned, args.final, theme()) }, { yes: Boolean(program.opts().yes), dryRun: false, interactive })) return;
10805
+ const spin = interactive ? createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true }).spinner() : void 0;
10806
+ runtime.busy = true;
10807
+ spin?.start("Preparing the upload");
10808
+ let result;
10809
+ try {
10810
+ const live = createIntentService(createMoodleGateway(client, { onSubmitProgress: (message) => spin?.message(message) }));
10811
+ result = await live.run("submit", { ref: planned.id, ...args, dry_run: false });
10812
+ const receipt = result.submission;
10813
+ spin?.stop(receipt.uploads.length ? `Uploaded ${receipt.uploads.map((file2) => file2.name).join(", ")} to ${receipt.name}` : `Submitted ${receipt.name}`);
10814
+ } catch (error) {
10815
+ spin?.error("The upload did not complete");
10816
+ throw error;
10817
+ } finally {
10818
+ runtime.busy = false;
10819
+ }
10820
+ await runtime.output(result, () => formatSubmissionReceipt(result.submission), options);
10821
+ });
10822
+ addOutputOptions(program.command("open").description("Open a unit or activity reference in the browser.").argument("<ref>", "Unit or activity id, URL, or UNIT TASK phrase")).action(async (ref2, options) => {
9441
10823
  const client = await runtime.getClient();
9442
10824
  let url;
9443
10825
  if (looksLikeUrl(ref2)) {
@@ -9453,11 +10835,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9453
10835
  }
9454
10836
  }
9455
10837
  if (!url || !/^https?:/u.test(url)) throw new UsageError("This item has no browser URL.");
9456
- await new Promise((resolve, reject) => {
9457
- const child = spawn3(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
9458
- child.once("error", reject);
9459
- child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
9460
- });
10838
+ await openInBrowser(url);
9461
10839
  await runtime.output({ opened: url }, () => `Opened ${url}`, options);
9462
10840
  });
9463
10841
  addOutputOptions(program.command("user").description("Show authenticated user info.")).action(async (options) => {
@@ -9495,7 +10873,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9495
10873
  const result = stripEmpty({ activities: rows.slice(0, count("limit", options.limit)), total: rows.length });
9496
10874
  await runtime.output(result, () => runtime.screen(result, options), options);
9497
10875
  });
9498
- addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").argument("<id>", "Course-module ID")).action(
10876
+ addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").summary("Show activity details").argument("<id>", "Course-module ID")).action(
9499
10877
  async (id2, options) => {
9500
10878
  await execute("item", { ref: parsePositiveInt(id2) }, options);
9501
10879
  }
@@ -9503,7 +10881,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9503
10881
  addOutputOptions(
9504
10882
  program.command("download").alias("dl").description("Download one authenticated Moodle file.").argument("<source>", "Course-module ID or authenticated Moodle file URL").option("--dest <path>", "Exact downloaded file path").option("--force", "Atomically replace an existing destination")
9505
10883
  ).action(async (source, options) => {
9506
- const destination = options.dest ? path3.resolve(io.cwd ?? process.cwd(), options.dest) : void 0;
10884
+ const destination = options.dest ? path4.resolve(io.cwd ?? process.cwd(), options.dest) : void 0;
9507
10885
  const receipt = await downloadMoodleFile(await runtime.getClient(), {
9508
10886
  source,
9509
10887
  destination,
@@ -9548,12 +10926,15 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
9548
10926
  await runtime.output(stripEmpty({ forums: forums2.map((f) => ({ id: f.id, name: f.name, unit_id: f.course_id })), total }), () => formatForumActivities(forums2), options);
9549
10927
  });
9550
10928
  addForumSearchCommand(forums.command("search").description("Search forum discussion titles and post text."), runtime, 20);
9551
- addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.")).action(async (options) => {
10929
+ addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.").summary("Diagnose runtime, session and MCP setup")).action(async (options) => {
9552
10930
  const result = await doctor(io);
9553
10931
  await runtime.output(result, () => result.checks.map((c) => `${c.status.toUpperCase()} ${c.name}: ${c.detail}${c.hint ? `
9554
- ${c.hint}` : ""}`).join("\n") + "\n\nTry moodle auth login \xB7 moodle mcp status", options);
10932
+ ${c.hint}` : ""}`).join("\n") + `
10933
+
10934
+ ${tryLines(doctorNextSteps(result.checks))}`, options);
10935
+ if (result.checks.some((c) => c.status === "fail")) process.exitCode = 3;
9555
10936
  });
9556
- program.command("completion").description("Print shell completion for zsh, bash or fish.").argument("<shell>").action((shell) => {
10937
+ program.command("completion").description("Print shell completion for zsh, bash or fish.").addArgument(program.createArgument("<shell>", "Shell to target").choices(["zsh", "bash", "fish"])).action((shell) => {
9557
10938
  const names = program.commands.filter((c) => c.name() !== "help").flatMap((c) => [c.name(), ...c.aliases()]);
9558
10939
  if (shell === "bash") stdout.write(`complete -W '${names.join(" ")}' moodle
9559
10940
  `);
@@ -9563,22 +10944,22 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
9563
10944
  else if (shell === "fish") stdout.write(names.map((n2) => `complete -c moodle -f -a '${n2}'`).join("\n") + "\n");
9564
10945
  else throw new UsageError("Choose zsh, bash or fish.");
9565
10946
  });
9566
- addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration."))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
9567
- const home = io.homeDir ?? homedir12();
10947
+ addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration.").summary("Remove background jobs, Worker and config"))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
10948
+ const home = io.homeDir ?? homedir15();
9568
10949
  const jobs = await ownedJobs(home);
9569
- const receipts = await readdir3(path3.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
9570
- const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config: path3.join(home, CONFIG_DIR_NAME), cache: path3.join(home, CACHE_DIR_NAME), package_command: "npm rm -g moodle-cli (or bun remove -g moodle-cli); for the standalone install: rm ~/.local/bin/moodle", remaining: options.remote ? "Only the configured Worker is removed. Other profiles remain remote." : "Remote Workers and credentials remain unless removed with moodle mcp remove." };
10950
+ const receipts = await readdir4(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10951
+ const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config: path4.join(home, CONFIG_DIR_NAME), cache: path4.join(home, CACHE_DIR_NAME), package_command: "npm rm -g moodle-cli (or bun remove -g moodle-cli); for the standalone install: rm ~/.local/bin/moodle", remaining: options.remote ? "Only the configured Worker is removed. Other profiles remain remote." : "Remote Workers and credentials remain unless removed with moodle mcp remove." };
9571
10952
  if (program.opts().dryRun) return runtime.output(result, () => JSON.stringify(result, null, 2), options);
9572
10953
  if (options.purge && receipts.length && !options.remote) throw new UsageError("Managed deployment receipts exist; remove the Worker before purging its recovery information.", "Run moodle mcp remove for each configured site, then moodle uninstall --purge.");
9573
10954
  if (options.purge && receipts.length > 1) throw new UsageError("Multiple managed deployment receipts exist; remove each Worker before purging configuration.");
9574
- if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY) })) return;
10955
+ if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: human() })) return;
9575
10956
  if (options.remote) await getMcpService().remove({ yes: true });
9576
10957
  if (process.platform === "darwin") await uninstallKeepalive({ homeDir: home });
9577
10958
  const renewal2 = new DefaultRenewalIntegration({ homeDirectory: home, executable: process.execPath });
9578
10959
  const profiles = new Set([...jobs.map((j) => j.profile), ...receipts.map((n2) => n2.replace(/\.json$/u, ""))].filter((p) => Boolean(p) && /^[a-z0-9_-]+$/u.test(p)));
9579
10960
  for (const profile of profiles) {
9580
10961
  await renewal2.remove(profile);
9581
- if (options.purge) await rm7(path3.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
10962
+ if (options.purge) await rm7(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
9582
10963
  }
9583
10964
  if (options.purge) {
9584
10965
  await rm7(result.config, { recursive: true, force: true });
@@ -9599,25 +10980,38 @@ ${result.package_command}`, options);
9599
10980
  ${note}`, options);
9600
10981
  }
9601
10982
  );
9602
- addOutputOptions(auth.command("login").description("Extract a fresh session, opening the browser when needed.")).action(
10983
+ addOutputOptions(
10984
+ auth.command("login").description("Sign in through a browser the CLI controls, then capture the session.").option("--paste", "Take the MoodleSession cookie from a prompt instead of the browser store.")
10985
+ ).action(
9603
10986
  async (options) => {
9604
10987
  const baseUrl = await runtime.baseUrl();
9605
- await invalidateCachedSession(baseUrl, { homeDir: io.homeDir });
9606
10988
  const humanOutput = outputFormat(options, stdout) === "table";
9607
- const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
10989
+ const session = options.paste ? await pasteLogin(baseUrl, humanOutput) : await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
9608
10990
  env: io.env,
9609
10991
  fetch: io.fetchImpl,
9610
10992
  homeDir: io.homeDir,
9611
- onBrowserOpened: humanOutput ? (url) => stderr.write(`No active Moodle session found. Complete login in your browser:
9612
- ${url}
9613
- `) : void 0
10993
+ captureMobileToken: true,
10994
+ onBrowserOpened: humanOutput ? () => stderr.write("A browser window opened. Sign in there; I'll capture the session automatically.\n") : void 0
9614
10995
  });
9615
10996
  const result = { base_url: baseUrl, userid: session.userid, cookie_source: session.cookie.source ?? "unknown" };
9616
10997
  await runtime.output(result, () => `Authenticated as userid ${result.userid} via ${result.cookie_source}`, options);
9617
10998
  }
9618
10999
  );
11000
+ async function pasteLogin(baseUrl, humanOutput) {
11001
+ const input2 = io.stdin ?? process.stdin;
11002
+ if (humanOutput && input2.isTTY) {
11003
+ stderr.write(`Copy the ${MOODLE_SESSION_COOKIE_PREFIX} cookie for ${baseUrl} from your browser's developer tools.
11004
+ The value is not echoed and is stored in the encrypted session cache.
11005
+ `);
11006
+ }
11007
+ const raw = await readSecretLine(input2, stderr, input2.isTTY ? `${MOODLE_SESSION_COOKIE_PREFIX}: ` : "");
11008
+ if (raw === null) {
11009
+ throw new CliError2("cancelled", "Login cancelled.");
11010
+ }
11011
+ return authenticateWithPastedCookie(baseUrl, raw, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true });
11012
+ }
9619
11013
  const keepalive = addOutputOptions(
9620
- auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
11014
+ auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").summary("Renew the session once").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
9621
11015
  ).action(async (options) => {
9622
11016
  const baseUrl = await runtime.baseUrl();
9623
11017
  const result = await keepAliveOnce(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl, renewOnExpiry: options.renew });
@@ -9629,10 +11023,10 @@ ${url}
9629
11023
  const globals = program.opts();
9630
11024
  if (!await confirm(
9631
11025
  { summary: `Install the Moodle session keepalive agent${options.interval ? ` with a ${options.interval}-minute interval` : ""}.` },
9632
- { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY) }
11026
+ { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
9633
11027
  )) return;
9634
11028
  const baseUrl = await runtime.baseUrl();
9635
- await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true });
11029
+ await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true, captureMobileToken: true });
9636
11030
  const result = await installKeepalive({ homeDir: io.homeDir, intervalMinutes: options.interval });
9637
11031
  await runtime.output(result, () => `Keepalive installed: renews every ${result.interval_minutes} min
9638
11032
  Agent: ${result.plist_path}
@@ -9643,7 +11037,7 @@ Log: ${result.log_path}`, options);
9643
11037
  const globals = program.opts();
9644
11038
  if (!await confirm(
9645
11039
  { summary: "Remove the Moodle session keepalive agent." },
9646
- { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY) }
11040
+ { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
9647
11041
  )) return;
9648
11042
  const result = await uninstallKeepalive({ homeDir: io.homeDir });
9649
11043
  await runtime.output(result, () => `Keepalive removed (${result.plist_path})`, options);
@@ -9655,7 +11049,7 @@ Log: ${result.log_path}`, options);
9655
11049
  await runtime.output(result, () => result.installed ? `Keepalive installed (${result.plist_path})` : "Keepalive not installed", options);
9656
11050
  }
9657
11051
  );
9658
- const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.");
11052
+ const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.").summary("Private MCP server on Cloudflare");
9659
11053
  addOutputOptions(mutating(mcp.command("deploy").description("Deploy or update the managed Moodle MCP server."))).option("--dry-run", "Preview deployment changes without applying them.").option("--repair", "Repair authentication and managed deployment state.").option("--rotate-key", "Rotate the session encryption key and migrate the active session.").option("--rotate-token", "Rotate the MCP access token with an overlap window.").option("--rollback", "Restore the previous healthy Worker release.").action(async (options) => {
9660
11054
  const dryRun = Boolean(options.dryRun || program.opts().dryRun);
9661
11055
  if (!dryRun && !await confirm(
@@ -9663,7 +11057,7 @@ Log: ${result.log_path}`, options);
9663
11057
  {
9664
11058
  yes: Boolean(program.opts().yes),
9665
11059
  dryRun: false,
9666
- interactive: Boolean(io.stdin?.isTTY ?? process.stdin.isTTY)
11060
+ interactive: human()
9667
11061
  }
9668
11062
  )) return;
9669
11063
  const result = await getMcpService().deploy({
@@ -9705,11 +11099,11 @@ Log: ${result.log_path}`, options);
9705
11099
  addOutputOptions(mcp.command("clients").description("List pending and approved OAuth clients.")).action(async (options) => {
9706
11100
  await outputMcpResult(runtime, await getMcpService().manageClients({}), options);
9707
11101
  });
9708
- addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
11102
+ addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]", "OAuth client id; omit with --all"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
9709
11103
  if (Boolean(clientId) === Boolean(options.all)) throw new UsageError("Provide a client ID or --all.");
9710
11104
  await outputMcpResult(runtime, await getMcpService().manageClients({ revoke: true, clientId }), options);
9711
11105
  });
9712
- addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server."))).action(
11106
+ addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server.").summary("Open a pairing window for Claude"))).action(
9713
11107
  async (options) => {
9714
11108
  await outputMcpResult(runtime, await getMcpService().pair(), options);
9715
11109
  }
@@ -9742,7 +11136,7 @@ Log: ${result.log_path}`, options);
9742
11136
  await runtime.output(description, () => JSON.stringify(description, null, 2), options);
9743
11137
  }
9744
11138
  );
9745
- const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.");
11139
+ const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.").summary("Agent skill metadata");
9746
11140
  skills.action(() => {
9747
11141
  stdout.write(`${formatSkillSummary()}
9748
11142
  `);
@@ -9752,24 +11146,46 @@ Log: ${result.log_path}`, options);
9752
11146
  stdout.write("Generated Moodle skill bundle\n");
9753
11147
  });
9754
11148
  skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
11149
+ for (const [title, names] of Object.entries(HELP_SECTIONS)) {
11150
+ for (const name of names) for (const command of program.commands) if (command.name() === name) helpSection(command, title);
11151
+ }
11152
+ examples(program, [
11153
+ "moodle # today: due items, alerts and news",
11154
+ "moodle UNIT grades",
11155
+ 'moodle submit UNIT "Assignment 2" report.pdf'
11156
+ ]);
9755
11157
  return program;
9756
11158
  }
11159
+ var HELP_SECTIONS = {
11160
+ "Core commands": ["due", "news", "find", "get", "open", "submit", "units", "activities", "grades", "threads", "forums"],
11161
+ "Additional commands": ["user", "todo", "alerts", "overview", "download", "auth", "doctor", "completion", "uninstall"],
11162
+ "Agent commands": ["mcp", "commands", "skills"]
11163
+ };
9757
11164
  async function runCli(argv = process.argv, io = {}) {
9758
11165
  const stderr = io.stderr ?? process.stderr;
9759
11166
  const stdout = io.stdout ?? process.stdout;
9760
11167
  const args = insertDefaultVerb(argv.slice(2), NOUNS);
9761
- const program = buildProgram({ ...io, rootArgs: args });
11168
+ const ui = createUi3({
11169
+ input: io.stdin ?? process.stdin,
11170
+ output: stderr,
11171
+ interactive: detectAudience({
11172
+ stdin: io.stdin ?? process.stdin,
11173
+ stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
11174
+ env: io.env ?? process.env,
11175
+ format: errorOutputFormat(args, stdout)
11176
+ }) === "human"
11177
+ });
9762
11178
  try {
9763
- await program.parseAsync(args, { from: "user" });
11179
+ await parseWithPrompts(() => buildProgram({ ...io, rootArgs: args }), args, { ui, fillers: { unit: pickUnit(io, ui) } });
9764
11180
  return 0;
9765
11181
  } catch (error) {
9766
- if (isCommanderCompletion(error)) {
11182
+ if (isInformationalExit(error)) {
9767
11183
  return 0;
9768
11184
  }
9769
11185
  const format = errorOutputFormat(args, stdout);
9770
- const normalized = normalizeError(error);
11186
+ const normalized = normalizeError(asNetworkError(error) ?? error);
9771
11187
  const reference = error instanceof ReferenceError ? error : void 0;
9772
- const reported = reportError(error, "json");
11188
+ const reported = reportError(asNetworkError(error) ?? error, "json");
9773
11189
  const envelope = JSON.parse(reported.text);
9774
11190
  const hint = reference?.hint || normalized.hint || { auth: "Run moodle auth login, or moodle doctor.", config: "Run moodle doctor to check configuration.", not_found: "Run moodle units or moodle find QUERY.", usage: "Run moodle --help or moodle commands --json.", upstream: "Run moodle doctor, then retry.", network: "Check the connection, then retry.", unexpected: "Run moodle doctor; use --verbose for request timings.", cancelled: "Retry when ready." }[normalized.code];
9775
11191
  envelope.error.hint = hint;
@@ -9786,6 +11202,23 @@ ${hint}
9786
11202
  return envelope.exit_code;
9787
11203
  }
9788
11204
  }
11205
+ function openInBrowser(url) {
11206
+ return new Promise((resolve, reject) => {
11207
+ const child = spawn4(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
11208
+ child.once("error", reject);
11209
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
11210
+ });
11211
+ }
11212
+ function doctorNextSteps(checks) {
11213
+ const failing = new Set(checks.filter((c) => c.status !== "pass").map((c) => c.name));
11214
+ const steps = [];
11215
+ if (failing.has("browser")) steps.push("grant Full Disk Access, then rerun moodle doctor");
11216
+ else if (failing.has("session")) steps.push("moodle auth login");
11217
+ if (failing.has("sqlite")) steps.push("install Node 22.13+ or Bun");
11218
+ if (failing.has("config")) steps.push("moodle units");
11219
+ if (failing.has("job")) steps.push("moodle auth keepalive install");
11220
+ return steps.length ? steps : ["moodle todo", "moodle mcp status"];
11221
+ }
9789
11222
  async function dispatchUrl(runtime, target, options) {
9790
11223
  const client = await runtime.getClient();
9791
11224
  const resolved = await resolveTopLevelUrl(client.baseUrl, target, (url) => client.resolveCourseIdForUrl(url));
@@ -9843,11 +11276,26 @@ function addForumSearchCommand(command, runtime, defaultLimit) {
9843
11276
  });
9844
11277
  }
9845
11278
  function addOutputOptions(command) {
9846
- return command.option("--pretty", "Indent JSON output.").option("--json", "Output as JSON.").option("--yaml", "Output as YAML.").option("--table", "Force human output.").option("--fields <fields>", "Keep only listed top-level fields in structured output.");
11279
+ for (const [flags, description] of [
11280
+ ["--pretty", "Indent JSON output."],
11281
+ ["--json", "Output as JSON."],
11282
+ ["--yaml", "Output as YAML."],
11283
+ ["--table", "Force human output."],
11284
+ ["--fields <fields>", "Keep only listed top-level fields in structured output."]
11285
+ ]) command.addOption(command.createOption(flags, description).hideHelp());
11286
+ return command;
9847
11287
  }
9848
11288
  function outputFormat(options, stdout) {
9849
11289
  return resolveFormat(options, Boolean(stdout && "isTTY" in stdout && stdout.isTTY));
9850
11290
  }
11291
+ function submissionSummary(plan, final, theme) {
11292
+ const destination = `${theme.target(plan.name)}${plan.unit_id ? theme.dim(` unit ${plan.unit_id}`) : ""}`;
11293
+ const lines = plan.uploads.length ? [`${theme.dim("Upload")} ${theme.subject(plan.uploads.map((file2) => file2.name).join(", "))}`, `${theme.dim(" to")} ${destination}`] : [`${theme.dim("Submit")} ${destination}`, `${theme.dim(" ")} ${theme.subject("the files already there")} for grading`];
11294
+ if (plan.removed.length) lines.push(`${theme.dim("Remove")} ${theme.tone("danger", plan.removed.join(", "))} ${theme.dim("first")}`);
11295
+ if (plan.statement) lines.push(`${theme.dim(" Agree")} "${plan.statement}"`);
11296
+ lines.push(final ? theme.tone("warning", "Then submit for grading. Moodle does not allow undoing this.") : theme.dim("Moodle keeps a draft where the assignment allows drafts; otherwise it submits at once."));
11297
+ return lines.join("\n");
11298
+ }
9851
11299
  function parsePositiveInt(value) {
9852
11300
  const parsed = Number(value);
9853
11301
  if (!Number.isInteger(parsed) || parsed < 1) {
@@ -9888,11 +11336,24 @@ function parseFields(data, value) {
9888
11336
  }
9889
11337
  return fields2;
9890
11338
  }
9891
- function isCommanderCompletion(error) {
9892
- if (!error || typeof error !== "object") return false;
9893
- if ("exitCode" in error && error.exitCode === 0) return true;
9894
- const code = "code" in error ? String(error.code) : "";
9895
- return code.startsWith("commander.help") || code === "commander.version";
11339
+ function pickUnit(io, ui) {
11340
+ return async () => {
11341
+ const spin = ui.spinner();
11342
+ spin.start("Loading your units");
11343
+ try {
11344
+ return await selectUnit(spin);
11345
+ } catch (error) {
11346
+ spin.stop("Could not load your units");
11347
+ throw error;
11348
+ }
11349
+ };
11350
+ async function selectUnit(spin) {
11351
+ const { baseUrl } = await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr: io.stderr ?? process.stderr, fetch: io.fetchImpl });
11352
+ const client = await createMoodleClient(baseUrl, { env: io.env, fetchImpl: io.fetchImpl, homeDir: io.homeDir });
11353
+ const courses = await client.getCourses();
11354
+ spin.stop(`${courses.length} units`);
11355
+ return ui.select("Which unit?", courses.map((course) => ({ value: String(course.id), label: course.fullname, ...course.shortname ? { hint: course.shortname } : {} })));
11356
+ }
9896
11357
  }
9897
11358
  function parseRootOutputOptions(args) {
9898
11359
  const fieldsIndex = args.findIndex((arg) => arg === "--fields" || arg.startsWith("--fields="));
@@ -9925,7 +11386,7 @@ function pathsReferToSameFile(moduleUrl, executable) {
9925
11386
  var isMain = import.meta.main === true || pathsReferToSameFile(import.meta.url, process.argv[1]);
9926
11387
  if (isMain) {
9927
11388
  runCli().then((code) => {
9928
- process.exitCode = code;
11389
+ process.exitCode = code || process.exitCode;
9929
11390
  });
9930
11391
  }
9931
11392
  export {