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/doctor.py ADDED
@@ -0,0 +1,1167 @@
1
+ """Diagnostics, and a support report that is safe to paste in public.
2
+
3
+ Two audiences, two functions, deliberately not shared.
4
+
5
+ ``render`` writes for the person at the keyboard. It may show their vault path, because
6
+ that is exactly what they need to see, and it always ends with **one** next command — a
7
+ diagnostic that lists six problems and no action is a diagnostic that gets ignored.
8
+
9
+ ``report`` writes for a stranger reading a GitHub issue. It is built from an **allowlist**:
10
+ each field is named and rendered individually, and anything not named is dropped. A denylist
11
+ would only remove the leaks someone anticipated, and every check added later would silently
12
+ become a new way to leak a name, a course code, or a home directory.
13
+
14
+ ``doctor`` contacts only the configured LEARN host. It performs no version check against
15
+ PyPI or GitHub, because a diagnostic command should not be a phone-home.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import platform
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ from collections.abc import Callable, Iterable, Sequence
28
+ from dataclasses import dataclass
29
+ from datetime import UTC, datetime
30
+ from pathlib import Path, PurePosixPath
31
+ from typing import Literal, Protocol
32
+ from urllib.parse import urlencode
33
+
34
+ import requests
35
+
36
+ from agent2learn import __version__, console, paths
37
+ from agent2learn import config as config_module
38
+ from agent2learn import session as session_module
39
+ from agent2learn import skills as skills_module
40
+ from agent2learn.auth import cdp
41
+ from agent2learn.errors import A2LError, SessionExpired
42
+ from agent2learn.index import read_content_map
43
+ from agent2learn.vault import Vault
44
+
45
+ Status = Literal["ok", "warn", "fail"]
46
+
47
+ ISSUE_URL = "https://github.com/ManagementMO/agent2learn/issues/new"
48
+ LONG_PATH_ADVISORY = 240
49
+ MAX_ISSUE_URL_LENGTH = 8_000
50
+
51
+ _STATUS_GLYPH: dict[Status, str] = {"ok": "ok", "warn": "warn", "fail": "fail"}
52
+ _A2L_RECOVERY = re.compile(
53
+ r"\ba2l\s+[a-z][a-z0-9-]*(?:\s+(?!--)[a-z][a-z0-9-]*)?(?:\s+--[a-z][a-z0-9-]*)*"
54
+ )
55
+ _REGISTERED_CLI_COMMANDS = frozenset(
56
+ {
57
+ "auth",
58
+ "calendar",
59
+ "courses",
60
+ "diff",
61
+ "doctor",
62
+ "fetch",
63
+ "init",
64
+ "open",
65
+ "privacy",
66
+ "skills",
67
+ "sync",
68
+ "today",
69
+ "where",
70
+ }
71
+ )
72
+ _GROUP_ORDER = (
73
+ "Environment",
74
+ "Filesystem",
75
+ "Session",
76
+ "Optional tools",
77
+ "Skills",
78
+ "Vault",
79
+ )
80
+
81
+
82
+ class DoctorClient(Protocol):
83
+ """The small live-session surface that diagnostics need from the API client."""
84
+
85
+ def get_json(self, path: str) -> object:
86
+ """Fetch one same-origin JSON endpoint through the bounded API transport."""
87
+
88
+
89
+ _COURSE_SOURCE_DIRECTORIES = frozenset(
90
+ {"announcements", "assignments", "content", "quizzes", "outlines"}
91
+ )
92
+ _SAFE_PUBLIC_NOTES = {"fs.vault": "vault root `~`"}
93
+ _PUBLIC_CHECK_NAMES = frozenset(
94
+ {
95
+ "config.load",
96
+ "env.encoding",
97
+ "env.platform",
98
+ "env.python",
99
+ "env.unavailable",
100
+ "env.uv",
101
+ "env.version",
102
+ "fs.disk",
103
+ "fs.git",
104
+ "fs.long_paths",
105
+ "fs.longest_path",
106
+ "fs.unavailable",
107
+ "fs.vault",
108
+ "session.age",
109
+ "session.api_versions",
110
+ "session.backend",
111
+ "session.present",
112
+ "session.unavailable",
113
+ "session.whoami",
114
+ "skills.installed",
115
+ "skills.unavailable",
116
+ "tools.browser",
117
+ "tools.tesseract",
118
+ "tools.unavailable",
119
+ "vault.citable",
120
+ "vault.courses",
121
+ "vault.empty_twins",
122
+ "vault.gaps",
123
+ "vault.last_sync",
124
+ "vault.present",
125
+ "vault.terms",
126
+ "vault.unavailable",
127
+ }
128
+ )
129
+ _PUBLIC_STATUSES = frozenset({"ok", "warn", "fail"})
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Check:
134
+ """One diagnostic result.
135
+
136
+ ``detail`` and ``fix`` are written for the user's own terminal and legitimately contain
137
+ vault paths and course names, so ``report`` never emits them.
138
+
139
+ ``public`` is a marker for a check that has something genuinely useful to contribute to a
140
+ support report. ``report`` still maps it through a fixed note allowlist, so a check added
141
+ later cannot leak arbitrary text by default.
142
+ """
143
+
144
+ group: str
145
+ name: str
146
+ status: Status
147
+ detail: str
148
+ fix: str | None = None
149
+ public: str | None = None
150
+
151
+
152
+ def run_checks(
153
+ cfg: config_module.Config, vault: Vault | None, *, client: DoctorClient | None = None
154
+ ) -> list[Check]:
155
+ """Collect every diagnostic. Never raises: a failed check is itself a result."""
156
+ checks: list[Check] = []
157
+ checks.extend(_safe_group("Environment", "env.unavailable", _environment))
158
+ checks.extend(_safe_group("Filesystem", "fs.unavailable", lambda: _filesystem(cfg, vault)))
159
+ checks.extend(_safe_group("Session", "session.unavailable", lambda: _session(client)))
160
+ checks.extend(_safe_group("Optional tools", "tools.unavailable", _optional_tools))
161
+ checks.extend(_safe_group("Skills", "skills.unavailable", lambda: _skills(cfg)))
162
+ checks.extend(_safe_group("Vault", "vault.unavailable", lambda: _vault(vault)))
163
+ return checks
164
+
165
+
166
+ def _safe_group(group: str, name: str, callback: Callable[[], list[Check]]) -> list[Check]:
167
+ """Turn an unexpected diagnostic implementation error into a safe result."""
168
+ try:
169
+ result = callback()
170
+ except Exception as exc:
171
+ return [
172
+ Check(
173
+ group,
174
+ name,
175
+ "fail",
176
+ f"{group.casefold()} check unavailable ({_exception_class(exc)})",
177
+ "run: a2l doctor",
178
+ )
179
+ ]
180
+ return (
181
+ result
182
+ if isinstance(result, list)
183
+ else [
184
+ Check(group, name, "fail", f"{group.casefold()} check unavailable", "run: a2l doctor")
185
+ ]
186
+ )
187
+
188
+
189
+ # ----------------------------------------------------------------------------------------
190
+ # Checks
191
+ # ----------------------------------------------------------------------------------------
192
+ def _environment() -> list[Check]:
193
+ encoding = (getattr(sys.stdout, "encoding", None) or "unknown").casefold()
194
+ encodable = console.GLYPH["ok"].isascii() or _can_encode(encoding)
195
+ return [
196
+ Check("Environment", "env.version", "ok", f"agent2learn {__version__}"),
197
+ Check("Environment", "env.python", "ok", platform.python_version()),
198
+ Check("Environment", "env.platform", "ok", f"{platform.system()} {platform.machine()}"),
199
+ Check(
200
+ "Environment",
201
+ "env.uv",
202
+ "ok" if shutil.which("uv") else "warn",
203
+ "uv found" if shutil.which("uv") else "uv not on PATH",
204
+ None if shutil.which("uv") else "install uv: https://docs.astral.sh/uv/",
205
+ ),
206
+ Check(
207
+ "Environment",
208
+ "env.encoding",
209
+ "ok" if encodable else "warn",
210
+ f"console encoding {encoding}",
211
+ None if encodable else "output falls back to ASCII glyphs",
212
+ ),
213
+ ]
214
+
215
+
216
+ def _filesystem(cfg: config_module.Config, vault: Vault | None) -> list[Check]:
217
+ checks: list[Check] = []
218
+ root = cfg.vault
219
+ exists = paths.long_path(root).is_dir()
220
+ writable = exists and os.access(os.fspath(paths.long_path(root)), os.W_OK)
221
+ checks.append(
222
+ Check(
223
+ "Filesystem",
224
+ "fs.vault",
225
+ "ok" if writable else ("fail" if exists else "warn"),
226
+ f"{root}" if exists else f"{root} does not exist yet",
227
+ None if writable else ("check folder permissions" if exists else "run: a2l init"),
228
+ # The public report deliberately emits only this fixed, home-redacted shape. The
229
+ # terminal detail above remains useful locally without becoming a future leak.
230
+ public=_SAFE_PUBLIC_NOTES["fs.vault"],
231
+ )
232
+ )
233
+
234
+ if exists:
235
+ try:
236
+ free_gib = shutil.disk_usage(os.fspath(paths.long_path(root))).free / (1024**3)
237
+ except OSError as exc:
238
+ checks.append(
239
+ Check(
240
+ "Filesystem",
241
+ "fs.disk",
242
+ "warn",
243
+ f"free space unavailable ({_exception_class(exc)})",
244
+ "check free disk space before the next sync",
245
+ )
246
+ )
247
+ else:
248
+ checks.append(
249
+ Check(
250
+ "Filesystem",
251
+ "fs.disk",
252
+ "ok" if free_gib >= 2 else "warn",
253
+ f"{free_gib:.1f} GiB free",
254
+ None if free_gib >= 2 else "free disk space before the next sync",
255
+ )
256
+ )
257
+ checks.append(_longest_path(root))
258
+
259
+ checks.append(_long_paths_enabled())
260
+ if vault is not None:
261
+ checks.append(_git_tracking(vault))
262
+ return checks
263
+
264
+
265
+ def _longest_path(root: Path) -> Check:
266
+ longest_relative = 0
267
+ longest_absolute = 0
268
+ try:
269
+ for path in paths.walk(root):
270
+ longest_relative = max(longest_relative, len(path.relative_to(root).as_posix()))
271
+ longest_absolute = max(longest_absolute, len(str(path)))
272
+ except (OSError, RuntimeError, ValueError) as exc:
273
+ return Check(
274
+ "Filesystem",
275
+ "fs.longest_path",
276
+ "warn",
277
+ f"longest path unavailable ({_exception_class(exc)})",
278
+ "check vault permissions before the next sync",
279
+ )
280
+
281
+ over = longest_absolute > LONG_PATH_ADVISORY
282
+ return Check(
283
+ "Filesystem",
284
+ "fs.longest_path",
285
+ "warn" if over else "ok",
286
+ f"longest path {longest_absolute} absolute / {longest_relative} vault-relative",
287
+ # Agent2Learn itself handles long paths; the risk is other software touching the
288
+ # same files, so the advice is to move the vault rather than to edit the registry.
289
+ "some editors and sync clients struggle past 260 characters; a shorter vault root avoids it"
290
+ if over
291
+ else None,
292
+ )
293
+
294
+
295
+ def _long_paths_enabled() -> Check:
296
+ """Report the Windows long-path registry flag, informationally and never as a failure.
297
+
298
+ Agent2Learn prefixes its own syscalls with ``\\\\?\\`` and works regardless, so this is
299
+ context for a user debugging another tool — not something to fix on our account.
300
+ """
301
+ if os.name != "nt":
302
+ return Check("Filesystem", "fs.long_paths", "ok", "not applicable on this platform")
303
+ # winreg exists only on Windows, so mypy on a POSIX host cannot see its attributes.
304
+ # The import is guarded by os.name above and every failure mode collapses to
305
+ # "unreadable" — this flag is informational and must never break a diagnostic run.
306
+ try:
307
+ import winreg # type: ignore[import-not-found,unused-ignore]
308
+
309
+ with winreg.OpenKey( # type: ignore[attr-defined,unused-ignore]
310
+ winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined,unused-ignore]
311
+ r"SYSTEM\CurrentControlSet\Control\FileSystem",
312
+ ) as key:
313
+ value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") # type: ignore[attr-defined,unused-ignore]
314
+ state = "enabled" if int(value) == 1 else "disabled"
315
+ except Exception:
316
+ state = "unreadable"
317
+ return Check(
318
+ "Filesystem",
319
+ "fs.long_paths",
320
+ "ok",
321
+ f"Windows LongPathsEnabled: {state} (informational; a2l handles long paths itself)",
322
+ )
323
+
324
+
325
+ def _git_tracking(vault: Vault) -> Check:
326
+ """Fail when private material is tracked by Git; warn when course sources are.
327
+
328
+ Ignore rules are not a privacy or copyright guarantee. A student who committed their
329
+ vault before adding a `.gitignore` still has the files in history, and a public push
330
+ would publish someone else's course material along with their own session state.
331
+ """
332
+ try:
333
+ inside_worktree = _inside_git_worktree(vault.root)
334
+ if not inside_worktree:
335
+ return Check("Filesystem", "fs.git", "ok", "vault is not inside a Git repository")
336
+ tracked = _tracked_files(vault.root)
337
+ except (OSError, UnicodeError, RuntimeError):
338
+ return Check(
339
+ "Filesystem",
340
+ "fs.git",
341
+ "fail",
342
+ "Git metadata is unreadable",
343
+ "inspect Git metadata locally before pushing anywhere",
344
+ )
345
+ if tracked is None:
346
+ return Check(
347
+ "Filesystem",
348
+ "fs.git",
349
+ "fail",
350
+ "inside Git, but the file list is unreadable",
351
+ "inspect Git metadata locally before pushing anywhere",
352
+ )
353
+
354
+ normalized = [(entry, _git_entry_parts(entry)) for entry in tracked]
355
+ private = sorted({entry for entry, parts in normalized if _is_private_git_entry(parts)})
356
+ if private:
357
+ return Check(
358
+ "Filesystem",
359
+ "fs.git",
360
+ "fail",
361
+ f"{len(private)} private file(s) are tracked by Git",
362
+ "untrack them and rewrite history before pushing anywhere",
363
+ )
364
+
365
+ sources = [entry for entry, parts in normalized if _is_course_source_entry(parts)]
366
+ if sources:
367
+ return Check(
368
+ "Filesystem",
369
+ "fs.git",
370
+ "warn",
371
+ f"{len(sources)} course source file(s) are tracked by Git",
372
+ "course material is not yours to redistribute; keep the vault out of a pushed repo",
373
+ )
374
+ return Check("Filesystem", "fs.git", "ok", "no private or course files are tracked")
375
+
376
+
377
+ def _session(client: DoctorClient | None) -> list[Check]:
378
+ try:
379
+ backend = session_module.backend_name()
380
+ except Exception as exc:
381
+ backend = "unavailable"
382
+ backend_detail = f"backend unavailable ({_exception_class(exc)})"
383
+ else:
384
+ backend_detail = {
385
+ "keyring": "OS credential store",
386
+ "file": "permission-restricted local file (not encrypted)",
387
+ }.get(backend, backend)
388
+
389
+ try:
390
+ current = session_module.load()
391
+ except Exception as exc:
392
+ return [
393
+ Check("Session", "session.backend", "warn", backend_detail),
394
+ Check(
395
+ "Session",
396
+ "session.present",
397
+ "fail",
398
+ f"stored session is unreadable ({_exception_class(exc)})",
399
+ "run: a2l auth",
400
+ ),
401
+ *_unavailable_api_checks(),
402
+ ]
403
+
404
+ if current is None:
405
+ return [
406
+ Check("Session", "session.backend", "ok", backend_detail),
407
+ Check("Session", "session.present", "warn", "no stored session", "run: a2l auth"),
408
+ *_unavailable_api_checks(),
409
+ ]
410
+
411
+ hours = current.age().total_seconds() / 3600
412
+ stale = hours > 24
413
+ checks = [
414
+ Check("Session", "session.backend", "ok", backend_detail),
415
+ Check(
416
+ "Session",
417
+ "session.age",
418
+ "warn" if stale else "ok",
419
+ f"harvested {hours:.1f} hours ago",
420
+ "run: a2l auth" if stale else None,
421
+ ),
422
+ ]
423
+ checks.extend(_session_api_checks(client))
424
+ return checks
425
+
426
+
427
+ def _unavailable_api_checks() -> list[Check]:
428
+ return [
429
+ Check(
430
+ "Session",
431
+ "session.api_versions",
432
+ "warn",
433
+ "API versions not checked; no usable session",
434
+ "run: a2l auth",
435
+ ),
436
+ Check(
437
+ "Session",
438
+ "session.whoami",
439
+ "warn",
440
+ "whoami not checked; no usable session",
441
+ "run: a2l auth",
442
+ ),
443
+ ]
444
+
445
+
446
+ def _session_api_checks(client: DoctorClient | None) -> list[Check]:
447
+ if client is None:
448
+ return _unavailable_api_checks()
449
+
450
+ try:
451
+ versions = client.get_json("/d2l/api/versions/")
452
+ lp_version = _latest_product_version(versions, "lp")
453
+ except Exception as exc:
454
+ return [
455
+ Check(
456
+ "Session",
457
+ "session.api_versions",
458
+ "fail",
459
+ f"API versions failed ({_api_failure_class(exc)})",
460
+ "run: a2l auth",
461
+ ),
462
+ Check(
463
+ "Session",
464
+ "session.whoami",
465
+ "fail",
466
+ "whoami not checked because API versions failed",
467
+ "run: a2l auth",
468
+ ),
469
+ ]
470
+
471
+ try:
472
+ payload = client.get_json(f"/d2l/api/lp/{lp_version}/users/whoami")
473
+ if not isinstance(payload, dict) or not isinstance(payload.get("Identifier"), str):
474
+ raise A2LError("whoami response was invalid")
475
+ except Exception as exc:
476
+ whoami = Check(
477
+ "Session",
478
+ "session.whoami",
479
+ "fail",
480
+ f"whoami failed ({_api_failure_class(exc)})",
481
+ "run: a2l auth",
482
+ )
483
+ else:
484
+ whoami = Check("Session", "session.whoami", "ok", "whoami reachable (2xx)")
485
+ return [
486
+ Check("Session", "session.api_versions", "ok", "API versions reachable (2xx)"),
487
+ whoami,
488
+ ]
489
+
490
+
491
+ def _latest_product_version(payload: object, product_code: str) -> str:
492
+ if not isinstance(payload, list):
493
+ raise A2LError("API versions response was invalid")
494
+ for item in payload:
495
+ if not isinstance(item, dict):
496
+ continue
497
+ version = item.get("LatestVersion")
498
+ if item.get("ProductCode") == product_code and isinstance(version, str) and version:
499
+ return version
500
+ raise A2LError("API versions response omitted the required product")
501
+
502
+
503
+ def _optional_tools() -> list[Check]:
504
+ tesseract = shutil.which("tesseract")
505
+ browser = _cdp_browser()
506
+ return [
507
+ Check(
508
+ "Optional tools",
509
+ "tools.tesseract",
510
+ "ok" if tesseract else "warn",
511
+ "tesseract found" if tesseract else "tesseract not installed",
512
+ None if tesseract else "OCR is skipped for image-only PDFs without it",
513
+ ),
514
+ Check(
515
+ "Optional tools",
516
+ "tools.browser",
517
+ "ok" if browser else "warn",
518
+ f"{browser} found" if browser else "no Chrome/Edge/Chromium found",
519
+ None if browser else "browser sign-in needs one; a2l auth --paste works without it",
520
+ ),
521
+ ]
522
+
523
+
524
+ def _skills(cfg: config_module.Config) -> list[Check]:
525
+ detected = skills_module.current_installations(project=cfg.vault, include_global=True)
526
+ destinations = tuple(
527
+ destination
528
+ for destination in detected
529
+ if _skill_scope(destination.path, cfg.vault) == "project"
530
+ or any(status != "created" for _, status in destination.skills)
531
+ )
532
+ if not destinations:
533
+ return [
534
+ Check(
535
+ "Skills",
536
+ "skills.installed",
537
+ "warn",
538
+ "no detected Agent2Learn skill destination",
539
+ "run: a2l skills install",
540
+ )
541
+ ]
542
+
543
+ agents = {agent for destination in destinations for agent in destination.agents}
544
+ current = sum(
545
+ status == "unchanged" for destination in destinations for _, status in destination.skills
546
+ )
547
+ stale = sum(
548
+ status == "updated" for destination in destinations for _, status in destination.skills
549
+ )
550
+ missing = sum(
551
+ status == "created" for destination in destinations for _, status in destination.skills
552
+ )
553
+ conflicts = sum(
554
+ status == "conflict" for destination in destinations for _, status in destination.skills
555
+ )
556
+ descriptions = [
557
+ f"{agent} ({_skill_scope(destination.path, cfg.vault)}): "
558
+ f"{_skill_destination_detail(destination)}"
559
+ for destination in destinations
560
+ for agent in destination.agents
561
+ ]
562
+ unhealthy = any(
563
+ status != "unchanged" for destination in destinations for _, status in destination.skills
564
+ )
565
+ detail = (
566
+ f"{len(destinations)} destination(s), {len(agents)} detected agent(s), "
567
+ f"{current} current skill(s), {stale} stale skill(s), {missing} missing skill(s), "
568
+ f"{conflicts} conflict skill(s) left alone: " + "; ".join(descriptions)
569
+ )
570
+ return [
571
+ Check(
572
+ "Skills",
573
+ "skills.installed",
574
+ "warn" if unhealthy else "ok",
575
+ detail,
576
+ "run: a2l skills install" if unhealthy else None,
577
+ )
578
+ ]
579
+
580
+
581
+ def _skill_scope(destination: Path, project: Path) -> str:
582
+ try:
583
+ destination.resolve().relative_to(project.resolve())
584
+ except ValueError:
585
+ return "global"
586
+ return "project"
587
+
588
+
589
+ def _skill_destination_detail(destination: skills_module.DestinationResult) -> str:
590
+ """Describe one skill root without disclosing its filesystem location."""
591
+ counts = {status: 0 for status in ("created", "updated", "unchanged", "conflict")}
592
+ versions: dict[str, list[str]] = {status: [] for status in ("updated", "unchanged", "conflict")}
593
+ unknown_versions = {status: 0 for status in versions}
594
+ for _, status, version in skills_module.installed_package_versions(destination):
595
+ counts[status] += 1
596
+ if status == "created":
597
+ continue
598
+ if version is None:
599
+ unknown_versions[status] += 1
600
+ else:
601
+ versions[status].append(version)
602
+
603
+ def version_detail(status: str) -> str:
604
+ known = sorted(set(versions[status]))
605
+ if unknown_versions[status]:
606
+ known.append("unknown")
607
+ return ", ".join(known)
608
+
609
+ parts: list[str] = []
610
+ for status, label in (("unchanged", "current"), ("updated", "stale")):
611
+ if counts[status]:
612
+ parts.append(f"{counts[status]} {label} skill(s), package {version_detail(status)}")
613
+ if counts["created"]:
614
+ parts.append(f"{counts['created']} missing skill(s)")
615
+ if counts["conflict"]:
616
+ parts.append(
617
+ f"{counts['conflict']} conflict skill(s) left alone, "
618
+ f"package {version_detail('conflict')}"
619
+ )
620
+ return "; ".join(parts)
621
+
622
+
623
+ def _vault(vault: Vault | None) -> list[Check]:
624
+ if vault is None or not paths.long_path(vault.root).is_dir():
625
+ return [Check("Vault", "vault.present", "warn", "no vault yet", "run: a2l sync")]
626
+
627
+ courses = 0
628
+ topics = 0
629
+ citable = 0
630
+ gaps = 0
631
+ empty_twins = 0
632
+ unreadable_maps = 0
633
+ term_stats: dict[str, list[int]] = {}
634
+ try:
635
+ map_paths = sorted(
636
+ path for path in paths.walk(vault.root) if path.name == "content_map.json"
637
+ )
638
+ except (OSError, RuntimeError) as exc:
639
+ return [
640
+ Check("Vault", "vault.present", "fail", f"vault scan failed ({_exception_class(exc)})")
641
+ ]
642
+
643
+ for map_path in map_paths:
644
+ try:
645
+ rows = read_content_map(map_path.parent.parent)["topics"]
646
+ except (A2LError, OSError, UnicodeError):
647
+ unreadable_maps += 1
648
+ continue
649
+ if not isinstance(rows, list):
650
+ unreadable_maps += 1
651
+ continue
652
+ courses += 1
653
+ term = map_path.parent.parent.parent.name
654
+ stats = term_stats.setdefault(term, [0, 0, 0, 0, 0])
655
+ stats[0] += 1
656
+ for row in rows:
657
+ if not isinstance(row, dict):
658
+ continue
659
+ topics += 1
660
+ stats[2] += 1
661
+ availability = str(row.get("availability", ""))
662
+ if availability == "markdown_ready":
663
+ citable += 1
664
+ stats[1] += 1
665
+ path = row.get("path")
666
+ if isinstance(path, str) and _is_empty_vault_file(vault, path):
667
+ empty_twins += 1
668
+ stats[4] += 1
669
+ elif availability in {
670
+ "unsupported_format",
671
+ "conversion_gap",
672
+ "integrity_gap",
673
+ "download_gap",
674
+ }:
675
+ gaps += 1
676
+ stats[3] += 1
677
+
678
+ if courses == 0:
679
+ if unreadable_maps:
680
+ present_detail = (
681
+ f"vault has no readable courses; {unreadable_maps} unreadable content map(s)"
682
+ )
683
+ terms_detail = (
684
+ f"no readable term/course coverage; {unreadable_maps} unreadable content map(s)"
685
+ )
686
+ else:
687
+ present_detail = "vault has no courses yet"
688
+ terms_detail = "no term/course coverage yet"
689
+ return [
690
+ Check("Vault", "vault.present", "warn", present_detail, "run: a2l sync"),
691
+ Check("Vault", "vault.terms", "warn", terms_detail, "run: a2l sync"),
692
+ Check("Vault", "vault.empty_twins", "ok", "0 empty markdown twin(s)"),
693
+ _last_sync_check(vault),
694
+ ]
695
+
696
+ term_detail = "; ".join(
697
+ f"{term}: {course_count} course(s), {resolved}/{total} topic(s) resolved, "
698
+ f"{term_gaps} coverage gap(s), {term_empty} empty twin(s)"
699
+ for term, (course_count, resolved, total, term_gaps, term_empty) in sorted(
700
+ term_stats.items()
701
+ )
702
+ )
703
+ checks = [
704
+ Check(
705
+ "Vault",
706
+ "vault.courses",
707
+ "warn" if unreadable_maps else "ok",
708
+ f"{courses} course(s), {topics} topic(s)"
709
+ + (f", {unreadable_maps} unreadable content map(s)" if unreadable_maps else ""),
710
+ ),
711
+ Check(
712
+ "Vault",
713
+ "vault.terms",
714
+ "ok" if citable == topics else "warn",
715
+ term_detail,
716
+ None if citable == topics else "run: a2l sync",
717
+ ),
718
+ Check(
719
+ "Vault",
720
+ "vault.citable",
721
+ "ok" if topics and citable == topics else "warn",
722
+ f"{citable} of {topics} topic(s) citable",
723
+ None if citable == topics else "run: a2l sync",
724
+ ),
725
+ Check(
726
+ "Vault",
727
+ "vault.gaps",
728
+ "ok" if gaps == 0 else "warn",
729
+ f"{gaps} coverage gap(s)",
730
+ None if gaps == 0 else "see .a2l/AUDIT.md",
731
+ ),
732
+ ]
733
+ checks.append(
734
+ Check(
735
+ "Vault",
736
+ "vault.empty_twins",
737
+ "warn" if empty_twins else "ok",
738
+ f"{empty_twins} empty markdown twin(s)",
739
+ None if empty_twins == 0 else "run: a2l sync",
740
+ )
741
+ )
742
+ checks.append(_last_sync_check(vault))
743
+ return checks
744
+
745
+
746
+ def _is_empty_vault_file(vault: Vault, value: str) -> bool:
747
+ try:
748
+ parts = PurePosixPath(value).parts
749
+ if (
750
+ not parts
751
+ or "\\" in value
752
+ or (len(value) >= 3 and value[1] == ":" and value[2] in "/\\")
753
+ or PurePosixPath(value).is_absolute()
754
+ or any(part in {"", ".", ".."} for part in parts)
755
+ ):
756
+ return False
757
+ candidate = (vault.root / Path(*parts)).resolve()
758
+ candidate.relative_to(vault.root)
759
+ return (
760
+ paths.long_path(candidate).is_file() and paths.long_path(candidate).stat().st_size == 0
761
+ )
762
+ except (OSError, RuntimeError, ValueError):
763
+ return False
764
+
765
+
766
+ def _last_sync_check(vault: Vault) -> Check:
767
+ snapshot_dir = vault.state() / "snapshots"
768
+ try:
769
+ candidates = sorted(
770
+ path for path in paths.walk(snapshot_dir) if path.suffix.casefold() == ".json"
771
+ )
772
+ except (OSError, RuntimeError, ValueError) as exc:
773
+ return Check(
774
+ "Vault",
775
+ "vault.last_sync",
776
+ "warn",
777
+ f"last sync unavailable ({_exception_class(exc)})",
778
+ "run: a2l sync",
779
+ )
780
+ timestamps: list[datetime] = []
781
+ unreadable = 0
782
+ for candidate in candidates:
783
+ try:
784
+ with open(
785
+ os.fspath(paths.long_path(candidate)), encoding="utf-8", newline=""
786
+ ) as handle:
787
+ raw = json.load(handle)
788
+ value = raw.get("created_at") if isinstance(raw, dict) else None
789
+ if isinstance(value, str):
790
+ timestamps.append(_parse_timestamp(value))
791
+ except (OSError, UnicodeError, ValueError, TypeError, json.JSONDecodeError):
792
+ unreadable += 1
793
+ continue
794
+ if not timestamps:
795
+ detail = "no completed sync recorded"
796
+ if unreadable:
797
+ detail += f"; {unreadable} unreadable snapshot(s)"
798
+ return Check("Vault", "vault.last_sync", "warn", detail, "run: a2l sync")
799
+ latest = max(timestamps).astimezone(UTC).isoformat().replace("+00:00", "Z")
800
+ detail = f"last sync {latest}"
801
+ if unreadable:
802
+ detail += f"; {unreadable} unreadable snapshot(s)"
803
+ return Check("Vault", "vault.last_sync", "warn" if unreadable else "ok", detail)
804
+
805
+
806
+ def _parse_timestamp(value: str) -> datetime:
807
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
808
+ parsed = datetime.fromisoformat(normalized)
809
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
810
+ raise ValueError("timestamp is not timezone-aware")
811
+ return parsed
812
+
813
+
814
+ # ----------------------------------------------------------------------------------------
815
+ # Rendering
816
+ # ----------------------------------------------------------------------------------------
817
+ def render(checks: Sequence[Check]) -> str:
818
+ """Render a grouped checklist for the user's own terminal, with one next command."""
819
+ lines: list[str] = []
820
+ for group in _ordered_groups(checks):
821
+ lines.append(f"{group}")
822
+ for check in [item for item in checks if item.group == group]:
823
+ glyph = console.GLYPH[_STATUS_GLYPH[check.status]]
824
+ lines.append(f" {glyph} {check.detail}")
825
+ lines.append("")
826
+
827
+ failures = [item for item in checks if item.status == "fail"]
828
+ warnings = [item for item in checks if item.status == "warn"]
829
+ if failures:
830
+ summary = f"{len(failures)} failure(s), {len(warnings)} warning(s)"
831
+ elif warnings:
832
+ summary = f"{len(warnings)} warning(s)"
833
+ else:
834
+ summary = "all clear"
835
+ lines.append(summary)
836
+
837
+ action = next_command(checks)
838
+ if action is not None:
839
+ # Fixes are stored as "run: a2l auth" so they read correctly inside a check's own
840
+ # line; the summary already says "Next", so the prefix would stutter here.
841
+ lines.append(f"Next: {action.removeprefix('run: ')}")
842
+ return "\n".join(lines) + "\n"
843
+
844
+
845
+ def next_command(checks: Sequence[Check]) -> str | None:
846
+ """Return the single most urgent suggested command, or ``None`` when all is well.
847
+
848
+ Exactly one. A diagnostic that emits a list of six things to do is one the user closes
849
+ without doing any of them, so failures outrank warnings and the first wins.
850
+ """
851
+ for status in ("fail", "warn"):
852
+ for check in checks:
853
+ if check.status != status or not check.fix:
854
+ continue
855
+ match = _A2L_RECOVERY.search(check.fix)
856
+ if match is None:
857
+ continue
858
+ candidate = match.group(0)
859
+ words = candidate.split()
860
+ if len(words) >= 2 and words[1] in _REGISTERED_CLI_COMMANDS:
861
+ return f"run: {candidate}"
862
+ # Free-form repair prose belongs in the check detail, not in the command slot. Keep the output
863
+ # contract literal even when all fixes are manual: the fallback is a real, reversible command.
864
+ return "run: a2l sync"
865
+
866
+
867
+ def exit_code(checks: Sequence[Check]) -> int:
868
+ """0 all clear, 1 warnings only, 2 at least one failure."""
869
+ if any(check.status == "fail" for check in checks):
870
+ return 2
871
+ return 1 if any(check.status == "warn" for check in checks) else 0
872
+
873
+
874
+ # ----------------------------------------------------------------------------------------
875
+ # Public support report
876
+ # ----------------------------------------------------------------------------------------
877
+ def report(checks: Sequence[Check]) -> str:
878
+ """Render a redacted markdown block that is safe to paste into a public issue.
879
+
880
+ Only these fields are emitted, each rendered individually rather than copied through:
881
+ package version, Python version, OS and architecture, install method, and — per check —
882
+ its stable identifier, its status, and any ``public`` note the check opted into.
883
+ ``detail`` and ``fix`` are excluded outright because they legitimately contain vault
884
+ paths and course names, and ``public`` is re-redacted here rather than trusted.
885
+ """
886
+ lines = [
887
+ "### Agent2Learn diagnostics",
888
+ "",
889
+ f"- version: `{_safe_token(__version__)}`",
890
+ f"- python: `{_safe_token(platform.python_version())}`",
891
+ f"- platform: `{_safe_token(platform.system())} {_safe_token(platform.machine())}`",
892
+ f"- install: `{_safe_token(_install_method())}`",
893
+ "",
894
+ "| check | status | note |",
895
+ "| --- | --- | --- |",
896
+ ]
897
+ for check in checks:
898
+ note = _safe_public_note(check)
899
+ lines.append(
900
+ f"| `{_public_check_name(check.name)}` | {_public_status(check.status)} | {note} |"
901
+ )
902
+
903
+ failures = [check.name for check in checks if check.status == "fail"]
904
+ if failures:
905
+ lines.extend(
906
+ ["", "Failing checks: " + ", ".join(f"`{_public_check_name(n)}`" for n in failures)]
907
+ )
908
+ lines.extend(
909
+ [
910
+ "",
911
+ "_Generated by `a2l doctor --report`. Names, student IDs, course codes, org-unit "
912
+ "IDs, absolute paths, cookies, tokens, and grades are excluded by construction._",
913
+ ]
914
+ )
915
+ return "\n".join(lines) + "\n"
916
+
917
+
918
+ def issue_url(checks: Sequence[Check]) -> str:
919
+ """Build a pre-filled issue URL bounded to ``MAX_ISSUE_URL_LENGTH`` characters."""
920
+ diagnostics = report(checks)
921
+ url = _encoded_issue_url(diagnostics)
922
+ if len(url) <= MAX_ISSUE_URL_LENGTH:
923
+ return url
924
+
925
+ lines = diagnostics.splitlines(keepends=True)
926
+ marker = "_(truncated)_\n"
927
+ lower = 0
928
+ upper = len(lines)
929
+ while lower < upper:
930
+ midpoint = (lower + upper + 1) // 2
931
+ candidate = "".join(lines[:midpoint]) + marker
932
+ if len(_encoded_issue_url(candidate)) <= MAX_ISSUE_URL_LENGTH:
933
+ lower = midpoint
934
+ else:
935
+ upper = midpoint - 1
936
+ return _encoded_issue_url("".join(lines[:lower]) + marker)
937
+
938
+
939
+ def _encoded_issue_url(diagnostics: str) -> str:
940
+ query = urlencode(
941
+ (("template", "bug_report.yml"), ("labels", "bug"), ("diagnostics", diagnostics))
942
+ )
943
+ return f"{ISSUE_URL}?{query}"
944
+
945
+
946
+ def open_notice(checks: Sequence[Check]) -> str:
947
+ """Show the body and explain that opening the prefilled page sends it to GitHub."""
948
+ return (
949
+ f"This opens {ISSUE_URL} in your browser with the block below pre-filled.\n"
950
+ "Opening this page sends the displayed redacted body to GitHub; review it there before "
951
+ "submitting.\n\n"
952
+ f"{report(checks)}"
953
+ )
954
+
955
+
956
+ # ----------------------------------------------------------------------------------------
957
+ # Helpers
958
+ # ----------------------------------------------------------------------------------------
959
+ def _safe_token(value: object) -> str:
960
+ """Reduce any value to a short, inert token.
961
+
962
+ Applied even to values that look obviously safe. ``platform.machine()`` and a version
963
+ string are attacker-influenced in principle and formatting-hostile in practice, and a
964
+ single uniform rule is easier to keep correct than a set of judgement calls.
965
+ """
966
+ text = "".join(char for char in str(value) if char.isalnum() or char in "._- +")
967
+ return text.strip()[:64] or "unknown"
968
+
969
+
970
+ def _public_check_name(value: object) -> str:
971
+ if isinstance(value, str) and value in _PUBLIC_CHECK_NAMES:
972
+ return value
973
+ return "unknown-check"
974
+
975
+
976
+ def _public_status(value: object) -> str:
977
+ if isinstance(value, str) and value in _PUBLIC_STATUSES:
978
+ return value
979
+ return "unknown"
980
+
981
+
982
+ def _safe_public_note(check: Check) -> str:
983
+ """Allow only fixed notes whose redaction does not depend on caller-supplied text.
984
+
985
+ ``Check.public`` is convenient inside the implementation, but it is not a security type:
986
+ tests, plugins, or a future check can construct it with arbitrary text. A strict note
987
+ allowlist makes the report safe even when a caller violates the convention in the docstring.
988
+ """
989
+ if check.public is None:
990
+ return ""
991
+ return _SAFE_PUBLIC_NOTES.get(check.name, "")
992
+
993
+
994
+ def _install_method() -> str:
995
+ executable = str(Path(sys.executable)).casefold()
996
+ if "uv" in executable or "uv" in str(Path(sys.prefix)).casefold():
997
+ return "uv tool"
998
+ if hasattr(sys, "real_prefix") or sys.prefix != sys.base_prefix:
999
+ return "virtualenv"
1000
+ return "system"
1001
+
1002
+
1003
+ def _can_encode(encoding: str) -> bool:
1004
+ try:
1005
+ "✓".encode(encoding)
1006
+ except (LookupError, UnicodeEncodeError):
1007
+ return False
1008
+ return True
1009
+
1010
+
1011
+ def _cdp_browser() -> str | None:
1012
+ try:
1013
+ return cdp.locate_browser().name
1014
+ except Exception:
1015
+ return None
1016
+
1017
+
1018
+ def _inside_git_worktree(root: Path) -> bool:
1019
+ return _git_metadata(root) is not None
1020
+
1021
+
1022
+ def _tracked_files(root: Path) -> list[str] | None:
1023
+ """List Git-tracked paths, using Git only for unsupported index layouts."""
1024
+ metadata = _git_metadata(root)
1025
+ if metadata is None:
1026
+ return None
1027
+ repo, git_directory = metadata
1028
+ index = git_directory / "index"
1029
+ try:
1030
+ with open(os.fspath(paths.long_path(index)), "rb") as handle:
1031
+ raw = handle.read()
1032
+ except OSError:
1033
+ return None
1034
+ parsed = _parse_git_index(raw, root, repo)
1035
+ return parsed if parsed is not None else _git_ls_files(root)
1036
+
1037
+
1038
+ def _git_ls_files(root: Path) -> list[str] | None:
1039
+ """Read a valid but unsupported index with Git, without releasing its filenames."""
1040
+ try:
1041
+ result = subprocess.run(
1042
+ ["git", "-C", os.fspath(paths.long_path(root)), "ls-files", "-z", "--"],
1043
+ stdin=subprocess.DEVNULL,
1044
+ stdout=subprocess.PIPE,
1045
+ stderr=subprocess.DEVNULL,
1046
+ check=False,
1047
+ shell=False,
1048
+ timeout=5,
1049
+ )
1050
+ except (OSError, subprocess.TimeoutExpired):
1051
+ return None
1052
+ if result.returncode != 0:
1053
+ return None
1054
+ return [entry for entry in os.fsdecode(result.stdout).split("\0") if entry]
1055
+
1056
+
1057
+ def _git_metadata(root: Path) -> tuple[Path, Path] | None:
1058
+ """Return worktree root and git dir, including linked-worktree ``.git`` files."""
1059
+ for directory in [root, *root.parents]:
1060
+ marker = directory / ".git"
1061
+ if paths.long_path(marker).is_dir():
1062
+ return directory, marker
1063
+ if not paths.long_path(marker).is_file():
1064
+ continue
1065
+ with open(os.fspath(paths.long_path(marker)), encoding="utf-8", newline="") as handle:
1066
+ line = handle.readline().strip()
1067
+ prefix = "gitdir:"
1068
+ if not line.casefold().startswith(prefix):
1069
+ return None
1070
+ git_directory = Path(line[len(prefix) :].strip())
1071
+ if not git_directory.is_absolute():
1072
+ git_directory = directory / git_directory
1073
+ return directory, git_directory.resolve()
1074
+ return None
1075
+
1076
+
1077
+ def _parse_git_index(raw: bytes, root: Path, repo: Path) -> list[str] | None:
1078
+ if not raw.startswith(b"DIRC") or len(raw) < 12:
1079
+ return None
1080
+ version = int.from_bytes(raw[4:8], "big")
1081
+ if version not in {2, 3}:
1082
+ return None
1083
+ count = int.from_bytes(raw[8:12], "big")
1084
+ entries: list[str] = []
1085
+ offset = 12
1086
+ prefix = ""
1087
+ try:
1088
+ relative = root.resolve().relative_to(repo.resolve()).as_posix()
1089
+ prefix = "" if relative == "." else f"{relative}/"
1090
+ except ValueError:
1091
+ prefix = ""
1092
+
1093
+ try:
1094
+ for _ in range(count):
1095
+ if offset + 62 > len(raw):
1096
+ return None
1097
+ flags = int.from_bytes(raw[offset + 60 : offset + 62], "big")
1098
+ name_start = offset + 62 + (2 if flags & 0x4000 else 0)
1099
+ end = raw.index(b"\x00", name_start)
1100
+ name = raw[name_start:end].decode("utf-8", "replace")
1101
+ if not prefix or name.startswith(prefix):
1102
+ entries.append(name[len(prefix) :] if prefix else name)
1103
+ offset = (end + 8) & ~7
1104
+ except (ValueError, UnicodeDecodeError):
1105
+ return None
1106
+ return entries
1107
+
1108
+
1109
+ def _git_entry_parts(entry: str) -> tuple[str, ...]:
1110
+ normalized = entry.replace("\\", "/")
1111
+ if normalized.startswith("./"):
1112
+ normalized = normalized[2:]
1113
+ # PurePosixPath intentionally normalizes interior ``.`` segments in Git-style paths.
1114
+ return tuple(part.casefold() for part in PurePosixPath(normalized).parts)
1115
+
1116
+
1117
+ def _is_private_git_entry(parts: tuple[str, ...]) -> bool:
1118
+ sensitive_components = {"session", "sessions", "discussions"}
1119
+ sensitive_basenames = {"session.json", "sessions.json", "discussions.json", "my_grades.json"}
1120
+ if set(parts) & sensitive_components:
1121
+ return True
1122
+ if parts and parts[-1] in sensitive_basenames:
1123
+ return True
1124
+ for index, part in enumerate(parts[:-1]):
1125
+ if part == ".a2l" and parts[index + 1] in {"private", "submissions"}:
1126
+ return True
1127
+ return False
1128
+
1129
+
1130
+ def _is_course_source_entry(parts: tuple[str, ...]) -> bool:
1131
+ return bool(set(parts) & _COURSE_SOURCE_DIRECTORIES)
1132
+
1133
+
1134
+ def _exception_class(exc: BaseException) -> str:
1135
+ return _safe_token(type(exc).__name__)
1136
+
1137
+
1138
+ def _api_failure_class(exc: BaseException) -> str:
1139
+ if isinstance(exc, SessionExpired):
1140
+ return "authentication failure"
1141
+ if isinstance(exc, requests.RequestException):
1142
+ response = getattr(exc, "response", None)
1143
+ status_code = getattr(response, "status_code", None)
1144
+ if isinstance(status_code, int) and 100 <= status_code <= 599:
1145
+ return f"HTTP {status_code // 100}xx"
1146
+ return "network failure"
1147
+ return _exception_class(exc)
1148
+
1149
+
1150
+ def _ordered_groups(checks: Iterable[Check]) -> list[str]:
1151
+ present = {check.group for check in checks}
1152
+ ordered = [group for group in _GROUP_ORDER if group in present]
1153
+ return ordered + sorted(present - set(ordered))
1154
+
1155
+
1156
+ __all__ = [
1157
+ "Check",
1158
+ "ISSUE_URL",
1159
+ "MAX_ISSUE_URL_LENGTH",
1160
+ "exit_code",
1161
+ "issue_url",
1162
+ "next_command",
1163
+ "open_notice",
1164
+ "render",
1165
+ "report",
1166
+ "run_checks",
1167
+ ]