moodle-cli 0.1.0b0__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.
@@ -0,0 +1,61 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["**"]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Test (Python ${{ matrix.python-version }})
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
20
+
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Set up uv
26
+ uses: astral-sh/setup-uv@v6
27
+
28
+ - name: Set up Python
29
+ uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+
33
+ - name: Sync dependencies
34
+ run: uv sync --locked
35
+
36
+ - name: Compile package
37
+ run: uv run python -m compileall moodle_cli
38
+
39
+ - name: Smoke test CLI
40
+ run: uv run moodle --help
41
+
42
+ build:
43
+ name: Build distribution
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - name: Checkout
47
+ uses: actions/checkout@v4
48
+
49
+ - name: Set up uv
50
+ uses: astral-sh/setup-uv@v6
51
+
52
+ - name: Set up Python
53
+ uses: actions/setup-python@v5
54
+ with:
55
+ python-version: "3.12"
56
+
57
+ - name: Build package
58
+ run: uv build
59
+
60
+ - name: Check distribution metadata
61
+ run: uvx twine check dist/*
@@ -0,0 +1,61 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ verify:
13
+ uses: ./.github/workflows/ci.yml
14
+
15
+ publish:
16
+ needs: verify
17
+ runs-on: ubuntu-latest
18
+ environment:
19
+ name: pypi
20
+ url: https://pypi.org/project/moodle-cli/
21
+ permissions:
22
+ id-token: write
23
+ contents: read
24
+
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: "3.12"
31
+
32
+ - name: Setup uv
33
+ uses: astral-sh/setup-uv@v6
34
+
35
+ - name: Verify tag matches package version
36
+ run: |
37
+ python - <<'PY'
38
+ import os
39
+ import pathlib
40
+ import tomllib
41
+
42
+ project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]
43
+ name = project["name"]
44
+ version = project["version"]
45
+ tag = os.environ["GITHUB_REF_NAME"]
46
+ expected = f"v{version}"
47
+ if name != "moodle-cli":
48
+ raise SystemExit(f"Refusing to publish unexpected package name: {name}")
49
+ if tag != expected:
50
+ raise SystemExit(f"Tag {tag} does not match package version {expected}")
51
+ print(f"Validated release tag {tag} for {name}")
52
+ PY
53
+
54
+ - name: Build package
55
+ run: uv build
56
+
57
+ - name: Check distribution metadata
58
+ run: uvx twine check dist/*
59
+
60
+ - name: Publish to PyPI
61
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,59 @@
1
+ name: Publish GitHub Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ verify:
13
+ uses: ./.github/workflows/ci.yml
14
+
15
+ release:
16
+ needs: verify
17
+ runs-on: ubuntu-latest
18
+ permissions:
19
+ contents: write
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - uses: actions/setup-python@v5
25
+ with:
26
+ python-version: "3.12"
27
+
28
+ - name: Setup uv
29
+ uses: astral-sh/setup-uv@v6
30
+
31
+ - name: Verify tag matches package version
32
+ run: |
33
+ python - <<'PY'
34
+ import os
35
+ import pathlib
36
+ import tomllib
37
+
38
+ project = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]
39
+ name = project["name"]
40
+ version = project["version"]
41
+ tag = os.environ["GITHUB_REF_NAME"]
42
+ expected = f"v{version}"
43
+ if name != "moodle-cli":
44
+ raise SystemExit(f"Refusing to release unexpected package name: {name}")
45
+ if tag != expected:
46
+ raise SystemExit(f"Tag {tag} does not match package version {expected}")
47
+ print(f"Validated release tag {tag} for {name}")
48
+ PY
49
+
50
+ - name: Build package
51
+ run: uv build
52
+
53
+ - name: Publish GitHub release
54
+ uses: softprops/action-gh-release@v2
55
+ with:
56
+ generate_release_notes: true
57
+ files: |
58
+ dist/*.tar.gz
59
+ dist/*.whl
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ config.yaml
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ *.pyd
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ dist/
11
+ build/
12
+ *.egg-info/
@@ -0,0 +1,56 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Build & Run
6
+
7
+ ```bash
8
+ uv sync # install dependencies
9
+ uv run moodle --help # run CLI
10
+ uv run moodle -v user # verbose mode (debug logging)
11
+ ```
12
+
13
+ Entry point: `moodle_cli.cli:main` (registered as `moodle` console script in pyproject.toml).
14
+
15
+ No tests exist yet. No linter/formatter is configured.
16
+
17
+ ## Architecture
18
+
19
+ Terminal CLI for Moodle LMS that piggybacks on the user's browser session — no API tokens needed.
20
+
21
+ ### API Strategy
22
+
23
+ Uses Moodle's **internal AJAX endpoint** (`/lib/ajax/service.php`), not the official Web Services token API. This endpoint accepts the `MoodleSession` browser cookie, same as the Moodle web UI. The client first loads an authenticated page to resolve `sesskey`, then tries AJAX APIs and falls back to page scraping when site-specific Moodle restrictions disable some services.
24
+
25
+ Request format: `POST /lib/ajax/service.php?sesskey={sesskey}&info={function_name}` with JSON body `[{"index": 0, "methodname": "...", "args": {...}}]`. Response: `[{"error": false, "data": ...}]`.
26
+
27
+ ### Data Flow
28
+
29
+ ```
30
+ auth.py (get cookie) → client.py (API calls) → parser.py (JSON→models) → formatter.py/output.py (display)
31
+ ↑ ↑
32
+ env var or sesskey auto-obtained
33
+ browser-cookie3 from get_site_info()
34
+ ```
35
+
36
+ - **cli.py**: Click command group. `main()` wraps `cli(standalone_mode=False)` to handle `AuthError`/`MoodleAPIError` cleanly. Client is lazily created via `ctx.obj["get_client"]()` closure — auth only happens when a command actually needs the API.
37
+ - **auth.py**: Priority: `MOODLE_SESSION` env var → browser-cookie3 extraction (Chrome, Firefox, Brave, Edge). Arc browser is not supported by browser-cookie3.
38
+ - **client.py**: `MoodleClient` — holds session cookie, auto-obtains `sesskey`+`userid` on first API call via `_ensure_session()`.
39
+ - **models.py**: Dataclasses (`UserInfo`, `Course`, `Section`, `Activity`) with `to_dict()` for serialization.
40
+ - **parser.py**: Pure functions transforming Moodle JSON dicts → model instances.
41
+ - **formatter.py**: Rich tables (courses) and trees (course sections→activities).
42
+ - **output.py**: `--json`/`--yaml` structured output to stdout.
43
+ - **config.py**: Loads `config.yaml` from CWD or `~/.config/moodle-cli/`. If no `base_url` is configured, it prompts the user, validates the root URL, probes the site, and saves the result. `MOODLE_BASE_URL` env var overrides.
44
+ - **constants.py**: API paths, function names, env var names.
45
+
46
+ ### Adding a New Command
47
+
48
+ 1. Add the Moodle AJAX function name to `constants.py`
49
+ 2. Add a method to `MoodleClient` in `client.py` (call `self._ensure_session()` first)
50
+ 3. Add model dataclass to `models.py`, parser function to `parser.py`
51
+ 4. Add Rich display function to `formatter.py`
52
+ 5. Add `@cli.command()` in `cli.py` with `--json`/`--yaml` options
53
+
54
+ ### Adding a New Moodle API Call
55
+
56
+ All API calls go through `MoodleClient._call(function_name, args)`. It handles the AJAX envelope format and error extraction. Just add a new public method that calls `self._call()` with the right function name.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bunizao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: moodle-cli
3
+ Version: 0.1.0b0
4
+ Summary: Terminal-first CLI for Moodle LMS
5
+ Project-URL: Homepage, https://github.com/bunizao/moodle-cli
6
+ Project-URL: Repository, https://github.com/bunizao/moodle-cli
7
+ Project-URL: Issues, https://github.com/bunizao/moodle-cli/issues
8
+ Author: bunizao
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 bunizao
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Requires-Python: >=3.10
32
+ Requires-Dist: beautifulsoup4>=4.12
33
+ Requires-Dist: browser-cookie3>=0.19
34
+ Requires-Dist: click>=8.0
35
+ Requires-Dist: pyyaml>=6.0
36
+ Requires-Dist: requests>=2.28
37
+ Requires-Dist: rich>=13.0
38
+ Description-Content-Type: text/markdown
39
+
40
+ # moodle-cli
41
+
42
+ Terminal-first CLI for Moodle LMS that reuses an authenticated browser session.
43
+
44
+ Repository: <https://github.com/bunizao/moodle-cli>
45
+
46
+ ## Features
47
+
48
+ - No API token setup required
49
+ - Reads `MoodleSession` from your browser or `MOODLE_SESSION`
50
+ - Works with Moodle AJAX APIs and falls back to authenticated page scraping when needed
51
+ - Terminal output plus `--json` and `--yaml`
52
+
53
+ ## Requirements
54
+
55
+ - Python 3.10+
56
+ - `uv`
57
+ - An active Moodle browser session, or a `MOODLE_SESSION` environment variable
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ uv sync
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```bash
68
+ uv run moodle --help
69
+ uv run moodle user
70
+ uv run moodle courses
71
+ uv run moodle activities 34637
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ On first run, the CLI will ask for your Moodle base URL and save it to `config.yaml`.
77
+
78
+ Safety checks during setup:
79
+
80
+ - Requires a full root URL such as `https://school.example.edu`
81
+ - Rejects paths, query strings, and fragments
82
+ - Probes the site before saving
83
+ - Requires explicit confirmation if the target does not look like Moodle
84
+
85
+ If you prefer to configure it manually, create `config.yaml` in the project directory or in `~/.config/moodle-cli/`:
86
+
87
+ ```yaml
88
+ base_url: https://school.example.edu
89
+ ```
90
+
91
+ You can copy from `config.example.yaml`.
92
+
93
+ Environment overrides:
94
+
95
+ - `MOODLE_BASE_URL`
96
+ - `MOODLE_SESSION`
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ uv run python -m compileall moodle_cli
102
+ uv build
103
+ ```
104
+
105
+ ## CI
106
+
107
+ GitHub Actions runs the following checks on pushes and pull requests:
108
+
109
+ - Dependency lock sync with `uv`
110
+ - Bytecode compilation
111
+ - CLI smoke check
112
+ - Package build
@@ -0,0 +1,73 @@
1
+ # moodle-cli
2
+
3
+ Terminal-first CLI for Moodle LMS that reuses an authenticated browser session.
4
+
5
+ Repository: <https://github.com/bunizao/moodle-cli>
6
+
7
+ ## Features
8
+
9
+ - No API token setup required
10
+ - Reads `MoodleSession` from your browser or `MOODLE_SESSION`
11
+ - Works with Moodle AJAX APIs and falls back to authenticated page scraping when needed
12
+ - Terminal output plus `--json` and `--yaml`
13
+
14
+ ## Requirements
15
+
16
+ - Python 3.10+
17
+ - `uv`
18
+ - An active Moodle browser session, or a `MOODLE_SESSION` environment variable
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ uv sync
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ uv run moodle --help
30
+ uv run moodle user
31
+ uv run moodle courses
32
+ uv run moodle activities 34637
33
+ ```
34
+
35
+ ## Configuration
36
+
37
+ On first run, the CLI will ask for your Moodle base URL and save it to `config.yaml`.
38
+
39
+ Safety checks during setup:
40
+
41
+ - Requires a full root URL such as `https://school.example.edu`
42
+ - Rejects paths, query strings, and fragments
43
+ - Probes the site before saving
44
+ - Requires explicit confirmation if the target does not look like Moodle
45
+
46
+ If you prefer to configure it manually, create `config.yaml` in the project directory or in `~/.config/moodle-cli/`:
47
+
48
+ ```yaml
49
+ base_url: https://school.example.edu
50
+ ```
51
+
52
+ You can copy from `config.example.yaml`.
53
+
54
+ Environment overrides:
55
+
56
+ - `MOODLE_BASE_URL`
57
+ - `MOODLE_SESSION`
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ uv run python -m compileall moodle_cli
63
+ uv build
64
+ ```
65
+
66
+ ## CI
67
+
68
+ GitHub Actions runs the following checks on pushes and pull requests:
69
+
70
+ - Dependency lock sync with `uv`
71
+ - Bytecode compilation
72
+ - CLI smoke check
73
+ - Package build
@@ -0,0 +1 @@
1
+ base_url: https://school.example.edu
@@ -0,0 +1,3 @@
1
+ """Terminal-first CLI for Moodle LMS."""
2
+
3
+ __version__ = "0.1.0b0"
@@ -0,0 +1,74 @@
1
+ """Authentication: extract MoodleSession cookie from env or browser."""
2
+
3
+ import os
4
+ import logging
5
+ from urllib.parse import urlparse
6
+
7
+ from moodle_cli.constants import ENV_MOODLE_SESSION
8
+ from moodle_cli.exceptions import AuthError
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def load_from_env() -> str | None:
14
+ """Check MOODLE_SESSION environment variable."""
15
+ value = os.environ.get(ENV_MOODLE_SESSION)
16
+ if value:
17
+ log.debug("Using MoodleSession from environment variable")
18
+ return value
19
+
20
+
21
+ def load_from_browser(domain: str) -> str | None:
22
+ """Extract MoodleSession cookie from browser cookie stores.
23
+
24
+ Tries Arc, Chrome, Brave, Edge, Firefox in order.
25
+ """
26
+ try:
27
+ import browser_cookie3 # noqa: F811
28
+ except ImportError:
29
+ log.warning("browser-cookie3 not installed; skipping browser cookie extraction")
30
+ return None
31
+
32
+ # browser-cookie3 loaders to try (in priority order)
33
+ loaders = [
34
+ ("Chrome", browser_cookie3.chrome),
35
+ ("Firefox", browser_cookie3.firefox),
36
+ ("Brave", browser_cookie3.brave),
37
+ ("Edge", browser_cookie3.edge),
38
+ ]
39
+
40
+ for name, loader in loaders:
41
+ try:
42
+ cj = loader(domain_name=domain)
43
+ for cookie in cj:
44
+ if cookie.name == "MoodleSession" and domain in (cookie.domain or ""):
45
+ log.debug("Found MoodleSession in %s", name)
46
+ return cookie.value
47
+ except Exception as exc:
48
+ log.debug("Could not read cookies from %s: %s", name, exc)
49
+
50
+ return None
51
+
52
+
53
+ def get_session(base_url: str) -> str:
54
+ """Get a valid MoodleSession cookie value.
55
+
56
+ Priority: env var → browser cookies.
57
+ Raises AuthError if no session is found.
58
+ """
59
+ # 1. Environment variable
60
+ session = load_from_env()
61
+ if session:
62
+ return session
63
+
64
+ # 2. Browser cookies
65
+ domain = urlparse(base_url).hostname or ""
66
+ session = load_from_browser(domain)
67
+ if session:
68
+ return session
69
+
70
+ raise AuthError(
71
+ "No MoodleSession found. Either:\n"
72
+ f" 1. Log in to {base_url} in your browser, or\n"
73
+ f" 2. Set the {ENV_MOODLE_SESSION} environment variable"
74
+ )
@@ -0,0 +1,142 @@
1
+ """CLI entry point using Click."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from moodle_cli import __version__
10
+ from moodle_cli.auth import get_session
11
+ from moodle_cli.client import MoodleClient
12
+ from moodle_cli.config import load_config
13
+ from moodle_cli.exceptions import AuthError, MoodleAPIError, MoodleCLIError
14
+ from moodle_cli.formatter import print_courses, print_course_contents, print_user_info
15
+ from moodle_cli.output import output_json, output_yaml
16
+
17
+ console = Console(stderr=True)
18
+
19
+
20
+ @click.group()
21
+ @click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
22
+ @click.version_option(version=__version__)
23
+ @click.pass_context
24
+ def cli(ctx: click.Context, verbose: bool) -> None:
25
+ """Terminal-first CLI for Moodle LMS."""
26
+ logging.basicConfig(
27
+ level=logging.DEBUG if verbose else logging.WARNING,
28
+ format="%(name)s: %(message)s",
29
+ )
30
+
31
+ config = load_config()
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["config"] = config
34
+
35
+ # Lazy client creation — only authenticate when a command needs it
36
+ ctx.obj["_client"] = None
37
+
38
+ def get_client() -> MoodleClient:
39
+ if ctx.obj["_client"] is None:
40
+ session_cookie = get_session(config["base_url"])
41
+ ctx.obj["_client"] = MoodleClient(config["base_url"], session_cookie)
42
+ return ctx.obj["_client"]
43
+
44
+ ctx.obj["get_client"] = get_client
45
+
46
+
47
+ @cli.command(name="user")
48
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
49
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
50
+ @click.pass_context
51
+ def user(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
52
+ """Show authenticated user info."""
53
+ client = ctx.obj["get_client"]()
54
+ info = client.get_site_info()
55
+
56
+ if as_json:
57
+ output_json(info.to_dict())
58
+ elif as_yaml:
59
+ output_yaml(info.to_dict())
60
+ else:
61
+ print_user_info(info)
62
+
63
+
64
+ @cli.command()
65
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
66
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
67
+ @click.pass_context
68
+ def courses(ctx: click.Context, as_json: bool, as_yaml: bool) -> None:
69
+ """List enrolled courses."""
70
+ client = ctx.obj["get_client"]()
71
+ course_list = client.get_courses()
72
+
73
+ if as_json:
74
+ output_json([c.to_dict() for c in course_list])
75
+ elif as_yaml:
76
+ output_yaml([c.to_dict() for c in course_list])
77
+ else:
78
+ print_courses(course_list)
79
+
80
+
81
+ @cli.command()
82
+ @click.argument("course_id", type=int)
83
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
84
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
85
+ @click.pass_context
86
+ def activities(ctx: click.Context, course_id: int, as_json: bool, as_yaml: bool) -> None:
87
+ """List activities in a course (sections and modules)."""
88
+ client = ctx.obj["get_client"]()
89
+ sections = client.get_course_contents(course_id)
90
+
91
+ if as_json:
92
+ output_json([s.to_dict() for s in sections])
93
+ elif as_yaml:
94
+ output_yaml([s.to_dict() for s in sections])
95
+ else:
96
+ print_course_contents(sections, course_label=f"Course {course_id}")
97
+
98
+
99
+ @cli.command()
100
+ @click.argument("course_id", type=int)
101
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
102
+ @click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")
103
+ @click.pass_context
104
+ def course(ctx: click.Context, course_id: int, as_json: bool, as_yaml: bool) -> None:
105
+ """Show course detail with sections."""
106
+ client = ctx.obj["get_client"]()
107
+ sections = client.get_course_contents(course_id)
108
+
109
+ if as_json:
110
+ output_json([s.to_dict() for s in sections])
111
+ elif as_yaml:
112
+ output_yaml([s.to_dict() for s in sections])
113
+ else:
114
+ print_course_contents(sections, course_label=f"Course {course_id}")
115
+
116
+
117
+ def main() -> None:
118
+ """Entry point with error handling."""
119
+ try:
120
+ cli(standalone_mode=False)
121
+ except click.exceptions.Abort:
122
+ sys.exit(130)
123
+ except click.exceptions.Exit as e:
124
+ sys.exit(e.exit_code)
125
+ except click.ClickException as e:
126
+ e.show()
127
+ sys.exit(e.exit_code)
128
+ except AuthError as e:
129
+ console.print(f"[bold red]Auth error:[/] {e}")
130
+ sys.exit(1)
131
+ except MoodleAPIError as e:
132
+ console.print(f"[bold red]API error:[/] {e}")
133
+ if e.error_code:
134
+ console.print(f" Error code: {e.error_code}")
135
+ sys.exit(1)
136
+ except MoodleCLIError as e:
137
+ console.print(f"[bold red]Error:[/] {e}")
138
+ sys.exit(1)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()