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>
158 lines
5.0 KiB
Python
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()
|