feat: add Canvas LMS skill for fetching courses and assignments (#3799)
Adds a Canvas LMS integration skill under optional-skills/productivity/canvas/ with a Python CLI wrapper (canvas_api.py) for listing courses and assignments via personal access token auth. Cherry-picked from PR #1250 by Alicorn-Max-S with: - Moved from skills/ to optional-skills/ (niche educational integration) - Fixed hardcoded ~/.hermes/ path to use $HERMES_HOME - Removed Canvas env vars from .env.example (optional skill) - Cleaned stale 'mini-swe-agent backend' reference from .env.example header Co-authored-by: Alicorn-Max-S <Alicorn-Max-S@users.noreply.github.com>
This commit is contained in:
@@ -98,7 +98,7 @@ FAL_KEY=
|
|||||||
HONCHO_API_KEY=
|
HONCHO_API_KEY=
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TERMINAL TOOL CONFIGURATION (mini-swe-agent backend)
|
# TERMINAL TOOL CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Backend type: "local", "singularity", "docker", "modal", or "ssh"
|
# Backend type: "local", "singularity", "docker", "modal", or "ssh"
|
||||||
# Terminal backend is configured in ~/.hermes/config.yaml (terminal.backend).
|
# Terminal backend is configured in ~/.hermes/config.yaml (terminal.backend).
|
||||||
|
|||||||
97
optional-skills/productivity/canvas/SKILL.md
Normal file
97
optional-skills/productivity/canvas/SKILL.md
Normal file
@@ -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 |
|
||||||
157
optional-skills/productivity/canvas/scripts/canvas_api.py
Normal file
157
optional-skills/productivity/canvas/scripts/canvas_api.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user