CLI tools for CBE / D2L / Brightspace. Written in Go on top of the Charmbracelet stack (cobra, bubbletea, lipgloss). Headless: no browser, no interactive MFA prompt.
schooltools is a headless command-line client for the Calgary Board of Education's D2L / Brightspace LMS. Six surfaces cover the day-to-day student workflow:
coursediscover enrolled coursescontentbrowse a course's modules and topicsdownloadpull a topic's file or metadataarchivesnapshot every course to disk on a scheduleassignmentdropbox folders, submissions, feedbackquizquiz list and attempts
Everything else (news, grade, discussion, calendar, due,
overdue, update, feed, award) is
implemented but rarely useful from a CLI — see
Advanced / rarely used below. If you're not
sure which command you need, start with the six above.
go install .-
Create a
.envin your working directory:CBE_EMAIL=you@example.com CBE_PASSWORD=... -
schooltools login(KMSI defaults on). -
Pick a course to start with:
schooltools course list schooltools content --course 1527886 # browse the TOC schooltools content --course 1527886 get 19278283 # one topic's metadata schooltools download --course 1527886 19278283 # download that topic's file schooltools archive # snapshot every course
These are the six surfaces a student opens the CLI for. They get the README spotlight; everything else lives further down.
| Command | Purpose |
|---|---|
course / enrollment |
List courses (course list) or show a course's detail (course get <orgUnitId>). |
content |
Show a course's modules + topics. Scope with --course <id>; subcommand get <topicId> for one topic. |
download |
Save a single topic's file (or metadata JSON). Scope with --course <id>; positional is the topicId alone. |
archive |
Snapshot courses into a versioned, UUID-named blob store. Subcommand list for the saved index. |
assignment / dropbox |
List folders, show my submissions, download files. Scope with --course <id>. |
quiz |
List quizzes and show my attempts. Scope with --course <id>. |
The CLI follows the huly convention: singular nouns at top level, scope
via flags, no me parent.
--course <orgUnitId> scope to one course
--org CSV scope to many courses
--from X --to Y ISO 8601 date or datetime, UTC
--window 1d|7d|30d relative duration (alias for --from now-…)
--event-type N calendar enum (1=Assignment, 2=Quiz, …)
-e, --env-file FILE .env path (default ".env")
--auto-refresh re-run login on expired session
--no-spinner suppress progress UI
-v, --verbose extra stderr noise
--all paginate fully
--limit N cap items returned
--json machine-readable output
--yes skip confirmation on bulk destructive ops
Persistent root flags: --no-auth-log, --version, -h/--help.
~/.config/schooltools/session.json holds the persisted cookies. Schema:
[
{ "Name": "d2lSessionVal", "Value": "...", "Domain": ".cbe.ab.ca",
"Path": "/", "Expires": "2026-09-02T23:00:00Z",
"Secure": true, "HttpOnly": true, "SameSite": 3 }
]session shows cookie expiry + the captured Brightspace token;
token shows just the token; whoami verifies the session live.
~/.config/schooltools/auth.log is a JSONL file append-only recording
login events, auto-refresh events, and logout events. Used to measure
session lifetimes and KMSI effectiveness over time. Opt out with
--no-auth-log or SCHOOLTOOLS_NO_AUTH_LOG=1. File mode 0o600,
directory 0o700.
schooltools download --course <id> <topicId> fetches a single D2L
topic and writes it under your home directory (override with --out):
schooltools download --course 1527886 19278283For a File topic the underlying file is downloaded with the server-reported filename. Example output:
Downloaded: /home/you/outline.pdf (823.45 KiB)
course: 1527886
topic: 19278283 — Course Outline
source: /content/enforced/1527886/outline.pdf
For a Link (or any hosted-HTML) topic the topic metadata JSON is
written instead (~/topic-<courseId>-<topicId>.json by default) and
the link URL is printed.
Flags:
--course <orgUnitId>— course scope (required)<topicId>— positional topic id--out DIR— output directory (default~/)--name NAME— override the output filename--no-clobber— refuse to overwrite an existing file--json— print the topic metadata JSON to stdout instead of writing a file--auto-refresh— re-runloginif the saved session is expired
The legacy positional form download <courseId>/topics/<topicId> still
works, prints a deprecation notice, and dispatches to the new form.
schooltools archive snapshots your D2L courses into
~/.config/schooltools/archive/ (override with --dir). The store is
content-addressed by random UUID and versioned: every blob's
filename is just a 128-bit random hex string, and every topic keeps the
full history of versions we have ever saved for it.
~/.config/schooltools/archive/
index.json # global manifest: courses + per-course counters
blobs/<uuid>.json # every document blob, flat, named only by UUID
courses/<courseId>/
index.json # per-course: topicId -> { title, lastModified,
# current: <uuid>, versions: [...] }
By default the pass covers every course returned by the manageCourses API. Pass one or more OrgUnitIds as positional arguments to restrict the pass:
schooltools archive # archive everything
schooltools archive 123456 # archive just one course
schooltools archive 123456 234567 345678 # archive a specific set
schooltools archive --diff 123456 # preview without writingEach pass is incremental and append-only at the blob layer: topics
whose LastModifiedDate has not moved are skipped; new or modified
topics get a brand-new blob under blobs/<uuid>.json. The old blob is
left on disk untouched and the topic's versions history grows. Nothing
is ever overwritten or deleted by archive.
schooltools archive list shows the saved index without making any API
requests. Flags: --dir, --json.
schooltools prune walks every courses/<id>/index.json, builds the
set of "live" UUIDs (the union of every topic's current pointer), and
deletes every other file in blobs/. Old versions and unreferenced
blobs go away; each topic's latest document stays exactly where
archive left it. The per-course index is not modified — it still
records the full version history, even after the blobs have been
reclaimed.
schooltools prune # delete old versions
schooltools prune --dry-run # report what would be deleted
schooltools prune --json # machine-readable summary
schooltools prune --dir /other # prune a non-default archive rootTypical use: run archive on a schedule (e.g. nightly via cron), and
run prune after a longer interval (e.g. weekly) to reclaim disk.
The commands in this section are complete and tested, but for most users they are not part of the day-to-day workflow — the D2L web UI covers them better. Listed here for completeness and for users who specifically need scriptable access.
| Command | Endpoint family | Notes |
|---|---|---|
calendar [--from X --to Y] [--event-type N] [--org CSV] |
/calendar/events/myEvents/ |
Date window required (default: 30 days centred on today). |
due [--window 7d] [--from X --to Y] [--org CSV] |
/content/myItems/due/ |
Items with upcoming due dates. |
overdue [--org CSV] |
/overdueItems/myItems |
Items past their due date. |
update [--org CSV] |
/updates/myUpdates/ |
Per-course unread message / unread grade counts. |
feed [--since X] [--until Y] |
/d2l/api/lp/<lp>/feed/ |
Aggregated activity feed across all tools. |
news [--since X] |
/d2l/api/le/<le>/news/user/me/ |
Cross-course news (omit --course). |
grade |
/d2l/api/le/<le>/grades/final/values/myGradeValues/ |
Cross-course finals, capped at 100. |
| Command | Endpoint family | Notes |
|---|---|---|
news [--course <id>] [get <newsId>] [attachment <newsId> <fileId>] |
/news/, /news/<id>/attachments/<fileId> |
Course announcements. |
grade [--course <id>] [--final] [get <gradeObjectId>] |
/grades/values/myGradeValues/, /grades/final/values/myGradeValue |
Numeric grades. |
discussion [--course <id>] [forum <forumId>] [post <topicId>] |
/discussions/forums/, /posts/ |
Page-number pagination, per D2L research §2.2. |
| Command | Endpoint family | Notes |
|---|---|---|
award |
/d2l/api/bas/<v>/issued/users/me/ |
List issued. |
award get <id> |
same | One. |
award download <certificateId> |
/d2l/api/bas/<v>/issued/certificates/<id>/pdf |
PDF stream. |
Removed surfaces (kept here as record):
lockerandeportfolio. The CBE tenant ships an empty locker for students and disables the ePortfolio product (/d2l/api/eP/<v>/returns 403 Forbidden). The code is removed; the endpoints stay listed inD2L_API_RESEARCH.mdfor tenants that do enable them.
Each of these has full --help text with endpoint details.
The CLI was previously TypeScript + commander + cheerio + tough-cookie;
the Go port at 0.1.0 replaced those with cobra, goquery, and
net/http/cookiejar. The 0.2.0 redesign adds D2L Valence feature
parity (see .kilo/plans/cli-feature-parity-redesign.md). Breaking
changes:
| Old | New |
|---|---|
schooltools content 1527886 |
schooltools content --course 1527886 |
schooltools download 1527886/topics/19278283 |
schooltools download --course 1527886 19278283 |
schooltools archive --list |
schooltools archive list |
schooltools courses / courses --json / courses --all |
schooltools course list [--json] [--all] [--limit N] |
The legacy positional forms of content and download still work, print
a one-line deprecation notice, and dispatch to the new form. They are
hidden from schooltools --help and will be removed in a future release.
Everything else (prune, login, logout, whoami, doctor,
session, session ttl, auth log, systemd, token) is unchanged.
The old TypeScript CLI used a different session-file schema. Re-running
schooltools login once after upgrading was required; existing
auth.log entries are forward-compatible.