canvas-pull 2.4.0__tar.gz → 2.5.0__tar.gz

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 (26) hide show
  1. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/CHANGELOG.md +20 -1
  2. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/PKG-INFO +56 -3
  3. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/README.md +55 -2
  4. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/pyproject.toml +1 -1
  5. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/__init__.py +1 -1
  6. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/cli.py +314 -73
  7. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_cli.py +104 -1
  8. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/.gitignore +0 -0
  9. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/LICENSE +0 -0
  10. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/__main__.py +0 -0
  11. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/api.py +0 -0
  12. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/collector.py +0 -0
  13. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/config.py +0 -0
  14. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/gitops.py +0 -0
  15. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/manifest.py +0 -0
  16. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/models.py +0 -0
  17. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/notify.py +0 -0
  18. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/schedule.py +0 -0
  19. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/summary.py +0 -0
  20. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/sync.py +0 -0
  21. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/__init__.py +0 -0
  22. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/fake_canvas.py +0 -0
  23. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_config.py +0 -0
  24. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_schedule.py +0 -0
  25. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_summary.py +0 -0
  26. {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_sync.py +0 -0
@@ -6,6 +6,24 @@ uses [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.5.0] - 2026-11-01
10
+
11
+ ### Added
12
+ - `--json` on `status`, `sync`, `log`, `courses`, `config` and `init`: one JSON object on stdout,
13
+ also for errors. Each change includes `local_path`.
14
+ - Stable exit codes: 3 not configured, 4 token rejected, 5 partly failed, 6 another sync running
15
+ (1 is still a network/Canvas error, 2 invalid arguments).
16
+ - Non-interactive `init`: `--url`, `--save-dir`, `--token-stdin`, `--no-input`.
17
+ - Agent skill (`skills/canvas-pull/SKILL.md`) and a Claude Code plugin:
18
+ `/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull`.
19
+
20
+ ### Changed
21
+ - A course that can't be scanned now makes `sync`/`status` exit with 5 instead of 0.
22
+ - Token rejected now exits with 4 instead of 1.
23
+ - GitHub Actions are pinned to commit SHAs and updated to their latest versions
24
+ (checkout v7, setup-uv v10, upload-artifact v7, download-artifact v8). CI now also tests the
25
+ build-artifact hand-off used by releases.
26
+
9
27
  ## [2.4.0] - 2026-11-01
10
28
 
11
29
  First release on PyPI: `uv tool install canvas-pull` or `pipx install canvas-pull`.
@@ -76,6 +94,7 @@ Complete rewrite as an installable CLI.
76
94
 
77
95
  - First version: download all files of enrolled courses.
78
96
 
79
- [Unreleased]: https://github.com/gxxxr-111/canvas-pull/compare/v2.4.0...HEAD
97
+ [Unreleased]: https://github.com/gxxxr-111/canvas-pull/compare/v2.5.0...HEAD
98
+ [2.5.0]: https://github.com/gxxxr-111/canvas-pull/compare/v2.4.0...v2.5.0
80
99
  [2.4.0]: https://github.com/gxxxr-111/canvas-pull/compare/v1.0.0...v2.4.0
81
100
  [1.0.0]: https://github.com/gxxxr-111/canvas-pull/releases/tag/v1.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: canvas-pull
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Sync your Canvas LMS course files: download only what changed and see what instructors added, modified, moved or deleted.
5
5
  Project-URL: Homepage, https://github.com/gxxxr-111/canvas-pull
6
6
  Project-URL: Repository, https://github.com/gxxxr-111/canvas-pull
@@ -41,14 +41,15 @@ Description-Content-Type: text/markdown
41
41
 
42
42
  <p align="center">
43
43
  <a href="https://github.com/gxxxr-111/canvas-pull/actions/workflows/ci.yml"><img src="https://github.com/gxxxr-111/canvas-pull/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
44
- <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull" alt="PyPI" /></a>
45
- <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull" alt="Python versions" /></a>
44
+ <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull?cacheSeconds=3600" alt="PyPI" /></a>
45
+ <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull?cacheSeconds=3600" alt="Python versions" /></a>
46
46
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license" /></a>
47
47
  </p>
48
48
 
49
49
  <p align="center">
50
50
  <a href="#quick-start">Quick start</a> ·
51
51
  <a href="#commands">Commands</a> ·
52
+ <a href="#use-with-ai-agents">AI agents</a> ·
52
53
  <a href="#faq">FAQ</a> ·
53
54
  <a href="#中文说明">中文说明</a>
54
55
  </p>
@@ -79,6 +80,8 @@ renamed/moved or deleted**. It can run on its own every morning and notify you.
79
80
  skip it (use `--refresh` to scan it again).
80
81
  - **Safe token storage.** `init` stores your token in the system keychain, not in a text file.
81
82
  - **Optional git.** `sync --git` commits each sync in the save folder.
83
+ - **Made for scripts and AI agents.** Every command has `--json` output and stable exit codes,
84
+ and there's an agent skill / Claude Code plugin.
82
85
  - **Read-only and private.** Only reads from Canvas; no telemetry, nothing leaves your machine.
83
86
  - **Fast.** Courses are scanned in parallel (a 32-course account scans in about 10 s instead of
84
87
  75 s), with parallel downloads, automatic retries with back-off and full pagination.
@@ -118,6 +121,50 @@ To get the latest unreleased code: `uv tool install git+https://github.com/gxxxr
118
121
  | `canvas-pull schedule status \| run \| disable` | Show the schedule and its log, trigger it now, or remove it. |
119
122
  | `canvas-pull config` | Show settings in use. |
120
123
 
124
+ ## Use with AI agents
125
+
126
+ canvas-pull is a plain CLI, so AI coding agents (Claude Code, Codex, Cursor, …) can run it
127
+ directly. Ask *"anything new in my courses?"* or *"summarize this week's new slides"* and the
128
+ agent syncs, reads the changed files and answers.
129
+
130
+ **Install the skill** so your agent knows how to use it:
131
+
132
+ ```
133
+ /plugin install canvas-pull --marketplace gxxxr-111/canvas-pull
134
+ ```
135
+
136
+ The skill is in [`skills/canvas-pull/SKILL.md`](skills/canvas-pull/SKILL.md). Other agents that
137
+ support Agent Skills can use that file as is.
138
+
139
+ **JSON output.** Add `--json` to `status`, `sync`, `log`, `courses`, `config` or `init`, and stdout
140
+ holds exactly one JSON object, also on errors:
141
+
142
+ ```bash
143
+ canvas-pull log --since 7d --json | jq -r '.changes[] | select(.kind=="new") | .local_path'
144
+ ```
145
+
146
+ Each change has `kind`, `course`, `path`, `local_path` (and `old_path` for moves).
147
+
148
+ **Exit codes**
149
+
150
+ | Code | Meaning |
151
+ | --- | --- |
152
+ | 0 | OK |
153
+ | 1 | Network or Canvas error |
154
+ | 2 | Invalid arguments |
155
+ | 3 | Not configured / invalid settings |
156
+ | 4 | Token rejected or expired |
157
+ | 5 | Finished, but some downloads or courses failed |
158
+ | 6 | Another sync is running |
159
+
160
+ **Non-interactive setup** (CI, dotfiles, servers):
161
+
162
+ ```bash
163
+ canvas-pull init --url oc.sjtu.edu.cn --save-dir ~/Canvas --token-stdin < token.txt
164
+ ```
165
+
166
+ Agents should never handle your token. Run `canvas-pull init` yourself.
167
+
121
168
  ## Configuration
122
169
 
123
170
  `init` writes a config file (path shown by `canvas-pull config`). You can edit it:
@@ -219,6 +266,10 @@ No. It only sends read (`GET`) requests.
219
266
  Create a new one in Canvas and run `canvas-pull init` again. Scheduled syncs show a notification
220
267
  when the token is rejected.
221
268
 
269
+ **Can it put assignment deadlines in my calendar?**
270
+ Canvas already can. Open the Canvas **Calendar**, click **Calendar Feed**, and subscribe to
271
+ that link in Apple Calendar, Google Calendar or Outlook.
272
+
222
273
  **How do I skip a course?**
223
274
  Add its ID or part of its name to `exclude` in the config file (`canvas-pull courses` shows IDs).
224
275
 
@@ -277,4 +328,6 @@ canvas-pull schedule enable --at 08:00 # 每天早上自动同步,有变化
277
328
  - 多门课并行扫描,32 门课大约 10 秒扫完。
278
329
  - `sync --git` 会在保存目录里自动提交一次 git,方便用 `git log` 回看。
279
330
  - 只读取 Canvas,不会修改任何内容;没有遥测,数据不离开你的电脑。
331
+ - 所有命令都支持 `--json` 输出和固定的退出码,方便脚本和 AI 助手调用。在 Claude Code 里运行 `/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull` 安装技能后,就可以直接问「我的课程这周有什么更新?帮我总结新课件」。
332
+ - 作业截止日期:在 Canvas 的「日历」页面点「日历订阅源」,把链接加到系统日历即可,无需本工具。
280
333
  - 在你的学校试过了?欢迎提交一个 [School report](https://github.com/gxxxr-111/canvas-pull/issues/new?template=school_report.yml),无论成功与否。
@@ -6,14 +6,15 @@
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://github.com/gxxxr-111/canvas-pull/actions/workflows/ci.yml"><img src="https://github.com/gxxxr-111/canvas-pull/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
9
- <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull" alt="PyPI" /></a>
10
- <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull" alt="Python versions" /></a>
9
+ <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull?cacheSeconds=3600" alt="PyPI" /></a>
10
+ <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull?cacheSeconds=3600" alt="Python versions" /></a>
11
11
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license" /></a>
12
12
  </p>
13
13
 
14
14
  <p align="center">
15
15
  <a href="#quick-start">Quick start</a> ·
16
16
  <a href="#commands">Commands</a> ·
17
+ <a href="#use-with-ai-agents">AI agents</a> ·
17
18
  <a href="#faq">FAQ</a> ·
18
19
  <a href="#中文说明">中文说明</a>
19
20
  </p>
@@ -44,6 +45,8 @@ renamed/moved or deleted**. It can run on its own every morning and notify you.
44
45
  skip it (use `--refresh` to scan it again).
45
46
  - **Safe token storage.** `init` stores your token in the system keychain, not in a text file.
46
47
  - **Optional git.** `sync --git` commits each sync in the save folder.
48
+ - **Made for scripts and AI agents.** Every command has `--json` output and stable exit codes,
49
+ and there's an agent skill / Claude Code plugin.
47
50
  - **Read-only and private.** Only reads from Canvas; no telemetry, nothing leaves your machine.
48
51
  - **Fast.** Courses are scanned in parallel (a 32-course account scans in about 10 s instead of
49
52
  75 s), with parallel downloads, automatic retries with back-off and full pagination.
@@ -83,6 +86,50 @@ To get the latest unreleased code: `uv tool install git+https://github.com/gxxxr
83
86
  | `canvas-pull schedule status \| run \| disable` | Show the schedule and its log, trigger it now, or remove it. |
84
87
  | `canvas-pull config` | Show settings in use. |
85
88
 
89
+ ## Use with AI agents
90
+
91
+ canvas-pull is a plain CLI, so AI coding agents (Claude Code, Codex, Cursor, …) can run it
92
+ directly. Ask *"anything new in my courses?"* or *"summarize this week's new slides"* and the
93
+ agent syncs, reads the changed files and answers.
94
+
95
+ **Install the skill** so your agent knows how to use it:
96
+
97
+ ```
98
+ /plugin install canvas-pull --marketplace gxxxr-111/canvas-pull
99
+ ```
100
+
101
+ The skill is in [`skills/canvas-pull/SKILL.md`](skills/canvas-pull/SKILL.md). Other agents that
102
+ support Agent Skills can use that file as is.
103
+
104
+ **JSON output.** Add `--json` to `status`, `sync`, `log`, `courses`, `config` or `init`, and stdout
105
+ holds exactly one JSON object, also on errors:
106
+
107
+ ```bash
108
+ canvas-pull log --since 7d --json | jq -r '.changes[] | select(.kind=="new") | .local_path'
109
+ ```
110
+
111
+ Each change has `kind`, `course`, `path`, `local_path` (and `old_path` for moves).
112
+
113
+ **Exit codes**
114
+
115
+ | Code | Meaning |
116
+ | --- | --- |
117
+ | 0 | OK |
118
+ | 1 | Network or Canvas error |
119
+ | 2 | Invalid arguments |
120
+ | 3 | Not configured / invalid settings |
121
+ | 4 | Token rejected or expired |
122
+ | 5 | Finished, but some downloads or courses failed |
123
+ | 6 | Another sync is running |
124
+
125
+ **Non-interactive setup** (CI, dotfiles, servers):
126
+
127
+ ```bash
128
+ canvas-pull init --url oc.sjtu.edu.cn --save-dir ~/Canvas --token-stdin < token.txt
129
+ ```
130
+
131
+ Agents should never handle your token. Run `canvas-pull init` yourself.
132
+
86
133
  ## Configuration
87
134
 
88
135
  `init` writes a config file (path shown by `canvas-pull config`). You can edit it:
@@ -184,6 +231,10 @@ No. It only sends read (`GET`) requests.
184
231
  Create a new one in Canvas and run `canvas-pull init` again. Scheduled syncs show a notification
185
232
  when the token is rejected.
186
233
 
234
+ **Can it put assignment deadlines in my calendar?**
235
+ Canvas already can. Open the Canvas **Calendar**, click **Calendar Feed**, and subscribe to
236
+ that link in Apple Calendar, Google Calendar or Outlook.
237
+
187
238
  **How do I skip a course?**
188
239
  Add its ID or part of its name to `exclude` in the config file (`canvas-pull courses` shows IDs).
189
240
 
@@ -242,4 +293,6 @@ canvas-pull schedule enable --at 08:00 # 每天早上自动同步,有变化
242
293
  - 多门课并行扫描,32 门课大约 10 秒扫完。
243
294
  - `sync --git` 会在保存目录里自动提交一次 git,方便用 `git log` 回看。
244
295
  - 只读取 Canvas,不会修改任何内容;没有遥测,数据不离开你的电脑。
296
+ - 所有命令都支持 `--json` 输出和固定的退出码,方便脚本和 AI 助手调用。在 Claude Code 里运行 `/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull` 安装技能后,就可以直接问「我的课程这周有什么更新?帮我总结新课件」。
297
+ - 作业截止日期:在 Canvas 的「日历」页面点「日历订阅源」,把链接加到系统日历即可,无需本工具。
245
298
  - 在你的学校试过了?欢迎提交一个 [School report](https://github.com/gxxxr-111/canvas-pull/issues/new?template=school_report.yml),无论成功与否。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "canvas-pull"
3
- version = "2.4.0"
3
+ version = "2.5.0"
4
4
  description = "Sync your Canvas LMS course files: download only what changed and see what instructors added, modified, moved or deleted."
5
5
  authors = [{ name = "Mirage_", email = "gaoxuran111@gmail.com" }]
6
6
  readme = "README.md"
@@ -1,3 +1,3 @@
1
1
  """Sync files from your active Canvas LMS courses."""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.5.0"
@@ -1,11 +1,19 @@
1
- """Command-line interface."""
1
+ """Command-line interface.
2
+
3
+ Every data command accepts --json: stdout then carries exactly one JSON object (also on
4
+ errors), and human-oriented output is switched off. Exit codes are stable; see `ExitCode`.
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
9
+ import json
10
+ import os
11
+ import sys
5
12
  from collections import defaultdict
6
13
  from contextlib import nullcontext
14
+ from enum import IntEnum
7
15
  from pathlib import Path
8
- from typing import Annotated
16
+ from typing import Annotated, Any
9
17
 
10
18
  import typer
11
19
  from rich.console import Console
@@ -15,7 +23,7 @@ from rich.table import Table
15
23
  from . import __version__, notify
16
24
  from . import schedule as sched
17
25
  from .api import AuthError, CanvasClient, CanvasError
18
- from .collector import CourseScan, course_dirnames, list_courses, scan_courses
26
+ from .collector import CourseScan, RemoteFile, course_dirnames, list_courses, scan_courses
19
27
  from .config import (
20
28
  DEFAULT_SAVE_DIR,
21
29
  Config,
@@ -40,6 +48,31 @@ app = typer.Typer(
40
48
  console = Console(highlight=False)
41
49
  err = Console(stderr=True, highlight=False)
42
50
 
51
+
52
+ class ExitCode(IntEnum):
53
+ OK = 0
54
+ ERROR = 1 # network or Canvas error
55
+ USAGE = 2 # bad command-line arguments (Typer's own code)
56
+ CONFIG = 3 # not configured, or invalid settings
57
+ AUTH = 4 # Canvas rejected the token (invalid or expired)
58
+ PARTIAL = 5 # finished, but some downloads or courses failed
59
+ BUSY = 6 # another sync is running on the same folder
60
+
61
+
62
+ _ERROR_TYPES = {
63
+ ExitCode.ERROR: "canvas",
64
+ ExitCode.CONFIG: "config",
65
+ ExitCode.AUTH: "auth",
66
+ ExitCode.BUSY: "busy",
67
+ }
68
+
69
+ JsonOpt = Annotated[
70
+ bool,
71
+ typer.Option("--json", help="Print one JSON object on stdout (for scripts and AI agents)."),
72
+ ]
73
+
74
+ _json_mode = False
75
+
43
76
  STYLE = {
44
77
  Kind.NEW: ("+", "green"),
45
78
  Kind.MODIFIED: ("~", "yellow"),
@@ -49,9 +82,25 @@ STYLE = {
49
82
  }
50
83
 
51
84
 
52
- def _fail(message: str) -> typer.Exit:
53
- err.print(f"[red]Error:[/] {message}")
54
- return typer.Exit(1)
85
+ def _start(json_: bool) -> None:
86
+ """Called first by every command: in JSON mode, silence human output."""
87
+ global _json_mode
88
+ _json_mode = json_
89
+ console.quiet = json_
90
+
91
+
92
+ def _emit(data: dict[str, Any]) -> None:
93
+ sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
94
+ sys.stdout.flush()
95
+
96
+
97
+ def _fail(message: str, code: ExitCode = ExitCode.ERROR) -> typer.Exit:
98
+ if _json_mode:
99
+ kind = _ERROR_TYPES.get(code, "error")
100
+ _emit({"ok": False, "error": {"type": kind, "message": message, "exit_code": int(code)}})
101
+ else:
102
+ err.print(f"[red]Error:[/] {message}")
103
+ return typer.Exit(int(code))
55
104
 
56
105
 
57
106
  def _pretty(path: Path) -> str:
@@ -66,7 +115,12 @@ def _load(require_token: bool = True) -> Config:
66
115
  try:
67
116
  return load_config(require_token=require_token)
68
117
  except ConfigError as exc:
69
- raise _fail(str(exc)) from None
118
+ raise _fail(str(exc), ExitCode.CONFIG) from None
119
+
120
+
121
+ def _canvas_failure(exc: CanvasError) -> typer.Exit:
122
+ code = ExitCode.AUTH if isinstance(exc, AuthError) else ExitCode.ERROR
123
+ return _fail(str(exc), code)
70
124
 
71
125
 
72
126
  def _matches(course: Course, patterns: list[str]) -> bool:
@@ -91,40 +145,85 @@ def main(
91
145
 
92
146
 
93
147
  @app.command()
94
- def init() -> None:
95
- """Set your Canvas URL, save folder and access token."""
148
+ def init(
149
+ url: Annotated[
150
+ str | None, typer.Option("--url", help="Canvas site, e.g. oc.sjtu.edu.cn.")
151
+ ] = None,
152
+ save_dir_opt: Annotated[
153
+ Path | None, typer.Option("--save-dir", help="Folder to save files in.")
154
+ ] = None,
155
+ token_stdin: Annotated[
156
+ bool, typer.Option("--token-stdin", help="Read the access token from stdin.")
157
+ ] = False,
158
+ no_input: Annotated[
159
+ bool, typer.Option("--no-input", help="Never prompt; fail if something is missing.")
160
+ ] = False,
161
+ json_: JsonOpt = False,
162
+ ) -> None:
163
+ """Set your Canvas URL, save folder and access token.
164
+
165
+ Non-interactive: canvas-pull init --url oc.sjtu.edu.cn --save-dir ~/Canvas --token-stdin
166
+ (the token can also come from CANVAS_TOKEN).
167
+ """
168
+ _start(json_)
169
+ interactive = not (no_input or json_ or token_stdin)
96
170
  try:
97
171
  current = load_config(require_token=False)
98
172
  except ConfigError:
99
173
  current = None
100
174
 
101
- console.print(
102
- "Create a token in Canvas: [bold]Account → Settings → New Access Token[/].\n"
103
- "The token is stored in your system keychain.\n"
104
- )
105
- website = normalize_website(
106
- typer.prompt("Canvas URL", default=current.website if current else "oc.sjtu.edu.cn")
107
- )
108
- save_dir = (
109
- Path(
110
- typer.prompt(
111
- "Save files to", default=str(current.save_dir) if current else DEFAULT_SAVE_DIR
112
- )
175
+ def ask(label: str, default: str | None, hide: bool = False) -> str:
176
+ if not interactive:
177
+ if default:
178
+ return default
179
+ raise _fail(f"Missing {label}; pass it as an option.", ExitCode.CONFIG)
180
+ return typer.prompt(label, default=default, hide_input=hide)
181
+
182
+ if interactive:
183
+ console.print(
184
+ "Create a token in Canvas: [bold]Account → Settings → New Access Token[/].\n"
185
+ "The token is stored in your system keychain.\n"
113
186
  )
114
- .expanduser()
115
- .resolve()
187
+ website_raw = url or ask(
188
+ "Canvas URL", current.website if current else ("oc.sjtu.edu.cn" if interactive else None)
116
189
  )
117
- token = typer.prompt("Access token", hide_input=True).strip()
190
+ try:
191
+ website = normalize_website(website_raw)
192
+ except ConfigError as exc:
193
+ raise _fail(str(exc), ExitCode.CONFIG) from None
194
+ default_dir = str(current.save_dir) if current else DEFAULT_SAVE_DIR
195
+ save_dir = Path(save_dir_opt or ask("Save files to", default_dir)).expanduser().resolve()
196
+
197
+ if token_stdin:
198
+ token = sys.stdin.readline().strip()
199
+ else:
200
+ token = os.environ.get("CANVAS_TOKEN", "") or ask("Access token", None, hide=True)
201
+ token = token.strip()
202
+ if not token:
203
+ raise _fail("No access token given.", ExitCode.CONFIG)
118
204
 
119
205
  try:
120
206
  with CanvasClient(website, token) as client:
121
207
  me = client.get("users/self")
122
208
  except CanvasError as exc:
123
- raise _fail(f"Could not sign in to {website}: {exc}") from None
209
+ code = ExitCode.AUTH if isinstance(exc, AuthError) else ExitCode.ERROR
210
+ raise _fail(f"Could not sign in to {website}: {exc}", code) from None
124
211
 
125
212
  cfg = current or Config(website=website, save_dir=save_dir)
126
213
  cfg.website, cfg.save_dir, cfg.token = website, save_dir, token
127
214
  where = save_config(cfg)
215
+ if json_:
216
+ _emit(
217
+ {
218
+ "ok": True,
219
+ "user": me.get("name"),
220
+ "website": website,
221
+ "save_dir": str(save_dir),
222
+ "config_file": str(default_config_file()),
223
+ "token_stored_in": where,
224
+ }
225
+ )
226
+ return
128
227
  console.print(f"[green]✓[/] Signed in as [bold]{me.get('name', '?')}[/].")
129
228
  console.print(f"Settings: {default_config_file()} · token: {where}")
130
229
  console.print("Next: [bold]canvas-pull sync[/]")
@@ -137,16 +236,36 @@ def courses(
137
236
  bool | None,
138
237
  typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
139
238
  ] = None,
239
+ json_: JsonOpt = False,
140
240
  ) -> None:
141
241
  """List the courses that would be synced."""
242
+ _start(json_)
142
243
  cfg = _load()
143
244
  with_completed = cfg.include_completed if completed is None else completed
144
245
  try:
145
246
  with _client(cfg) as client:
146
247
  found = list_courses(client, include_past=all_, include_completed=with_completed)
147
248
  except CanvasError as exc:
148
- raise _fail(str(exc)) from None
149
-
249
+ raise _canvas_failure(exc) from None
250
+
251
+ if json_:
252
+ _emit(
253
+ {
254
+ "ok": True,
255
+ "courses": [
256
+ {
257
+ "id": c.id,
258
+ "name": c.label,
259
+ "code": c.course_code,
260
+ "term": c.term.name if c.term else None,
261
+ "completed": c.completed,
262
+ "excluded": _matches(c, cfg.exclude),
263
+ }
264
+ for c in found
265
+ ],
266
+ }
267
+ )
268
+ return
150
269
  table = Table(box=None, header_style="bold")
151
270
  table.add_column("ID", justify="right")
152
271
  table.add_column("Course")
@@ -203,10 +322,13 @@ def _scan(
203
322
  _KIND_ORDER = {Kind.NEW: 0, Kind.MODIFIED: 1, Kind.MOVED: 2, Kind.RESTORED: 3, Kind.DELETED: 4}
204
323
 
205
324
 
325
+ def _sorted(changes: list[Change]) -> list[Change]:
326
+ return sorted(changes, key=lambda c: (c.course.lower(), _KIND_ORDER[c.kind], c.path.lower()))
327
+
328
+
206
329
  def _print_changes(changes: list[Change]) -> None:
207
330
  by_course: dict[str, list[Change]] = defaultdict(list)
208
- ordered = sorted(changes, key=lambda c: (c.course.lower(), _KIND_ORDER[c.kind], c.path.lower()))
209
- for c in ordered:
331
+ for c in _sorted(changes):
210
332
  by_course[c.course].append(c)
211
333
  for course, items in by_course.items():
212
334
  console.print(f"\n[bold]{course}[/]")
@@ -226,6 +348,43 @@ def _summary(changes: list[Change]) -> str:
226
348
  return ", ".join(parts) if parts else "no changes"
227
349
 
228
350
 
351
+ def _counts(changes: list[Change]) -> dict[str, int]:
352
+ counts: dict[str, int] = defaultdict(int)
353
+ for c in changes:
354
+ counts[str(c.kind)] += 1
355
+ return dict(counts)
356
+
357
+
358
+ def _change_json(c: Change, save_dir: Path) -> dict[str, Any]:
359
+ d = c.to_dict()
360
+ d["local_path"] = str(save_dir / c.path)
361
+ return d
362
+
363
+
364
+ def _sync_json(
365
+ *,
366
+ dry_run: bool,
367
+ save_dir: Path,
368
+ changes: list[Change],
369
+ failed: list[tuple[Change, str]] = (), # type: ignore[assignment]
370
+ locked: list[RemoteFile] = (), # type: ignore[assignment]
371
+ scans: list[CourseScan] = (), # type: ignore[assignment]
372
+ archived: int = 0,
373
+ ) -> dict[str, Any]:
374
+ skipped = [{"course": s.course.label, "error": s.error} for s in scans if s.error]
375
+ return {
376
+ "ok": not failed and not skipped,
377
+ "dry_run": dry_run,
378
+ "save_dir": str(save_dir),
379
+ "summary": _counts(changes),
380
+ "changes": [_change_json(c, save_dir) for c in _sorted(changes)],
381
+ "failed": [{"course": c.course, "path": c.path, "error": e} for c, e in failed],
382
+ "locked": [{"course": rf.course.label, "path": rf.path} for rf in locked],
383
+ "skipped_courses": skipped,
384
+ "archived_courses_skipped": archived,
385
+ }
386
+
387
+
229
388
  def _alert(enabled: bool, message: str) -> None:
230
389
  if enabled:
231
390
  notify.send(message)
@@ -257,15 +416,17 @@ def sync(
257
416
  refresh: Annotated[
258
417
  bool, typer.Option("--refresh", help="Scan archived completed courses again.")
259
418
  ] = False,
419
+ json_: JsonOpt = False,
260
420
  ) -> None:
261
421
  """Download new and changed files from your active courses."""
262
- if not console.is_terminal and not dry_run:
422
+ _start(json_)
423
+ if not console.is_terminal and not dry_run and not json_:
263
424
  console.print(f"--- {now_iso()} canvas-pull sync") # separator in scheduled-run logs
264
425
  try:
265
426
  cfg = load_config()
266
427
  except ConfigError as exc:
267
428
  _alert(notify_, f"Sync failed: {exc}")
268
- raise _fail(str(exc)) from None
429
+ raise _fail(str(exc), ExitCode.CONFIG) from None
269
430
  use_git = cfg.git if git is None else git
270
431
  with_completed = cfg.include_completed if completed is None else completed
271
432
  manifest = Manifest.load(cfg.save_dir)
@@ -284,22 +445,46 @@ def sync(
284
445
  if not scans:
285
446
  if not archived:
286
447
  console.print("No matching active courses.")
448
+ if json_:
449
+ _emit(
450
+ _sync_json(
451
+ dry_run=dry_run, save_dir=cfg.save_dir, changes=[], archived=archived
452
+ )
453
+ )
287
454
  return
288
455
  plan = make_plan(scans, manifest, cfg.save_dir)
289
456
 
290
457
  for s in scans:
291
458
  if s.error:
292
- err.print(f"[yellow]![/] Skipped {s.course.label}: {s.error}")
459
+ if not json_:
460
+ err.print(f"[yellow]![/] Skipped {s.course.label}: {s.error}")
293
461
  elif s.files_tab_hidden:
294
462
  console.print(
295
463
  f"[dim]{s.course.label}: Files tab hidden; used Modules instead.[/]"
296
464
  )
465
+ scan_failed = any(s.error for s in scans)
297
466
 
298
467
  if dry_run:
299
- _print_changes(plan.changes)
300
- console.print(f"\n{_summary(plan.changes)} · {_pretty(cfg.save_dir)}")
301
- if plan.locked:
302
- console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
468
+ if json_:
469
+ _emit(
470
+ _sync_json(
471
+ dry_run=True,
472
+ save_dir=cfg.save_dir,
473
+ changes=plan.changes,
474
+ locked=plan.locked,
475
+ scans=scans,
476
+ archived=archived,
477
+ )
478
+ )
479
+ else:
480
+ _print_changes(plan.changes)
481
+ console.print(f"\n{_summary(plan.changes)} · {_pretty(cfg.save_dir)}")
482
+ if plan.locked:
483
+ console.print(
484
+ f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]"
485
+ )
486
+ if scan_failed:
487
+ raise typer.Exit(int(ExitCode.PARTIAL))
303
488
  return
304
489
 
305
490
  total = sum(1 for c in plan.changes if c.needs_download)
@@ -328,39 +513,54 @@ def sync(
328
513
  finally:
329
514
  manifest.save()
330
515
  except SyncLocked:
331
- raise _fail("Another sync is already running for this folder.") from None
516
+ raise _fail("Another sync is already running for this folder.", ExitCode.BUSY) from None
332
517
  except AuthError as exc:
333
518
  _alert(notify_, "Sync failed: Canvas rejected your token. Run `canvas-pull init`.")
334
- raise _fail(str(exc)) from None
519
+ raise _canvas_failure(exc) from None
335
520
  except CanvasError as exc:
336
521
  if exc.status is not None: # stay quiet when simply offline
337
522
  _alert(notify_, f"Sync failed: {exc}")
338
- raise _fail(str(exc)) from None
339
-
340
- _print_changes(result.applied)
341
- for change, reason in result.failed:
342
- err.print(f"[red]✗[/] {change.path}: {reason}")
343
- console.print(f"\n{_summary(result.applied)} · {_pretty(cfg.save_dir)}")
344
- if plan.locked:
345
- console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
523
+ raise _canvas_failure(exc) from None
346
524
 
347
525
  if result.applied:
348
526
  record = {"time": now_iso(), "changes": [c.to_dict() for c in result.applied]}
349
527
  append_log(cfg.save_dir, record)
528
+ git_summary = None
350
529
  if use_git:
351
530
  try:
352
- summary = commit_sync(cfg.save_dir, result.applied)
353
- if summary:
354
- console.print(f"[dim]git: {summary}[/]")
531
+ git_summary = commit_sync(cfg.save_dir, result.applied)
355
532
  except GitError as exc:
356
- err.print(f"[yellow]git commit skipped:[/] {exc}")
533
+ if not json_:
534
+ err.print(f"[yellow]git commit skipped:[/] {exc}")
357
535
  if result.applied or result.failed:
358
536
  message = notify.describe(result.applied)
359
537
  if result.failed:
360
538
  message = f"{message} · {len(result.failed)} failed".lstrip(" ·")
361
539
  _alert(notify_, message)
362
- if result.failed:
363
- raise typer.Exit(1)
540
+
541
+ if json_:
542
+ data = _sync_json(
543
+ dry_run=False,
544
+ save_dir=cfg.save_dir,
545
+ changes=result.applied,
546
+ failed=result.failed,
547
+ locked=plan.locked,
548
+ scans=scans,
549
+ archived=archived,
550
+ )
551
+ data["git_commit"] = git_summary
552
+ _emit(data)
553
+ else:
554
+ _print_changes(result.applied)
555
+ for change, reason in result.failed:
556
+ err.print(f"[red]✗[/] {change.path}: {reason}")
557
+ console.print(f"\n{_summary(result.applied)} · {_pretty(cfg.save_dir)}")
558
+ if plan.locked:
559
+ console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
560
+ if git_summary:
561
+ console.print(f"[dim]git: {git_summary}[/]")
562
+ if result.failed or scan_failed:
563
+ raise typer.Exit(int(ExitCode.PARTIAL))
364
564
 
365
565
 
366
566
  @app.command()
@@ -375,6 +575,7 @@ def status(
375
575
  refresh: Annotated[
376
576
  bool, typer.Option("--refresh", help="Scan archived completed courses again.")
377
577
  ] = False,
578
+ json_: JsonOpt = False,
378
579
  ) -> None:
379
580
  """Show what changed on Canvas since the last sync (downloads nothing)."""
380
581
  sync(
@@ -385,6 +586,7 @@ def status(
385
586
  git=False,
386
587
  notify_=False,
387
588
  refresh=refresh,
589
+ json_=json_,
388
590
  )
389
591
 
390
592
 
@@ -397,15 +599,41 @@ def log(
397
599
  "--since", "-s", help="Net summary since 7d, 24h, 2w, today or a date (2026-10-01)."
398
600
  ),
399
601
  ] = None,
602
+ json_: JsonOpt = False,
400
603
  ) -> None:
401
604
  """Show recent syncs, or a merged summary with --since (e.g. --since 7d)."""
605
+ _start(json_)
402
606
  cfg = _load(require_token=False)
403
607
  if since:
404
608
  try:
405
609
  start = parse_since(since)
406
610
  except ValueError as exc:
407
- raise _fail(str(exc)) from None
611
+ raise _fail(str(exc), ExitCode.USAGE) from None
408
612
  net = summarize(read_log(cfg.save_dir, None), start)
613
+ if json_:
614
+ counts: dict[str, int] = defaultdict(int)
615
+ for n in net:
616
+ counts[n.kind] += 1
617
+ _emit(
618
+ {
619
+ "ok": True,
620
+ "since": start.isoformat(),
621
+ "save_dir": str(cfg.save_dir),
622
+ "summary": dict(counts),
623
+ "changes": [
624
+ {
625
+ "kind": n.kind,
626
+ "course": n.course,
627
+ "path": n.path,
628
+ **({"old_path": n.old_path} if n.old_path else {}),
629
+ "time": n.time,
630
+ "local_path": str(cfg.save_dir / n.path),
631
+ }
632
+ for n in net
633
+ ],
634
+ }
635
+ )
636
+ return
409
637
  local = start.astimezone().strftime("%Y-%m-%d %H:%M")
410
638
  if not net:
411
639
  console.print(f"No changes since {local}.")
@@ -415,10 +643,10 @@ def log(
415
643
  for n in net:
416
644
  by_course[n.course].append(n)
417
645
  for course_name, items in by_course.items():
418
- counts: dict[str, int] = defaultdict(int)
646
+ tally_counts: dict[str, int] = defaultdict(int)
419
647
  for n in items:
420
- counts[n.kind] += 1
421
- tally = ", ".join(f"{v} {k}" for k, v in counts.items())
648
+ tally_counts[n.kind] += 1
649
+ tally = ", ".join(f"{v} {k}" for k, v in tally_counts.items())
422
650
  console.print(f"\n[bold]{course_name}[/] [dim]{tally}[/]")
423
651
  for n in items:
424
652
  mark, color = STYLE[Kind(n.kind)]
@@ -429,6 +657,9 @@ def log(
429
657
  return
430
658
 
431
659
  records = read_log(cfg.save_dir, limit)
660
+ if json_:
661
+ _emit({"ok": True, "save_dir": str(cfg.save_dir), "syncs": list(reversed(records))})
662
+ return
432
663
  if not records:
433
664
  console.print("No syncs recorded yet.")
434
665
  return
@@ -442,25 +673,34 @@ def log(
442
673
 
443
674
 
444
675
  @app.command("config")
445
- def show_config() -> None:
676
+ def show_config(json_: JsonOpt = False) -> None:
446
677
  """Show the settings in use (token hidden)."""
678
+ _start(json_)
447
679
  cfg = _load(require_token=False)
448
- token = "set" if cfg.token else "missing"
680
+ values: dict[str, Any] = {
681
+ "config_file": str(default_config_file()),
682
+ "website": cfg.website,
683
+ "save_dir": str(cfg.save_dir),
684
+ "token": "set" if cfg.token else "missing",
685
+ "concurrency": cfg.concurrency,
686
+ "scan_concurrency": cfg.scan_concurrency,
687
+ "keep_history": cfg.keep_history,
688
+ "git": cfg.git,
689
+ "exclude": cfg.exclude,
690
+ "include_completed": cfg.include_completed,
691
+ "sources": cfg.sources,
692
+ }
693
+ if json_:
694
+ _emit({"ok": True, **values})
695
+ return
449
696
  table = Table(box=None, show_header=False)
450
- for key, value in [
451
- ("config file", str(default_config_file())),
452
- ("website", cfg.website),
453
- ("save_dir", _pretty(cfg.save_dir)),
454
- ("token", token),
455
- ("concurrency", str(cfg.concurrency)),
456
- ("scan_concurrency", str(cfg.scan_concurrency)),
457
- ("keep_history", str(cfg.keep_history)),
458
- ("git", str(cfg.git)),
459
- ("exclude", ", ".join(cfg.exclude) or "-"),
460
- ("include_completed", str(cfg.include_completed)),
461
- ("sources", ", ".join(cfg.sources)),
462
- ]:
463
- table.add_row(f"[bold]{key}[/]", value)
697
+ for key, value in values.items():
698
+ if key == "save_dir":
699
+ value = _pretty(cfg.save_dir)
700
+ elif isinstance(value, list):
701
+ value = ", ".join(value) or "-"
702
+ label = "config file" if key == "config_file" else key
703
+ table.add_row(f"[bold]{label}[/]", str(value))
464
704
  console.print(table)
465
705
 
466
706
 
@@ -482,12 +722,13 @@ def schedule_enable(
482
722
  ] = None,
483
723
  ) -> None:
484
724
  """Install (or replace) the automatic sync."""
725
+ _start(False)
485
726
  _load() # fail now, not silently at 9am
486
727
  try:
487
728
  spec = sched.parse_spec(at or [], every)
488
729
  path = sched.enable(spec, workdir=Path.cwd())
489
730
  except sched.ScheduleError as exc:
490
- raise _fail(str(exc)) from None
731
+ raise _fail(str(exc), ExitCode.CONFIG) from None
491
732
  cmd = sched.command()
492
733
  console.print(f"[green]✓[/] canvas-pull will sync [bold]{spec.describe()}[/].")
493
734
  console.print(f"[dim]Job: {path}\nCommand: {' '.join(cmd)}\nLog: {sched.log_file()}[/]")
@@ -1,5 +1,7 @@
1
+ import json
1
2
  import shutil
2
3
  import subprocess
4
+ from pathlib import Path
3
5
 
4
6
  import pytest
5
7
  from typer.testing import CliRunner
@@ -74,7 +76,7 @@ def test_git_commit(env, monkeypatch):
74
76
  def test_bad_token_message(env, monkeypatch):
75
77
  monkeypatch.setenv("CANVAS_TOKEN", "expired")
76
78
  r = runner.invoke(cli.app, ["sync"])
77
- assert r.exit_code == 1 and "token" in r.output.lower()
79
+ assert r.exit_code == 4 and "token" in r.output.lower()
78
80
 
79
81
 
80
82
  def test_notify_on_changes_and_on_bad_token(env, monkeypatch):
@@ -139,3 +141,104 @@ def _loader(cfg_file):
139
141
  from canvas_pull.config import load_config
140
142
 
141
143
  return lambda **kw: load_config(config_file=cfg_file, use_keyring=False, **kw)
144
+
145
+
146
+ def _json(result):
147
+ import json
148
+
149
+ return json.loads(result.stdout)
150
+
151
+
152
+ def test_json_status_and_sync(env):
153
+ fake, out = env
154
+ r = runner.invoke(cli.app, ["status", "--json"])
155
+ assert r.exit_code == 0, r.output
156
+ data = _json(r)
157
+ assert data["ok"] and data["dry_run"] and data["summary"] == {"new": 1}
158
+ assert data["changes"][0]["path"] == "Algorithms/hw1.pdf"
159
+
160
+ r = runner.invoke(cli.app, ["sync", "--json"])
161
+ data = _json(r)
162
+ assert data["ok"] and not data["dry_run"]
163
+ change = data["changes"][0]
164
+ assert change["kind"] == "new" and Path(change["local_path"]).read_bytes() == b"one"
165
+
166
+
167
+ def test_json_log_courses_config(env):
168
+ fake, _ = env
169
+ runner.invoke(cli.app, ["sync"])
170
+ assert _json(runner.invoke(cli.app, ["log", "--since", "7d", "--json"]))["summary"] == {
171
+ "new": 1
172
+ }
173
+ assert len(_json(runner.invoke(cli.app, ["log", "--json"]))["syncs"]) == 1
174
+ courses = _json(runner.invoke(cli.app, ["courses", "--json"]))["courses"]
175
+ assert courses == [
176
+ {
177
+ "id": 1,
178
+ "name": "Algorithms",
179
+ "code": None,
180
+ "term": "Fall",
181
+ "completed": False,
182
+ "excluded": False,
183
+ }
184
+ ]
185
+ cfg = _json(runner.invoke(cli.app, ["config", "--json"]))
186
+ assert cfg["token"] == "set" and "good-token" not in json.dumps(cfg)
187
+
188
+
189
+ def test_json_errors_and_exit_codes(env, monkeypatch):
190
+ monkeypatch.setenv("CANVAS_TOKEN", "expired")
191
+ r = runner.invoke(cli.app, ["status", "--json"])
192
+ assert r.exit_code == 4
193
+ assert _json(r)["error"]["type"] == "auth"
194
+
195
+ r = runner.invoke(cli.app, ["log", "--since", "soon", "--json"])
196
+ assert r.exit_code == 2
197
+
198
+ monkeypatch.delenv("CANVAS_URL")
199
+ r = runner.invoke(cli.app, ["courses", "--json"])
200
+ assert r.exit_code == 3 and _json(r)["error"]["type"] == "config"
201
+
202
+
203
+ def test_partial_failure_exit_code(env):
204
+ fake, _ = env
205
+ fake.flaky["/api/v1/courses/1/folders"] = 99 # this course can't be scanned
206
+ r = runner.invoke(cli.app, ["sync", "--json"])
207
+ assert r.exit_code == 5
208
+ data = _json(r)
209
+ assert not data["ok"] and data["skipped_courses"][0]["course"] == "Algorithms"
210
+
211
+
212
+ def test_busy_exit_code(env):
213
+ from canvas_pull.manifest import sync_lock
214
+
215
+ _, out = env
216
+ with sync_lock(out):
217
+ r = runner.invoke(cli.app, ["sync", "--json"])
218
+ assert r.exit_code == 6 and _json(r)["error"]["type"] == "busy"
219
+
220
+
221
+ def test_init_non_interactive(env, monkeypatch, tmp_path):
222
+ import keyring
223
+
224
+ store = {}
225
+ monkeypatch.setattr(keyring, "set_password", lambda s, u, p: store.__setitem__(u, p))
226
+ cfg_file = tmp_path / "written.toml"
227
+ monkeypatch.setattr(config, "default_config_file", lambda: cfg_file)
228
+ monkeypatch.setattr(cli, "default_config_file", lambda: cfg_file)
229
+ monkeypatch.delenv("CANVAS_TOKEN")
230
+ r = runner.invoke(
231
+ cli.app,
232
+ ["init", "--url", BASE, "--save-dir", str(tmp_path / "c"), "--token-stdin", "--json"],
233
+ input="good-token\n",
234
+ )
235
+ assert r.exit_code == 0, r.output
236
+ data = _json(r)
237
+ assert data["user"] == "Student" and data["token_stored_in"] == "keychain"
238
+ assert store == {BASE: "good-token"} and "good-token" not in cfg_file.read_text()
239
+
240
+
241
+ def test_init_no_input_reports_missing(env, monkeypatch):
242
+ monkeypatch.delenv("CANVAS_URL")
243
+ r = runner.invoke(cli.app, ["init", "--no-input", "--json"])
244
+ assert r.exit_code == 3 and "Canvas URL" in _json(r)["error"]["message"]
File without changes
File without changes