diff --git a/.env.example b/.env.example index c13f5c0d..bcb5708d 100644 --- a/.env.example +++ b/.env.example @@ -98,7 +98,7 @@ FAL_KEY= HONCHO_API_KEY= # ============================================================================= -# TERMINAL TOOL CONFIGURATION (mini-swe-agent backend) +# TERMINAL TOOL CONFIGURATION # ============================================================================= # Backend type: "local", "singularity", "docker", "modal", or "ssh" # Terminal backend is configured in ~/.hermes/config.yaml (terminal.backend). diff --git a/optional-skills/productivity/canvas/SKILL.md b/optional-skills/productivity/canvas/SKILL.md new file mode 100644 index 00000000..88299d0a --- /dev/null +++ b/optional-skills/productivity/canvas/SKILL.md @@ -0,0 +1,97 @@ +--- +name: canvas +description: Canvas LMS integration — fetch enrolled courses and assignments using API token authentication. +version: 1.0.0 +author: community +license: MIT +prerequisites: + env_vars: [CANVAS_API_TOKEN, CANVAS_BASE_URL] +metadata: + hermes: + tags: [Canvas, LMS, Education, Courses, Assignments] +--- + +# Canvas LMS — Course & Assignment Access + +Read-only access to Canvas LMS for listing courses and assignments. + +## Scripts + +- `scripts/canvas_api.py` — Python CLI for Canvas API calls + +## Setup + +1. Log in to your Canvas instance in a browser +2. Go to **Account → Settings** (click your profile icon, then Settings) +3. Scroll to **Approved Integrations** and click **+ New Access Token** +4. Name the token (e.g., "Hermes Agent"), set an optional expiry, and click **Generate Token** +5. Copy the token and add to `~/.hermes/.env`: + +``` +CANVAS_API_TOKEN=your_token_here +CANVAS_BASE_URL=https://yourschool.instructure.com +``` + +The base URL is whatever appears in your browser when you're logged into Canvas (no trailing slash). + +## Usage + +```bash +CANVAS="python $HERMES_HOME/skills/productivity/canvas/scripts/canvas_api.py" + +# List all active courses +$CANVAS list_courses --enrollment-state active + +# List all courses (any state) +$CANVAS list_courses + +# List assignments for a specific course +$CANVAS list_assignments 12345 + +# List assignments ordered by due date +$CANVAS list_assignments 12345 --order-by due_at +``` + +## Output Format + +**list_courses** returns: +```json +[{"id": 12345, "name": "Intro to CS", "course_code": "CS101", "workflow_state": "available", "start_at": "...", "end_at": "..."}] +``` + +**list_assignments** returns: +```json +[{"id": 67890, "name": "Homework 1", "due_at": "2025-02-15T23:59:00Z", "points_possible": 100, "submission_types": ["online_upload"], "html_url": "...", "description": "...", "course_id": 12345}] +``` + +Note: Assignment descriptions are truncated to 500 characters. The `html_url` field links to the full assignment page in Canvas. + +## API Reference (curl) + +```bash +# List courses +curl -s -H "Authorization: Bearer $CANVAS_API_TOKEN" \ + "$CANVAS_BASE_URL/api/v1/courses?enrollment_state=active&per_page=10" + +# List assignments for a course +curl -s -H "Authorization: Bearer $CANVAS_API_TOKEN" \ + "$CANVAS_BASE_URL/api/v1/courses/COURSE_ID/assignments?per_page=10&order_by=due_at" +``` + +Canvas uses `Link` headers for pagination. The Python script handles pagination automatically. + +## Rules + +- This skill is **read-only** — it only fetches data, never modifies courses or assignments +- On first use, verify auth by running `$CANVAS list_courses` — if it fails with 401, guide the user through setup +- Canvas rate-limits to ~700 requests per 10 minutes; check `X-Rate-Limit-Remaining` header if hitting limits + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| 401 Unauthorized | Token invalid or expired — regenerate in Canvas Settings | +| 403 Forbidden | Token lacks permission for this course | +| Empty course list | Try `--enrollment-state active` or omit the flag to see all states | +| Wrong institution | Verify `CANVAS_BASE_URL` matches the URL in your browser | +| Timeout errors | Check network connectivity to your Canvas instance | diff --git a/optional-skills/productivity/canvas/scripts/canvas_api.py b/optional-skills/productivity/canvas/scripts/canvas_api.py new file mode 100644 index 00000000..13599c57 --- /dev/null +++ b/optional-skills/productivity/canvas/scripts/canvas_api.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Canvas LMS API CLI for Hermes Agent. + +A thin CLI wrapper around the Canvas REST API. +Authenticates using a personal access token from environment variables. + +Usage: + python canvas_api.py list_courses [--per-page N] [--enrollment-state STATE] + python canvas_api.py list_assignments COURSE_ID [--per-page N] [--order-by FIELD] +""" + +import argparse +import json +import os +import sys + +import requests + +CANVAS_API_TOKEN = os.environ.get("CANVAS_API_TOKEN", "") +CANVAS_BASE_URL = os.environ.get("CANVAS_BASE_URL", "").rstrip("/") + + +def _check_config(): + """Validate required environment variables are set.""" + missing = [] + if not CANVAS_API_TOKEN: + missing.append("CANVAS_API_TOKEN") + if not CANVAS_BASE_URL: + missing.append("CANVAS_BASE_URL") + if missing: + print( + f"Missing required environment variables: {', '.join(missing)}\n" + "Set them in ~/.hermes/.env or export them in your shell.\n" + "See the canvas skill SKILL.md for setup instructions.", + file=sys.stderr, + ) + sys.exit(1) + + +def _headers(): + return {"Authorization": f"Bearer {CANVAS_API_TOKEN}"} + + +def _paginated_get(url, params=None, max_items=200): + """Fetch all pages up to max_items, following Canvas Link headers.""" + results = [] + while url and len(results) < max_items: + resp = requests.get(url, headers=_headers(), params=params, timeout=30) + resp.raise_for_status() + results.extend(resp.json()) + params = None # params are included in the Link URL for subsequent pages + url = None + link = resp.headers.get("Link", "") + for part in link.split(","): + if 'rel="next"' in part: + url = part.split(";")[0].strip().strip("<>") + return results[:max_items] + + +# ========================================================================= +# Commands +# ========================================================================= + + +def list_courses(args): + """List enrolled courses.""" + _check_config() + url = f"{CANVAS_BASE_URL}/api/v1/courses" + params = {"per_page": args.per_page} + if args.enrollment_state: + params["enrollment_state"] = args.enrollment_state + try: + courses = _paginated_get(url, params) + except requests.HTTPError as e: + print(f"API error: {e.response.status_code} {e.response.text}", file=sys.stderr) + sys.exit(1) + output = [ + { + "id": c["id"], + "name": c.get("name", ""), + "course_code": c.get("course_code", ""), + "enrollment_term_id": c.get("enrollment_term_id"), + "start_at": c.get("start_at"), + "end_at": c.get("end_at"), + "workflow_state": c.get("workflow_state", ""), + } + for c in courses + ] + print(json.dumps(output, indent=2)) + + +def list_assignments(args): + """List assignments for a course.""" + _check_config() + url = f"{CANVAS_BASE_URL}/api/v1/courses/{args.course_id}/assignments" + params = {"per_page": args.per_page} + if args.order_by: + params["order_by"] = args.order_by + try: + assignments = _paginated_get(url, params) + except requests.HTTPError as e: + print(f"API error: {e.response.status_code} {e.response.text}", file=sys.stderr) + sys.exit(1) + output = [ + { + "id": a["id"], + "name": a.get("name", ""), + "description": (a.get("description") or "")[:500], + "due_at": a.get("due_at"), + "points_possible": a.get("points_possible"), + "submission_types": a.get("submission_types", []), + "html_url": a.get("html_url", ""), + "course_id": a.get("course_id"), + } + for a in assignments + ] + print(json.dumps(output, indent=2)) + + +# ========================================================================= +# CLI parser +# ========================================================================= + + +def main(): + parser = argparse.ArgumentParser( + description="Canvas LMS API CLI for Hermes Agent" + ) + sub = parser.add_subparsers(dest="command", required=True) + + # --- list_courses --- + p = sub.add_parser("list_courses", help="List enrolled courses") + p.add_argument("--per-page", type=int, default=50, help="Results per page (default 50)") + p.add_argument( + "--enrollment-state", + default="", + help="Filter by enrollment state (active, invited_or_pending, completed)", + ) + p.set_defaults(func=list_courses) + + # --- list_assignments --- + p = sub.add_parser("list_assignments", help="List assignments for a course") + p.add_argument("course_id", help="Canvas course ID") + p.add_argument("--per-page", type=int, default=50, help="Results per page (default 50)") + p.add_argument( + "--order-by", + default="", + help="Order by field (due_at, name, position)", + ) + p.set_defaults(func=list_assignments) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main()