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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- 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()
|