brightspace-d2l-mcp 1.0.0 โ 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +54 -5
- package/build/cli/close-window.js +58 -0
- package/build/cli/doctor.js +148 -0
- package/build/cli/main.js +6 -1
- package/build/cli/setup.js +7 -0
- package/build/cli/ui.js +19 -0
- package/build/cli/update-check.js +76 -0
- package/build/index.js +12 -3
- package/build/prompts.js +77 -0
- package/build/server.js +7 -1
- package/build/tools/export-due-dates-calendar.js +40 -0
- package/build/tools/get-announcements.js +1 -0
- package/build/tools/get-assignment-files.js +1 -0
- package/build/tools/get-assignments.js +1 -0
- package/build/tools/get-calendar-events.js +81 -0
- package/build/tools/get-classlist-emails.js +3 -1
- package/build/tools/get-course-content.js +1 -0
- package/build/tools/get-discussions.js +1 -0
- package/build/tools/get-grade-outlook.js +34 -0
- package/build/tools/get-my-courses.js +1 -0
- package/build/tools/get-my-grades.js +1 -0
- package/build/tools/get-roster.js +2 -1
- package/build/tools/get-syllabus.js +1 -0
- package/build/tools/get-todo.js +3 -1
- package/build/tools/get-upcoming-due-dates.js +4 -2
- package/build/tools/get-whats-new.js +86 -0
- package/build/tools/index.js +4 -0
- package/build/tools/schemas.js +26 -0
- package/build/utils/grade-math.js +86 -0
- package/build/utils/ics.js +66 -0
- package/llms.txt +75 -0
- package/package.json +22 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
- Update check. Running a command like `npx brightspace-d2l-mcp setup` on an older version now says which version is out and asks "Update now? (Y/n)". Yes switches to the new version and carries on with the same command. It never asks when an AI app runs the server, in CI, with `--yes`, or when `BRIGHTSPACE_NO_UPDATE_CHECK` is set.
|
|
6
|
+
- Running `npx brightspace-d2l-mcp` with no command in a terminal now shows help instead of silently starting the server. AI apps are unaffected.
|
|
7
|
+
|
|
8
|
+
## 1.3.0
|
|
9
|
+
|
|
10
|
+
- Setup closes its window when it finishes on Windows, after a 10 second countdown. Press Enter to close it straight away, type k to keep it open, or pass `--keep-open`. Every command now exits as soon as it is done, so the terminal is never left hanging.
|
|
11
|
+
- New `get_calendar_events` tool. It reads the course calendars for exams, tests, labs, classes and other events instructors post, so you can ask "when is my midterm?". Search by text and look up to 180 days ahead.
|
|
12
|
+
- New `get_whats_new` tool. One catch-up across every course: new announcements, newly marked work you have not opened, unread discussion posts and quizzes you have not tried.
|
|
13
|
+
- New prompts `catch_me_up` and `when_are_my_exams`. `weekly_digest` now includes events and newly marked work.
|
|
14
|
+
- Fixed releases not reaching npm and the MCP Registry. 1.1.0 and 1.2.0 never published because npm refused the upload; the workflow now accepts trusted publishing or an `NPM_TOKEN` secret, explains what to set up when npm refuses, and publishes to the MCP Registry right after npm.
|
|
15
|
+
- New `npm run stats` and a weekly `stats` workflow that report npm downloads, GitHub stars and traffic, registry versions, and how this server compares with other Brightspace MCP servers.
|
|
16
|
+
- Removed the unused write methods from the API client, marked every `get_` tool read only, and added a test that fails if a write request is ever added. Documented how read-only is enforced in SECURITY.md.
|
|
17
|
+
|
|
18
|
+
## 1.2.0
|
|
19
|
+
|
|
20
|
+
- New `get_grade_outlook` tool. It shows where you stand in a course, your best and worst possible final grade, and the average you need on the remaining work to reach a target grade ("what do I need on the final to get an 80?").
|
|
21
|
+
- New `export_due_dates_calendar` tool. It builds an .ics file of your unfinished deadlines that opens in Google Calendar, Apple Calendar and Outlook, with a link back to Brightspace and a one day reminder on each event.
|
|
22
|
+
- New built-in MCP prompts for apps that show prompt shortcuts: `what_should_i_do_now`, `plan_my_week`, `weekly_digest`, `grade_target`, `exam_prep`, `summarize_assignment` and `put_deadlines_in_my_calendar`.
|
|
23
|
+
- README, `llms.txt` and package keywords updated so people searching for these features can find them.
|
|
24
|
+
|
|
25
|
+
## 1.1.0
|
|
26
|
+
|
|
27
|
+
- New `doctor` command. It checks Node, your saved school, whether Brightspace is reachable, your saved sign-in, your connected AI apps and the latest npm version, and says how to fix each problem.
|
|
28
|
+
- Clearer tool descriptions so AI agents pick the right tool, for example `get_todo` vs `get_upcoming_due_dates` and `get_roster` vs `get_classlist_emails`.
|
|
29
|
+
- Downloads badge, `llms.txt` and README improvements.
|
|
30
|
+
|
|
31
|
+
## 1.0.0
|
|
32
|
+
|
|
33
|
+
- First release. 13 read-only tools for due dates with real submission status, grades, assignments, rubrics, course content, announcements, discussions, rosters and syllabi.
|
package/README.md
CHANGED
|
@@ -6,15 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="https://www.npmjs.com/package/brightspace-d2l-mcp"><img src="https://img.shields.io/npm/v/brightspace-d2l-mcp?style=flat-square&label=npm&labelColor=1b2033&color=ff6a3d" alt="npm version"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/brightspace-d2l-mcp"><img src="https://img.shields.io/npm/dm/brightspace-d2l-mcp?style=flat-square&label=downloads&labelColor=1b2033&color=ff6a3d" alt="npm downloads per month"></a>
|
|
9
10
|
<a href="https://github.com/AnonymousOnyx22/brightspace-d2l-mcp/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/AnonymousOnyx22/brightspace-d2l-mcp/ci.yml?style=flat-square&label=tests&labelColor=1b2033" alt="tests"></a>
|
|
10
11
|
<img src="https://img.shields.io/badge/node-20%2B-3ddc97?style=flat-square&labelColor=1b2033" alt="Node 20+">
|
|
11
12
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-9fb4ff?style=flat-square&labelColor=1b2033" alt="MIT license"></a>
|
|
12
13
|
</p>
|
|
13
14
|
|
|
14
|
-
<h3 align="center">Connect D2L Brightspace to Claude, ChatGPT, Cursor
|
|
15
|
+
<h3 align="center">Connect D2L Brightspace to Claude, ChatGPT, Cursor and Copilot.<br>It knows what you already handed in, so it only shows what is actually left.</h3>
|
|
15
16
|
|
|
16
17
|
<p align="center">One command. Sign in the way you always do. No passwords stored.</p>
|
|
17
18
|
|
|
19
|
+
<p align="center">By Nick, a networking student at Fleming College</p>
|
|
20
|
+
|
|
18
21
|
<br>
|
|
19
22
|
|
|
20
23
|
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that gives AI assistants and agents read access to your Brightspace courses: due dates with real submission status, grades, assignments, rubrics, course content, announcements, discussions and class lists. It works at any school that uses D2L Brightspace, including schools with Microsoft, Duo, Okta or Shibboleth single sign-on.
|
|
@@ -50,6 +53,10 @@ New to this? The **[step-by-step setup guide](docs/setup.md)** walks through eve
|
|
|
50
53
|
| ๐ **Assignment files** | "What does the rubric for the project ask for?" |
|
|
51
54
|
| ๐ฌ **Discussions** | "What are people saying in the final project thread?" |
|
|
52
55
|
| ๐ฅ **Class list** | "Who's the TA for my networking course?" |
|
|
56
|
+
| ๐ฏ **Target grade** | "What do I need on the final to get an 80?" ยท "Can I still pass?" |
|
|
57
|
+
| ๐๏ธ **Calendar** | "Put my deadlines in my calendar" |
|
|
58
|
+
| ๐ **Exams** | "When is my midterm?" ยท "What's on my schedule this week?" |
|
|
59
|
+
| ๐ **What's new** | "Catch me up" ยท "Did anything get graded?" |
|
|
53
60
|
|
|
54
61
|
### It knows what you've already handed in
|
|
55
62
|
|
|
@@ -66,7 +73,7 @@ This one checks your actual submission for every assignment:
|
|
|
66
73
|
|
|
67
74
|
## Tools
|
|
68
75
|
|
|
69
|
-
|
|
76
|
+
17 MCP tools for D2L Brightspace. Everything reads from Brightspace and nothing is ever changed there:
|
|
70
77
|
|
|
71
78
|
| Tool | What it does |
|
|
72
79
|
|---|---|
|
|
@@ -83,6 +90,15 @@ This one checks your actual submission for every assignment:
|
|
|
83
90
|
| `get_roster` | Instructors and TAs with contact info, optionally the full class list |
|
|
84
91
|
| `get_classlist_emails` | Email addresses for everyone in a course |
|
|
85
92
|
| `download_file` | Saves a course file or submission to a folder you choose |
|
|
93
|
+
| `get_grade_outlook` | Where you stand in a course and what you need on the remaining work to hit a target grade |
|
|
94
|
+
| `export_due_dates_calendar` | Builds a calendar file (.ics) of your unfinished deadlines for Google Calendar, Apple Calendar and Outlook |
|
|
95
|
+
| `get_calendar_events` | Exams, tests, labs, classes and other events from your course calendars |
|
|
96
|
+
| `get_whats_new` | Catch-up across every course: new announcements, newly marked work, unread discussions and untried quizzes |
|
|
97
|
+
|
|
98
|
+
### Built-in prompts
|
|
99
|
+
|
|
100
|
+
Apps that support MCP prompts show these as one-click shortcuts or slash commands:
|
|
101
|
+
`what_should_i_do_now` ยท `plan_my_week` ยท `weekly_digest` ยท `grade_target` ยท `exam_prep` ยท `summarize_assignment` ยท `put_deadlines_in_my_calendar` ยท `catch_me_up` ยท `when_are_my_exams`
|
|
86
102
|
|
|
87
103
|
## Works at any Brightspace school
|
|
88
104
|
|
|
@@ -95,6 +111,15 @@ Instead of guessing how your school works, setup opens a **normal browser window
|
|
|
95
111
|
|
|
96
112
|
After that it stays signed in quietly in the background. If your school ever asks you to sign in again, the browser window simply pops up and closes by itself when you're done.
|
|
97
113
|
|
|
114
|
+
## Does my school work?
|
|
115
|
+
|
|
116
|
+
If your school uses D2L Brightspace, yes. Setup opens a normal browser window so any sign-in works, including Microsoft, Duo, Okta and Shibboleth.
|
|
117
|
+
|
|
118
|
+
| School | Status |
|
|
119
|
+
|---|---|
|
|
120
|
+
| Fleming College | Tested by the author |
|
|
121
|
+
| Any other D2L school | Should work. Open an issue with yours and I will add it here |
|
|
122
|
+
|
|
98
123
|
## Works with your AI app or agent
|
|
99
124
|
|
|
100
125
|
**Connected automatically by setup:**
|
|
@@ -109,14 +134,26 @@ OpenAI Agents SDK ยท Claude Agent SDK ยท LangChain / LangGraph ยท Vercel AI SDK
|
|
|
109
134
|
It runs over **stdio** for local apps and **streamable HTTP** (`serve --http`) for ChatGPT and hosted agents.
|
|
110
135
|
See **[docs/agents.md](docs/agents.md)** for copy-paste setup for every one of these.
|
|
111
136
|
|
|
137
|
+
## Install it with your AI
|
|
138
|
+
|
|
139
|
+
Paste this into Claude Code, Cursor, Windsurf, Copilot or Codex and it will set everything up for you.
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
Install brightspace-d2l-mcp for me by following
|
|
143
|
+
https://github.com/AnonymousOnyx22/brightspace-d2l-mcp/blob/main/llms.txt
|
|
144
|
+
```
|
|
145
|
+
|
|
112
146
|
## Commands
|
|
113
147
|
|
|
114
148
|
| Command | What it does |
|
|
115
149
|
|---|---|
|
|
116
|
-
| `npx brightspace-d2l-mcp setup` | Pick your school, sign in, connect your AI apps |
|
|
150
|
+
| `npx brightspace-d2l-mcp setup` | Pick your school, sign in, connect your AI apps. On Windows the window closes by itself when done (`--keep-open` to stop that) |
|
|
117
151
|
| `npx brightspace-d2l-mcp login` | Sign in again |
|
|
118
152
|
| `npx brightspace-d2l-mcp status` | Show your school, whether you're signed in, and connected apps |
|
|
153
|
+
| `npx brightspace-d2l-mcp doctor` | Check Node, your school, sign-in and connected apps, and say how to fix anything wrong |
|
|
119
154
|
| `npx brightspace-d2l-mcp logout` | Delete the saved session |
|
|
155
|
+
|
|
156
|
+
When a newer version is out, commands ask whether to update first. Set `BRIGHTSPACE_NO_UPDATE_CHECK=1` to turn that off.
|
|
120
157
|
| `npx brightspace-d2l-mcp uninstall` | Remove from all AI apps and delete all local data |
|
|
121
158
|
| `npx brightspace-d2l-mcp serve --http` | Run over HTTP for ChatGPT and HTTP-based agents |
|
|
122
159
|
| `npx brightspace-d2l-mcp config` | Print the MCP config JSON for any other app |
|
|
@@ -138,7 +175,7 @@ App ids: `claude-desktop`, `claude-code`, `cursor`, `vscode`, `windsurf`, `codex
|
|
|
138
175
|
## Privacy and security
|
|
139
176
|
|
|
140
177
|
- **Your password is never seen or stored.** You type it into your school's own sign-in page in a real browser.
|
|
141
|
-
- **Read-only.** It can't submit, post, or change anything in Brightspace.
|
|
178
|
+
- **Read-only.** It can't submit, post, or change anything in Brightspace. No tool sends anything but GET requests, the API client has no write methods at all, and a test fails the build if one is ever added. See [how read-only is enforced](SECURITY.md#how-read-only-is-enforced).
|
|
142
179
|
- **Local only.** Everything runs on your computer and talks only to your school's Brightspace. There's no server in between, and no analytics.
|
|
143
180
|
- **Uses the official Brightspace API** with the same access you have in your browser.
|
|
144
181
|
- Your session is saved in `~/.brightspace-d2l-mcp/`, readable only by your user account. `logout` deletes it; `uninstall` deletes everything.
|
|
@@ -170,7 +207,7 @@ Claude Desktop rewrites its settings while it's open, so setup offers to restart
|
|
|
170
207
|
<summary><b>It keeps asking me to sign in</b></summary>
|
|
171
208
|
|
|
172
209
|
Some schools end sessions every few hours. When that happens a browser window opens so you can sign in again.
|
|
173
|
-
Run `npx brightspace-d2l-mcp
|
|
210
|
+
Run `npx brightspace-d2l-mcp doctor` to find out what is wrong, `status` for a quick look, or `login` to sign in right away.
|
|
174
211
|
If you'd rather it never opens a window on its own, add `"autoLogin": false` to `~/.brightspace-d2l-mcp/config.json`.
|
|
175
212
|
</details>
|
|
176
213
|
|
|
@@ -230,6 +267,18 @@ No. Brightspace API keys normally require an administrator. This uses your own s
|
|
|
230
267
|
Yes. You sign in yourself in a real browser window, so any sign-in method your school uses works.
|
|
231
268
|
</details>
|
|
232
269
|
|
|
270
|
+
<details>
|
|
271
|
+
<summary><b>Can it tell me what I need on my final to reach a grade?</b></summary>
|
|
272
|
+
|
|
273
|
+
Yes. Ask "what do I need on the final to get an 80 in Calculus?" and `get_grade_outlook` uses the weights Brightspace reports to give your current percentage, your best and worst possible final grade, and the average you need on everything left.
|
|
274
|
+
</details>
|
|
275
|
+
|
|
276
|
+
<details>
|
|
277
|
+
<summary><b>Can I get my Brightspace due dates in Google Calendar, Apple Calendar or Outlook?</b></summary>
|
|
278
|
+
|
|
279
|
+
Yes. Ask your AI to put your deadlines in your calendar. `export_due_dates_calendar` makes an .ics file with a link back to Brightspace and a one day reminder on each event, and only includes work you have not handed in.
|
|
280
|
+
</details>
|
|
281
|
+
|
|
233
282
|
<details>
|
|
234
283
|
<summary><b>Which schools does it work with?</b></summary>
|
|
235
284
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import * as ui from "./ui.js";
|
|
3
|
+
const SHELLS = new Set(["cmd.exe", "powershell.exe", "pwsh.exe"]);
|
|
4
|
+
/**
|
|
5
|
+
* The outermost shell above `start`, which owns the console window.
|
|
6
|
+
* Walks past the cmd.exe /c wrappers that npx.cmd adds, and stops at the first non-shell
|
|
7
|
+
* above it (explorer.exe, WindowsTerminal.exe, an IDE...).
|
|
8
|
+
*/
|
|
9
|
+
export function outermostShell(processes, start) {
|
|
10
|
+
let found = null;
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
for (let pid = start; processes.has(pid) && !seen.has(pid); pid = processes.get(pid).ppid) {
|
|
13
|
+
seen.add(pid);
|
|
14
|
+
const name = processes.get(pid).name.toLowerCase();
|
|
15
|
+
if (SHELLS.has(name))
|
|
16
|
+
found = pid;
|
|
17
|
+
else if (found !== null)
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
function windowsProcesses() {
|
|
23
|
+
const script = "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.Name)\" }";
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 15_000, windowsHide: true }, (error, stdout) => {
|
|
26
|
+
const map = new Map();
|
|
27
|
+
if (!error) {
|
|
28
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
29
|
+
const [pid, ppid, name] = line.split("\t");
|
|
30
|
+
if (pid && ppid && name)
|
|
31
|
+
map.set(Number(pid), { ppid: Number(ppid), name: name.trim() });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
resolve(map);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Whether setup should offer to close the window: an interactive Windows console that isn't an editor's built-in terminal. */
|
|
39
|
+
export function canCloseWindow() {
|
|
40
|
+
return process.platform === "win32" && ui.interactive && process.env.TERM_PROGRAM !== "vscode" && !process.env.BRIGHTSPACE_KEEP_OPEN;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Counts down, then closes the console window setup is running in.
|
|
44
|
+
* Enter closes it at once; typing k (keep) leaves it open.
|
|
45
|
+
*/
|
|
46
|
+
export async function closeWindowAfterCountdown(seconds = 10) {
|
|
47
|
+
const shellPromise = windowsProcesses().then((processes) => outermostShell(processes, process.pid));
|
|
48
|
+
const answer = await ui.askWithTimeout(ui.dim(`This window closes in ${seconds} seconds. Press Enter to close it now, or type k and Enter to keep it open.`), seconds * 1000);
|
|
49
|
+
if (answer?.toLowerCase().startsWith("k"))
|
|
50
|
+
return;
|
|
51
|
+
const shell = await shellPromise;
|
|
52
|
+
if (shell === null)
|
|
53
|
+
return;
|
|
54
|
+
// taskkill /T ends the shell and everything under it, this process included, which closes the window.
|
|
55
|
+
spawn("taskkill", ["/PID", String(shell), "/T", "/F"], { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=close-window.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { loadConfig } from "../utils/config.js";
|
|
2
|
+
import { configPath, dataDir, PACKAGE_NAME, SETUP_COMMAND } from "../utils/paths.js";
|
|
3
|
+
import { packageVersion } from "../utils/version.js";
|
|
4
|
+
import { TokenManager } from "../auth/index.js";
|
|
5
|
+
import { detectClients, isConfiguredIn } from "./clients.js";
|
|
6
|
+
import * as ui from "./ui.js";
|
|
7
|
+
const MIN_NODE_MAJOR = 20;
|
|
8
|
+
const NETWORK_TIMEOUT_MS = 8000;
|
|
9
|
+
export function checkNodeVersion(version = process.versions.node) {
|
|
10
|
+
const major = parseInt(version.split(".")[0] ?? "0", 10);
|
|
11
|
+
if (major >= MIN_NODE_MAJOR)
|
|
12
|
+
return { level: "ok", message: `Node.js ${version}` };
|
|
13
|
+
return {
|
|
14
|
+
level: "fail",
|
|
15
|
+
message: `Node.js ${version} is too old (need ${MIN_NODE_MAJOR} or newer)`,
|
|
16
|
+
fix: "Install the current LTS from https://nodejs.org and open a new terminal.",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** True when `latest` is a higher dotted version than `current`. Ignores pre-release tags. */
|
|
20
|
+
export function isNewerVersion(latest, current) {
|
|
21
|
+
const parse = (v) => v.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
22
|
+
const a = parse(latest);
|
|
23
|
+
const b = parse(current);
|
|
24
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
25
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
26
|
+
if (diff !== 0)
|
|
27
|
+
return diff > 0;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
32
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS) });
|
|
33
|
+
}
|
|
34
|
+
export function checkSchool(baseUrl) {
|
|
35
|
+
if (baseUrl)
|
|
36
|
+
return { level: "ok", message: `School: ${baseUrl}` };
|
|
37
|
+
return { level: "fail", message: "No school saved yet", fix: `Run: ${SETUP_COMMAND}` };
|
|
38
|
+
}
|
|
39
|
+
export async function checkConnectivity(baseUrl) {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetchWithTimeout(baseUrl, { method: "HEAD", redirect: "follow" });
|
|
42
|
+
// Any HTTP answer means the site is reachable. Sign-in redirects and 401s are normal.
|
|
43
|
+
return { level: "ok", message: `Brightspace is reachable (HTTP ${response.status})` };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return {
|
|
47
|
+
level: "fail",
|
|
48
|
+
message: `Can't reach ${baseUrl}`,
|
|
49
|
+
fix: "Check your internet, VPN or campus network. Some schools only allow on-campus access.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function checkSession(baseUrl, tokenTtl) {
|
|
54
|
+
try {
|
|
55
|
+
const token = await new TokenManager({ baseUrl, tokenTtl }).getToken();
|
|
56
|
+
if (token)
|
|
57
|
+
return { level: "ok", message: "Saved sign-in is valid" };
|
|
58
|
+
return {
|
|
59
|
+
level: "warn",
|
|
60
|
+
message: "Saved sign-in has expired",
|
|
61
|
+
fix: "It reopens sign-in on your next question. To do it now, run: npx -y brightspace-d2l-mcp@latest login",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return {
|
|
66
|
+
level: "warn",
|
|
67
|
+
message: "Couldn't read the saved sign-in",
|
|
68
|
+
fix: `Run: npx -y brightspace-d2l-mcp@latest login${error instanceof Error ? ` (${error.message})` : ""}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function checkLatestVersion(current) {
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetchWithTimeout(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
throw new Error(String(response.status));
|
|
77
|
+
const { version } = (await response.json());
|
|
78
|
+
if (version && isNewerVersion(version, current)) {
|
|
79
|
+
return {
|
|
80
|
+
level: "warn",
|
|
81
|
+
message: `Version ${current} installed, ${version} is available`,
|
|
82
|
+
fix: `Using npx with @latest updates itself. Otherwise run: npm install -g ${PACKAGE_NAME}@latest`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { level: "ok", message: `Version ${current} is up to date` };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { level: "warn", message: `Version ${current} (couldn't check for updates)` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checkClients() {
|
|
92
|
+
const clients = detectClients();
|
|
93
|
+
if (!clients.length) {
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
level: "warn",
|
|
97
|
+
message: "No supported AI apps found on this computer",
|
|
98
|
+
fix: `Run ${SETUP_COMMAND} again once one is installed, or use: npx -y ${PACKAGE_NAME}@latest config`,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
return clients.map((client) => isConfiguredIn(client)
|
|
103
|
+
? { level: "ok", message: `${client.name} is connected` }
|
|
104
|
+
: { level: "warn", message: `${client.name} isn't connected`, fix: `Run: ${SETUP_COMMAND}` });
|
|
105
|
+
}
|
|
106
|
+
function print(result) {
|
|
107
|
+
if (result.level === "ok")
|
|
108
|
+
ui.ok(result.message);
|
|
109
|
+
else if (result.level === "warn")
|
|
110
|
+
ui.warn(result.message);
|
|
111
|
+
else
|
|
112
|
+
ui.fail(result.message);
|
|
113
|
+
if (result.fix && result.level !== "ok")
|
|
114
|
+
ui.info(ui.dim(` ${result.fix}`));
|
|
115
|
+
}
|
|
116
|
+
/** Runs every check, prints the result and returns 1 if anything failed outright. */
|
|
117
|
+
export async function runDoctor() {
|
|
118
|
+
const config = loadConfig();
|
|
119
|
+
const results = [];
|
|
120
|
+
const add = (...items) => {
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
results.push(item);
|
|
123
|
+
print(item);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
console.log(`\n${ui.bold("brightspace-d2l-mcp doctor")} ${ui.dim(`v${packageVersion()}`)}\n`);
|
|
127
|
+
add(checkNodeVersion());
|
|
128
|
+
add(checkSchool(config.baseUrl));
|
|
129
|
+
if (config.baseUrl) {
|
|
130
|
+
add(await checkConnectivity(config.baseUrl));
|
|
131
|
+
add(await checkSession(config.baseUrl, config.tokenTtl));
|
|
132
|
+
}
|
|
133
|
+
add(...checkClients());
|
|
134
|
+
add(await checkLatestVersion(packageVersion()));
|
|
135
|
+
const failures = results.filter((r) => r.level === "fail").length;
|
|
136
|
+
const warnings = results.filter((r) => r.level === "warn").length;
|
|
137
|
+
console.log();
|
|
138
|
+
if (failures)
|
|
139
|
+
ui.fail(`${failures} problem${failures === 1 ? "" : "s"} found. Fix the first one above and run doctor again.`);
|
|
140
|
+
else if (warnings)
|
|
141
|
+
ui.warn(`No blocking problems, ${warnings} thing${warnings === 1 ? "" : "s"} worth a look.`);
|
|
142
|
+
else
|
|
143
|
+
ui.ok("Everything looks good.");
|
|
144
|
+
ui.info(ui.dim(`Config: ${configPath()} Data: ${dataDir()}`));
|
|
145
|
+
console.log();
|
|
146
|
+
return failures ? 1 : 0;
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=doctor.js.map
|
package/build/cli/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { packageVersion } from "../utils/version.js";
|
|
|
5
5
|
import { loginWithBrowser, LoginError, SessionStore, TokenManager } from "../auth/index.js";
|
|
6
6
|
import { APP_IDS, detectClients, isConfiguredIn, removeFromClient, serverEntry } from "./clients.js";
|
|
7
7
|
import { runSetup, printManualConfig } from "./setup.js";
|
|
8
|
+
import { runDoctor } from "./doctor.js";
|
|
8
9
|
import * as ui from "./ui.js";
|
|
9
10
|
function flagValue(args, name) {
|
|
10
11
|
const index = args.indexOf(name);
|
|
@@ -21,11 +22,14 @@ export async function runCli(command, args) {
|
|
|
21
22
|
yes: args.includes("--yes") || args.includes("-y"),
|
|
22
23
|
skipLogin: args.includes("--skip-login"),
|
|
23
24
|
apps: flagValue(args, "--apps")?.split(",").map((s) => s.trim()),
|
|
25
|
+
keepOpen: args.includes("--keep-open"),
|
|
24
26
|
});
|
|
25
27
|
case "login":
|
|
26
28
|
return login();
|
|
27
29
|
case "status":
|
|
28
30
|
return status();
|
|
31
|
+
case "doctor":
|
|
32
|
+
return runDoctor();
|
|
29
33
|
case "logout":
|
|
30
34
|
await new SessionStore().clear();
|
|
31
35
|
fs.rmSync(browserProfileDir(), { recursive: true, force: true });
|
|
@@ -148,13 +152,14 @@ ${ui.bold("brightspace-d2l-mcp")} ${ui.dim(`v${packageVersion()}`)} Brightspace
|
|
|
148
152
|
${ui.cyan("setup")} Pick your school, sign in, and connect your AI apps
|
|
149
153
|
${ui.cyan("login")} Sign in again
|
|
150
154
|
${ui.cyan("status")} Show your school, session and connected apps
|
|
155
|
+
${ui.cyan("doctor")} Check Node, your school, sign-in and AI apps, and say how to fix problems
|
|
151
156
|
${ui.cyan("logout")} Delete the saved session
|
|
152
157
|
${ui.cyan("uninstall")} Remove from AI apps and delete all local data
|
|
153
158
|
${ui.cyan("serve")} Run the MCP server over stdio (what AI apps launch)
|
|
154
159
|
${ui.cyan("serve --http")} Run over HTTP for ChatGPT and HTTP agents [--port 8787] [--host 127.0.0.1] [--no-auth]
|
|
155
160
|
${ui.cyan("config")} Print the MCP config JSON for any other app
|
|
156
161
|
|
|
157
|
-
Setup options: --school <address> --yes --skip-login --apps ${APP_IDS.join(",")}
|
|
162
|
+
Setup options: --school <address> --yes --skip-login --keep-open --apps ${APP_IDS.join(",")}
|
|
158
163
|
`);
|
|
159
164
|
printManualConfig();
|
|
160
165
|
console.log();
|
package/build/cli/setup.js
CHANGED
|
@@ -4,8 +4,15 @@ import { loginWithBrowser, LoginError, NoBrowserError, TokenManager } from "../a
|
|
|
4
4
|
import { SERVER_NAME } from "../utils/paths.js";
|
|
5
5
|
import { resolveSchool } from "./school.js";
|
|
6
6
|
import { addToClient, allowToolsInClaudeCode, detectClients, isClaudeDesktopRunning, quitClaudeDesktop, serverEntry, } from "./clients.js";
|
|
7
|
+
import { canCloseWindow, closeWindowAfterCountdown } from "./close-window.js";
|
|
7
8
|
import * as ui from "./ui.js";
|
|
8
9
|
export async function runSetup(flags) {
|
|
10
|
+
const code = await setupSteps(flags);
|
|
11
|
+
if (code === 0 && !flags.keepOpen && canCloseWindow())
|
|
12
|
+
await closeWindowAfterCountdown();
|
|
13
|
+
return code;
|
|
14
|
+
}
|
|
15
|
+
async function setupSteps(flags) {
|
|
9
16
|
if (flags.yes)
|
|
10
17
|
ui.setNonInteractive();
|
|
11
18
|
console.log(`\n${ui.bold("Brightspace for your AI apps")} ${ui.dim("ยท setup takes about a minute")}`);
|
package/build/cli/ui.js
CHANGED
|
@@ -28,6 +28,25 @@ export async function ask(question, fallback = "") {
|
|
|
28
28
|
rl.close();
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/** Like ask, but resolves with null if nothing is entered within timeoutMs. */
|
|
32
|
+
export async function askWithTimeout(question, timeoutMs) {
|
|
33
|
+
if (!interactive)
|
|
34
|
+
return null;
|
|
35
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
38
|
+
try {
|
|
39
|
+
return (await rl.question(` ${question} `, { signal: controller.signal })).trim();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
console.log();
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
rl.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
31
50
|
export async function confirm(question, defaultYes = true) {
|
|
32
51
|
if (!interactive)
|
|
33
52
|
return defaultYes;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { PACKAGE_NAME } from "../utils/paths.js";
|
|
4
|
+
import { packageVersion } from "../utils/version.js";
|
|
5
|
+
import { isNewerVersion } from "./doctor.js";
|
|
6
|
+
import * as ui from "./ui.js";
|
|
7
|
+
const CHECK_TIMEOUT_MS = 1500;
|
|
8
|
+
/** Set on the relaunched process so it never asks again. */
|
|
9
|
+
const RELAUNCHED_ENV = "BRIGHTSPACE_UPDATED";
|
|
10
|
+
/** Commands that never prompt: the MCP server itself, and output meant for scripts. */
|
|
11
|
+
const SKIP_COMMANDS = new Set(["serve", "config", "--version", "-v"]);
|
|
12
|
+
export function shouldCheckForUpdate(command, args, env = process.env, tty = process.stdin.isTTY === true && process.stdout.isTTY === true) {
|
|
13
|
+
if (!tty || SKIP_COMMANDS.has(command))
|
|
14
|
+
return false;
|
|
15
|
+
if (args.includes("--yes") || args.includes("-y"))
|
|
16
|
+
return false;
|
|
17
|
+
return !env.CI && !env.BRIGHTSPACE_NO_UPDATE_CHECK && !env[RELAUNCHED_ENV];
|
|
18
|
+
}
|
|
19
|
+
async function latestVersion() {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
return null;
|
|
24
|
+
const { version } = (await response.json());
|
|
25
|
+
return typeof version === "string" ? version : null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function quoteForCmd(arg) {
|
|
32
|
+
return /[\s"&|<>^]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
33
|
+
}
|
|
34
|
+
function relaunch(version, command, args) {
|
|
35
|
+
const windows = process.platform === "win32";
|
|
36
|
+
const argv = ["-y", `${PACKAGE_NAME}@${version}`, command, ...args];
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn("npx", windows ? argv.map(quoteForCmd) : argv, {
|
|
39
|
+
stdio: "inherit",
|
|
40
|
+
shell: windows,
|
|
41
|
+
// Run outside the current folder, so a project that itself depends on this package can't shadow the new version.
|
|
42
|
+
cwd: homedir(),
|
|
43
|
+
env: { ...process.env, [RELAUNCHED_ENV]: "1" },
|
|
44
|
+
});
|
|
45
|
+
child.on("error", () => resolve(-1));
|
|
46
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Before an interactive command, offers to switch to a newer release.
|
|
51
|
+
* Returns the exit code of the updated run, or null to carry on with this version.
|
|
52
|
+
*/
|
|
53
|
+
export async function offerUpdate(command, args) {
|
|
54
|
+
if (!shouldCheckForUpdate(command, args))
|
|
55
|
+
return null;
|
|
56
|
+
const current = packageVersion();
|
|
57
|
+
const latest = await latestVersion();
|
|
58
|
+
if (!latest || !isNewerVersion(latest, current))
|
|
59
|
+
return null;
|
|
60
|
+
console.log(`\n ${ui.yellow("โ")} ${ui.bold(`brightspace-d2l-mcp v${latest}`)} is out ${ui.dim(`(you have v${current})`)}`);
|
|
61
|
+
if (!(await ui.confirm("Update now?"))) {
|
|
62
|
+
ui.info(ui.dim(`Skipped. Run npx -y ${PACKAGE_NAME}@latest ${command} any time to update.`));
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
ui.info(ui.dim(`Downloading v${latest}...`));
|
|
66
|
+
const code = await relaunch(latest, command, args);
|
|
67
|
+
if (code === -1) {
|
|
68
|
+
ui.warn(`Couldn't start the update. Carrying on with v${current}.`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!process.argv[1]?.includes("_npx")) {
|
|
72
|
+
ui.info(ui.dim(`To update your installed copy too, run: npm install -g ${PACKAGE_NAME}@latest`));
|
|
73
|
+
}
|
|
74
|
+
return code;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=update-check.js.map
|
package/build/index.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const CLI_COMMANDS = new Set(["setup", "login", "status", "logout", "uninstall", "serve", "config", "help", "--help", "-h", "--version", "-v"]);
|
|
3
|
-
|
|
2
|
+
const CLI_COMMANDS = new Set(["setup", "login", "status", "doctor", "logout", "uninstall", "serve", "config", "help", "--help", "-h", "--version", "-v"]);
|
|
3
|
+
// AI apps launch the server with piped stdio. A person typing the bare command in a terminal gets help instead of a silent server.
|
|
4
|
+
const command = process.argv[2] ?? (process.stdin.isTTY ? "help" : undefined);
|
|
4
5
|
if (command && CLI_COMMANDS.has(command)) {
|
|
5
6
|
const { runCli } = await import("./cli/main.js");
|
|
6
|
-
|
|
7
|
+
const { offerUpdate } = await import("./cli/update-check.js");
|
|
8
|
+
const args = process.argv.slice(3);
|
|
9
|
+
const code = (await offerUpdate(command, args)) ?? (await runCli(command, args));
|
|
10
|
+
// Exit as soon as a command finishes. A browser or timer left behind would otherwise keep the terminal busy.
|
|
11
|
+
// `serve` only returns once its stdio server is running, so it keeps the process alive.
|
|
12
|
+
if (command === "serve")
|
|
13
|
+
process.exitCode = code;
|
|
14
|
+
else
|
|
15
|
+
process.stdout.write("", () => process.exit(code));
|
|
7
16
|
}
|
|
8
17
|
else {
|
|
9
18
|
const { startServer } = await import("./server.js");
|
package/build/prompts.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
function userMessage(text) {
|
|
3
|
+
return { messages: [{ role: "user", content: { type: "text", text } }] };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Ready-made starting points that show up as slash commands or prompt pickers in apps that support MCP prompts.
|
|
7
|
+
* Each one just tells the AI which tools to use and what a good answer looks like.
|
|
8
|
+
*/
|
|
9
|
+
export function registerPrompts(server) {
|
|
10
|
+
server.registerPrompt("what_should_i_do_now", {
|
|
11
|
+
title: "What should I work on right now?",
|
|
12
|
+
description: "Prioritized list of what is left to do, soonest deadline first, skipping anything already handed in.",
|
|
13
|
+
}, () => userMessage("Call get_todo and tell me what to work on right now. Start with what is due soonest, mention anything overdue " +
|
|
14
|
+
"that can still be submitted, and skip everything I already handed in. Keep it short, with due dates in my local time."));
|
|
15
|
+
server.registerPrompt("plan_my_week", {
|
|
16
|
+
title: "Plan my week",
|
|
17
|
+
description: "Turns the next seven days of deadlines into a day by day plan.",
|
|
18
|
+
}, () => userMessage("Call get_todo with daysAhead 7 and build me a day by day plan for this week. Put the heaviest or earliest work first, " +
|
|
19
|
+
"spread the rest so no day is overloaded, and flag anything that opens late or needs a quiz check. " +
|
|
20
|
+
"Only include work I have not submitted."));
|
|
21
|
+
server.registerPrompt("weekly_digest", {
|
|
22
|
+
title: "Weekly digest",
|
|
23
|
+
description: "Deadlines, new announcements and recent grades in one summary.",
|
|
24
|
+
}, () => userMessage("Give me a weekly digest. Use get_todo for what is due, get_whats_new for announcements and newly marked work from the last 7 days, " +
|
|
25
|
+
"get_calendar_events for exams and events this week, and get_my_grades for the marks themselves. " +
|
|
26
|
+
"Group it as Due, Events, News and Grades, and keep each part to a few lines."));
|
|
27
|
+
server.registerPrompt("grade_target", {
|
|
28
|
+
title: "What do I need to hit my target grade?",
|
|
29
|
+
description: "Works out the score needed on the remaining work in a course to reach a target.",
|
|
30
|
+
argsSchema: {
|
|
31
|
+
course: z.string().optional().describe("Course name or code"),
|
|
32
|
+
target: z.string().optional().describe("Target grade as a percentage, for example 80"),
|
|
33
|
+
},
|
|
34
|
+
}, ({ course, target }) => userMessage(`Use get_my_courses to find ${course ? `my course "${course}"` : "the course I mean (ask me if it is unclear)"}, ` +
|
|
35
|
+
`then call get_grade_outlook${target ? ` with targetPercent ${target}` : ""}. ` +
|
|
36
|
+
"Tell me where I stand now, the best and worst final grade I could still get, and the average I need on what is left. " +
|
|
37
|
+
"If the course has no weights, read get_syllabus to find them."));
|
|
38
|
+
server.registerPrompt("exam_prep", {
|
|
39
|
+
title: "Prepare for an exam",
|
|
40
|
+
description: "Pulls together the syllabus, course content and announcements for one course to build a study plan.",
|
|
41
|
+
argsSchema: {
|
|
42
|
+
course: z.string().optional().describe("Course name or code"),
|
|
43
|
+
examDate: z.string().optional().describe("When the exam is, for example 'next Friday'"),
|
|
44
|
+
},
|
|
45
|
+
}, ({ course, examDate }) => userMessage(`Help me prepare for an exam in ${course ? `"${course}"` : "my course (ask which one if unclear)"}${examDate ? `, ${examDate}` : ""}. ` +
|
|
46
|
+
"Find the course with get_my_courses, then read get_syllabus, get_course_content and get_announcements for what the exam covers. " +
|
|
47
|
+
"Build a study plan that works back from the exam date, lists the topics and the materials to review for each, " +
|
|
48
|
+
"and checks get_todo so the plan does not clash with other deadlines."));
|
|
49
|
+
server.registerPrompt("summarize_assignment", {
|
|
50
|
+
title: "Summarize an assignment",
|
|
51
|
+
description: "Reads the instructions and rubric for an assignment and explains what is actually being asked.",
|
|
52
|
+
argsSchema: {
|
|
53
|
+
assignment: z.string().optional().describe("Assignment name"),
|
|
54
|
+
course: z.string().optional().describe("Course name or code"),
|
|
55
|
+
},
|
|
56
|
+
}, ({ assignment, course }) => userMessage(`Explain ${assignment ? `the assignment "${assignment}"` : "my next assignment"}${course ? ` in "${course}"` : ""}. ` +
|
|
57
|
+
"Use get_assignments to find it, then get_assignment_files to read the instructions and rubric. " +
|
|
58
|
+
"Give me the deliverables, the deadline, how it is marked and a short checklist to work through."));
|
|
59
|
+
server.registerPrompt("put_deadlines_in_my_calendar", {
|
|
60
|
+
title: "Put my deadlines in my calendar",
|
|
61
|
+
description: "Exports upcoming unfinished work as a calendar file.",
|
|
62
|
+
}, () => userMessage("Call export_due_dates_calendar for the next 30 days and give me the .ics file so I can add my deadlines to my calendar. " +
|
|
63
|
+
"If you can create calendar events directly with another tool, offer that instead."));
|
|
64
|
+
server.registerPrompt("catch_me_up", {
|
|
65
|
+
title: "Catch me up",
|
|
66
|
+
description: "What changed since you last looked: new announcements, newly marked work, unread discussions.",
|
|
67
|
+
}, () => userMessage("Call get_whats_new and catch me up. Lead with newly marked work (unread feedback) and use get_my_grades to tell me the marks, " +
|
|
68
|
+
"then summarize new announcements in a line each, then mention unread discussions and quizzes I have not tried. " +
|
|
69
|
+
"Skip courses with nothing new."));
|
|
70
|
+
server.registerPrompt("when_are_my_exams", {
|
|
71
|
+
title: "When are my exams?",
|
|
72
|
+
description: "Finds exams, tests and midterms on the course calendars and in announcements.",
|
|
73
|
+
}, () => userMessage("Find my upcoming exams, tests, midterms and finals. Call get_calendar_events with daysAhead 120, then check " +
|
|
74
|
+
"get_announcements and get_syllabus for any exam dates that are not on the calendar. List each with course, date, time and location, " +
|
|
75
|
+
"soonest first, and say where you found it."));
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=prompts.js.map
|