Skip to content

About

Agent-First Headless Go CLI for the CBE / D2L / Brightspace LMS

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

schooltools

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.

What this CLI is for

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:

  • course discover enrolled courses
  • content browse a course's modules and topics
  • download pull a topic's file or metadata
  • archive snapshot every course to disk on a schedule
  • assignment dropbox folders, submissions, feedback
  • quiz quiz 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.

Quickstart

go install .
  1. Create a .env in your working directory:

    CBE_EMAIL=you@example.com
    CBE_PASSWORD=...
    
  2. schooltools login (KMSI defaults on).

  3. 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 update                       # snapshot every course
    schooltools archive find "safety contract"      # search your archive

Commands (promoted)

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>.

Common flags

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
--no-related             skip the extra lookups a `get` does
--yes                    skip confirmation on bulk destructive ops

Persistent root flags: --no-auth-log, --version, -h/--help.

What the output looks like

Every command prints a report, not a dump of raw fields.

  • Lists are tables with real column headers, sized to your terminal.
  • Detail and get views are aligned key/value panels, with Section headings separating blocks.
  • Times read like Mon, Oct 19 2026, 4:00 AM · 2 days ago. Datetimes are shown in your local timezone; date-only values (like an award's issue date) are shown as-is.
  • Status is a word (open, closed, overdue, graded, pending), coloured on a TTY and plain under NO_COLOR or when piped.
  • Empty states are a dim sentence — No feedback released yet. — and most reports end with a → schooltools … hint for the next command.

--json emits an envelope with a count field for lists:

{ "count": 2, "items": [ ... ] }

Single-record and aggregated get output is one object with named sub-objects (folder, submissions, feedback, quiz, attempts, news, related).

Proactive get views

assignment get, quiz get and course get fetch the related records themselves and render one combined report. For example assignment get <folderId> --course <id> shows the folder and its window, the instructions and attachments, the full rubric by criterion name, your submissions with their files and the exact download command for each, and the released feedback scored as 13 / 14 points → Level 4.

Those extra lookups run concurrently and degrade quietly when D2L refuses (a closed dropbox blocks submissions but still serves feedback). Pass --no-related to fetch only the primary record.

Session file

~/.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.

Auth log

~/.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.

Download

schooltools download --course <id> <topicId> fetches a single D2L topic and writes it under your home directory (override with --out):

schooltools download --course 1527886 19278283

For 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-run login if 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.

Archive

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 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 metadata document, flat, named by UUID
  blobs/<sha256>.bin              # every file body (PDF/DOCX/…), content-addressed
  courses/<courseId>/
    index.json                    # per-course: topicId → { title, lastModified,
                                  #             current: <uuid>, versions: […],
                                  #             currentBody: <sha256>,
                                  #             bodyVersions: […] }

The CLI is organised around jobs, not storage internals. Bare schooltools archive prints a status summary (counts, freshness, missing bodies); the mutating pass is archive update.

Commands

Command Job
archive Status (counts, freshness, missing bodies, lock, try: hints)
archive update [courseId...] Fetch new/changed + fill missing bodies
archive update --dry-run Plan only — writes nothing
archive list Courses in the store
archive find [query...] Search titles / URLs (--ext, --type, --with-bodies, --missing-bodies)
archive show <ref> Topic record + version history
archive cat <ref> Bytes (File) / pretty JSON (else) to stdout
archive path <ref> Absolute on-disk path
archive export [--out DIR] [--flat] [--dry-run] Copy file bodies out as <CourseCode>/<Title>.<ext>
archive prune [--delete] Drop old versions; plan only by default
archive verify [--deep] Integrity check (pointers, dangling versions, orphans)

<ref> is auto-detected from a numeric topicId, a 32-hex metadata UUID, or a 64-hex body SHA-256. --kind metadata|body forces one kind on show/cat/path.

Workflow

schooltools archive update                        # snapshot every course
schooltools archive update 123456                 # one course
schooltools archive update --course 12345 67890   # two courses (repeatable --course)
schooltools archive update --dry-run              # preview the plan
schooltools archive find "safety contract"        # search
schooltools archive cat 19448654 > contract.pdf   # one file out
schooltools archive export --out ~/pdfs --ext pdf # many files out
schooltools archive prune --delete                # reclaim disk
schooltools archive verify                        # integrity check

Convergence and idempotence

Each 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. update converges: it also fills in any File topic whose body is missing on disk (whether because the first attempt errored, because an earlier run was invoked with --no-bodies, or because the blob was manually removed), so the archive converges to "every File topic has a body" after each pass.

The archive blob store is never modified or deleted by update. Reclamation happens via archive prune --delete.

Prune

schooltools archive prune walks every courses/<id>/index.json, builds the set of "live" blobs (every topic's current metadata UUID and current body SHA), and deletes every other file in blobs/. After pruning, per-course indexes are rewritten in the same pass so no Versions / BodyVersions record points at a deleted blob.

schooltools archive prune              # plan only — reports what would be deleted
schooltools archive prune --delete     # apply
schooltools archive prune --json       # machine-readable plan
schooltools archive prune --dir /other # prune a non-default archive root

prune is plan-only by default — destructive commands print a plan first; applying is an explicit --delete. Use archive prune --wait when you need to wait on the lock instead of failing.

Advanced / rarely used

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.

Cross-course queries (flat top-level, no me parent)

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.

Per-course (scope with --course <id>)

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.

User-scoped

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): locker and eportfolio. 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 in D2L_API_RESEARCH.md for tenants that do enable them.

Each of these has full --help text with endpoint details.

Migration notes

0.1.0 → 0.2.0

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 archive --diff <id> schooltools archive update --dry-run [--course <id>]
schooltools archive (bare) schooltools archive (now status) or schooltools archive update (fetch)
schooltools archive files --ext pdf schooltools archive find --type File --ext pdf
schooltools archive read <topicId> schooltools archive cat <topicId> (pass --meta to force the metadata blob)
schooltools archive body <sha> schooltools archive cat <sha>
schooltools prune --dry-run schooltools archive prune (plan only)
schooltools prune --delete schooltools archive prune --delete
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 (login, logout, whoami, doctor, session, session ttl, auth log, systemd, token) is unchanged.

Earlier (TypeScript → Go)

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.

About

Agent-First Headless Go CLI for the CBE / D2L / Brightspace LMS

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages