agent2learn 0.1.2__py3-none-any.whl

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.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/cli.py ADDED
@@ -0,0 +1,2039 @@
1
+ """Agent2Learn command-line entry point.
2
+
3
+ Every command is a thin wrapper: argument parsing and presentation live here, all
4
+ behaviour lives in the module that owns it. The few local probes needed for command
5
+ confirmation use the shared path boundary; network and browser behaviour stay in their
6
+ owning modules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import sys
16
+ from collections.abc import Callable, Iterable, Sequence
17
+ from dataclasses import asdict, replace
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Annotated, Any, TypeVar
21
+
22
+ import typer
23
+
24
+ from agent2learn import __version__, clock, config, console, paths
25
+ from agent2learn import calendar as calendar_module
26
+ from agent2learn import check as check_module
27
+ from agent2learn import doctor as doctor_module
28
+ from agent2learn import ground as ground_module
29
+ from agent2learn import index as index_module
30
+ from agent2learn import pipeline as pipeline_module
31
+ from agent2learn import privacy as privacy_module
32
+ from agent2learn import session as session_store
33
+ from agent2learn import skills as skills_module
34
+ from agent2learn import snapshot as snapshot_module
35
+ from agent2learn import submit as submit_module
36
+ from agent2learn import upgrade as upgrade_module
37
+ from agent2learn.api import Client
38
+ from agent2learn.auth import authenticate
39
+ from agent2learn.auth import clear_profile as remove_profile
40
+ from agent2learn.auth import verify as verify_session
41
+ from agent2learn.calibrate import (
42
+ Calibration,
43
+ CourseRef,
44
+ calibrate,
45
+ display_courses,
46
+ load_calibration,
47
+ )
48
+ from agent2learn.errors import A2LError, AuthenticationError, NotConfigured, SessionExpired
49
+ from agent2learn.ingest import (
50
+ PRIORITY_BUDGET_BYTES,
51
+ MetadataReport,
52
+ TopicRecord,
53
+ fetch_topic,
54
+ ingest_metadata,
55
+ is_downloadable_topic,
56
+ is_media_topic,
57
+ load_metadata_report,
58
+ select_priority_topics,
59
+ )
60
+ from agent2learn.schools import UWaterloo, parse_api_timestamp, render_timestamp
61
+ from agent2learn.vault import Vault
62
+
63
+ app = typer.Typer(
64
+ name="a2l",
65
+ help="Turn your LEARN courses into a local vault your AI agent can read and cite.",
66
+ add_completion=True,
67
+ no_args_is_help=True,
68
+ )
69
+
70
+ skills_app = typer.Typer(
71
+ name="skills",
72
+ help="Install or refresh Agent2Learn's canonical agent skills.",
73
+ no_args_is_help=True,
74
+ )
75
+ app.add_typer(skills_app, name="skills")
76
+
77
+ privacy_app = typer.Typer(
78
+ name="privacy",
79
+ help="Inspect or deliberately remove locally retained sensitive categories.",
80
+ no_args_is_help=True,
81
+ )
82
+ app.add_typer(privacy_app, name="privacy")
83
+
84
+
85
+ def _version_callback(value: bool) -> None:
86
+ if value:
87
+ typer.echo(f"agent2learn {__version__}")
88
+ raise typer.Exit()
89
+
90
+
91
+ @app.callback()
92
+ def main(
93
+ version: bool = typer.Option(
94
+ False,
95
+ "--version",
96
+ "-V",
97
+ help="Show the installed version and exit.",
98
+ callback=_version_callback,
99
+ is_eager=True,
100
+ ),
101
+ ) -> None:
102
+ """Agent2Learn."""
103
+
104
+
105
+ class _InitFailure(Exception):
106
+ """A sanitized, single-recovery failure from the interactive initializer."""
107
+
108
+ def __init__(self, stage: str, next_command: str, *, exit_code: int = 1, detail: str) -> None:
109
+ super().__init__(stage)
110
+ self.stage = stage
111
+ self.next_command = next_command
112
+ self.exit_code = exit_code
113
+ self.detail = detail
114
+
115
+
116
+ _T = TypeVar("_T")
117
+ _INIT_SCHEMA_VERSION = 1
118
+ _INIT_STATE_FILENAME = "init.json"
119
+ _INIT_FILE_SCOPES = frozenset({"full", "priority", "later"})
120
+ _INIT_SKILL_STATUSES = frozenset({"installed", "declined", "unavailable"})
121
+ _INIT_AUTH_BACKENDS = frozenset({"auto", "paste"})
122
+
123
+
124
+ def _interactive_terminal() -> bool:
125
+ """Return whether onboarding has both halves of a controlling terminal."""
126
+
127
+ return _stream_is_tty(sys.stdin) and _stream_is_tty(sys.stdout)
128
+
129
+
130
+ def _stream_is_tty(stream: object) -> bool:
131
+ isatty = getattr(stream, "isatty", None)
132
+ try:
133
+ return bool(callable(isatty) and isatty())
134
+ except (AttributeError, OSError):
135
+ return False
136
+
137
+
138
+ def _local_vault() -> tuple[config.Config, Vault, UWaterloo]:
139
+ """Load the configured local vault without opening a network or browser session."""
140
+
141
+ try:
142
+ cfg = config.load()
143
+ except (OSError, ValueError) as exc:
144
+ raise NotConfigured("configuration is unreadable · run: a2l init") from exc
145
+ root = Path(cfg.vault).expanduser()
146
+ try:
147
+ if not Vault.is_vault(root):
148
+ raise NotConfigured("local vault is unavailable · run: a2l init")
149
+ vault = Vault(root)
150
+ except NotConfigured:
151
+ raise
152
+ except (OSError, RuntimeError, ValueError) as exc:
153
+ raise NotConfigured("local vault is unavailable · run: a2l init") from exc
154
+ if cfg.school != UWaterloo.id:
155
+ raise A2LError("configured school adapter is unavailable")
156
+ return cfg, vault, UWaterloo()
157
+
158
+
159
+ @app.command()
160
+ def init(
161
+ vault: Annotated[
162
+ Path | None,
163
+ typer.Option(
164
+ "--vault",
165
+ help="Use PATH as the local vault root instead of the configured default.",
166
+ ),
167
+ ] = None,
168
+ ) -> None:
169
+ """Create or resume a consentful local vault onboarding session."""
170
+
171
+ if not _interactive_terminal():
172
+ # This check must precede config.load(), Vault.claim(), skill detection, and auth: each
173
+ # may create local state, and a piped installer must never turn into an implicit setup.
174
+ typer.echo("run: a2l init", err=True)
175
+ raise typer.Exit(code=NotConfigured.exit_code)
176
+
177
+ try:
178
+ _run_init(vault)
179
+ except _InitFailure as exc:
180
+ _render_init_failure(exc)
181
+ except KeyboardInterrupt:
182
+ _render_init_failure(
183
+ _InitFailure("onboarding", "a2l init", exit_code=130, detail="KeyboardInterrupt")
184
+ )
185
+ except Exception as exc:
186
+ # Interactive setup is a public boundary. Never expose an arbitrary exception string,
187
+ # which can contain a home path, response body, cookie, or other local secret.
188
+ _render_init_failure(_InitFailure("onboarding", "a2l init", detail=type(exc).__name__))
189
+
190
+
191
+ @app.command()
192
+ def courses(
193
+ all_terms: bool = typer.Option(
194
+ False,
195
+ "--all-terms",
196
+ help="Show every discovered academic offering, grouped by term.",
197
+ ),
198
+ json_output: bool = typer.Option(
199
+ False,
200
+ "--json",
201
+ help="Emit stable machine-readable course metadata.",
202
+ ),
203
+ ) -> None:
204
+ """List calibrated course offerings without downloading course content."""
205
+
206
+ try:
207
+ calibration = load_calibration()
208
+ except NotConfigured as exc:
209
+ typer.echo(str(exc), err=True)
210
+ raise typer.Exit(code=exc.exit_code) from exc
211
+
212
+ selected = display_courses(calibration, all_terms=all_terms)
213
+ if json_output:
214
+ typer.echo(_courses_json(selected, all_terms=all_terms))
215
+ return
216
+ _print_courses(selected, all_terms=all_terms)
217
+
218
+
219
+ @app.command()
220
+ def sync(
221
+ all_files: Annotated[
222
+ bool,
223
+ typer.Option(
224
+ "--all",
225
+ help="Fetch every eligible configured-course document; media remains separate.",
226
+ ),
227
+ ] = False,
228
+ priority: Annotated[
229
+ bool,
230
+ typer.Option(
231
+ "--priority",
232
+ help="Use the deterministic byte-bounded priority file scope for this run.",
233
+ ),
234
+ ] = False,
235
+ include_media: Annotated[
236
+ bool,
237
+ typer.Option(
238
+ "--include-media",
239
+ help="Include otherwise-excluded audio and video files for this run.",
240
+ ),
241
+ ] = False,
242
+ ) -> None:
243
+ """Incrementally ingest, convert, index, and audit using saved configuration.
244
+
245
+ Grade and discussion collection follow the existing saved configuration and remain off by
246
+ default. ``--all`` and ``--priority`` override the valid persisted onboarding scope once.
247
+ """
248
+
249
+ if all_files and priority:
250
+ raise typer.BadParameter("--all and --priority are mutually exclusive")
251
+
252
+ try:
253
+ cfg, vault, school = _local_vault()
254
+ explicit_scope: pipeline_module.SyncScope | None
255
+ if all_files:
256
+ explicit_scope = "all"
257
+ elif priority:
258
+ explicit_scope = "priority"
259
+ else:
260
+ explicit_scope = None
261
+ # Validate the persisted course boundary before session access or network calibration. A
262
+ # corrupt selection must never silently broaden into requests for every enrolled course.
263
+ preferences = pipeline_module.load_sync_preferences(
264
+ vault,
265
+ scope_override=explicit_scope,
266
+ )
267
+ try:
268
+ saved = session_store.load()
269
+ except (OSError, ValueError) as exc:
270
+ raise AuthenticationError("stored session is unreadable · run: a2l auth") from exc
271
+ if saved is None:
272
+ raise NotConfigured("no saved session · run: a2l auth")
273
+
274
+ client = Client(school, saved)
275
+ calibration = calibrate(client)
276
+ # The fresh calibration is the selected-course inventory for both ingest phases. Storing
277
+ # it on the client avoids a second machine-state read while retaining ingest's public API.
278
+ client.courses = calibration.courses # type: ignore[attr-defined]
279
+ report = pipeline_module.run_pipeline(
280
+ client,
281
+ vault,
282
+ school,
283
+ scope=preferences.scope,
284
+ include_media=include_media,
285
+ include_grades=cfg.include_grades,
286
+ include_discussions=cfg.include_discussions,
287
+ ocr_words_per_page=cfg.ocr_words_per_page,
288
+ term=preferences.term,
289
+ only=preferences.only,
290
+ metadata_observer=_print_sync_metadata,
291
+ profile_consent=preferences.profile_consent,
292
+ )
293
+ except SessionExpired:
294
+ typer.echo("session expired · run: a2l auth", err=True)
295
+ raise typer.Exit(code=SessionExpired.exit_code) from None
296
+ except A2LError as exc:
297
+ typer.echo(str(exc), err=True)
298
+ raise typer.Exit(code=exc.exit_code) from None
299
+ except (OSError, RuntimeError, ValueError) as exc:
300
+ typer.echo(f"sync failed ({type(exc).__name__}) · run: a2l doctor", err=True)
301
+ raise typer.Exit(code=1) from None
302
+
303
+ if report.exit_code == SessionExpired.exit_code:
304
+ typer.echo("session expired · run: a2l auth", err=True)
305
+ raise typer.Exit(code=SessionExpired.exit_code)
306
+ typer.echo(pipeline_module.render_report(report), nl=False)
307
+ if report.exit_code:
308
+ raise typer.Exit(code=report.exit_code)
309
+
310
+
311
+ @app.command()
312
+ def today() -> None:
313
+ """Show local deadlines, overdue work, changes, and the next exam countdown."""
314
+
315
+ try:
316
+ cfg, vault, school = _local_vault()
317
+ report = calendar_module.build_today(
318
+ vault,
319
+ school,
320
+ include_grades=cfg.include_grades,
321
+ )
322
+ except A2LError as exc:
323
+ typer.echo(str(exc), err=True)
324
+ raise typer.Exit(code=exc.exit_code) from None
325
+ except OSError:
326
+ typer.echo("today failed because local vault metadata is unavailable", err=True)
327
+ raise typer.Exit(code=1) from None
328
+ typer.echo(calendar_module.render_today(report, include_grades=cfg.include_grades), nl=False)
329
+
330
+
331
+ @app.command()
332
+ def diff(
333
+ since: Annotated[
334
+ str | None,
335
+ typer.Option("--since", help="Compare with one exact earlier snapshot identifier."),
336
+ ] = None,
337
+ ) -> None:
338
+ """Show structured changes between local vault snapshots."""
339
+
340
+ try:
341
+ cfg, vault, _school = _local_vault()
342
+ result = snapshot_module.diff_vault(
343
+ vault,
344
+ since=since,
345
+ include_grades=cfg.include_grades,
346
+ )
347
+ except A2LError as exc:
348
+ typer.echo(str(exc), err=True)
349
+ raise typer.Exit(code=exc.exit_code) from None
350
+ except (OSError, ValueError) as exc:
351
+ typer.echo(f"diff failed ({type(exc).__name__}); run: a2l sync", err=True)
352
+ raise typer.Exit(code=1) from None
353
+ typer.echo(
354
+ snapshot_module.render_diff(result, include_grades=cfg.include_grades),
355
+ nl=False,
356
+ )
357
+
358
+
359
+ @app.command()
360
+ def calendar(
361
+ output: Annotated[
362
+ Path | None,
363
+ typer.Option("--output", "-o", help="Write the calendar atomically to FILE."),
364
+ ] = None,
365
+ ) -> None:
366
+ """Export local deadlines, exams, and office hours as an iCalendar file."""
367
+
368
+ try:
369
+ _cfg, vault, school = _local_vault()
370
+ if output is None:
371
+ typer.echo(calendar_module.render_ics(vault, school), nl=False)
372
+ else:
373
+ written = calendar_module.write_ics(vault, school, output)
374
+ typer.echo(f"calendar exported: {_display_path(written)}")
375
+ except A2LError as exc:
376
+ typer.echo(str(exc), err=True)
377
+ raise typer.Exit(code=exc.exit_code) from None
378
+ except OSError:
379
+ typer.echo("calendar failed because local vault metadata is unavailable", err=True)
380
+ raise typer.Exit(code=1) from None
381
+
382
+
383
+ @app.command()
384
+ def where(
385
+ query: str = typer.Argument(..., help="Words to find in local course content metadata."),
386
+ json_output: bool = typer.Option(
387
+ False,
388
+ "--json",
389
+ help="Emit stable machine-readable matches instead of terminal lines.",
390
+ ),
391
+ ) -> None:
392
+ """Fuzzy-find a non-sensitive topic across every local course and term."""
393
+
394
+ try:
395
+ _cfg, vault, _school = _local_vault()
396
+ matches = index_module.search_topics(vault, query)
397
+ except A2LError as exc:
398
+ typer.echo(str(exc), err=True)
399
+ raise typer.Exit(code=exc.exit_code) from None
400
+ except OSError:
401
+ typer.echo("where failed because local content maps are unavailable", err=True)
402
+ raise typer.Exit(code=1) from None
403
+
404
+ if json_output:
405
+ typer.echo(
406
+ json.dumps([asdict(match) for match in matches], ensure_ascii=False, sort_keys=True)
407
+ )
408
+ return
409
+ if not matches:
410
+ typer.echo("No matching topics found.")
411
+ return
412
+ for match in matches:
413
+ locations = [
414
+ f"twin={match.path}" if match.path else None,
415
+ f"source={match.source_path}" if match.source_path else None,
416
+ f"stub={match.stub_path}" if match.stub_path else None,
417
+ ]
418
+ target = ", ".join(value for value in locations if value) or "metadata only"
419
+ typer.echo(f"{match.course} · {match.title} [{match.kind}] · {target}")
420
+
421
+
422
+ @app.command()
423
+ def ground(
424
+ course: str = typer.Argument(
425
+ ..., help="Course code, course folder, name, or term-qualified selector."
426
+ ),
427
+ item: str = typer.Argument(
428
+ ...,
429
+ help="Assignment title (e.g. 'Lab 4' or Lab4), its LEARN Dropbox id, or its folder name.",
430
+ ),
431
+ ) -> None:
432
+ """Assemble a cited grounding pack from current, provenance-backed class material."""
433
+
434
+ try:
435
+ _cfg, vault, _school = _local_vault()
436
+ pack = ground_module.write_grounding_pack(vault, course, item)
437
+ except A2LError as exc:
438
+ typer.echo(str(exc), err=True)
439
+ raise typer.Exit(code=exc.exit_code) from None
440
+ except OSError:
441
+ typer.echo("ground failed because local course material is unavailable", err=True)
442
+ raise typer.Exit(code=1) from None
443
+ typer.echo(f"grounding pack: {paths.rel_posix(pack.path, vault.root)}")
444
+
445
+
446
+ @app.command()
447
+ def check(
448
+ draft: Annotated[
449
+ Path,
450
+ typer.Argument(help="Draft file: .md, .txt, .ipynb, .py, .r, .rmd, or .tex."),
451
+ ],
452
+ course: str | None = typer.Option(
453
+ None, "--course", help="Course selector. Inferred from the draft location when omitted."
454
+ ),
455
+ assignment: str | None = typer.Option(
456
+ None, "--assignment", help="Scope the scan to one assignment's sources."
457
+ ),
458
+ output_format: str = typer.Option(
459
+ "md", "--format", help="Report format: md or json.", metavar="md|json"
460
+ ),
461
+ strict: bool = typer.Option(
462
+ False,
463
+ "--strict",
464
+ help=(
465
+ "Exit non-zero when a claim has no matching evidence or is worth comparing. "
466
+ "A review reminder only: a status is not proof of correctness, incorrectness, "
467
+ "policy compliance, or academic integrity."
468
+ ),
469
+ ),
470
+ ) -> None:
471
+ """Run an experimental lexical evidence scan of a draft against your own course material."""
472
+
473
+ if output_format not in {"md", "json"}:
474
+ typer.echo("--format must be md or json", err=True)
475
+ raise typer.Exit(code=2)
476
+ try:
477
+ _cfg, vault, _school = _local_vault()
478
+ course_dir = (
479
+ index_module.resolve_course(vault, course)
480
+ if course
481
+ else _infer_course_dir(vault, draft)
482
+ )
483
+ report = check_module.check(draft, course_dir, assignment=assignment)
484
+ except A2LError as exc:
485
+ typer.echo(str(exc), err=True)
486
+ raise typer.Exit(code=exc.exit_code) from None
487
+ except OSError:
488
+ typer.echo("check failed because local course material is unavailable", err=True)
489
+ raise typer.Exit(code=1) from None
490
+
491
+ rendered = (
492
+ check_module.render_json(report) if output_format == "json" else check_module.render(report)
493
+ )
494
+ typer.echo(rendered, nl=False)
495
+ if strict and report.review_required:
496
+ raise typer.Exit(code=1)
497
+
498
+
499
+ @app.command("enable-submit")
500
+ def enable_submit() -> None:
501
+ """Record a one-time local acknowledgement for uploads. This never uploads anything."""
502
+
503
+ try:
504
+ # The build-level refusal comes first: pointing at `a2l init` to enable an upload path
505
+ # this build does not have would be a false next action.
506
+ submit_module.require_available()
507
+ cfg, _vault, _school = _local_vault()
508
+ typer.echo(
509
+ "Upload is the only Agent2Learn action that changes anything in LEARN.\n"
510
+ "This acknowledgement alone never uploads: every file still needs a fresh\n"
511
+ "confirmation typed at your own terminal, and the preview always comes first."
512
+ )
513
+ submit_module.enable_submit(cfg)
514
+ except A2LError as exc:
515
+ typer.echo(str(exc), err=True)
516
+ raise typer.Exit(code=exc.exit_code) from None
517
+ except (OSError, ValueError):
518
+ typer.echo("enable-submit failed because local configuration is unavailable", err=True)
519
+ raise typer.Exit(code=1) from None
520
+ typer.echo("submission acknowledged; each upload still requires your confirmation")
521
+
522
+
523
+ @app.command()
524
+ def submit(
525
+ course: Annotated[
526
+ str, typer.Argument(help="Course code, folder, name, or term-qualified selector.")
527
+ ],
528
+ item: Annotated[str, typer.Argument(help="Exact LEARN Dropbox folder name.")],
529
+ file: Annotated[Path, typer.Argument(help="The finished local file to upload.")],
530
+ ) -> None:
531
+ """Preview an upload to one LEARN Dropbox, then require your own typed confirmation."""
532
+
533
+ preview: submit_module.SubmissionPreview | None = None
534
+ try:
535
+ # Refuse before configuration and authentication: a disabled build must not send the user
536
+ # to `a2l init` or `a2l auth` for a path it will refuse anyway.
537
+ submit_module.require_available()
538
+ cfg, vault, school = _local_vault()
539
+ submit_module.require_available(cfg)
540
+ try:
541
+ saved = session_store.load()
542
+ except (OSError, ValueError) as exc:
543
+ raise AuthenticationError("stored session is unreadable · run: a2l auth") from exc
544
+ if saved is None:
545
+ raise NotConfigured("no saved session · run: a2l auth")
546
+ client = Client(school, saved)
547
+ calibration = calibrate(client)
548
+ preview = submit_module.prepare(
549
+ vault,
550
+ client,
551
+ cfg,
552
+ course=course,
553
+ item=item,
554
+ file=file,
555
+ le_version=calibration.le,
556
+ )
557
+ typer.echo(submit_module.render_preview(preview), nl=False)
558
+ if not _interactive_terminal():
559
+ typer.echo("no controlling terminal: stopping at the preview", err=True)
560
+ raise typer.Exit(code=1)
561
+ phrase = typer.prompt("Type the phrase above to upload", default="")
562
+ receipt = submit_module.confirm_and_upload(
563
+ vault,
564
+ client,
565
+ preview,
566
+ phrase=phrase,
567
+ interactive=True,
568
+ )
569
+ except A2LError as exc:
570
+ typer.echo(str(exc), err=True)
571
+ raise typer.Exit(code=exc.exit_code) from None
572
+ except (OSError, ValueError):
573
+ typer.echo("submit failed because local state is unavailable", err=True)
574
+ raise typer.Exit(code=1) from None
575
+ finally:
576
+ if preview is not None and not preview.consumed:
577
+ submit_module.discard_preview(preview)
578
+ typer.echo(f"upload {receipt.status}: {receipt.outcome}")
579
+
580
+
581
+ @app.command()
582
+ def upgrade(
583
+ check: bool = typer.Option(
584
+ False, "--check", help="Report the installed and latest versions without installing."
585
+ ),
586
+ ) -> None:
587
+ """Check for a newer Agent2Learn and install it.
588
+
589
+ This is the only command that contacts the network on its own behalf. It reads
590
+ https://pypi.org/pypi/agent2learn/json once, when you run it. Agent2Learn performs no
591
+ background version checks and sends no telemetry, so there is nothing to opt out of.
592
+ """
593
+
594
+ try:
595
+ latest = upgrade_module.latest_version()
596
+ plan = upgrade_module.plan_upgrade(
597
+ installed=upgrade_module.current_version(), latest=latest
598
+ )
599
+ typer.echo(upgrade_module.render_plan(plan), nl=False)
600
+ if check or not plan.needed:
601
+ return
602
+ if not _interactive_terminal():
603
+ raise A2LError(
604
+ "upgrade requires a controlling terminal for confirmation; run it interactively"
605
+ )
606
+ if not typer.confirm(f"Install Agent2Learn {plan.latest} now?", default=False):
607
+ raise A2LError("upgrade cancelled; nothing was installed")
608
+ upgrade_module.apply_upgrade(plan)
609
+ upgrade_module.verify_installation(plan.latest)
610
+ _offer_stale_project_skill_refresh()
611
+ except A2LError as exc:
612
+ typer.echo(str(exc), err=True)
613
+ raise typer.Exit(code=exc.exit_code) from None
614
+ typer.echo(f"upgraded to {plan.latest}")
615
+
616
+
617
+ def _offer_stale_project_skill_refresh() -> None:
618
+ """Offer a separate, previewed refresh for stale project-local managed skills."""
619
+
620
+ try:
621
+ cfg = config.load()
622
+ project = Path(cfg.vault)
623
+ planned = skills_module.current_installations(project=project)
624
+ except (OSError, ValueError, skills_module.SkillsInstallError):
625
+ # An upgrade is still valid when onboarding has not configured a vault yet. In that case
626
+ # there is simply no project-local skill destination whose freshness can be checked.
627
+ return
628
+
629
+ stale = tuple(result for result in planned if result.status != "unchanged")
630
+ if not stale:
631
+ return
632
+ typer.echo("Stale managed project-local skills were found:")
633
+ typer.echo(skills_module.render_preview(stale, link=False, force=False), nl=False)
634
+ if not _interactive_terminal():
635
+ typer.echo("refresh skipped; run: a2l skills install", err=True)
636
+ return
637
+ if not typer.confirm("Refresh these project-local skills now?", default=False):
638
+ typer.echo("skill refresh skipped; run: a2l skills install")
639
+ return
640
+ try:
641
+ result = skills_module.install(
642
+ scope="project",
643
+ project=project,
644
+ confirm=lambda _preview: True,
645
+ )
646
+ except skills_module.SkillsInstallError:
647
+ typer.echo("skill refresh failed; run: a2l skills install", err=True)
648
+ return
649
+ if result.cancelled:
650
+ typer.echo("skill refresh skipped; run: a2l skills install")
651
+ else:
652
+ typer.echo("project-local skills refreshed")
653
+
654
+
655
+ @app.command()
656
+ def completions(
657
+ shell: Annotated[str, typer.Argument(help="One of: bash, zsh, fish, powershell.")],
658
+ ) -> None:
659
+ """Print a shell completion script for a2l to standard output.
660
+
661
+ Nothing is installed and no shell profile is edited: pipe or redirect the output yourself, so
662
+ the change to your shell stays yours to make and to undo.
663
+ """
664
+
665
+ shells = upgrade_module.completion_shells()
666
+ if shell not in shells:
667
+ typer.echo(f"unsupported shell: {shell}; choose one of {', '.join(shells)}", err=True)
668
+ raise typer.Exit(code=2)
669
+ try:
670
+ script = upgrade_module.completion_script(shell)
671
+ except A2LError as exc:
672
+ typer.echo(str(exc), err=True)
673
+ raise typer.Exit(code=exc.exit_code) from None
674
+ typer.echo(script, nl=False)
675
+
676
+
677
+ def _infer_course_dir(vault: Vault, draft: Path) -> Path:
678
+ """Locate the course a draft sits in, refusing to guess when it sits outside the vault."""
679
+
680
+ for parent in Path(draft).expanduser().resolve().parents:
681
+ if (parent / "_meta" / "content_map.json").is_file():
682
+ return parent
683
+ raise A2LError("draft is not inside a local course; pass --course")
684
+
685
+
686
+ @app.command("open")
687
+ def open_course(
688
+ course: str = typer.Argument(
689
+ ..., help="Course code, course folder, name, or term-qualified selector."
690
+ ),
691
+ ) -> None:
692
+ """Ask the operating system to reveal one known local course folder."""
693
+
694
+ try:
695
+ _cfg, vault, _school = _local_vault()
696
+ course_dir = index_module.resolve_course(vault, course)
697
+ paths.reveal(course_dir)
698
+ except A2LError as exc:
699
+ typer.echo(str(exc), err=True)
700
+ raise typer.Exit(code=exc.exit_code) from None
701
+ except OSError:
702
+ typer.echo("open failed because local course metadata is unavailable", err=True)
703
+ raise typer.Exit(code=1) from None
704
+ typer.echo(f"requested opening: {_display_path(course_dir)}")
705
+
706
+
707
+ @privacy_app.command("status")
708
+ def privacy_status() -> None:
709
+ """Show sensitive-category collection flags and redacted local locations."""
710
+
711
+ try:
712
+ cfg, vault, _school = _local_vault()
713
+ value = privacy_module.status(vault, cfg)
714
+ except A2LError as exc:
715
+ typer.echo(str(exc), err=True)
716
+ raise typer.Exit(code=exc.exit_code) from None
717
+ except (OSError, ValueError):
718
+ typer.echo("privacy status failed because local state is unavailable", err=True)
719
+ raise typer.Exit(code=1) from None
720
+ typer.echo(privacy_module.render_status(value), nl=False)
721
+
722
+
723
+ @privacy_app.command("purge")
724
+ def privacy_purge(
725
+ category: str = typer.Argument(..., help="Exactly one of: grades, discussions, logs."),
726
+ ) -> None:
727
+ """Preview an exact privacy purge and require a fresh controlling-terminal phrase."""
728
+
729
+ try:
730
+ _cfg, vault, _school = _local_vault()
731
+ plan = privacy_module.plan_purge(vault, category)
732
+ typer.echo(privacy_module.render_plan(plan), nl=False)
733
+ if not plan.targets:
734
+ return
735
+ if not _interactive_terminal():
736
+ typer.echo("refusing to purge without an interactive terminal", err=True)
737
+ raise typer.Exit(code=1)
738
+ phrase = typer.prompt(f"Type PURGE {plan.category.upper()} to continue", default="")
739
+ privacy_module.execute_purge(
740
+ vault,
741
+ plan,
742
+ phrase=phrase,
743
+ interactive=True,
744
+ )
745
+ except A2LError as exc:
746
+ typer.echo(str(exc), err=True)
747
+ raise typer.Exit(code=exc.exit_code) from None
748
+ except (OSError, ValueError):
749
+ typer.echo("privacy purge failed because local state is unavailable", err=True)
750
+ raise typer.Exit(code=1) from None
751
+ typer.echo("privacy purge complete (logical deletion only)")
752
+
753
+
754
+ @app.command()
755
+ def auth(
756
+ paste: bool = typer.Option(
757
+ False,
758
+ "--paste",
759
+ help="Read a manually exported cookie blob from a controlling hidden-input TTY.",
760
+ ),
761
+ check: bool = typer.Option(
762
+ False,
763
+ "--check",
764
+ help="Verify the saved session without opening a browser or asking for cookies.",
765
+ ),
766
+ clear_profile: bool = typer.Option(
767
+ False,
768
+ "--clear-profile",
769
+ help=(
770
+ "Clear the saved API session and remove the dedicated browser profile "
771
+ "after confirmation."
772
+ ),
773
+ ),
774
+ ) -> None:
775
+ """Establish, verify, or deliberately clear the same-device LEARN session."""
776
+
777
+ selected = sum((paste, check, clear_profile))
778
+ if selected > 1:
779
+ raise typer.BadParameter("--paste, --check, and --clear-profile are mutually exclusive")
780
+
781
+ school = UWaterloo()
782
+ try:
783
+ if clear_profile:
784
+ remove_profile()
785
+ typer.echo("dedicated browser profile removed; saved API session cleared")
786
+ return
787
+
788
+ if check:
789
+ try:
790
+ saved = session_store.load()
791
+ except (OSError, ValueError) as exc:
792
+ raise AuthenticationError("stored session is unreadable · run: a2l auth") from exc
793
+ if saved is None:
794
+ typer.echo("no saved session · run: a2l auth", err=True)
795
+ raise typer.Exit(code=3)
796
+ if verify_session(saved, school) is None:
797
+ typer.echo("session expired · run: a2l auth", err=True)
798
+ raise typer.Exit(code=SessionExpired.exit_code)
799
+ typer.echo("authentication verified")
800
+ return
801
+
802
+ authenticate(school, backend="paste" if paste else "auto")
803
+ except A2LError as exc:
804
+ typer.echo(str(exc), err=True)
805
+ raise typer.Exit(code=exc.exit_code) from None
806
+
807
+ if paste:
808
+ typer.echo(
809
+ "authentication verified; clear your clipboard if it still contains session cookies"
810
+ )
811
+ else:
812
+ typer.echo("authentication verified")
813
+
814
+
815
+ @app.command()
816
+ def fetch(
817
+ topic: str = typer.Argument(..., help="Stable topic ID, source key, title, or vault path."),
818
+ allow_large: bool = typer.Option(
819
+ False,
820
+ "--allow-large",
821
+ help="Permit this one oversized or unknown-length source after confirmation.",
822
+ ),
823
+ ) -> None:
824
+ """Fetch one known topic and print its verified citation path."""
825
+
826
+ try:
827
+ try:
828
+ cfg = config.load()
829
+ except (OSError, ValueError) as exc:
830
+ raise NotConfigured("configuration is unreadable · run: a2l init") from exc
831
+ try:
832
+ saved = session_store.load()
833
+ except (OSError, ValueError) as exc:
834
+ raise AuthenticationError("stored session is unreadable · run: a2l auth") from exc
835
+ if saved is None:
836
+ raise NotConfigured("no saved session · run: a2l auth")
837
+ school = UWaterloo()
838
+ vault = Vault(Path(cfg.vault))
839
+
840
+ def confirm_large(size: int | None) -> bool:
841
+ if not _interactive_terminal():
842
+ raise A2LError(
843
+ "large-file fetch requires a controlling terminal; run it interactively"
844
+ )
845
+ try:
846
+ free = shutil.disk_usage(paths.long_path(vault.root)).free
847
+ except OSError as exc:
848
+ raise A2LError(
849
+ "free disk space is unavailable; check the vault permissions"
850
+ ) from exc
851
+ advertised = "unknown size" if size is None else f"{size:,} bytes"
852
+ typer.echo(f"large-file override: {advertised}; free space: {free:,} bytes")
853
+ return typer.confirm("Fetch this one source?", default=False)
854
+
855
+ result = fetch_topic(
856
+ Client(school, saved),
857
+ vault,
858
+ school,
859
+ topic,
860
+ allow_large=allow_large,
861
+ confirm=confirm_large if allow_large else None,
862
+ ocr_words_per_page=cfg.ocr_words_per_page,
863
+ )
864
+ except A2LError as exc:
865
+ typer.echo(str(exc), err=True)
866
+ raise typer.Exit(code=exc.exit_code) from None
867
+ except OSError as exc:
868
+ typer.echo("fetch failed because local filesystem access is unavailable", err=True)
869
+ raise typer.Exit(code=1) from exc
870
+
871
+ citation = result.citation_path
872
+ if citation is None or result.availability != "markdown_ready":
873
+ action = result.next_action or "inspect the recorded gap: a2l doctor"
874
+ typer.echo(
875
+ f"source fetched, but no verified citation twin is available; {action}", err=True
876
+ )
877
+ raise typer.Exit(code=1)
878
+ typer.echo(f"verified citation: {citation}")
879
+
880
+
881
+ @skills_app.command("install")
882
+ def skills_install(
883
+ global_install: Annotated[
884
+ bool,
885
+ typer.Option(
886
+ "--global",
887
+ help="Install into detected user-level agent skill directories.",
888
+ ),
889
+ ] = False,
890
+ project: Annotated[
891
+ Path | None,
892
+ typer.Option(
893
+ "--project",
894
+ help="Install into detected project-local agent skill directories under PATH.",
895
+ ),
896
+ ] = None,
897
+ link: Annotated[
898
+ bool,
899
+ typer.Option(
900
+ "--link",
901
+ help="Symlink to the canonical source instead of copying skill directories.",
902
+ ),
903
+ ] = False,
904
+ force: Annotated[
905
+ bool,
906
+ typer.Option(
907
+ "--force",
908
+ help="Refresh recognized Agent2Learn skill directories after previewing the change.",
909
+ ),
910
+ ] = False,
911
+ ) -> None:
912
+ """Install or refresh the four canonical Agent2Learn skills."""
913
+
914
+ if global_install and project is not None:
915
+ raise typer.BadParameter("--global and --project are mutually exclusive")
916
+ try:
917
+ skills_module.ensure_interactive_scope(
918
+ explicit_project=project is not None,
919
+ global_install=global_install,
920
+ stdin_is_tty=sys.stdin.isatty(),
921
+ )
922
+ resolved_project = Path.cwd() if global_install else skills_module.resolve_project(project)
923
+ scope: skills_module.Scope = "global" if global_install else "project"
924
+
925
+ def confirm(preview: str) -> bool:
926
+ typer.echo(preview, nl=False)
927
+ return typer.confirm("Install Agent2Learn skills?", default=False)
928
+
929
+ result = skills_module.install(
930
+ scope=scope,
931
+ project=resolved_project,
932
+ force=force,
933
+ link=link,
934
+ confirm=confirm,
935
+ )
936
+ except skills_module.SkillsInstallError as exc:
937
+ typer.echo(str(exc), err=True)
938
+ raise typer.Exit(code=1) from None
939
+
940
+ if result.cancelled:
941
+ typer.echo("skills install cancelled")
942
+ raise typer.Exit(code=1)
943
+ typer.echo("skills installed")
944
+
945
+
946
+ @app.command()
947
+ def doctor(
948
+ report: bool = typer.Option(
949
+ False,
950
+ "--report",
951
+ help="Print a redacted markdown block that is safe to paste into a public issue.",
952
+ ),
953
+ open_issue: bool = typer.Option(
954
+ False,
955
+ "--open",
956
+ help="Show the redacted report and the exact GitHub destination, then offer to open it.",
957
+ ),
958
+ ) -> None:
959
+ """Diagnose the installation and end with exactly one next command."""
960
+
961
+ config_failure: doctor_module.Check | None = None
962
+ try:
963
+ cfg = config.load()
964
+ except (OSError, ValueError) as exc:
965
+ # Doctor must be useful precisely when its own config is broken. Do not echo the
966
+ # parser's path or raw value; those can contain a user's home, course, or token-like text.
967
+ cfg = config.Config()
968
+ config_failure = doctor_module.Check(
969
+ "Environment",
970
+ "config.load",
971
+ "fail",
972
+ f"configuration is unreadable ({type(exc).__name__})",
973
+ "run: a2l init",
974
+ )
975
+
976
+ root = Path(cfg.vault)
977
+ try:
978
+ vault = Vault(root) if Vault.is_vault(root) else None
979
+ except (OSError, RuntimeError, ValueError):
980
+ vault = None
981
+
982
+ client = None
983
+ try:
984
+ saved = session_store.load()
985
+ if saved is not None and cfg.school == "uwaterloo":
986
+ client = Client(UWaterloo(), saved)
987
+ except Exception:
988
+ # _session() reports the redacted storage failure; client construction is only an
989
+ # optional live probe and must never prevent the rest of doctor from rendering.
990
+ client = None
991
+
992
+ checks = doctor_module.run_checks(cfg, vault, client=client)
993
+ if config_failure is not None:
994
+ checks.insert(0, config_failure)
995
+
996
+ if open_issue:
997
+ # The body is shown before the browser opens. Opening the page itself sends the
998
+ # displayed redacted body to GitHub; the user still reviews and submits manually.
999
+ typer.echo(doctor_module.open_notice(checks))
1000
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
1001
+ typer.echo("refusing to open a report without an interactive terminal", err=True)
1002
+ raise typer.Exit(code=max(1, doctor_module.exit_code(checks)))
1003
+ if typer.confirm("Open this pre-filled issue in your browser?", default=False):
1004
+ typer.launch(doctor_module.issue_url(checks))
1005
+ raise typer.Exit(code=doctor_module.exit_code(checks))
1006
+
1007
+ typer.echo(doctor_module.report(checks) if report else doctor_module.render(checks))
1008
+ raise typer.Exit(code=doctor_module.exit_code(checks))
1009
+
1010
+
1011
+ def _run_init(requested_vault: Path | None) -> None:
1012
+ """Run the ordered, resumable onboarding state machine."""
1013
+
1014
+ cfg = _init_stage("configuration", "a2l init", config.load)
1015
+ if cfg.school.casefold() != UWaterloo.id:
1016
+ raise _InitFailure("school selection", "a2l init", detail="unsupported school")
1017
+
1018
+ school = UWaterloo()
1019
+ requested = _init_stage("vault", "a2l init", lambda: _resolve_init_vault(requested_vault, cfg))
1020
+ if _agent2learn_checkout(requested):
1021
+ raise _InitFailure("vault", "a2l init", detail="source checkout selected")
1022
+
1023
+ candidate, already_vault = _init_stage("vault", "a2l init", lambda: _preview_vault(requested))
1024
+ state = _init_stage("vault", "a2l init", lambda: _read_init_state(candidate))
1025
+
1026
+ if (not already_vault or state.get("vault_confirmed") is not True) and not typer.confirm(
1027
+ _vault_prompt(requested, candidate, already_vault), default=True
1028
+ ):
1029
+ raise _InitFailure("vault", "a2l init", detail="cancelled")
1030
+
1031
+ claimed = _init_stage("vault", "a2l init", lambda: Vault.claim(candidate, allow_suffix=False))
1032
+ if claimed != candidate:
1033
+ raise _InitFailure("vault", "a2l init", detail="vault location changed")
1034
+ _init_stage("vault", "a2l init", lambda: Vault(claimed).manifest())
1035
+ state = _init_stage(
1036
+ "vault",
1037
+ "a2l init",
1038
+ lambda: _update_init_state(claimed, state, school=school.id, vault_confirmed=True),
1039
+ )
1040
+ _init_stage("vault", "a2l init", lambda: _ensure_obsidian_config(claimed))
1041
+
1042
+ typer.echo(f"{console.GLYPH['ok']} vault {_display_path(claimed)}")
1043
+ typer.echo(f"{console.GLYPH['ok']} school {school.name} ({school.base_url})")
1044
+
1045
+ state = _init_stage("agent skills", "a2l init", lambda: _configure_init_skills(claimed, state))
1046
+ state, include_grades = _init_stage(
1047
+ "grade preference", "a2l init", lambda: _configure_init_grades(claimed, state, cfg)
1048
+ )
1049
+ cfg = _init_stage(
1050
+ "configuration", "a2l init", lambda: _save_init_config(cfg, claimed, include_grades)
1051
+ )
1052
+
1053
+ state, backend = _init_stage(
1054
+ "browser profile", "a2l init", lambda: _configure_init_auth(claimed, state)
1055
+ )
1056
+ session_value: session_store.Session | None
1057
+ try:
1058
+ session_value = session_store.load()
1059
+ except (OSError, ValueError):
1060
+ # A stale or malformed local projection is recoverable by authenticating again.
1061
+ session_value = None
1062
+ if session_value is not None and not _session_matches_school(session_value, school):
1063
+ # A saved projection is local convenience state, not transferable identity. Never send
1064
+ # cookies harvested for one LEARN origin to another configured school.
1065
+ session_value = None
1066
+
1067
+ if session_value is None:
1068
+ if backend == "auto":
1069
+ typer.echo("→ opening your browser — sign in to LEARN (WatIAM + Duo)…")
1070
+ else:
1071
+ typer.echo("→ waiting for hidden-TTY cookie paste…")
1072
+ session_value = _init_stage(
1073
+ "authentication",
1074
+ "a2l auth",
1075
+ lambda: authenticate(school, backend=backend),
1076
+ )
1077
+ if session_value is None:
1078
+ raise _InitFailure("authentication", "a2l auth", detail="no verified session")
1079
+ state = _init_stage(
1080
+ "authentication",
1081
+ "a2l auth",
1082
+ lambda: _update_init_state(
1083
+ claimed,
1084
+ state,
1085
+ authenticated=True,
1086
+ auth_backend=backend,
1087
+ ),
1088
+ )
1089
+ typer.echo(f"{console.GLYPH['ok']} signed in")
1090
+ else:
1091
+ typer.echo(f"{console.GLYPH['ok']} signed in (saved local session)")
1092
+
1093
+ client = _init_stage("course discovery", "a2l init", lambda: Client(school, session_value))
1094
+ calibration = _init_stage("course discovery", "a2l init", lambda: calibrate(client))
1095
+ courses = _init_stage("course discovery", "a2l init", lambda: _calibration_courses(calibration))
1096
+ active = [course for course in courses if course.is_active and course.term is not None]
1097
+ state_term = state.get("term")
1098
+ previous_term = state_term if isinstance(state_term, str) else None
1099
+ seen_term = state.get("last_seen_term")
1100
+ last_seen_term = seen_term if isinstance(seen_term, str) else previous_term
1101
+ latest_active_term = max(
1102
+ (course.term for course in active if course.term is not None),
1103
+ key=_term_sort_key,
1104
+ default=None,
1105
+ )
1106
+ preferred_term = previous_term
1107
+ approved_new_term = False
1108
+ new_terms = _new_active_terms(active, last_seen_term)
1109
+ if new_terms:
1110
+ newest = max(new_terms, key=_term_sort_key)
1111
+ count = sum(1 for course in active if course.term == newest)
1112
+ if not typer.confirm(f"New term detected: {count} courses. Sync?", default=True):
1113
+ _init_stage(
1114
+ "course discovery",
1115
+ "a2l init",
1116
+ lambda: _update_init_state(
1117
+ claimed,
1118
+ state,
1119
+ last_seen_term=latest_active_term or previous_term,
1120
+ ),
1121
+ )
1122
+ typer.echo("new term skipped; run: a2l init")
1123
+ return
1124
+ preferred_term = newest
1125
+ approved_new_term = True
1126
+ term = _init_stage(
1127
+ "course discovery",
1128
+ "a2l init",
1129
+ lambda: _choose_active_term(active, school, preferred_term=preferred_term),
1130
+ )
1131
+ if term is None:
1132
+ raise _InitFailure(
1133
+ "course discovery",
1134
+ "a2l courses --all-terms",
1135
+ exit_code=NotConfigured.exit_code,
1136
+ detail="no active academic term",
1137
+ )
1138
+
1139
+ active_for_term = _sort_courses(course for course in active if course.term == term)
1140
+ if previous_term is not None and previous_term != term:
1141
+ if not approved_new_term and not typer.confirm(
1142
+ f"New term detected: {len(active_for_term)} courses. Sync?", default=True
1143
+ ):
1144
+ typer.echo("new term skipped; run: a2l init")
1145
+ return
1146
+ state = dict(state)
1147
+ state.pop("selected_offering_ids", None)
1148
+ state.pop("file_scope", None)
1149
+ state.pop("file_complete", None)
1150
+ state["metadata_complete"] = False
1151
+ state["term"] = term
1152
+ state = _init_stage(
1153
+ "course selection", "a2l init", lambda: _save_init_state(claimed, state)
1154
+ )
1155
+
1156
+ selection_is_persisted = state.get("term") == term and "selected_offering_ids" in state
1157
+ if selection_is_persisted:
1158
+ selected_ids = _state_offering_ids(state)
1159
+ by_id = {course.org_unit_id: course for course in active_for_term}
1160
+ selected = [by_id[offering_id] for offering_id in selected_ids if offering_id in by_id]
1161
+ _print_course_selection(term, active_for_term, selected, school, persisted=True)
1162
+ else:
1163
+ selected = _init_stage(
1164
+ "course selection",
1165
+ "a2l init",
1166
+ lambda: _prompt_course_selection(term, active_for_term, school),
1167
+ )
1168
+ state = dict(state)
1169
+ state.update(
1170
+ {
1171
+ "term": term,
1172
+ "selected_offering_ids": [course.org_unit_id for course in selected],
1173
+ "metadata_complete": False,
1174
+ "file_complete": False,
1175
+ }
1176
+ )
1177
+ state = _init_stage(
1178
+ "course selection", "a2l init", lambda: _save_init_state(claimed, state)
1179
+ )
1180
+
1181
+ selected_ids = [course.org_unit_id for course in selected]
1182
+ metadata: MetadataReport | None = None
1183
+ if state.get("metadata_complete") is not True:
1184
+ typer.echo(
1185
+ f"→ reading {len(selected)} courses… (metadata only — seconds)"
1186
+ )
1187
+ metadata = _init_stage(
1188
+ "metadata sync",
1189
+ "a2l init",
1190
+ lambda: ingest_metadata(
1191
+ client,
1192
+ Vault(claimed),
1193
+ school,
1194
+ term=term,
1195
+ only=selected_ids,
1196
+ include_grades=include_grades,
1197
+ create_snapshot=False,
1198
+ ),
1199
+ )
1200
+ if _report_has_errors(metadata):
1201
+ categories = _report_error_categories(metadata)
1202
+ raise _InitFailure("metadata sync", "a2l init", detail=f"coverage gap: {categories}")
1203
+ state = _init_stage(
1204
+ "metadata sync",
1205
+ "a2l init",
1206
+ lambda: _update_init_state(
1207
+ claimed,
1208
+ state,
1209
+ metadata_complete=True,
1210
+ last_seen_term=latest_active_term or term,
1211
+ ),
1212
+ )
1213
+ else:
1214
+ typer.echo(f"{console.GLYPH['ok']} metadata already synced")
1215
+
1216
+ _init_stage(
1217
+ "summary",
1218
+ "a2l init",
1219
+ lambda: _print_metadata_summary(
1220
+ metadata,
1221
+ claimed,
1222
+ school,
1223
+ term=term,
1224
+ selected_count=len(selected),
1225
+ include_grades=include_grades,
1226
+ ),
1227
+ )
1228
+
1229
+ if state.get("file_complete") is not True:
1230
+ if metadata is None:
1231
+ metadata = _init_stage(
1232
+ "file estimate",
1233
+ "a2l init",
1234
+ lambda: load_metadata_report(Vault(claimed), school, selected),
1235
+ )
1236
+ estimate_topics: Iterable[object] = _iter_report_topics(metadata)
1237
+ _init_stage("file estimate", "a2l init", lambda: _print_file_estimates(estimate_topics))
1238
+ stored_scope = state.get("file_scope")
1239
+ if stored_scope in _INIT_FILE_SCOPES:
1240
+ scope_choice = str(stored_scope)
1241
+ else:
1242
+ scope_choice = _init_stage("file choice", "a2l init", _prompt_file_scope)
1243
+ state = _init_stage(
1244
+ "file choice",
1245
+ "a2l init",
1246
+ lambda: _update_init_state(claimed, state, file_scope=scope_choice),
1247
+ )
1248
+
1249
+ download_files = scope_choice != "later"
1250
+ pipeline_report = _init_stage(
1251
+ "file sync" if download_files else "local finalization",
1252
+ "a2l init",
1253
+ lambda: pipeline_module.run_pipeline(
1254
+ client,
1255
+ Vault(claimed),
1256
+ school,
1257
+ scope="priority" if scope_choice == "priority" else "all",
1258
+ include_media=False,
1259
+ include_grades=include_grades,
1260
+ include_discussions=False,
1261
+ ocr_words_per_page=cfg.ocr_words_per_page,
1262
+ term=term,
1263
+ only=selected_ids,
1264
+ metadata=metadata,
1265
+ download_files=download_files,
1266
+ render_outlines=download_files,
1267
+ profile_consent=state.get("profile_consent") is True,
1268
+ ),
1269
+ )
1270
+ if pipeline_report.exit_code:
1271
+ exit_code = 130 if pipeline_report.files.interrupted else pipeline_report.exit_code
1272
+ raise _InitFailure(
1273
+ "file sync" if download_files else "local finalization",
1274
+ "a2l init",
1275
+ exit_code=exit_code,
1276
+ detail="incomplete",
1277
+ )
1278
+ state = _init_stage(
1279
+ "file sync" if download_files else "file choice",
1280
+ "a2l init",
1281
+ lambda: _update_init_state(claimed, state, file_complete=True),
1282
+ )
1283
+ if pipeline_report.gaps:
1284
+ typer.echo(
1285
+ f"{console.GLYPH['warn']} recorded gaps ({', '.join(pipeline_report.gaps)}); "
1286
+ "details are in .a2l/AUDIT.md",
1287
+ err=True,
1288
+ )
1289
+ if download_files:
1290
+ typer.echo(
1291
+ f"{console.GLYPH['ok']} files · {pipeline_report.files.downloaded} downloaded · "
1292
+ f"{pipeline_report.files.skipped} skipped"
1293
+ )
1294
+ else:
1295
+ typer.echo("files deferred; metadata and deadlines are ready locally")
1296
+ else:
1297
+ typer.echo(
1298
+ f"{console.GLYPH['ok']} files already handled ({state.get('file_scope', 'later')})"
1299
+ )
1300
+
1301
+ typer.echo(
1302
+ "\nTry: a2l today\n"
1303
+ ' or ask your agent: "quiz me on COURSE 101 using only my lecture slides"\n'
1304
+ " include large media later: a2l sync --all --include-media"
1305
+ )
1306
+
1307
+
1308
+ def _init_stage(stage: str, next_command: str, operation: Callable[[], _T]) -> _T:
1309
+ """Run one stage and collapse all expected failures to one safe recovery command."""
1310
+
1311
+ try:
1312
+ return operation()
1313
+ except _InitFailure:
1314
+ raise
1315
+ except SessionExpired as exc:
1316
+ raise _InitFailure(
1317
+ stage, "a2l auth", exit_code=SessionExpired.exit_code, detail=type(exc).__name__
1318
+ ) from None
1319
+ except KeyboardInterrupt:
1320
+ raise
1321
+ except Exception as exc:
1322
+ raise _InitFailure(stage, next_command, detail=type(exc).__name__) from None
1323
+
1324
+
1325
+ def _render_init_failure(failure: _InitFailure) -> None:
1326
+ """Render a sanitized initializer failure and exactly one actionable command."""
1327
+
1328
+ typer.echo(f"init stopped during {failure.stage} ({failure.detail}).", err=True)
1329
+ typer.echo(f"run: {failure.next_command}", err=True)
1330
+ raise typer.Exit(code=failure.exit_code)
1331
+
1332
+
1333
+ def _resolve_init_vault(requested_vault: Path | None, cfg: config.Config) -> Path:
1334
+ value = cfg.vault if requested_vault is None else requested_vault
1335
+ return Path(value).expanduser().resolve()
1336
+
1337
+
1338
+ def _agent2learn_checkout(path: Path) -> bool:
1339
+ source_root = Path(__file__).resolve().parents[2]
1340
+ if not (
1341
+ paths.long_path(source_root / "pyproject.toml").is_file()
1342
+ and paths.long_path(source_root / "src" / "agent2learn").is_dir()
1343
+ ):
1344
+ return False
1345
+ try:
1346
+ path.relative_to(source_root)
1347
+ except ValueError:
1348
+ return False
1349
+ return True
1350
+
1351
+
1352
+ def _preview_vault(requested: Path) -> tuple[Path, bool]:
1353
+ """Return the existing vault or first safe candidate without writing anything."""
1354
+
1355
+ candidate = requested
1356
+ for suffix in range(2, 1002):
1357
+ if paths.is_link(candidate):
1358
+ candidate = requested.with_name(f"{requested.name}-{suffix}")
1359
+ continue
1360
+ if Vault.is_vault(candidate):
1361
+ return candidate, True
1362
+ if not paths.collides(candidate):
1363
+ return candidate, False
1364
+ candidate = requested.with_name(f"{requested.name}-{suffix}")
1365
+ raise ValueError("could not allocate a safe vault name")
1366
+
1367
+
1368
+ def _vault_prompt(requested: Path, candidate: Path, already_vault: bool) -> str:
1369
+ if already_vault:
1370
+ return (
1371
+ "Agent2Learn will use the existing local vault at "
1372
+ f"{_display_path(candidate)}. Continue?"
1373
+ )
1374
+ if candidate != requested:
1375
+ return (
1376
+ f"{_display_path(requested)} is occupied and is not an Agent2Learn vault. "
1377
+ f"Agent2Learn will create {_display_path(candidate)} instead. Continue?"
1378
+ )
1379
+ return f"Agent2Learn will create a local vault at {_display_path(candidate)}. Continue?"
1380
+
1381
+
1382
+ def _display_path(path: Path) -> str:
1383
+ home = Path.home()
1384
+ try:
1385
+ relative = path.relative_to(home)
1386
+ except ValueError:
1387
+ return str(path)
1388
+ return "~" if not relative.parts else f"~/{relative.as_posix()}"
1389
+
1390
+
1391
+ def _ensure_obsidian_config(root: Path) -> None:
1392
+ destination = root / ".obsidian"
1393
+ # Check the complete path before probing ``is_dir``: a linked vault component could otherwise
1394
+ # make an external Obsidian directory look like this vault's configuration.
1395
+ if paths.has_link_component(destination, root=root):
1396
+ raise ValueError("Obsidian configuration path contains a symlink")
1397
+ if paths.is_link(destination):
1398
+ raise ValueError("Obsidian configuration directory is a symlink")
1399
+ if paths.long_path(destination).is_dir():
1400
+ return
1401
+ if paths.long_path(destination).is_file():
1402
+ raise ValueError("Obsidian configuration path is not a directory")
1403
+ paths.long_path(destination).mkdir(parents=True, exist_ok=False)
1404
+ if paths.has_link_component(destination, root=root):
1405
+ raise ValueError("Obsidian configuration path contains a symlink")
1406
+ paths.atomic_write_text(destination / "app.json", '{"showLineNumber":true}\n', root=root)
1407
+
1408
+
1409
+ def _read_init_state(root: Path) -> dict[str, object]:
1410
+ state_dir = root / ".a2l"
1411
+ if paths.is_link(state_dir):
1412
+ raise ValueError("initializer state directory must not be a symlink")
1413
+ if paths.long_path(state_dir).is_file():
1414
+ raise ValueError("initializer state path is not a directory")
1415
+ if not paths.long_path(state_dir).is_dir():
1416
+ return {}
1417
+ destination = state_dir / _INIT_STATE_FILENAME
1418
+ if paths.is_link(destination):
1419
+ raise ValueError("initializer state must not be a symlink")
1420
+ if not paths.long_path(destination).is_file():
1421
+ return {}
1422
+ try:
1423
+ with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
1424
+ raw: Any = json.load(handle)
1425
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1426
+ raise ValueError("initializer state is unreadable") from exc
1427
+ if not isinstance(raw, dict):
1428
+ raise ValueError("initializer state must be an object")
1429
+ state = {str(key): value for key, value in raw.items()}
1430
+ _validate_init_state(state)
1431
+ return state
1432
+
1433
+
1434
+ def _validate_init_state(state: dict[str, object]) -> None:
1435
+ version = state.get("schema_version", _INIT_SCHEMA_VERSION)
1436
+ if isinstance(version, bool) or version != _INIT_SCHEMA_VERSION:
1437
+ raise ValueError("initializer state schema is unsupported")
1438
+ for key in (
1439
+ "vault_confirmed",
1440
+ "grades_configured",
1441
+ "include_grades",
1442
+ "profile_consent",
1443
+ "authenticated",
1444
+ "metadata_complete",
1445
+ "file_complete",
1446
+ ):
1447
+ if key in state and not isinstance(state[key], bool):
1448
+ raise ValueError(f"initializer state field {key} is invalid")
1449
+ for key in ("school", "term", "last_seen_term"):
1450
+ if key in state and not isinstance(state[key], str):
1451
+ raise ValueError(f"initializer state field {key} is invalid")
1452
+ for key in ("file_scope", "skills_status", "auth_backend"):
1453
+ if key in state and not isinstance(state[key], str):
1454
+ raise ValueError(f"initializer state field {key} is invalid")
1455
+ if state.get("file_scope") not in (None, *_INIT_FILE_SCOPES):
1456
+ raise ValueError("initializer state file scope is invalid")
1457
+ if state.get("skills_status") not in (None, *_INIT_SKILL_STATUSES):
1458
+ raise ValueError("initializer state skill status is invalid")
1459
+ if state.get("auth_backend") not in (None, *_INIT_AUTH_BACKENDS):
1460
+ raise ValueError("initializer state authentication backend is invalid")
1461
+ offering_ids = state.get("selected_offering_ids")
1462
+ if offering_ids is not None and (
1463
+ not isinstance(offering_ids, list)
1464
+ or any(
1465
+ isinstance(value, bool) or not isinstance(value, int) or value <= 0
1466
+ for value in offering_ids
1467
+ )
1468
+ or len(set(offering_ids)) != len(offering_ids)
1469
+ ):
1470
+ raise ValueError("initializer state offering IDs are invalid")
1471
+
1472
+
1473
+ def _save_init_state(root: Path, state: dict[str, object]) -> dict[str, object]:
1474
+ _validate_init_state(state)
1475
+ state_dir = root / ".a2l"
1476
+ if paths.is_link(state_dir):
1477
+ raise ValueError("initializer state directory must not be a symlink")
1478
+ if not paths.long_path(state_dir).is_dir():
1479
+ raise ValueError("initializer state directory is unavailable")
1480
+ payload = {"schema_version": _INIT_SCHEMA_VERSION, **state}
1481
+ paths.atomic_write_text(
1482
+ state_dir / _INIT_STATE_FILENAME,
1483
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
1484
+ root=root,
1485
+ )
1486
+ return payload
1487
+
1488
+
1489
+ def _update_init_state(
1490
+ root: Path, state: dict[str, object], **updates: object
1491
+ ) -> dict[str, object]:
1492
+ updated = dict(state)
1493
+ updated.update(updates)
1494
+ return _save_init_state(root, updated)
1495
+
1496
+
1497
+ def _configure_init_skills(root: Path, state: dict[str, object]) -> dict[str, object]:
1498
+ if state.get("skills_status") in {"installed", "declined"}:
1499
+ return state
1500
+ detected_agents = skills_module.detect_installed_agents()
1501
+ project_destinations = skills_module.detect_destinations(scope="project", project=root)
1502
+ if detected_agents:
1503
+ agents = list(detected_agents)
1504
+ elif project_destinations:
1505
+ agents = sorted(
1506
+ {agent for destination in project_destinations for agent in destination.agents}
1507
+ )
1508
+ else:
1509
+ typer.echo("No detected agent skill destinations; skipping project-local skills.")
1510
+ return _update_init_state(root, state, skills_status="unavailable")
1511
+ typer.echo(f"Found {_join_words(agents)}.")
1512
+
1513
+ def confirm(preview: str) -> bool:
1514
+ typer.echo(preview, nl=False)
1515
+ return typer.confirm(
1516
+ f"Install {len(skills_module.SKILL_SLUGS)} skills into this project?", default=True
1517
+ )
1518
+
1519
+ if detected_agents:
1520
+ result = skills_module.install_detected_project(
1521
+ project=root,
1522
+ agents=detected_agents,
1523
+ force=False,
1524
+ link=False,
1525
+ confirm=confirm,
1526
+ )
1527
+ else:
1528
+ result = skills_module.install(
1529
+ scope="project",
1530
+ project=root,
1531
+ force=False,
1532
+ link=False,
1533
+ confirm=confirm,
1534
+ )
1535
+ if result.cancelled:
1536
+ typer.echo("agent skills skipped")
1537
+ return _update_init_state(root, state, skills_status="declined")
1538
+ typer.echo(
1539
+ f"{console.GLYPH['ok']} agent skills "
1540
+ f"{len(skills_module.SKILL_SLUGS)} installed project-locally"
1541
+ )
1542
+ return _update_init_state(root, state, skills_status="installed")
1543
+
1544
+
1545
+ def _configure_init_grades(
1546
+ root: Path, state: dict[str, object], cfg: config.Config
1547
+ ) -> tuple[dict[str, object], bool]:
1548
+ if state.get("grades_configured") is True:
1549
+ return state, state.get("include_grades") is True
1550
+ include_grades = typer.confirm(
1551
+ "Include private grade values in local syncs?", default=cfg.include_grades
1552
+ )
1553
+ return (
1554
+ _update_init_state(
1555
+ root,
1556
+ state,
1557
+ grades_configured=True,
1558
+ include_grades=include_grades,
1559
+ ),
1560
+ include_grades,
1561
+ )
1562
+
1563
+
1564
+ def _save_init_config(cfg: config.Config, root: Path, include_grades: bool) -> config.Config:
1565
+ updated = replace(
1566
+ cfg,
1567
+ vault=root,
1568
+ school=UWaterloo.id,
1569
+ include_grades=include_grades,
1570
+ )
1571
+ if updated != cfg:
1572
+ config.save(updated)
1573
+ return updated
1574
+
1575
+
1576
+ def _configure_init_auth(root: Path, state: dict[str, object]) -> tuple[dict[str, object], str]:
1577
+ if "profile_consent" not in state:
1578
+ typer.echo(
1579
+ "Agent2Learn will open a dedicated local browser profile. It keeps Waterloo/Duo\n"
1580
+ "remembered sign-in state on this device. Clear it later with: a2l auth --clear-profile"
1581
+ )
1582
+ profile_consent = typer.confirm("Continue?", default=True)
1583
+ state = dict(state)
1584
+ state["profile_consent"] = profile_consent
1585
+ state = _save_init_state(root, state)
1586
+ else:
1587
+ profile_consent = state.get("profile_consent") is True
1588
+
1589
+ if profile_consent:
1590
+ backend = "auto"
1591
+ else:
1592
+ stored_backend = state.get("auth_backend")
1593
+ if stored_backend == "paste":
1594
+ backend = "paste"
1595
+ else:
1596
+ typer.echo("Dedicated profile skipped; the hidden-TTY paste path is still available.")
1597
+ if not typer.confirm("Use hidden-TTY cookie paste now?", default=True):
1598
+ raise _InitFailure("authentication", "a2l auth --paste", detail="cancelled")
1599
+ backend = "paste"
1600
+ state = dict(state)
1601
+ state["auth_backend"] = backend
1602
+ return _save_init_state(root, state), backend
1603
+
1604
+
1605
+ def _calibration_courses(calibration: Calibration) -> list[CourseRef]:
1606
+ courses = calibration.courses
1607
+ if not isinstance(courses, Sequence):
1608
+ raise ValueError("calibration courses are invalid")
1609
+ if any(not isinstance(course, CourseRef) for course in courses):
1610
+ raise ValueError("calibration courses are invalid")
1611
+ return list(courses)
1612
+
1613
+
1614
+ def _sort_courses(courses: Iterable[CourseRef]) -> list[CourseRef]:
1615
+ return sorted(courses, key=lambda course: (course.code.casefold(), course.org_unit_id))
1616
+
1617
+
1618
+ def _infer_active_term(courses: Sequence[CourseRef]) -> str | None:
1619
+ terms = {course.term for course in courses if course.term is not None}
1620
+ if not terms:
1621
+ return None
1622
+ return max(terms, key=_term_sort_key)
1623
+
1624
+
1625
+ def _new_active_terms(courses: Sequence[CourseRef], previous_term: str | None) -> tuple[str, ...]:
1626
+ if previous_term is None:
1627
+ return ()
1628
+ previous_key = _term_sort_key(previous_term)
1629
+ return tuple(
1630
+ sorted(
1631
+ {
1632
+ course.term
1633
+ for course in courses
1634
+ if course.term is not None and _term_sort_key(course.term) > previous_key
1635
+ },
1636
+ key=_term_sort_key,
1637
+ )
1638
+ )
1639
+
1640
+
1641
+ def _choose_active_term(
1642
+ courses: Sequence[CourseRef], school: UWaterloo, *, preferred_term: str | None = None
1643
+ ) -> str | None:
1644
+ """Require an explicit term choice when the enrollment projection is ambiguous."""
1645
+
1646
+ terms = sorted(
1647
+ {course.term for course in courses if course.term is not None},
1648
+ key=_term_sort_key,
1649
+ )
1650
+ if not terms:
1651
+ return None
1652
+ if len(terms) == 1:
1653
+ return terms[0]
1654
+
1655
+ default = preferred_term if preferred_term in terms else _infer_active_term(courses)
1656
+ typer.echo("Multiple active academic terms found; choose which term to sync:")
1657
+ for term in terms:
1658
+ count = sum(1 for course in courses if course.term == term)
1659
+ typer.echo(f" {_term_label(school, term)} [{term}] · {count} courses")
1660
+ choices = {term.casefold(): term for term in terms}
1661
+ while True:
1662
+ selected = str(typer.prompt("Choose an active term code", default=default)).strip()
1663
+ resolved = choices.get(selected.casefold())
1664
+ if resolved is not None:
1665
+ return resolved
1666
+ typer.echo(f"Choose one of: {', '.join(terms)}.")
1667
+
1668
+
1669
+ def _term_sort_key(term: str) -> tuple[int, str]:
1670
+ return (int(term), term) if term.isdigit() else (0, term.casefold())
1671
+
1672
+
1673
+ def _term_label(school: UWaterloo, term: str) -> str:
1674
+ try:
1675
+ return school.term_label(term)
1676
+ except ValueError:
1677
+ return f"Term {term}"
1678
+
1679
+
1680
+ def _prompt_course_selection(
1681
+ term: str, courses: Sequence[CourseRef], school: UWaterloo
1682
+ ) -> list[CourseRef]:
1683
+ label = _term_label(school, term)
1684
+ typer.echo(f"{label} · {len(courses)} academic courses found.")
1685
+ for index, course in enumerate(courses, start=1):
1686
+ typer.echo(f" {index}. {course.code} [{course.org_unit_id}] — {course.name}")
1687
+ if typer.confirm(f"{label} · {len(courses)} academic courses found. Sync all?", default=True):
1688
+ return list(courses)
1689
+ while True:
1690
+ value = typer.prompt(
1691
+ "Select courses by number, code, or stable offering ID "
1692
+ "(comma-separated; empty for none)",
1693
+ default="none",
1694
+ )
1695
+ try:
1696
+ return _parse_course_selection(value, courses)
1697
+ except ValueError:
1698
+ typer.echo("Selection did not match a known course; try again.")
1699
+
1700
+
1701
+ def _parse_course_selection(value: str, courses: Sequence[CourseRef]) -> list[CourseRef]:
1702
+ tokens = [token for token in re.split(r"[,\s]+", value.strip()) if token]
1703
+ if not tokens or tokens == ["none"]:
1704
+ return []
1705
+ if len(tokens) == 1 and tokens[0].casefold() == "all":
1706
+ return list(courses)
1707
+ selected: list[CourseRef] = []
1708
+ for token in tokens:
1709
+ match: CourseRef | None = None
1710
+ if token.isdigit():
1711
+ match = next((course for course in courses if str(course.org_unit_id) == token), None)
1712
+ if match is None:
1713
+ position = int(token)
1714
+ if 1 <= position <= len(courses):
1715
+ match = courses[position - 1]
1716
+ else:
1717
+ match = next(
1718
+ (course for course in courses if course.code.casefold() == token.casefold()), None
1719
+ )
1720
+ if match is None:
1721
+ raise ValueError("course selection contains an unknown offering")
1722
+ if match not in selected:
1723
+ selected.append(match)
1724
+ return selected
1725
+
1726
+
1727
+ def _print_course_selection(
1728
+ term: str,
1729
+ available: Sequence[CourseRef],
1730
+ selected: Sequence[CourseRef],
1731
+ school: UWaterloo,
1732
+ *,
1733
+ persisted: bool,
1734
+ ) -> None:
1735
+ label = _term_label(school, term)
1736
+ suffix = " (saved selection)" if persisted else ""
1737
+ typer.echo(f"{label} · {len(available)} academic courses found{suffix}.")
1738
+ typer.echo(f"{console.GLYPH['ok']} {len(selected)} academic offerings selected")
1739
+
1740
+
1741
+ def _state_offering_ids(state: dict[str, object]) -> list[int]:
1742
+ value = state.get("selected_offering_ids", [])
1743
+ if not isinstance(value, list):
1744
+ raise ValueError("initializer state offering IDs are invalid")
1745
+ return [value for value in value if isinstance(value, int) and not isinstance(value, bool)]
1746
+
1747
+
1748
+ def _print_sync_metadata(report: MetadataReport) -> None:
1749
+ """Expose the complete cheap metadata value before browser or file work starts."""
1750
+
1751
+ typer.echo(
1752
+ f"{console.GLYPH['ok']} metadata · {len(report.courses)} courses · "
1753
+ f"{report.topic_count} topics · {report.deadline_count} deadlines"
1754
+ )
1755
+
1756
+
1757
+ def _report_has_errors(report: MetadataReport) -> bool:
1758
+ errors = getattr(report, "errors", ())
1759
+ exit_code = getattr(report, "exit_code", 0)
1760
+ return bool(errors) or (isinstance(exit_code, int) and exit_code != 0)
1761
+
1762
+
1763
+ def _session_matches_school(session_value: object, school: object) -> bool:
1764
+ """Allow a saved session only when its LEARN origin matches the selected school."""
1765
+
1766
+ session_base = getattr(session_value, "base_url", None)
1767
+ school_base = getattr(school, "base_url", None)
1768
+ return (
1769
+ isinstance(session_base, str)
1770
+ and isinstance(school_base, str)
1771
+ and session_base.rstrip("/") == school_base.rstrip("/")
1772
+ )
1773
+
1774
+
1775
+ def _report_error_categories(report: MetadataReport) -> str:
1776
+ """Join the report's sanitized category-level error strings without duplicates."""
1777
+
1778
+ return ", ".join(dict.fromkeys(report.errors)) or "unreadable report"
1779
+
1780
+
1781
+ def _read_metadata_rows(path: Path) -> list[dict[str, object]]:
1782
+ if paths.is_link(path) or not paths.long_path(path).is_file():
1783
+ return []
1784
+ try:
1785
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
1786
+ raw: Any = json.load(handle)
1787
+ except (OSError, UnicodeError, json.JSONDecodeError):
1788
+ return []
1789
+ if not isinstance(raw, list):
1790
+ return []
1791
+ return [row for row in raw if isinstance(row, dict)]
1792
+
1793
+
1794
+ _WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
1795
+ _MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
1796
+
1797
+
1798
+ def _format_deadline(value: str, school: UWaterloo) -> str:
1799
+ try:
1800
+ local = datetime.fromisoformat(render_timestamp(value, school))
1801
+ except (TypeError, ValueError):
1802
+ return "time unavailable"
1803
+ hour = local.hour % 12 or 12
1804
+ suffix = "am" if local.hour < 12 else "pm"
1805
+ return (
1806
+ f"{_WEEKDAYS[local.weekday()]} {_MONTHS[local.month - 1]} {local.day}, "
1807
+ f"{hour}:{local.minute:02d}{suffix}"
1808
+ )
1809
+
1810
+
1811
+ def _first_value_deadlines(
1812
+ rows: Sequence[tuple[str, str, str]], school: UWaterloo, *, limit: int = 5
1813
+ ) -> list[tuple[str, str, str]]:
1814
+ del school
1815
+ now = clock.now()
1816
+ upcoming: list[tuple[datetime, tuple[str, str, str]]] = []
1817
+ overdue: list[tuple[datetime, tuple[str, str, str]]] = []
1818
+ invalid: list[tuple[str, str, str]] = []
1819
+ for row in rows:
1820
+ try:
1821
+ instant = parse_api_timestamp(row[0])
1822
+ except (TypeError, ValueError):
1823
+ invalid.append(row)
1824
+ continue
1825
+ (upcoming if instant >= now else overdue).append((instant, row))
1826
+ ordered = [row for _, row in sorted(upcoming, key=lambda item: item[0])]
1827
+ ordered.extend(row for _, row in sorted(overdue, key=lambda item: item[0], reverse=True))
1828
+ ordered.extend(sorted(invalid))
1829
+ return ordered[:limit]
1830
+
1831
+
1832
+ def _print_metadata_summary(
1833
+ report: MetadataReport | None,
1834
+ root: Path,
1835
+ school: UWaterloo,
1836
+ *,
1837
+ term: str,
1838
+ selected_count: int,
1839
+ include_grades: bool,
1840
+ ) -> None:
1841
+ if report is None:
1842
+ typer.echo(f"{console.GLYPH['ok']} metadata is ready for {_term_label(school, term)}")
1843
+ return
1844
+ reports = getattr(report, "courses", ())
1845
+ course_count = len(reports) if isinstance(reports, Sequence) else selected_count
1846
+ topic_count = getattr(report, "topic_count", 0)
1847
+ deadline_count = getattr(report, "deadline_count", 0)
1848
+ if not isinstance(topic_count, int):
1849
+ topic_count = 0
1850
+ if not isinstance(deadline_count, int):
1851
+ deadline_count = 0
1852
+
1853
+ assignment_count = 0
1854
+ quiz_count = 0
1855
+ deadlines: list[tuple[str, str, str]] = []
1856
+ if isinstance(reports, Sequence):
1857
+ for course_report in reports:
1858
+ directory = getattr(course_report, "directory", None)
1859
+ course = getattr(course_report, "course", None)
1860
+ if not isinstance(directory, Path):
1861
+ continue
1862
+ code = str(getattr(course, "code", "course"))
1863
+ assignments = _read_metadata_rows(directory / "_meta" / "assignments.json")
1864
+ quizzes = _read_metadata_rows(directory / "_meta" / "quizzes.json")
1865
+ assignment_count += len(assignments)
1866
+ quiz_count += len(quizzes)
1867
+ for row in [*assignments, *quizzes]:
1868
+ due = row.get("due_date")
1869
+ title = row.get("title")
1870
+ if isinstance(due, str) and due and isinstance(title, str) and title:
1871
+ deadlines.append((due, title, code))
1872
+
1873
+ grade_text = "grades synced" if include_grades else "grades not synced"
1874
+ detail = f"{deadline_count} deadlines"
1875
+ if assignment_count or quiz_count:
1876
+ detail = f"{assignment_count} assignments · {quiz_count} quizzes · {detail}"
1877
+ typer.echo(
1878
+ f"{console.GLYPH['ok']} {course_count} courses · {topic_count} topics · "
1879
+ f"{detail} · {grade_text}"
1880
+ )
1881
+ for due, title, code in _first_value_deadlines(deadlines, school):
1882
+ typer.echo(f" {code} · {title} — due {_format_deadline(due, school)}")
1883
+ if not deadlines:
1884
+ typer.echo(f" No upcoming deadlines recorded in {_term_label(school, term)} metadata.")
1885
+ del root
1886
+
1887
+
1888
+ def _iter_report_topics(value: MetadataReport | Iterable[object] | None) -> Iterable[object]:
1889
+ if value is None:
1890
+ return ()
1891
+ if not isinstance(value, MetadataReport):
1892
+ return value
1893
+ reports = value.courses
1894
+ topics: list[object] = []
1895
+ for course_report in reports:
1896
+ values = getattr(course_report, "topics", ())
1897
+ if isinstance(values, Sequence) and not isinstance(values, (str, bytes)):
1898
+ topics.extend(values)
1899
+ return topics
1900
+
1901
+
1902
+ def _topic_is_media(topic: object) -> bool:
1903
+ return isinstance(topic, TopicRecord) and is_media_topic(topic)
1904
+
1905
+
1906
+ def _topic_size_summary(topics: Sequence[TopicRecord]) -> tuple[int, bool, int]:
1907
+ size = 0
1908
+ unknown = False
1909
+ for topic in topics:
1910
+ remote_size = topic.remote_size
1911
+ if isinstance(remote_size, int) and not isinstance(remote_size, bool) and remote_size >= 0:
1912
+ size += remote_size
1913
+ else:
1914
+ unknown = True
1915
+ return size, unknown, len(topics)
1916
+
1917
+
1918
+ def _print_file_estimates(topics: Iterable[object]) -> None:
1919
+ downloadable = [
1920
+ topic
1921
+ for topic in topics
1922
+ if isinstance(topic, TopicRecord) and is_downloadable_topic(topic, include_media=True)
1923
+ ]
1924
+ documents = [topic for topic in downloadable if not _topic_is_media(topic)]
1925
+ media = [topic for topic in downloadable if _topic_is_media(topic)]
1926
+ priority_topics = list(select_priority_topics(documents, budget=PRIORITY_BUDGET_BYTES))
1927
+ document_size, document_unknown, document_count = _topic_size_summary(documents)
1928
+ priority_size, priority_unknown, priority_count = _topic_size_summary(priority_topics)
1929
+ media_size, media_unknown, media_count = _topic_size_summary(media)
1930
+
1931
+ full = _size_estimate(document_size, document_unknown, document_count)
1932
+ priority = _size_estimate(priority_size, priority_unknown, priority_count)
1933
+ duration = _duration_estimate(document_size, document_unknown)
1934
+ priority_duration = _duration_estimate(priority_size, priority_unknown)
1935
+ typer.echo("Files:")
1936
+ typer.echo(f" full document archive {full} ({duration}; recommended; media excluded)")
1937
+ typer.echo(
1938
+ f" priority set {priority} ({priority_duration}; "
1939
+ f"{PRIORITY_BUDGET_BYTES // 1_000_000} MB budget)"
1940
+ )
1941
+ typer.echo(" or download later")
1942
+ if media_count:
1943
+ media_label = _size_estimate(media_size, media_unknown, media_count)
1944
+ typer.echo(f" audio/video {media_label} excluded; opt in later with --include-media")
1945
+
1946
+
1947
+ def _size_estimate(size: int, unknown: bool, count: int) -> str:
1948
+ if unknown:
1949
+ return "unknown"
1950
+ if count == 0:
1951
+ return "0 B"
1952
+ if size >= 1024 * 1024:
1953
+ return f"~{size / (1024 * 1024):.0f} MB"
1954
+ if size >= 1024:
1955
+ return f"~{size / 1024:.0f} KB"
1956
+ return f"~{size} B"
1957
+
1958
+
1959
+ def _duration_estimate(size: int, unknown: bool) -> str:
1960
+ if unknown:
1961
+ return "duration unknown"
1962
+ seconds = max(1, (size + (5 * 1024 * 1024) - 1) // (5 * 1024 * 1024))
1963
+ minutes = max(1, (seconds + 59) // 60)
1964
+ return f"~{minutes} min"
1965
+
1966
+
1967
+ def _prompt_file_scope() -> str:
1968
+ while True:
1969
+ choice = str(typer.prompt("Choose [full/priority/later]", default="full"))
1970
+ choice = choice.strip().casefold()
1971
+ if choice in _INIT_FILE_SCOPES:
1972
+ return choice
1973
+ typer.echo("Choose one of: full, priority, later.")
1974
+
1975
+
1976
+ def _join_words(values: Sequence[str]) -> str:
1977
+ if len(values) == 1:
1978
+ return values[0]
1979
+ if len(values) == 2:
1980
+ return f"{values[0]} and {values[1]}"
1981
+ return f"{', '.join(values[:-1])}, and {values[-1]}"
1982
+
1983
+
1984
+ def _courses_json(courses: list[CourseRef], *, all_terms: bool) -> str:
1985
+ """Serialize only typed calibration fields; no live response or hidden API data is emitted."""
1986
+
1987
+ rows = [
1988
+ {
1989
+ "code": course.code,
1990
+ "is_active": course.is_active,
1991
+ "name": course.name,
1992
+ "org_unit_id": course.org_unit_id,
1993
+ "term": course.term,
1994
+ }
1995
+ for course in courses
1996
+ ]
1997
+ terms_set: set[str] = set()
1998
+ for course in courses:
1999
+ if course.term is not None:
2000
+ terms_set.add(course.term)
2001
+ terms = sorted(terms_set)
2002
+ return json.dumps(
2003
+ {"all_terms": all_terms, "courses": rows, "distinct_terms": terms},
2004
+ ensure_ascii=False,
2005
+ sort_keys=True,
2006
+ indent=2,
2007
+ )
2008
+
2009
+
2010
+ def _print_courses(courses: list[CourseRef], *, all_terms: bool) -> None:
2011
+ if not courses:
2012
+ typer.echo("No calibrated academic course offerings found.")
2013
+ return
2014
+
2015
+ school = UWaterloo()
2016
+ if all_terms:
2017
+ terms: list[str | None] = sorted(
2018
+ {course.term for course in courses}, key=lambda term: (term is None, term or "")
2019
+ )
2020
+ typer.echo(f"Distinct terms: {len(terms)}")
2021
+ for term in terms:
2022
+ label = "unclassified" if term is None else school.term_label(term)
2023
+ typer.echo(f"\n{label} ({term or 'none'})")
2024
+ for course in courses:
2025
+ if course.term == term:
2026
+ typer.echo(_course_line(course))
2027
+ return
2028
+
2029
+ typer.echo(f"Active academic offerings: {len(courses)}")
2030
+ for course in courses:
2031
+ typer.echo(_course_line(course))
2032
+
2033
+
2034
+ def _course_line(course: CourseRef) -> str:
2035
+ return f" {course.code} [{course.org_unit_id}] — {course.name}"
2036
+
2037
+
2038
+ if __name__ == "__main__": # pragma: no cover
2039
+ app()