Files
hermes-agent/optional-skills/productivity/canvas/scripts/canvas_api.py
Teknium 563101e2a9 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>
2026-03-29 15:28:32 -07:00

158 lines
5.0 KiB
Python

#!/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()