@lukeguo12210/canvas-cli 0.0.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.
@@ -0,0 +1,1160 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/core/redaction.ts
4
+ var REDACTED = "[REDACTED]";
5
+ var SECRET_KEY_PATTERN = /(^|[_-])(authorization|access[_-]?token|token|api[_-]?key|secret)$/i;
6
+ var BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi;
7
+ var QUERY_SECRET_PATTERN = /([?&](?:access_token|token|api_key|verifier|code)=)[^&#\s]+/gi;
8
+ function redactSecrets(value) {
9
+ if (typeof value === "string") {
10
+ return redactString(value);
11
+ }
12
+ if (Array.isArray(value)) {
13
+ return value.map((item) => redactSecrets(item));
14
+ }
15
+ if (value && typeof value === "object") {
16
+ const output2 = {};
17
+ for (const [key, nested] of Object.entries(value)) {
18
+ output2[key] = SECRET_KEY_PATTERN.test(key) ? REDACTED : redactSecrets(nested);
19
+ }
20
+ return output2;
21
+ }
22
+ return value;
23
+ }
24
+ function redactString(value) {
25
+ return value.replace(BEARER_PATTERN, `Bearer ${REDACTED}`).replace(QUERY_SECRET_PATTERN, `$1${REDACTED}`);
26
+ }
27
+
28
+ // src/core/output.ts
29
+ function formatOutput(result, options = {}) {
30
+ const format = options.format ?? "json";
31
+ const safeResult = redactSecrets(result);
32
+ if (format === "json") {
33
+ return `${JSON.stringify(safeResult, null, 2)}
34
+ `;
35
+ }
36
+ if (format === "ndjson") {
37
+ return `${JSON.stringify(safeResult)}
38
+ `;
39
+ }
40
+ if (!safeResult.ok) {
41
+ const status = safeResult.error.status ? ` (${safeResult.error.status})` : "";
42
+ return `Error ${safeResult.error.code}${status}: ${safeResult.error.message}
43
+ `;
44
+ }
45
+ if (format === "table") {
46
+ return tableOutput(safeResult.data);
47
+ }
48
+ return prettyOutput(safeResult.data);
49
+ }
50
+ async function writeOutput(result, options = {}) {
51
+ const stream = result.ok ? process.stdout : process.stderr;
52
+ stream.write(formatOutput(result, options));
53
+ }
54
+ function prettyOutput(data) {
55
+ if (typeof data === "string") {
56
+ return `${data}
57
+ `;
58
+ }
59
+ return `${JSON.stringify(data, null, 2)}
60
+ `;
61
+ }
62
+ function tableOutput(data) {
63
+ if (!Array.isArray(data)) {
64
+ return prettyOutput(data);
65
+ }
66
+ if (data.length === 0) {
67
+ return "\n";
68
+ }
69
+ const rows = data.filter((row) => {
70
+ return row !== null && typeof row === "object" && !Array.isArray(row);
71
+ });
72
+ if (rows.length === 0) {
73
+ return prettyOutput(data);
74
+ }
75
+ const columns = Object.keys(rows[0] ?? {});
76
+ const widths = columns.map(
77
+ (column) => Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length))
78
+ );
79
+ const line = (values) => `${values.map((value, index) => value.padEnd(widths[index] ?? value.length)).join(" ")}
80
+ `;
81
+ let output2 = line(columns);
82
+ output2 += line(widths.map((width) => "-".repeat(width)));
83
+ for (const row of rows) {
84
+ output2 += line(columns.map((column) => String(row[column] ?? "")));
85
+ }
86
+ return output2;
87
+ }
88
+
89
+ // src/core/errors.ts
90
+ var CanvasCliError = class extends Error {
91
+ code;
92
+ status;
93
+ retryable;
94
+ constructor(code, message, options = {}) {
95
+ super(message, { cause: options.cause });
96
+ this.name = "CanvasCliError";
97
+ this.code = code;
98
+ this.status = options.status;
99
+ this.retryable = options.retryable ?? false;
100
+ }
101
+ };
102
+ function toErrorEnvelope(error) {
103
+ if (error instanceof CanvasCliError) {
104
+ return {
105
+ ok: false,
106
+ error: {
107
+ code: error.code,
108
+ message: error.message,
109
+ status: error.status,
110
+ retryable: error.retryable
111
+ }
112
+ };
113
+ }
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ return {
116
+ ok: false,
117
+ error: {
118
+ code: "UNEXPECTED_ERROR",
119
+ message,
120
+ retryable: false
121
+ }
122
+ };
123
+ }
124
+
125
+ // src/core/pagination.ts
126
+ function parseLinkHeader(header) {
127
+ if (!header) {
128
+ return {};
129
+ }
130
+ const links = {};
131
+ for (const part of splitHeader(header)) {
132
+ const match = part.match(/^\s*<([^>]+)>\s*;\s*rel="([^"]+)"\s*$/i);
133
+ if (!match) {
134
+ continue;
135
+ }
136
+ const [, url, rel] = match;
137
+ if (isLinkRelation(rel)) {
138
+ links[rel] = url;
139
+ }
140
+ }
141
+ return links;
142
+ }
143
+ function splitHeader(header) {
144
+ const parts = [];
145
+ let current = "";
146
+ let inQuotes = false;
147
+ for (const char of header) {
148
+ if (char === '"') {
149
+ inQuotes = !inQuotes;
150
+ }
151
+ if (char === "," && !inQuotes) {
152
+ parts.push(current);
153
+ current = "";
154
+ continue;
155
+ }
156
+ current += char;
157
+ }
158
+ if (current.trim()) {
159
+ parts.push(current);
160
+ }
161
+ return parts;
162
+ }
163
+ function isLinkRelation(value) {
164
+ return ["current", "next", "prev", "first", "last"].includes(value);
165
+ }
166
+
167
+ // src/core/canvas-client.ts
168
+ var CanvasClient = class {
169
+ baseUrl;
170
+ token;
171
+ fetchImpl;
172
+ constructor(options) {
173
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
174
+ this.token = options.token;
175
+ this.fetchImpl = options.fetchImpl ?? fetch;
176
+ }
177
+ async get(path2, options = {}) {
178
+ if (!path2.startsWith("/api/v1/")) {
179
+ throw new CanvasCliError("INVALID_API_PATH", "Canvas API paths must start with /api/v1/.");
180
+ }
181
+ let nextUrl = buildUrl(this.baseUrl, path2, options.query);
182
+ const pages = [];
183
+ let pagesFetched = 0;
184
+ const pageLimit = options.pageLimit ?? 50;
185
+ let hasNext = false;
186
+ while (nextUrl) {
187
+ pagesFetched += 1;
188
+ if (pagesFetched > pageLimit) {
189
+ throw new CanvasCliError(
190
+ "PAGE_LIMIT_EXCEEDED",
191
+ `Stopped after ${pageLimit} pages. Increase --page-limit if needed.`
192
+ );
193
+ }
194
+ const response = await this.fetchImpl(nextUrl, {
195
+ method: "GET",
196
+ headers: {
197
+ Authorization: `Bearer ${this.token}`,
198
+ Accept: "application/json+canvas-string-ids"
199
+ }
200
+ }).catch((error) => {
201
+ throw new CanvasCliError("CANVAS_NETWORK_ERROR", "Could not reach Canvas.", {
202
+ retryable: true,
203
+ cause: error
204
+ });
205
+ });
206
+ if (!response.ok) {
207
+ throw new CanvasCliError(
208
+ mapStatusCode(response.status),
209
+ `Canvas request failed with status ${response.status}.`,
210
+ { status: response.status, retryable: response.status === 429 || response.status >= 500 }
211
+ );
212
+ }
213
+ const data = await response.json();
214
+ pages.push(data);
215
+ const links = parseLinkHeader(response.headers.get("link"));
216
+ hasNext = Boolean(links.next);
217
+ nextUrl = options.pageAll ? links.next ?? null : null;
218
+ }
219
+ const merged = mergePages(pages);
220
+ return {
221
+ data: redactSecrets(merged),
222
+ meta: {
223
+ request: {
224
+ method: "GET",
225
+ path: path2
226
+ },
227
+ pagination: {
228
+ pagesFetched,
229
+ hasNext
230
+ }
231
+ }
232
+ };
233
+ }
234
+ };
235
+ function normalizeBaseUrl(input2) {
236
+ const trimmed = input2.trim().replace(/\/+$/, "");
237
+ const url = new URL(trimmed);
238
+ if (url.protocol !== "https:") {
239
+ throw new CanvasCliError("INVALID_BASE_URL", "Canvas base URL must use https://.");
240
+ }
241
+ if (url.pathname.includes("/api/")) {
242
+ throw new CanvasCliError("INVALID_BASE_URL", "Canvas base URL should not include /api/.");
243
+ }
244
+ url.pathname = url.pathname.replace(/\/+$/, "");
245
+ url.search = "";
246
+ url.hash = "";
247
+ return url.toString().replace(/\/+$/, "");
248
+ }
249
+ function buildUrl(baseUrl, path2, query = {}) {
250
+ const url = new URL(path2, baseUrl);
251
+ for (const [key, value] of Object.entries(query)) {
252
+ if (value === void 0) {
253
+ continue;
254
+ }
255
+ if (Array.isArray(value)) {
256
+ for (const item of value) {
257
+ url.searchParams.append(key, String(item));
258
+ }
259
+ continue;
260
+ }
261
+ url.searchParams.set(key, String(value));
262
+ }
263
+ return url.toString();
264
+ }
265
+ function mergePages(pages) {
266
+ if (pages.length === 1) {
267
+ return pages[0];
268
+ }
269
+ if (pages.every(Array.isArray)) {
270
+ return pages.flat();
271
+ }
272
+ return pages;
273
+ }
274
+ function mapStatusCode(status) {
275
+ if (status === 401) return "CANVAS_UNAUTHORIZED";
276
+ if (status === 403) return "CANVAS_FORBIDDEN";
277
+ if (status === 404) return "CANVAS_NOT_FOUND";
278
+ if (status === 429) return "CANVAS_RATE_LIMITED";
279
+ if (status >= 500) return "CANVAS_SERVER_ERROR";
280
+ return "CANVAS_REQUEST_FAILED";
281
+ }
282
+
283
+ // src/core/config-store.ts
284
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
285
+ import { dirname } from "path";
286
+
287
+ // src/core/paths.ts
288
+ import os from "os";
289
+ import path from "path";
290
+ function canvasHome() {
291
+ return process.env.CANVAS_HOME || path.join(os.homedir(), ".canvas");
292
+ }
293
+ function configPath() {
294
+ return path.join(canvasHome(), "config.json");
295
+ }
296
+
297
+ // src/core/config-store.ts
298
+ var ConfigStore = class {
299
+ constructor(filePath = configPath()) {
300
+ this.filePath = filePath;
301
+ }
302
+ filePath;
303
+ async read() {
304
+ try {
305
+ const raw = await readFile(this.filePath, "utf8");
306
+ return JSON.parse(raw);
307
+ } catch (error) {
308
+ if (isNotFound(error)) {
309
+ return null;
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+ async write(config) {
315
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
316
+ await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
317
+ `, {
318
+ encoding: "utf8",
319
+ mode: 384
320
+ });
321
+ }
322
+ async remove() {
323
+ await rm(this.filePath, { force: true });
324
+ }
325
+ async readRedacted() {
326
+ const config = await this.read();
327
+ return config ? redactSecrets(config) : null;
328
+ }
329
+ async activeProfile() {
330
+ const config = await this.read();
331
+ if (!config) {
332
+ throw new CanvasCliError("NO_AUTH_CONFIG", "No Canvas auth config found. Run canvas auth login.");
333
+ }
334
+ const profile = config.profiles[config.activeProfile];
335
+ if (!profile) {
336
+ throw new CanvasCliError(
337
+ "NO_ACTIVE_PROFILE",
338
+ `Active Canvas profile not found: ${config.activeProfile}`
339
+ );
340
+ }
341
+ return profile;
342
+ }
343
+ };
344
+ function isNotFound(error) {
345
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
346
+ }
347
+
348
+ // src/core/browser.ts
349
+ import { spawn } from "child_process";
350
+ async function openBrowser(url) {
351
+ const command = openCommand(url);
352
+ const child = spawn(command.command, command.args, {
353
+ detached: true,
354
+ stdio: "ignore"
355
+ });
356
+ child.unref();
357
+ }
358
+ function openCommand(url) {
359
+ if (process.platform === "darwin") {
360
+ return { command: "open", args: [url] };
361
+ }
362
+ if (process.platform === "win32") {
363
+ return { command: "cmd", args: ["/c", "start", "", url] };
364
+ }
365
+ return { command: "xdg-open", args: [url] };
366
+ }
367
+
368
+ // src/core/prompt.ts
369
+ import { createInterface } from "readline/promises";
370
+ import { stdin as input, stdout as output } from "process";
371
+ function createPrompt() {
372
+ return createInterface({ input, output });
373
+ }
374
+ async function promptHidden(prompt) {
375
+ if (!process.stdin.isTTY) {
376
+ const io = createPrompt();
377
+ try {
378
+ return (await io.question(prompt)).trim();
379
+ } finally {
380
+ io.close();
381
+ }
382
+ }
383
+ return new Promise((resolve, reject) => {
384
+ const stdin = process.stdin;
385
+ const onData = (char) => {
386
+ const value = char.toString("utf8");
387
+ if (value === "\n" || value === "\r" || value === "\r\n") {
388
+ stdin.setRawMode(false);
389
+ stdin.pause();
390
+ stdin.off("data", onData);
391
+ process.stdout.write("\n");
392
+ resolve(buffer.trim());
393
+ return;
394
+ }
395
+ if (value === "") {
396
+ stdin.setRawMode(false);
397
+ stdin.pause();
398
+ stdin.off("data", onData);
399
+ process.stdout.write("\n");
400
+ reject(new Error("Prompt cancelled."));
401
+ return;
402
+ }
403
+ if (value === "\x7F") {
404
+ buffer = buffer.slice(0, -1);
405
+ return;
406
+ }
407
+ buffer += value;
408
+ };
409
+ let buffer = "";
410
+ process.stdout.write(prompt);
411
+ stdin.setRawMode(true);
412
+ stdin.resume();
413
+ stdin.on("data", onData);
414
+ });
415
+ }
416
+
417
+ // src/registry/schools.ts
418
+ var SCHOOLS = [
419
+ { name: "Brown University", url: "https://canvas.brown.edu" },
420
+ { name: "Carnegie Mellon University", url: "https://canvas.cmu.edu" },
421
+ { name: "Columbia University (CourseWorks)", url: "https://courseworks2.columbia.edu" },
422
+ { name: "Cornell University", url: "https://canvas.cornell.edu" },
423
+ { name: "Dartmouth College", url: "https://canvas.dartmouth.edu" },
424
+ { name: "Duke University", url: "https://go.canvas.duke.edu" },
425
+ { name: "Emory University", url: "https://canvas.emory.edu" },
426
+ { name: "Georgetown University", url: "https://canvas.georgetown.edu" },
427
+ { name: "Georgia Institute of Technology", url: "https://canvas.gatech.edu" },
428
+ { name: "Harvard University", url: "https://canvas.harvard.edu" },
429
+ { name: "Massachusetts Institute of Technology (MIT)", url: "https://canvas.mit.edu" },
430
+ { name: "Northeastern University", url: "https://canvas.northeastern.edu" },
431
+ { name: "Northwestern University", url: "https://canvas.northwestern.edu" },
432
+ { name: "Ohio State University", url: "https://canvas.osu.edu" },
433
+ { name: "Pennsylvania State University", url: "https://canvas.psu.edu" },
434
+ { name: "Princeton University", url: "https://canvas.princeton.edu" },
435
+ { name: "Rice University", url: "https://canvas.rice.edu" },
436
+ { name: "Stanford University", url: "https://canvas.stanford.edu" },
437
+ { name: "Tufts University", url: "https://canvas.tufts.edu" },
438
+ { name: "University of California, Berkeley (bCourses)", url: "https://bcourses.berkeley.edu" },
439
+ { name: "University of California, Davis", url: "https://canvas.ucdavis.edu" },
440
+ { name: "University of California, Irvine", url: "https://canvas.eee.uci.edu" },
441
+ { name: "University of California, Los Angeles (Bruin Learn)", url: "https://bruinlearn.ucla.edu" },
442
+ { name: "University of California, San Diego", url: "https://canvas.ucsd.edu" },
443
+ { name: "University of California, Santa Barbara", url: "https://canvas.ucsb.edu" },
444
+ { name: "University of California, Santa Cruz", url: "https://canvas.ucsc.edu" },
445
+ { name: "University of Chicago", url: "https://canvas.uchicago.edu" },
446
+ { name: "University of Michigan", url: "https://canvas.umich.edu" },
447
+ { name: "University of North Carolina at Chapel Hill", url: "https://canvas.unc.edu" },
448
+ { name: "University of Notre Dame", url: "https://canvas.nd.edu" },
449
+ { name: "University of Pennsylvania", url: "https://canvas.upenn.edu" },
450
+ { name: "University of Southern California", url: "https://canvas.usc.edu" },
451
+ { name: "University of Virginia", url: "https://canvas.its.virginia.edu" },
452
+ { name: "University of Washington", url: "https://canvas.uw.edu" },
453
+ { name: "Yale University", url: "https://canvas.yale.edu" }
454
+ ];
455
+ function searchSchools(query, limit = 10) {
456
+ const normalized = query.trim().toLowerCase();
457
+ if (!normalized) {
458
+ return SCHOOLS.slice(0, limit);
459
+ }
460
+ return SCHOOLS.filter((school) => {
461
+ return school.name.toLowerCase().includes(normalized) || school.url.toLowerCase().includes(normalized);
462
+ }).slice(0, limit);
463
+ }
464
+ function makeCustomSchool(name, url) {
465
+ return {
466
+ name: name.trim() || "Custom Canvas School",
467
+ url: normalizeBaseUrl(url)
468
+ };
469
+ }
470
+
471
+ // src/workflows/context-bootstrap.ts
472
+ async function runPostLoginBootstrap() {
473
+ return {
474
+ skipped: true,
475
+ reason: "Context bootstrap is planned for Phase 4."
476
+ };
477
+ }
478
+
479
+ // src/commands/auth.ts
480
+ var TOKEN_PURPOSE = "Hyperknow";
481
+ async function handleAuthCommand(argv, options) {
482
+ const [subcommand] = argv;
483
+ if (subcommand === "login") {
484
+ return authLogin(options);
485
+ }
486
+ if (subcommand === "status") {
487
+ return authStatus(options);
488
+ }
489
+ if (subcommand === "logout") {
490
+ return authLogout(options);
491
+ }
492
+ await writeOutput(
493
+ {
494
+ ok: false,
495
+ error: {
496
+ code: "UNKNOWN_COMMAND",
497
+ message: `Unknown auth command: ${argv.join(" ")}`,
498
+ retryable: false
499
+ }
500
+ },
501
+ options
502
+ );
503
+ return 1;
504
+ }
505
+ async function authLogin(options) {
506
+ const io = createPrompt();
507
+ try {
508
+ const school = await chooseSchool(io);
509
+ const settingsUrl = `${school.url}/profile/settings`;
510
+ process.stdout.write(tokenInstructions(school, settingsUrl));
511
+ await io.question("Press Enter to open Canvas settings in your browser...");
512
+ await openBrowser(settingsUrl);
513
+ process.stdout.write("\nWaiting for your Canvas personal access token.\n");
514
+ const token = await promptHidden("Paste token: ");
515
+ if (!token) {
516
+ throw new CanvasCliError("EMPTY_TOKEN", "No token entered.");
517
+ }
518
+ const client = new CanvasClient({ baseUrl: school.url, token });
519
+ const user = await validateToken(client);
520
+ const now = (/* @__PURE__ */ new Date()).toISOString();
521
+ const config = {
522
+ version: 1,
523
+ activeProfile: "default",
524
+ profiles: {
525
+ default: {
526
+ schoolName: school.name,
527
+ baseUrl: school.url,
528
+ token,
529
+ createdAt: now,
530
+ validatedAt: now,
531
+ user
532
+ }
533
+ }
534
+ };
535
+ await new ConfigStore().write(config);
536
+ const bootstrap = await runPostLoginBootstrap();
537
+ await writeOutput(
538
+ {
539
+ ok: true,
540
+ data: {
541
+ authenticated: true,
542
+ school: {
543
+ name: school.name,
544
+ baseUrl: school.url
545
+ },
546
+ user,
547
+ contextBootstrap: bootstrap,
548
+ next: "canvas context show"
549
+ },
550
+ meta: {
551
+ command: "auth login"
552
+ }
553
+ },
554
+ options
555
+ );
556
+ return 0;
557
+ } catch (error) {
558
+ await writeOutput(toErrorEnvelope(error), options);
559
+ return 1;
560
+ } finally {
561
+ io.close();
562
+ }
563
+ }
564
+ async function authStatus(options) {
565
+ const store = new ConfigStore();
566
+ const config = await store.readRedacted();
567
+ if (!config) {
568
+ await writeOutput(
569
+ {
570
+ ok: true,
571
+ data: {
572
+ authenticated: false,
573
+ message: "No Canvas auth config found. Run canvas auth login."
574
+ },
575
+ meta: {
576
+ command: "auth status"
577
+ }
578
+ },
579
+ options
580
+ );
581
+ return 0;
582
+ }
583
+ await writeOutput(
584
+ {
585
+ ok: true,
586
+ data: {
587
+ authenticated: true,
588
+ activeProfile: config.activeProfile,
589
+ profile: config.profiles[config.activeProfile]
590
+ },
591
+ meta: {
592
+ command: "auth status"
593
+ }
594
+ },
595
+ options
596
+ );
597
+ return 0;
598
+ }
599
+ async function authLogout(options) {
600
+ await new ConfigStore().remove();
601
+ await writeOutput(
602
+ {
603
+ ok: true,
604
+ data: {
605
+ authenticated: false,
606
+ message: "Canvas auth config removed."
607
+ },
608
+ meta: {
609
+ command: "auth logout"
610
+ }
611
+ },
612
+ options
613
+ );
614
+ return 0;
615
+ }
616
+ async function chooseSchool(io, write = (message) => process.stdout.write(message)) {
617
+ write("Search for your school, or press Enter to browse the first matches.\n");
618
+ const query = await io.question("School: ");
619
+ const matches = searchSchools(query);
620
+ if (matches.length === 1) {
621
+ const school2 = matches[0];
622
+ const answer = (await io.question(`Is this your school: ${school2.name} (${school2.url})? Choose: y/n `)).trim().toLowerCase();
623
+ if (answer === "y" || answer === "yes") {
624
+ return {
625
+ name: school2.name,
626
+ url: normalizeBaseUrl(school2.url)
627
+ };
628
+ }
629
+ if (answer === "n" || answer === "no") {
630
+ return promptCustomSchool(io);
631
+ }
632
+ throw new CanvasCliError("INVALID_SELECTION", "Please answer y or n.");
633
+ }
634
+ for (const [index, school2] of matches.entries()) {
635
+ write(`${index + 1}. ${school2.name}
636
+ ${school2.url}
637
+ `);
638
+ }
639
+ write(`${matches.length + 1}. Not found? Add your own
640
+ `);
641
+ const selected = Number.parseInt(await io.question("Choose: "), 10);
642
+ if (!Number.isFinite(selected) || selected < 1 || selected > matches.length + 1) {
643
+ throw new CanvasCliError("INVALID_SELECTION", "Invalid school selection.");
644
+ }
645
+ if (selected === matches.length + 1) {
646
+ return promptCustomSchool(io);
647
+ }
648
+ const school = matches[selected - 1];
649
+ return {
650
+ name: school.name,
651
+ url: normalizeBaseUrl(school.url)
652
+ };
653
+ }
654
+ async function promptCustomSchool(io) {
655
+ const name = await io.question("School display name: ");
656
+ const url = await io.question("Canvas base URL: ");
657
+ return makeCustomSchool(name, url);
658
+ }
659
+ function tokenInstructions(school, settingsUrl) {
660
+ return `
661
+ Canvas token setup for ${school.name}
662
+
663
+ 1. Go to ${settingsUrl}
664
+ 2. Click "+ New Access Token"
665
+ 3. Enter "${TOKEN_PURPOSE}" as the purpose
666
+ 4. Optionally set an expiration date
667
+ 5. Click "Generate Token"
668
+ 6. Copy the token and paste it back here
669
+
670
+ `;
671
+ }
672
+ async function validateToken(client) {
673
+ try {
674
+ const response = await client.get(
675
+ "/api/v1/users/self/profile"
676
+ );
677
+ return {
678
+ id: response.data.id,
679
+ name: response.data.name ?? response.data.short_name
680
+ };
681
+ } catch (error) {
682
+ if (error instanceof CanvasCliError && error.status === 404) {
683
+ await client.get("/api/v1/courses");
684
+ return {};
685
+ }
686
+ throw error;
687
+ }
688
+ }
689
+
690
+ // src/commands/config.ts
691
+ async function handleConfigCommand(argv, options) {
692
+ const [subcommand] = argv;
693
+ if (subcommand !== "show") {
694
+ await writeOutput(
695
+ {
696
+ ok: false,
697
+ error: {
698
+ code: "UNKNOWN_COMMAND",
699
+ message: `Unknown config command: ${argv.join(" ")}`,
700
+ retryable: false
701
+ }
702
+ },
703
+ options
704
+ );
705
+ return 1;
706
+ }
707
+ const config = await new ConfigStore().readRedacted();
708
+ await writeOutput(
709
+ {
710
+ ok: true,
711
+ data: config ?? {
712
+ configured: false,
713
+ message: "No Canvas config found. Run canvas auth login."
714
+ },
715
+ meta: {
716
+ command: "config show"
717
+ }
718
+ },
719
+ options
720
+ );
721
+ return 0;
722
+ }
723
+
724
+ // src/commands/shared.ts
725
+ async function activeCanvas() {
726
+ const profile = await new ConfigStore().activeProfile();
727
+ return {
728
+ profile,
729
+ client: new CanvasClient({
730
+ baseUrl: profile.baseUrl,
731
+ token: profile.token
732
+ })
733
+ };
734
+ }
735
+ function flagValue(argv, flag) {
736
+ const index = argv.indexOf(flag);
737
+ if (index === -1) {
738
+ return void 0;
739
+ }
740
+ return argv[index + 1];
741
+ }
742
+ function hasFlag(argv, flag) {
743
+ return argv.includes(flag);
744
+ }
745
+ function pageOptions(argv) {
746
+ const pageLimitRaw = flagValue(argv, "--page-limit");
747
+ return {
748
+ pageAll: hasFlag(argv, "--page-all"),
749
+ pageLimit: pageLimitRaw ? Number.parseInt(pageLimitRaw, 10) : void 0
750
+ };
751
+ }
752
+ function positionalArgs(argv) {
753
+ const valueFlags = /* @__PURE__ */ new Set([
754
+ "--course-id",
755
+ "--module-id",
756
+ "--item-id",
757
+ "--assignment-id",
758
+ "--quiz-id",
759
+ "--topic-id",
760
+ "--page",
761
+ "--path",
762
+ "--out",
763
+ "--format",
764
+ "--page-limit",
765
+ "--page-size",
766
+ "--page-delay",
767
+ "--enrollment-state",
768
+ "--state",
769
+ "--include",
770
+ "--params"
771
+ ]);
772
+ const values = [];
773
+ for (let index = 0; index < argv.length; index += 1) {
774
+ const arg = argv[index];
775
+ if (arg.startsWith("--")) {
776
+ if (valueFlags.has(arg)) {
777
+ index += 1;
778
+ }
779
+ continue;
780
+ }
781
+ values.push(arg);
782
+ }
783
+ return values;
784
+ }
785
+
786
+ // src/commands/courses.ts
787
+ async function handleCoursesCommand(argv, options) {
788
+ const [subcommand] = argv;
789
+ try {
790
+ if (subcommand === "list") {
791
+ return await listCourses(argv.slice(1), options);
792
+ }
793
+ if (subcommand === "search") {
794
+ return await searchCourses(argv.slice(1), options);
795
+ }
796
+ if (subcommand === "show") {
797
+ return await showCourse(argv.slice(1), options);
798
+ }
799
+ if (subcommand === "overview") {
800
+ return await overviewCourse(argv.slice(1), options);
801
+ }
802
+ await writeOutput(
803
+ {
804
+ ok: false,
805
+ error: {
806
+ code: "UNKNOWN_COMMAND",
807
+ message: `Unknown courses command: ${argv.join(" ")}`,
808
+ retryable: false
809
+ }
810
+ },
811
+ options
812
+ );
813
+ return 1;
814
+ } catch (error) {
815
+ await writeOutput(toErrorEnvelope(error), options);
816
+ return 1;
817
+ }
818
+ }
819
+ async function listCourses(argv, options) {
820
+ const { client, profile } = await activeCanvas();
821
+ const response = await client.get("/api/v1/courses", {
822
+ query: courseListQuery(argv),
823
+ ...pageOptions(argv)
824
+ });
825
+ await writeOutput(
826
+ {
827
+ ok: true,
828
+ data: response.data.map(normalizeCourse),
829
+ meta: {
830
+ ...response.meta,
831
+ baseUrl: profile.baseUrl
832
+ }
833
+ },
834
+ options
835
+ );
836
+ return 0;
837
+ }
838
+ async function searchCourses(argv, options) {
839
+ const query = positionalArgs(argv).join(" ").trim();
840
+ const { client, profile } = await activeCanvas();
841
+ const response = await client.get("/api/v1/courses", {
842
+ query: courseListQuery(["--active"]),
843
+ pageAll: true
844
+ });
845
+ const matches = response.data.map(normalizeCourse).filter((course) => {
846
+ const haystack = `${course.name ?? ""} ${course.courseCode ?? ""}`.toLowerCase();
847
+ return haystack.includes(query.toLowerCase());
848
+ });
849
+ await writeOutput(
850
+ {
851
+ ok: true,
852
+ data: matches,
853
+ meta: {
854
+ ...response.meta,
855
+ baseUrl: profile.baseUrl,
856
+ query
857
+ }
858
+ },
859
+ options
860
+ );
861
+ return 0;
862
+ }
863
+ async function showCourse(argv, options) {
864
+ const courseId = positionalArgs(argv)[0];
865
+ if (!courseId) {
866
+ throw new Error("Usage: canvas courses show <course-id>");
867
+ }
868
+ const { client, profile } = await activeCanvas();
869
+ const response = await client.get(`/api/v1/courses/${courseId}`, {
870
+ query: {
871
+ "include[]": ["term", "course_image", "total_scores", "teachers"]
872
+ }
873
+ });
874
+ await writeOutput(
875
+ {
876
+ ok: true,
877
+ data: normalizeCourse(response.data),
878
+ meta: {
879
+ ...response.meta,
880
+ baseUrl: profile.baseUrl
881
+ }
882
+ },
883
+ options
884
+ );
885
+ return 0;
886
+ }
887
+ async function overviewCourse(argv, options) {
888
+ const courseId = positionalArgs(argv)[0];
889
+ if (!courseId) {
890
+ throw new Error("Usage: canvas courses overview <course-id>");
891
+ }
892
+ const { client, profile } = await activeCanvas();
893
+ const [course, tabs, modules, assignments] = await Promise.all([
894
+ client.get(`/api/v1/courses/${courseId}`, {
895
+ query: { "include[]": ["term", "course_image"] }
896
+ }),
897
+ client.get(
898
+ `/api/v1/courses/${courseId}/tabs`
899
+ ),
900
+ client.get(
901
+ `/api/v1/courses/${courseId}/modules`,
902
+ { query: { per_page: 100 } }
903
+ ),
904
+ client.get(
905
+ `/api/v1/courses/${courseId}/assignments`,
906
+ { query: { bucket: "upcoming", per_page: 20 } }
907
+ )
908
+ ]);
909
+ await writeOutput(
910
+ {
911
+ ok: true,
912
+ data: {
913
+ course: normalizeCourse(course.data),
914
+ tabs: tabs.data,
915
+ setup: {
916
+ hasModules: modules.data.length > 0,
917
+ hasAssignments: assignments.data.length > 0,
918
+ hasFilesTab: tabs.data.some((tab) => tab.id === "files"),
919
+ isModuleHeavy: modules.data.length > 0
920
+ },
921
+ counts: {
922
+ tabs: tabs.data.length,
923
+ modules: modules.data.length,
924
+ upcomingAssignments: assignments.data.length
925
+ },
926
+ modules: modules.data.map((module) => ({
927
+ id: module.id,
928
+ name: module.name,
929
+ position: module.position
930
+ })),
931
+ upcomingAssignments: assignments.data.map((assignment) => ({
932
+ id: assignment.id,
933
+ name: assignment.name,
934
+ dueAt: assignment.due_at
935
+ }))
936
+ },
937
+ meta: {
938
+ baseUrl: profile.baseUrl,
939
+ request: {
940
+ method: "GET",
941
+ path: `/api/v1/courses/${courseId}/overview`
942
+ }
943
+ }
944
+ },
945
+ options
946
+ );
947
+ return 0;
948
+ }
949
+ function courseListQuery(argv) {
950
+ return {
951
+ enrollment_state: hasFlag(argv, "--active") ? "active" : flagValue(argv, "--enrollment-state"),
952
+ state: flagValue(argv, "--state"),
953
+ per_page: flagValue(argv, "--page-size"),
954
+ "include[]": ["term", "total_scores"]
955
+ };
956
+ }
957
+ function normalizeCourse(course) {
958
+ return {
959
+ id: String(course.id),
960
+ name: course.name,
961
+ courseCode: course.course_code,
962
+ workflowState: course.workflow_state,
963
+ enrollmentTermId: course.enrollment_term_id,
964
+ term: course.term ? {
965
+ id: course.term.id,
966
+ name: course.term.name,
967
+ startAt: course.term.start_at,
968
+ endAt: course.term.end_at
969
+ } : void 0,
970
+ enrollments: course.enrollments?.map((enrollment) => ({
971
+ type: enrollment.type,
972
+ role: enrollment.role,
973
+ state: enrollment.enrollment_state
974
+ }))
975
+ };
976
+ }
977
+
978
+ // src/commands/me.ts
979
+ async function handleMeCommand(options) {
980
+ try {
981
+ const profile = await new ConfigStore().activeProfile();
982
+ const client = new CanvasClient({
983
+ baseUrl: profile.baseUrl,
984
+ token: profile.token
985
+ });
986
+ const response = await client.get("/api/v1/users/self/profile");
987
+ await writeOutput(
988
+ {
989
+ ok: true,
990
+ data: response.data,
991
+ meta: {
992
+ ...response.meta,
993
+ baseUrl: profile.baseUrl
994
+ }
995
+ },
996
+ options
997
+ );
998
+ return 0;
999
+ } catch (error) {
1000
+ await writeOutput(toErrorEnvelope(error), options);
1001
+ return 1;
1002
+ }
1003
+ }
1004
+
1005
+ // src/commands/tabs.ts
1006
+ async function handleTabsCommand(argv, options) {
1007
+ const [subcommand] = argv;
1008
+ if (subcommand !== "list") {
1009
+ await writeOutput(
1010
+ {
1011
+ ok: false,
1012
+ error: {
1013
+ code: "UNKNOWN_COMMAND",
1014
+ message: `Unknown tabs command: ${argv.join(" ")}`,
1015
+ retryable: false
1016
+ }
1017
+ },
1018
+ options
1019
+ );
1020
+ return 1;
1021
+ }
1022
+ try {
1023
+ const courseId = flagValue(argv, "--course-id");
1024
+ if (!courseId) {
1025
+ throw new Error("Usage: canvas tabs list --course-id <course-id>");
1026
+ }
1027
+ const { client, profile } = await activeCanvas();
1028
+ const response = await client.get(`/api/v1/courses/${courseId}/tabs`);
1029
+ await writeOutput(
1030
+ {
1031
+ ok: true,
1032
+ data: response.data.map(normalizeTab),
1033
+ meta: {
1034
+ ...response.meta,
1035
+ baseUrl: profile.baseUrl
1036
+ }
1037
+ },
1038
+ options
1039
+ );
1040
+ return 0;
1041
+ } catch (error) {
1042
+ await writeOutput(toErrorEnvelope(error), options);
1043
+ return 1;
1044
+ }
1045
+ }
1046
+ function normalizeTab(tab) {
1047
+ return {
1048
+ id: tab.id,
1049
+ label: tab.label,
1050
+ type: tab.type,
1051
+ position: tab.position,
1052
+ hidden: tab.hidden,
1053
+ visibility: tab.visibility,
1054
+ htmlUrl: tab.html_url,
1055
+ fullUrl: tab.full_url
1056
+ };
1057
+ }
1058
+
1059
+ // src/bin/canvas.ts
1060
+ var VERSION = "0.0.0";
1061
+ function helpText() {
1062
+ return `canvas \u2014 Canvas LMS CLI for students and agents.
1063
+
1064
+ USAGE:
1065
+ canvas <command> [options]
1066
+
1067
+ COMMANDS:
1068
+ auth login Interactive Canvas PAT setup
1069
+ auth status Show redacted auth status
1070
+ auth logout Remove local Canvas auth config
1071
+ config show Show redacted local config
1072
+ me Show current Canvas user profile
1073
+ context show Show cached post-login context
1074
+ courses list List active Canvas courses
1075
+ review pack Create a local course review pack
1076
+ version Print CLI version
1077
+
1078
+ FLAGS:
1079
+ -h, --help Show help
1080
+ --format <fmt> Output format: json | pretty | table | ndjson
1081
+
1082
+ MVP STATUS:
1083
+ Auth/config/me/courses/tabs are in progress. Review commands are planned next.
1084
+ `;
1085
+ }
1086
+ async function main(argv) {
1087
+ const parsed = parseGlobalOptions(argv);
1088
+ const [command] = parsed.argv;
1089
+ if (!command || command === "--help" || command === "-h" || command === "help") {
1090
+ process.stdout.write(helpText());
1091
+ return 0;
1092
+ }
1093
+ if (command === "version" || command === "--version" || command === "-v") {
1094
+ await writeOutput(
1095
+ {
1096
+ ok: true,
1097
+ data: { version: VERSION },
1098
+ meta: { command: "version" }
1099
+ },
1100
+ { format: parsed.format === "json" ? "json" : "pretty" }
1101
+ );
1102
+ return 0;
1103
+ }
1104
+ if (command === "auth") {
1105
+ return handleAuthCommand(parsed.argv.slice(1), { format: parsed.format });
1106
+ }
1107
+ if (command === "config") {
1108
+ return handleConfigCommand(parsed.argv.slice(1), { format: parsed.format });
1109
+ }
1110
+ if (command === "me") {
1111
+ return handleMeCommand({ format: parsed.format });
1112
+ }
1113
+ if (command === "courses") {
1114
+ return handleCoursesCommand(parsed.argv.slice(1), { format: parsed.format });
1115
+ }
1116
+ if (command === "tabs") {
1117
+ return handleTabsCommand(parsed.argv.slice(1), { format: parsed.format });
1118
+ }
1119
+ await writeOutput(
1120
+ {
1121
+ ok: false,
1122
+ error: {
1123
+ code: "UNKNOWN_COMMAND",
1124
+ message: `Unknown command: ${parsed.argv.join(" ")}`,
1125
+ retryable: false
1126
+ }
1127
+ },
1128
+ { format: parsed.format }
1129
+ );
1130
+ return 1;
1131
+ }
1132
+ function parseGlobalOptions(argv) {
1133
+ const nextArgv = [];
1134
+ let format = "json";
1135
+ for (let index = 0; index < argv.length; index += 1) {
1136
+ const arg = argv[index];
1137
+ if (arg === "--format") {
1138
+ const value = argv[index + 1];
1139
+ if (isOutputFormat(value)) {
1140
+ format = value;
1141
+ index += 1;
1142
+ continue;
1143
+ }
1144
+ }
1145
+ nextArgv.push(arg);
1146
+ }
1147
+ return { argv: nextArgv, format };
1148
+ }
1149
+ function isOutputFormat(value) {
1150
+ return value === "json" || value === "pretty" || value === "table" || value === "ndjson";
1151
+ }
1152
+ main(process.argv.slice(2)).then((code) => {
1153
+ process.exitCode = code;
1154
+ }).catch((error) => {
1155
+ const message = error instanceof Error ? error.message : String(error);
1156
+ process.stderr.write(`canvas: ${message}
1157
+ `);
1158
+ process.exitCode = 1;
1159
+ });
1160
+ //# sourceMappingURL=canvas.js.map