Skill: jpnarchi/canvascli (version 1.0.0)

SKILL.md canvas-cli Use canvas-cli for Canvas LMS — courses, assignments, grades, submissions, discussions, files, and more. Uses SAML SSO with TOTP (session cached after first login). Setup (once) canvas-cli configure canvas-cli whoami Common commands Courses: canvas-cli courses Course detail: canvas-cli courses <id> Course users: canvas-cli courses <id> users Assignments: canvas-cli assignments <course> Assignment detail: canvas-cli assignments <course> <id> Grades overview: canvas-cli grades Course grades: canvas-cli grades <course> Submission: canvas-cli submissions <course> <assign> Submit text: canvas-cli submit <course> <assign> --text "content" Submit URL: canvas-cli submit <course> <assign> --url <url> Todo: canvas-cli todo Upcoming: canvas-cli upcoming Missing: canvas-cli missing Calendar: canvas-cli calendar --start <YYYY-MM-DD> --end <YYYY-MM-DD> Modules: canvas-cli modules <course> Module items: canvas-cli modules <course> <module> Discussions: canvas-cli discussions <course> View discussion: canvas-cli discussions <course> <topic> Reply: canvas-cli discussions <course> <topic> --reply "message" Announcements: canvas-cli announcements [course] Files: canvas-cli files <course> Download: canvas-cli download <file_id> -o <path> Notifications: canvas-cli notifications Debug login: canvas-cli debug-login Notes Session cookies are saved after first login — no TOTP needed on subsequent runs until session expires. Use --json on any command for raw JSON output (useful for scripting/piping). Use --per-page <n> to control pagination (default 50). Grades are color-coded: green (A), cyan (B), yellow (C), red (D/F). Missing/overdue items are highlighted in red. File sizes are human-readable (KB/MB/GB). Config is stored at ~/.canvas-cli/config.json (permissions 0600).

path: README.md
content: ```
   ██████╗ █████╗ ███╗   ██╗██╗   ██╗ █████╗ ███████╗
  ██╔════╝██╔══██╗████╗  ██║██║   ██║██╔══██╗██╔════╝
  ██║     ███████║██╔██╗ ██║██║   ██║███████║███████╗
  ██║     ██╔══██║██║╚██╗██║╚██╗ ██╔╝██╔══██║╚════██║
  ╚██████╗██║  ██║██║ ╚████║ ╚████╔╝ ██║  ██║███████║
   ╚═════╝╚═╝  ╚═╝╚═╝  ╚═══╝  ╚═══╝  ╚═╝  ╚═╝╚══════╝  CLI
```

A command-line client for **Canvas LMS** written in Go. Manage your courses, assignments, grades, discussions, files, and more — entirely from the terminal.

## Features

- Full SAML SSO authentication (with TOTP/MFA support)
- Session caching — login once, reuse until session expires
- 20+ commands covering courses, assignments, grades, submissions, modules, discussions, files, calendar, and more
- Color-coded output with human-readable formatting
- `--json` flag on any command for scripting/piping
- Zero external Go dependencies

## Requirements

- Go 1.21+ (for building from source)
- A Canvas LMS account with SAML SSO login

## Installation

```bash
# Clone and build
git clone <repo-url> canvas-cli
cd canvas-cli
go build -o canvas-cli .

# Optional: add to PATH
sudo ln -s $(pwd)/canvas-cli /usr/local/bin/canvas-cli
```

## Quick Start

```bash
# 1. Configure your Canvas URL and credentials
canvas-cli configure

# 2. Verify login (will prompt for TOTP code on first run)
canvas-cli whoami

# 3. List your courses
canvas-cli courses

# 4. Check your grades
canvas-cli grades

# 5. See what's due
canvas-cli todo
```

After the first successful login, your session is cached — subsequent commands won't ask for your TOTP code again until the session expires.

## Commands

### Setup

| Command | Description |
|---------|-------------|
| `canvas-cli configure` | Set up Canvas URL, username, and password |
| `canvas-cli whoami` | Show your profile info |
| `canvas-cli debug-login` | Test login flow with verbose output |
| `canvas-cli version` | Show CLI version |

### Courses

| Command | Description |
|---------|-------------|
| `canvas-cli courses` | List your active courses |
| `canvas-cli courses <id>` | Show course details (syllabus, term, grade) |
| `canvas-cli courses <id> users` | List users enrolled in a course |

### Assignments & Grades

| Command | Description |
|---------|-------------|
| `canvas-cli assignments <course>` | List assignments with due dates and status |
| `canvas-cli assignments <course> <id>` | Show assignment details and your submission |
| `canvas-cli grades` | Grades overview across all courses |
| `canvas-cli grades <course>` | Detailed grades for a specific course |
| `canvas-cli submissions <course> <assign>` | View your submission, comments, and rubric |
| `canvas-cli submit <course> <assign> --text "..."` | Submit text entry |
| `canvas-cli submit <course> <assign> --url <url>` | Submit a URL |

### Productivity

| Command | Description |
|---------|-------------|
| `canvas-cli todo` | Pending to-do items |
| `canvas-cli upcoming` | Upcoming events and assignments |
| `canvas-cli missing` | Missing/late submissions |
| `canvas-cli calendar` | Calendar events (next 30 days) |
| `canvas-cli calendar --start 2026-01-01 --end 2026-02-01` | Events in a date range |

### Content

| Command | Description |
|---------|-------------|
| `canvas-cli modules <course>` | List modules in a course |
| `canvas-cli modules <course> <id>` | List items in a module |
| `canvas-cli discussions <course>` | List discussion topics |
| `canvas-cli discussions <course> <id>` | View a discussion thread |
| `canvas-cli discussions <course> <id> --reply "msg"` | Post a reply |
| `canvas-cli announcements` | Recent announcements (all courses) |
| `canvas-cli announcements <course>` | Announcements for a specific course |

### Files

| Command | Description |
|---------|-------------|
| `canvas-cli files <course>` | List course files with sizes |
| `canvas-cli download <file_id>` | Download a file |
| `canvas-cli download <file_id> -o ~/path` | Download to a specific path |

### Other

| Command | Description |
|---------|-------------|
| `canvas-cli notifications` | Activity stream (announcements, messages, submissions) |

## Global Flags

| Flag | Description |
|------|-------------|
| `--json` | Output raw JSON (works on any command) |
| `--per-page <n>` | Results per page, default 50 |
| `-h, --help` | Show help |

## Authentication Flow

This CLI handles the full SAML SSO chain automatically:

1. Canvas redirects to your institution's Identity Provider (IdP)
2. Credentials are submitted (password is Base64-encoded per IdP requirements)
3. Device fingerprinting is handled (JWS signed + JWE encrypted)
4. TOTP/MFA code is prompted interactively
5. SAMLResponse is posted back to Canvas
6. Session cookies are saved to `~/.canvas-cli/config.json`

On subsequent runs, saved cookies are tested first. If still valid, no login is required. If expired, the full flow runs again.

## Configuration

Config is stored at `~/.canvas-cli/config.json` with `0600` permissions:

```json
{
  "api_url": "https://yourschool.instructure.com",
  "username": "[REDACTED_SECRET]",
  "password": "yourpassword",
  "cookies": [...]
}
```

## Project Structure

```
canvas-cli/
├── main.go                    # Entry point
├── go.mod                     # Go module (zero external deps)
├── cmd/
│   ├── root.go                # Command router, global flags, help
│   ├── announcements.go       # Announcements command
│   ├── assignments.go         # Assignments command
│   ├── calendar.go            # Calendar command
│   ├── courses.go             # Courses command
│   ├── debug.go               # Debug login command
│   ├── discussions.go         # Discussions command
│   ├── files.go               # Files & download commands
│   ├── grades.go              # Grades command
│   ├── modules.go             # Modules command
│   ├── notifications.go       # Notifications command
│   ├── submissions.go         # Submissions & submit commands
│   ├── todo.go                # Todo, upcoming, missing commands
│   └── whoami.go              # Whoami command
├── internal/
│   ├── api/
│   │   └── client.go          # HTTP client, SAML SSO, session management
│   ├── config/
│   │   └── config.go          # Configuration load/save
│   └── ui/
│       └── ui.go              # Colors, tables, formatting helpers
├── SKILL.md
└── README.md
```

## License

MIT


path: main.go
content: package main

import "canvas-cli/cmd"

func main() {
	cmd.Execute()
}


path: internal/api/client.go
content: package api

import (
	"bufio"
	"crypto/aes"
	"crypto/cipher"
	"crypto/hmac"
	cryptorand "crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"regexp"
	"strings"
	"time"

	"canvas-cli/internal/config"
)

var cryptoRandReader = io.Reader(cryptorand.Reader)

type Client struct {
	BaseURL    string
	SiteURL    string
	Config     *config.Config
	HTTPClient *http.Client
	PerPage    int
	loggedIn   bool
	retrying   bool
	Debug      bool
}

func NewClient(cfg *config.Config) *Client {
	jar, _ := cookiejar.New(nil)
	return &Client{
		SiteURL: strings.TrimRight(cfg.APIURL, "/"),
		BaseURL: strings.TrimRight(cfg.APIURL, "/") + "/api/v1",
		Config:  cfg,
		HTTPClient: &http.Client{
			Timeout: 60 * time.Second,
			Jar:     jar,
		},
		PerPage: 50,
	}
}

func (c *Client) debugf(format string, args ...interface{}) {
	if c.Debug {
		fmt.Printf("  [debug] "+format+"\n", args...)
	}
}

// Login performs the full SAML SSO login flow for Tec de Monterrey:
// 1. GET Canvas /login → 302 to /login/saml → 302 to amfs.tec.mx with SAMLRequest
// 2. GET amfs.tec.mx SAML SSO page → returns page with form action to credential endpoint
// 3. POST credentials to the credential form action URL
// 4. Response contains SAMLResponse form → POST back to Canvas
// 5. Canvas establishes session
func (c *Client) Login() error {
	if c.loggedIn {
		return nil
	}

	// Try restoring saved session cookies first
	if len(c.Config.Cookies) > 0 && !c.Debug {
		if c.tryRestoredSession() {
			return nil
		}
	}

	if !c.Debug {
		fmt.Print("  Logging in... ")
	}

	noRedir := c.noRedirectClient()

	// Step 1: GET Canvas /login → auto-follow all redirects to amfs.tec.mx IdP login form
	// We use the main HTTP client (with auto-redirect) to preserve all cookies from the chain.
	// Then we use the body from the FINAL 200 response directly — no separate GET.
	c.debugf("Step 1: GET %s/login → follow all redirects to IdP", c.SiteURL)

	startReq, _ := http.NewRequest("GET", c.SiteURL+"/login", nil)
	startReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")
	startReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")

	resp, err := c.HTTPClient.Do(startReq)
	if err != nil {
		c.loginFailed()
		return fmt.Errorf("reaching IdP: %w", err)
	}
	idpBody, _ := io.ReadAll(resp.Body)
	resp.Body.Close()

	idpPageBody := string(idpBody)
	idpPageURL := resp.Request.URL.String()

	c.debugf("  → Final URL: %s", idpPageURL)
	c.debugf("  → Status: %d, Body: %d bytes", resp.StatusCode, len(idpBody))

	if resp.StatusCode != 200 || idpPageBody == "" {
		c.loginFailed()
		return fmt.Errorf("could not reach login form (status %d)", resp.StatusCode)
	}

	if c.Debug {
		os.WriteFile("/tmp/canvas_step1.html", []byte(idpPageBody), 0644)
		c.debugf("  → Saved initial page to /tmp/canvas_step1.html (%d bytes)", len(idpPageBody))
	}

	// The IdP may return an intermediate auto-submit form page (JavaScript does document.forms[0].submit()).
	// We need to detect this and perform the POST to get the actual login form.
	if strings.Contains(idpPageBody, "document.forms[0].submit()") || (len(idpPageBody) < 1000 && strings.Contains(idpPageBody, "<form") && !strings.Contains(idpPageBody, "Ecom_User_ID")) {
		autoAction := extractFormAction2(idpPageBody, idpPageURL)
		if autoAction == "" {
			autoAction = idpPageURL
		}
		autoFields := extractAllHiddenFields(idpPageBody)
		c.debugf("  → Auto-submit form detected → POST to %s", autoAction)

		autoReq, _ := http.NewRequest("POST", autoAction, strings.NewReader(autoFields.Encode()))
		autoReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		autoReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
		autoReq.Header.Set("Referer", idpPageURL)
		autoReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")

		autoResp, err := c.HTTPClient.Do(autoReq)
		if err != nil {
			c.loginFailed()
			return fmt.Errorf("submitting auto-form: %w", err)
		}
		autoBody, _ := io.ReadAll(autoResp.Body)
		autoResp.Body.Close()

		idpPageBody = string(autoBody)
		idpPageURL = autoResp.Request.URL.String()

		c.debugf("  → Login form URL: %s", idpPageURL)
		c.debugf("  → Login form size: %d bytes", len(idpPageBody))

		if c.Debug {
			os.WriteFile("/tmp/canvas_step1b.html", autoBody, 0644)
			c.debugf("  → Saved login form to /tmp/canvas_step1b.html")
		}
	}

	// Debug: show cookies
	if c.Debug {
		postURL, _ := url.Parse(idpPageURL)
		if postURL != nil {
			cookies := c.HTTPClient.Jar.Cookies(postURL)
			c.debugf("  → Cookies for %s: %d", postURL.Host, len(cookies))
			for _, cookie := range cookies {
				val := cookie.Value
				if len(val) > 60 {
					val = val[:60] + "..."
				}
				c.debugf("    %s = %s", cookie.Name, val)
			}
		}
	}

	// Step 2: Extract form action and hidden fields from the login form
	c.debugf("Step 2: Extracting form data from login form")
	postAction := extractFormAction2(idpPageBody, idpPageURL)
	if postAction == "" {
		postAction = idpPageURL
	}
	c.debugf("  → POST action: %s", postAction)

	hiddenFields := extractAllHiddenFields(idpPageBody)
	c.debugf("  → Hidden fields: %v", hiddenFieldNames(hiddenFields))
	for k, v := range hiddenFields {
		val := v[0]
		if len(val) > 80 {
			val = val[:80] + "..."
		}
		c.debugf("    %s = %s", k, val)
	}

	// Step 3: POST credentials
	c.debugf("Step 3: POST credentials to %s", postAction)
	form := url.Values{}
	// Include any hidden fields from the form
	for k, v := range hiddenFields {
		form[k] = v
	}

	// Set credentials
	// The IdP expects Ecom_User_ID in "user@domain" format (validated by JS)
	// ddlDomain should remain empty as in the original form
	form.Set("Ecom_User_ID", c.Config.Username)
	form.Set("Ecom_Password", c.Config.Password)

	// The Tec de Monterrey IdP encodes the password as Base64 in the "itesm64" field
	// This is done by JavaScript in the browser before form submission
	encodedPassword := base64.StdEncoding.EncodeToString([]byte(c.Config.Password))
	form.Set("itesm64", encodedPassword)
	c.debugf("  → itesm64 (base64 password): set")

	req, _ := http.NewRequest("POST", postAction, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("Referer", idpPageURL)
	req.Header.Set("Origin", extractOrigin(idpPageURL))
	req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")

	postResp, err := noRedir.Do(req)
	if err != nil {
		c.loginFailed()
		return fmt.Errorf("credential POST failed: %w", err)
	}
	postBody, _ := io.ReadAll(postResp.Body)
	postResp.Body.Close()
	postBodyStr := string(postBody)

	c.debugf("  → Status: %d, Body: %d bytes", postResp.StatusCode, len(postBody))
	c.debugf("  → Has SAMLResponse: %v", strings.Contains(postBodyStr, "SAMLResponse"))
	if c.Debug {
		os.WriteFile("/tmp/canvas_step3.html", postBody, 0644)
		c.debugf("  → Saved POST response to /tmp/canvas_step3.html")
	}

	// Show posted form values in debug
	c.debugf("  → Posted fields:")
	for k, v := range form {
		val := v[0]
		if k == "Ecom_Password" {
			val = "***"
		}
		if len(val) > 80 {
			val = val[:80] + "..."
		}
		c.debugf("    %s = %s", k, val)
	}

	// Check for error messages in response
	if c.Debug && !strings.Contains(postBodyStr, "SAMLResponse") {
		// Look for error divs/spans
		errorRe := regexp.MustCompile(`(?i)class="[^"]*error[^"]*"[^>]*>([^<]+)`)
		errorMatches := errorRe.FindAllStringSubmatch(postBodyStr, 5)
		for _, m := range errorMatches {
			c.debugf("  → Error element: %s", strings.TrimSpace(m[1]))
		}
		// Look for alert messages
		alertRe := regexp.MustCompile(`(?i)class="[^"]*alert[^"]*"[^>]*>([^<]+)`)
		alertMatches := alertRe.FindAllStringSubmatch(postBodyStr, 5)
		for _, m := range alertMatches {
			c.debugf("  → Alert element: %s", strings.TrimSpace(m[1]))
		}
		// Show a snippet around any "error" or "invalid" text
		lowerBody := strings.ToLower(postBodyStr)
		for _, keyword := range []string{"error", "invalid", "incorrecto", "failed", "denied"} {
			idx := strings.Index(lowerBody, keyword)
			if idx >= 0 {
				start := idx - 100
				if start < 0 {
					start = 0
				}
				end := idx + 200
				if end > len(postBodyStr) {
					end = len(postBodyStr)
				}
				c.debugf("  → Found '%s' near: ...%s...", keyword, strings.TrimSpace(postBodyStr[start:end]))
				break
			}
		}
	}

	// Step 4: Handle the response — may require device fingerprinting or contain SAMLResponse
	if postResp.StatusCode == 302 || postResp.StatusCode == 301 || postResp.StatusCode == 303 {
		location := postResp.Header.Get("Location")
		c.debugf("Step 4: Following redirect to %s", location)
		err = c.followChainAndHandleSAML(location, idpPageURL)
		if err != nil {
			c.loginFailed()
			return fmt.Errorf("following post-login redirects: %w", err)
		}
	} else {
		err = c.handlePostLoginResponse(postResp.StatusCode, postBodyStr, noRedir, idpPageURL)
		if err != nil {
			c.loginFailed()
			return err
		}
	}

	// Step 5: Verify API access
	c.debugf("Step 5: Verifying API access")
	testReq, _ := http.NewRequest("GET", c.BaseURL+"/users/self/profile", nil)
	testReq.Header.Set("Accept", "application/json")
	testResp, err := c.HTTPClient.Do(testReq)
	if err != nil {
		c.loginFailed()
		return fmt.Errorf("verifying login: %w", err)
	}
	testBody, _ := io.ReadAll(testResp.Body)
	testResp.Body.Close()

	c.debugf("  → API Status: %d", testResp.StatusCode)

	if testResp.StatusCode == 200 {
		var profile struct {
			Name string `json:"name"`
		}
		json.Unmarshal(testBody, &profile)
		if !c.Debug {
			if profile.Name != "" {
				fmt.Printf("OK (%s)\n", profile.Name)
			} else {
				fmt.Println("OK")
			}
		}
		c.loggedIn = true
		c.saveSessionCookies()
		return nil
	}

	c.loginFailed()
	return fmt.Errorf("login flow completed but API returned %d — session not established", testResp.StatusCode)
}

// handlePostLoginResponse processes the response after credential POST.
// It handles: SAMLResponse pages, device fingerprinting (recon) pages, redirects, or login form errors.
// The method may recurse since the recon step produces another response to handle.
func (c *Client) handlePostLoginResponse(statusCode int, bodyStr string, noRedir *http.Client, referer string) error {
	if statusCode == 200 {
		if strings.Contains(bodyStr, "SAMLResponse") {
			c.debugf("Step 4: Found SAMLResponse → submitting to Canvas")
			return c.submitSAMLResponse(bodyStr)
		}

		// Device fingerprinting / reconnaissance page
		if strings.Contains(bodyStr, "deviceRecon") || strings.Contains(bodyStr, "deviceAttributes") {
			c.debugf("Step 4: Device fingerprinting page detected")
			return c.handleDeviceRecon(bodyStr, noRedir, referer)
		}

		// TOTP / MFA page — user must provide a one-time code
		if strings.Contains(bodyStr, "nffc") || strings.Contains(bodyStr, "TOTP") || strings.Contains(bodyStr, "One-Time") || strings.Contains(bodyStr, "One Time Password") {
			c.debugf("  → MFA/TOTP page detected")
			return c.handleMFA(bodyStr, noRedir, referer)
		}

		// Login form returned again — bad credentials
		if strings.Contains(bodyStr, "Ecom_User_ID") || strings.Contains(bodyStr, "loginForm") {
			return fmt.Errorf("login failed — the IdP returned the login form again. Check username/password (run: canvas-cli configure)")
		}

		// Auto-submit form (JavaScript does document.forms[...].submit())
		if strings.Contains(bodyStr, ".submit()") && strings.Contains(bodyStr, "<form") {
			autoAction := extractFormAction2(bodyStr, referer)
			if autoAction != "" {
				c.debugf("  → Auto-submit form detected → POST to %s", autoAction)
				autoFields := extractAllHiddenFields(bodyStr)
				c.debugf("  → Auto-submit fields: %v", hiddenFieldNames(autoFields))

				autoReq, _ := http.NewRequest("POST", autoAction, strings.NewReader(autoFields.Encode()))
				autoReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
				autoReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
				autoReq.Header.Set("Referer", referer)
				autoReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")

				autoResp, err := c.HTTPClient.Do(autoReq)
				if err != nil {
					return fmt.Errorf("auto-submit form POST failed: %w", err)
				}
				autoBody, _ := io.ReadAll(autoResp.Body)
				autoResp.Body.Close()
				autoBodyStr := string(autoBody)

				c.debugf("  → Auto-submit response: %d, %d bytes, final URL: %s", autoResp.StatusCode, len(autoBody), autoResp.Request.URL.String())
				if c.Debug {
					os.WriteFile("/tmp/canvas_autosubmit.html", autoBody, 0644)
				}

				// The auto-redirect client may have followed redirects; check final page
				if strings.Contains(autoBodyStr, "SAMLResponse") {
					return c.submitSAMLResponse(autoBodyStr)
				}
				// Recurse (with depth protection via the auto-submit check)
				return c.handlePostLoginResponse(autoResp.StatusCode, autoBodyStr, noRedir, autoResp.Request.URL.String())
			}
		}

		// JavaScript redirect: window.location.href='...'
		jsRedirectRe := regexp.MustCompile(`(?i)window\.location\.href\s*=\s*'([^']+)'`)
		if m := jsRedirectRe.FindStringSubmatch(bodyStr); len(m) >= 2 {
			jsURL := resolveURL(referer, m[1])
			c.debugf("  → JavaScript redirect detected → GET %s", jsURL)

			jsReq, _ := http.NewRequest("GET", jsURL, nil)
			jsReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
			jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")

			jsResp, err := c.HTTPClient.Do(jsReq)
			if err != nil {
				return fmt.Errorf("JS redirect GET failed: %w", err)
			}
			jsBody, _ := io.ReadAll(jsResp.Body)
			jsResp.Body.Close()
			jsBodyStr := string(jsBody)
			jsFinalURL := jsResp.Request.URL.String()

			c.debugf("  → JS redirect response: %d, %d bytes, final URL: %s", jsResp.StatusCode, len(jsBody), jsFinalURL)
			c.debugf("  → Has SAMLResponse: %v", strings.Contains(jsBodyStr, "SAMLResponse"))
			if c.Debug {
				os.WriteFile("/tmp/canvas_jsredirect.html", jsBody, 0644)
			}

			if strings.Contains(jsBodyStr, "SAMLResponse") {
				return c.submitSAMLResponse(jsBodyStr)
			}
			return c.handlePostLoginResponse(jsResp.StatusCode, jsBodyStr, noRedir, jsFinalURL)
		}

		// Unknown 200 page
		if c.Debug {
			os.WriteFile("/tmp/canvas_unknown.html", []byte(bodyStr), 0644)
			c.debugf("  → Unknown page saved to /tmp/canvas_unknown.html (%d bytes)", len(bodyStr))
		}
		return fmt.Errorf("unexpected response after login POST (no SAMLResponse found, %d bytes)", len(bodyStr))
	}

	return fmt.Errorf("unexpected login response: HTTP %d", statusCode)
}

// handleDeviceRecon handles the NetIQ/NAM device fingerprinting (reconnaissance) page.
// The IdP requires a device fingerprint before completing the SAML flow.
// The fingerprint is signed+encrypted using JWE with keys embedded in the page.
// Since this requires complex crypto (node-jose), we generate a minimal fingerprint
// and submit it. If the server rejects it, we'll need a browser-assisted approach.
func (c *Client) handleDeviceRecon(bodyStr string, noRedir *http.Client, referer string) error {
	// Extract the form action
	actionURL := extractFormAction2(bodyStr, referer)
	if actionURL == "" {
		return fmt.Errorf("no form action in device recon page")
	}
	c.debugf("  → Recon form action: %s", actionURL)

	// Extract hidden fields
	reconFields := extractAllHiddenFields(bodyStr)

	// Extract the signing and encryption keys from the inline JavaScript
	sigKeyRe := regexp.MustCompile(`"use":"sig"[^}]*"k":"([^"]+)"`)
	encKeyRe := regexp.MustCompile(`"use":"enc"[^}]*"k":"([^"]+)"`)

	sigKeyMatch := sigKeyRe.FindStringSubmatch(bodyStr)
	encKeyMatch := encKeyRe.FindStringSubmatch(bodyStr)

	var sigKeyB64, encKeyB64 string
	if len(sigKeyMatch) >= 2 {
		sigKeyB64 = sigKeyMatch[1]
	}
	if len(encKeyMatch) >= 2 {
		encKeyB64 = encKeyMatch[1]
	}
	c.debugf("  → Signing key: %s", sigKeyB64)
	c.debugf("  → Encryption key: %s", encKeyB64)

	// Build a plausible device fingerprint JSON.
	// The IdP's getFingerprint() collects browser attributes and returns a JSON string.
	// We construct one that matches what a real browser would send.
	fingerprint := fmt.Sprintf(`{"deviceType":"Desktop",[REDACTED_SECRET]:"1440x900",[REDACTED_SECRET]:"en-US",[REDACTED_SECRET]:"1440x900","accept-language":"en-US,en;q=0.9,es;q=0.8","userAgent_uaVersion":"537.36","cpuArchitecture_cpuArchitecture":"amd64","dnt":"unspecified","operatingSystem_osVersion":"10.15.7","accept-charset":"utf-8",[REDACTED_SECRET]:"en-US,en,es","userAgent_uaName":"Chrome","navigatorConcurrency":"8","deviceTouchSupport":"false","timezoneOffset":"-360","deviceTouchPoints":"0",[REDACTED_SECRET]:"MacIntel","colorDepth":"24","operatingSystem_osName":"Mac OS","accept-encoding":"gzip, deflate, br","userDN":"","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","userAgent_uaString":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}`)

	// The server expects the fingerprint to be signed (JWS) then encrypted (JWE).
	// We need to produce a JWE compact serialization.
	// If we have valid keys, try to do the crypto. Otherwise submit raw.
	var deviceAttrValue string
	if sigKeyB64 != "" && encKeyB64 != "" {
		signed, err := c.jwsSign(fingerprint, sigKeyB64)
		if err != nil {
			c.debugf("  → JWS signing failed: %v, submitting raw fingerprint", err)
			deviceAttrValue = fingerprint
		} else {
			encrypted, err := c.jweEncrypt(signed, encKeyB64)
			if err != nil {
				c.debugf("  → JWE encryption failed: %v, submitting signed fingerprint", err)
				deviceAttrValue = signed
			} else {
				deviceAttrValue = encrypted
				c.debugf("  → Fingerprint signed+encrypted successfully")
			}
		}
	} else {
		c.debugf("  → No crypto keys found, submitting raw fingerprint")
		deviceAttrValue = fingerprint
	}

	reconFields.Set("deviceAttributes", deviceAttrValue)
	c.debugf("  → Submitting recon form to %s", actionURL)

	req, _ := http.NewRequest("POST", actionURL, strings.NewReader(reconFields.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("Referer", referer)
	req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")

	resp, err := noRedir.Do(req)
	if err != nil {
		return fmt.Errorf("recon POST failed: %w", err)
	}
	reconRespBody, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	reconRespStr := string(reconRespBody)

	c.debugf("  → Recon response: %d, %d bytes", resp.StatusCode, len(reconRespBody))
	c.debugf("  → Has SAMLResponse: %v", strings.Contains(reconRespStr, "SAMLResponse"))

	if c.Debug {
		os.WriteFile("/tmp/canvas_step4_recon.html", reconRespBody, 0644)
		c.debugf("  → Saved recon response to /tmp/canvas_step4_recon.html")
	}

	// Handle redirect after recon
	if resp.StatusCode == 302 || resp.StatusCode == 301 || resp.StatusCode == 303 {
		location := resp.Header.Get("Location")
		c.debugf("  → Recon redirect to: %s", location)
		return c.followChainAndHandleSAML(location, referer)
	}

	// Recurse to handle the next page (could be SAMLResponse, another form, etc.)
	return c.handlePostLoginResponse(resp.StatusCode, reconRespStr, noRedir, referer)
}

// jwsSign creates a JWS compact serialization (HS256) of the payload
func (c *Client) jwsSign(payload string, keyB64 string) (string, error) {
	keyBytes, err := base64.StdEncoding.DecodeString(keyB64)
	if err != nil {
		// Try URL-safe base64
		keyBytes, err = base64.URLEncoding.DecodeString(keyB64)
		if err != nil {
			return "", fmt.Errorf("decode signing key: %w", err)
		}
	}

	// JWS header: {"alg":"HS256"}
	header := base64URLEncode([]byte(`{"alg":"HS256"}`))
	payloadB64 := base64URLEncode([]byte(payload))
	signingInput := header + "." + payloadB64

	// HMAC-SHA256
	mac := hmacSHA256([]byte(signingInput), keyBytes)
	signature := base64URLEncode(mac)

	return signingInput + "." + signature, nil
}

// jweEncrypt creates a JWE compact serialization (A128CBC-HS256, dir key agreement) of the payload
func (c *Client) jweEncrypt(payload string, keyB64 string) (string, error) {
	keyBytes, err := base64.StdEncoding.DecodeString(keyB64)
	if err != nil {
		keyBytes, err = base64.URLEncoding.DecodeString(keyB64)
		if err != nil {
			return "", fmt.Errorf("decode encryption key: %w", err)
		}
	}

	// A128CBC-HS256 uses a 32-byte key: first 16 for HMAC, last 16 for AES
	if len(keyBytes) < 32 {
		return "", fmt.Errorf("encryption key too short: %d bytes (need 32)", len(keyBytes))
	}

	macKey := keyBytes[:16]
	encKey := keyBytes[16:32]

	// JWE header: {"alg":"dir","enc":"A128CBC-HS256"}
	headerJSON := `{"alg":"dir","enc":"A128CBC-HS256"}`
	headerB64 := base64URLEncode([]byte(headerJSON))

	// No wrapped key for "dir" algorithm
	encryptedKeyB64 := ""

	// Generate random IV (16 bytes for AES-CBC)
	iv := make([]byte, 16)
	if _, err := io.ReadFull(cryptoRandReader, iv); err != nil {
		return "", fmt.Errorf("generating IV: %w", err)
	}
	ivB64 := base64URLEncode(iv)

	// Pad plaintext with PKCS7
	plaintext := []byte(payload)
	padLen := 16 - (len(plaintext) % 16)
	for i := 0; i < padLen; i++ {
		plaintext = append(plaintext, byte(padLen))
	}

	// AES-128-CBC encrypt
	block, err := aes.NewCipher(encKey)
	if err != nil {
		return "", fmt.Errorf("AES cipher: %w", err)
	}
	ciphertext := make([]byte, len(plaintext))
	mode := cipher.NewCBCEncrypter(block, iv)
	mode.CryptBlocks(ciphertext, plaintext)
	ciphertextB64 := base64URLEncode(ciphertext)

	// Compute authentication tag: HMAC-SHA256 over AAD || IV || ciphertext || AL
	// AAD = header bytes (ASCII), AL = big-endian 64-bit bit length of AAD
	aad := []byte(headerB64)
	al := make([]byte, 8)
	aadBitLen := uint64(len(aad) * 8)
	al[0] = byte(aadBitLen >> 56)
	al[1] = byte(aadBitLen >> 48)
	al[2] = byte(aadBitLen >> 40)
	al[3] = byte(aadBitLen >> 32)
	al[4] = byte(aadBitLen >> 24)
	al[5] = byte(aadBitLen >> 16)
	al[6] = byte(aadBitLen >> 8)
	al[7] = byte(aadBitLen)

	var authInput []byte
	authInput = append(authInput, aad...)
	authInput = append(authInput, iv...)
	authInput = append(authInput, ciphertext...)
	authInput = append(authInput, al...)

	fullTag := hmacSHA256(authInput, macKey)
	// Truncate to first 16 bytes for A128CBC-HS256
	tag := fullTag[:16]
	tagB64 := base64URLEncode(tag)

	// JWE compact: header.encryptedKey.iv.ciphertext.tag
	return headerB64 + "." + encryptedKeyB64 + "." + ivB64 + "." + ciphertextB64 + "." + tagB64, nil
}

// handleMFA handles the TOTP/MFA page by prompting the user for their one-time code.
func (c *Client) handleMFA(bodyStr string, noRedir *http.Client, referer string) error {
	// Extract form action
	actionURL := extractFormAction2(bodyStr, referer)
	if actionURL == "" {
		return fmt.Errorf("no form action in MFA page")
	}
	c.debugf("  → MFA form action: %s", actionURL)

	// Extract all hidden fields
	mfaFields := extractAllHiddenFields(bodyStr)
	c.debugf("  → MFA hidden fields: %v", hiddenFieldNames(mfaFields))

	// Prompt user for TOTP code
	fmt.Println()
	fmt.Print("  Enter your TOTP code: ")
	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()
	totpCode := strings.TrimSpace(scanner.Text())

	if totpCode == "" {
		return fmt.Errorf("no TOTP code provided")
	}

	// Set the OTP field
	mfaFields.Set("nffc", totpCode)
	c.debugf("  → Submitting TOTP code to %s", actionURL)

	req, _ := http.NewRequest("POST", actionURL, strings.NewReader(mfaFields.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("Referer", referer)
	req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")

	// Use auto-redirect client to follow the full chain after MFA
	resp, err := c.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("MFA POST failed: %w", err)
	}
	mfaRespBody, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	mfaRespStr := string(mfaRespBody)
	finalURL := resp.Request.URL.String()

	c.debugf("  → MFA response: %d, %d bytes, final URL: %s", resp.StatusCode, len(mfaRespBody), finalURL)
	c.debugf("  → Has SAMLResponse: %v", strings.Contains(mfaRespStr, "SAMLResponse"))

	if c.Debug {
		os.WriteFile("/tmp/canvas_step_mfa.html", mfaRespBody, 0644)
		c.debugf("  → Saved MFA response to /tmp/canvas_step_mfa.html")
	}

	// Check if auto-redirect already landed on Canvas (session established)
	if strings.Contains(finalURL, c.SiteURL) || strings.Contains(finalURL, "experiencia21") {
		c.debugf("  → Redirected back to Canvas — session should be established")
		return nil
	}

	// If the response contains SAMLResponse, submit it
	if strings.Contains(mfaRespStr, "SAMLResponse") {
		c.debugf("  → Found SAMLResponse after MFA")
		return c.submitSAMLResponse(mfaRespStr)
	}

	// Handle auto-submit forms or other intermediate pages
	return c.handlePostLoginResponse(resp.StatusCode, mfaRespStr, noRedir, finalURL)
}

// submitSAMLResponse extracts SAMLResponse from HTML and POSTs it to the SP (Canvas)
func (c *Client) submitSAMLResponse(bodyStr string) error {
	// Extract form action
	actionRe := regexp.MustCompile(`(?i)<form[^>]*action="([^"]+)"`)
	actionMatches := actionRe.FindStringSubmatch(bodyStr)
	if len(actionMatches) < 2 {
		return fmt.Errorf("no form action in SAML response page")
	}
	actionURL := strings.ReplaceAll(actionMatches[1], "&amp;", "&")
	c.debugf("  → SAML POST to: %s", actionURL)

	// Extract all hidden fields (SAMLResponse, RelayState, etc)
	form := extractAllHiddenFields(bodyStr)
	c.debugf("  → SAML fields: %v", hiddenFieldNames(form))

	if form.Get("SAMLResponse") == "" {
		return fmt.Errorf("SAMLResponse field is empty")
	}

	// POST to Canvas SAML consumer
	req, _ := http.NewRequest("POST", actionURL, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")

	// Use main client to follow all redirects and collect session cookies
	resp, err := c.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("SAML response POST: %w", err)
	}
	io.ReadAll(resp.Body)
	resp.Body.Close()

	c.debugf("  → SAML response POST result: %d, final URL: %s", resp.StatusCode, resp.Request.URL.String())

	return nil
}

// followChainAndHandleSAML follows redirects and handles any SAML forms encountered
func (c *Client) followChainAndHandleSAML(startURL string, referer string) error {
	noRedir := c.noRedirectClient()
	currentURL := startURL

	for i := 0; i < 15; i++ {
		if !strings.HasPrefix(currentURL, "http") {
			currentURL = resolveURL(referer, currentURL)
		}

		req, _ := http.NewRequest("GET", currentURL, nil)
		req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 canvas-cli/1.0")
		req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")

		resp, err := noRedir.Do(req)
		if err != nil {
			return err
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()

		if resp.StatusCode == 302 || resp.StatusCode == 301 || resp.StatusCode == 303 {
			currentURL = resp.Header.Get("Location")
			continue
		}

		if resp.StatusCode == 200 {
			bodyStr := string(body)
			if strings.Contains(bodyStr, "SAMLResponse") {
				return c.submitSAMLResponse(bodyStr)
			}
			return nil
		}

		return fmt.Errorf("unexpected status %d during redirect", resp.StatusCode)
	}
	return fmt.Errorf("too many redirects")
}

func (c *Client) noRedirectClient() *http.Client {
	return &http.Client{
		Jar:     c.HTTPClient.Jar,
		Timeout: 60 * time.Second,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

func (c *Client) loginFailed() {
	if !c.Debug {
		fmt.Println("failed")
	}
}

// tryRestoredSession loads saved cookies into the jar and tests if the session is still valid.
func (c *Client) tryRestoredSession() bool {
	siteURL, _ := url.Parse(c.SiteURL)
	if siteURL == nil {
		return false
	}

	// Build http.Cookie list from saved cookies
	var cookies []*http.Cookie
	for _, sc := range c.Config.Cookies {
		cookies = append(cookies, &http.Cookie{
			Name:   sc.Name,
			Value:  sc.Value,
			Domain: sc.Domain,
			Path:   sc.Path,
		})
	}
	c.HTTPClient.Jar.SetCookies(siteURL, cookies)

	// Quick test: GET /api/v1/users/self/profile
	testReq, _ := http.NewRequest("GET", c.BaseURL+"/users/self/profile", nil)
	testReq.Header.Set("Accept", "application/json")
	testResp, err := c.HTTPClient.Do(testReq)
	if err != nil {
		return false
	}
	testBody, _ := io.ReadAll(testResp.Body)
	testResp.Body.Close()

	if testResp.StatusCode == 200 {
		var profile struct {
			Name string `json:"name"`
		}
		json.Unmarshal(testBody, &profile)
		if profile.Name != "" {
			fmt.Printf("  Session restored (%s)\n", profile.Name)
		}
		c.loggedIn = true
		return true
	}
	return false
}

// saveSessionCookies persists the current cookie jar to config for reuse.
func (c *Client) saveSessionCookies() {
	siteURL, _ := url.Parse(c.SiteURL)
	if siteURL == nil {
		return
	}

	cookies := c.HTTPClient.Jar.Cookies(siteURL)
	var saved []config.SavedCookie
	for _, cookie := range cookies {
		saved = append(saved, config.SavedCookie{
			Name:   cookie.Name,
			Value:  cookie.Value,
			Domain: cookie.Domain,
			Path:   cookie.Path,
		})
	}

	c.Config.Cookies = saved
	config.Save(c.Config)
}

func (c *Client) ensureLoggedIn() error {
	if c.loggedIn {
		return nil
	}
	return c.Login()
}

func (c *Client) request(method, endpoint string, body io.Reader, contentType string) ([]byte, error) {
	if err := c.ensureLoggedIn(); err != nil {
		return nil, err
	}

	u := c.BaseURL + endpoint
	if !strings.Contains(u, "per_page=") {
		sep := "?"
		if strings.Contains(u, "?") {
			sep = "&"
		}
		u += fmt.Sprintf("%sper_page=%d", sep, c.PerPage)
	}

	req, err := http.NewRequest(method, u, body)
	if err != nil {
		return nil, fmt.Errorf("creating request: %w", err)
	}

	req.Header.Set("Accept", "application/json")
	if contentType != "" {
		req.Header.Set("Content-Type", contentType)
	}

	resp, err := c.HTTPClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading response: %w", err)
	}

	if resp.StatusCode == 401 && !c.retrying {
		c.loggedIn = false
		c.retrying = true
		if err := c.Login(); err != nil {
			c.retrying = false
			return nil, fmt.Errorf("session expired and re-login failed: %w", err)
		}
		result, err := c.request(method, endpoint, body, contentType)
		c.retrying = false
		return result, err
	}

	if resp.StatusCode == 403 {
		return nil, fmt.Errorf("forbidden — you don't have permission for this action")
	}
	if resp.StatusCode == 404 {
		return nil, fmt.Errorf("not found — check the ID and try again")
	}
	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(data))
	}

	return data, nil
}

func (c *Client) GET(endpoint string) ([]byte, error) {
	return c.request("GET", endpoint, nil, "")
}

func (c *Client) POST(endpoint string, form url.Values) ([]byte, error) {
	return c.request("POST", endpoint, strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
}

func (c *Client) PUT(endpoint string, form url.Values) ([]byte, error) {
	return c.request("PUT", endpoint, strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
}

func (c *Client) DELETE(endpoint string) ([]byte, error) {
	return c.request("DELETE", endpoint, nil, "")
}

func (c *Client) GetJSON(endpoint string, target interface{}) error {
	data, err := c.GET(endpoint)
	if err != nil {
		return err
	}
	return json.Unmarshal(data, target)
}

func (c *Client) GetPaginated(endpoint string) ([]json.RawMessage, error) {
	var all []json.RawMessage
	page := 1
	for {
		sep := "?"
		if strings.Contains(endpoint, "?") {
			sep = "&"
		}
		pageURL := fmt.Sprintf("%s%spage=%d", endpoint, sep, page)
		data, err := c.GET(pageURL)
		if err != nil {
			return nil, err
		}
		var items []json.RawMessage
		if err := json.Unmarshal(data, &items); err != nil {
			return nil, err
		}
		if len(items) == 0 {
			break
		}
		all = append(all, items...)
		if len(items) < c.PerPage {
			break
		}
		page++
	}
	return all, nil
}

// --- Helper functions ---

// extractFormAction2 extracts form action URL from HTML body
func extractFormAction2(bodyStr, pageURL string) string {
	// Try loginForm specifically
	re1 := regexp.MustCompile(`(?i)<form[^>]*id\s*=\s*"loginForm"[^>]*action\s*=\s*"([^"]+)"`)
	if m := re1.FindStringSubmatch(bodyStr); len(m) >= 2 {
		return resolveURL(pageURL, strings.ReplaceAll(m[1], "&amp;", "&"))
	}

	// Try any form with action
	re2 := regexp.MustCompile(`(?i)<form[^>]*action\s*=\s*"([^"]+)"`)
	if m := re2.FindStringSubmatch(bodyStr); len(m) >= 2 {
		return resolveURL(pageURL, strings.ReplaceAll(m[1], "&amp;", "&"))
	}

	return ""
}

// extractAllHiddenFields extracts all hidden input fields from HTML
func extractAllHiddenFields(bodyStr string) url.Values {
	form := url.Values{}

	// Match: <input type="hidden" name="X" value="Y">
	re1 := regexp.MustCompile(`(?i)<input[^>]*type\s*=\s*"hidden"[^>]*name\s*=\s*"([^"]+)"[^>]*value\s*=\s*"([^"]*)"`)
	for _, m := range re1.FindAllStringSubmatch(bodyStr, -1) {
		form.Set(m[1], m[2])
	}

	// Match: <input name="X" value="Y" type="hidden">
	re2 := regexp.MustCompile(`(?i)<input[^>]*name\s*=\s*"([^"]+)"[^>]*value\s*=\s*"([^"]*)"[^>]*type\s*=\s*"hidden"`)
	for _, m := range re2.FindAllStringSubmatch(bodyStr, -1) {
		if _, exists := form[m[1]]; !exists {
			form.Set(m[1], m[2])
		}
	}

	// Match: <input type="hidden" value="Y" name="X">
	re3 := regexp.MustCompile(`(?i)<input[^>]*type\s*=\s*"hidden"[^>]*value\s*=\s*"([^"]*)"[^>]*name\s*=\s*"([^"]+)"`)
	for _, m := range re3.FindAllStringSubmatch(bodyStr, -1) {
		if _, exists := form[m[2]]; !exists {
			form.Set(m[2], m[1])
		}
	}

	// Broader catch: any input with name and value (for SAML forms without type=hidden)
	re4 := regexp.MustCompile(`(?i)<input[^>]*name\s*=\s*"([^"]+)"[^>]*value\s*=\s*"([^"]*)"`)
	for _, m := range re4.FindAllStringSubmatch(bodyStr, -1) {
		if _, exists := form[m[1]]; !exists {
			form.Set(m[1], m[2])
		}
	}
	re5 := regexp.MustCompile(`(?i)<input[^>]*value\s*=\s*"([^"]*)"[^>]*name\s*=\s*"([^"]+)"`)
	for _, m := range re5.FindAllStringSubmatch(bodyStr, -1) {
		if _, exists := form[m[2]]; !exists {
			form.Set(m[2], m[1])
		}
	}

	return form
}

func hiddenFieldNames(form url.Values) []string {
	var names []string
	for k := range form {
		names = append(names, k)
	}
	return names
}

func resolveURL(baseURL, ref string) string {
	if strings.HasPrefix(ref, "http") {
		return ref
	}
	u, err := url.Parse(baseURL)
	if err != nil {
		return ref
	}
	refURL, err := url.Parse(ref)
	if err != nil {
		return ref
	}
	return u.ResolveReference(refURL).String()
}

func extractOrigin(rawURL string) string {
	u, err := url.Parse(rawURL)
	if err != nil {
		return rawURL
	}
	return u.Scheme + "://" + u.Host
}

// base64URLEncode encodes bytes to base64url without padding (per JWS/JWE spec)
func base64URLEncode(data []byte) string {
	return base64.RawURLEncoding.EncodeToString(data)
}

// hmacSHA256 computes HMAC-SHA256
func hmacSHA256(data, key []byte) []byte {
	h := hmac.New(sha256.New, key)
	h.Write(data)
	return h.Sum(nil)
}


path: internal/config/config.go
content: package config

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

type SavedCookie struct {
	Name   string `json:"name"`
	Value  string `json:"value"`
	Domain string `json:"domain"`
	Path   string `json:"path"`
}

type Config struct {
	APIURL   string `json:"api_url"`
	Username string `json:"username"`
	Password string `json:"password"`
	// Saved session cookies (populated after successful login)
	Cookies []SavedCookie `json:"cookies,omitempty"`
}

func ConfigDir() string {
	home, _ := os.UserHomeDir()
	return filepath.Join(home, ".canvas-cli")
}

func ConfigPath() string {
	return filepath.Join(ConfigDir(), "config.json")
}

func Load() (*Config, error) {
	data, err := os.ReadFile(ConfigPath())
	if err != nil {
		return nil, fmt.Errorf("not configured. Run: canvas-cli configure")
	}
	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("corrupt config file: %w", err)
	}
	if cfg.APIURL == "" || cfg.Username == "" || cfg.Password == "" {
		return nil, fmt.Errorf("incomplete config. Run: canvas-cli configure")
	}
	return &cfg, nil
}

func Save(cfg *Config) error {
	if err := os.MkdirAll(ConfigDir(), 0700); err != nil {
		return err
	}
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(ConfigPath(), data, 0600)
}

func RunSetup() (*Config, error) {
	reader := bufio.NewReader(os.Stdin)

	fmt.Println("=== Canvas CLI Configuration ===")
	fmt.Println()
	fmt.Println("Enter your Canvas login credentials.")
	fmt.Println("They will be stored locally in:", ConfigPath())
	fmt.Println()

	fmt.Print("Canvas URL (e.g. https://myschool.instructure.com): ")
	url, _ := reader.ReadString('\n')
	url = strings.TrimSpace(url)
	url = strings.TrimRight(url, "/")
	if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
		url = "https://" + url
	}

	fmt.Print("Username / Email: ")
	username, _ := reader.ReadString('\n')
	username = strings.TrimSpace(username)

	fmt.Print("Password: ")
	password, _ := reader.ReadString('\n')
	[REDACTED_SECRET])

	if url == "" || username == "" || password == "" {
		return nil, fmt.Errorf("all fields are required")
	}

	cfg := &Config{
		APIURL:   url,
		Username: username,
		[REDACTED_SECRET],
	}

	if err := Save(cfg); err != nil {
		return nil, fmt.Errorf("failed to save config: %w", err)
	}

	fmt.Println()
	fmt.Println("Configuration saved to", ConfigPath())
	return cfg, nil
}


path: internal/ui/ui.go
content: package ui

import (
	"fmt"
	"os"
	"strings"
	"time"
)

// ANSI color codes
const (
	Reset   = "\033[0m"
	Bold    = "\033[1m"
	Dim     = "\033[2m"
	Red     = "\033[31m"
	Green   = "\033[32m"
	Yellow  = "\033[33m"
	Blue    = "\033[34m"
	Magenta = "\033[35m"
	Cyan    = "\033[36m"
	White   = "\033[37m"
)

var noColor bool

func init() {
	// Disable colors if not a terminal or NO_COLOR is set
	if os.Getenv("NO_COLOR") != "" {
		noColor = true
	}
	fi, _ := os.Stdout.Stat()
	if fi != nil && (fi.Mode()&os.ModeCharDevice) == 0 {
		noColor = true
	}
}

func C(color, text string) string {
	if noColor {
		return text
	}
	return color + text + Reset
}

func Header(text string) {
	fmt.Println()
	fmt.Println(C(Bold+Cyan, "  "+text))
	fmt.Println(C(Dim, "  "+strings.Repeat("─", len(text)+2)))
}

func Error(msg string) {
	fmt.Fprintf(os.Stderr, "%s %s\n", C(Red, "Error:"), msg)
}

func Success(msg string) {
	fmt.Printf("%s %s\n", C(Green, "✓"), msg)
}

func Info(msg string) {
	fmt.Printf("%s %s\n", C(Cyan, "→"), msg)
}

func Warning(msg string) {
	fmt.Printf("%s %s\n", C(Yellow, "!"), msg)
}

// Table prints a formatted table
func Table(headers []string, rows [][]string) {
	if len(rows) == 0 {
		fmt.Println(C(Dim, "  No results."))
		return
	}

	// Calculate column widths
	widths := make([]int, len(headers))
	for i, h := range headers {
		widths[i] = len(h)
	}
	for _, row := range rows {
		for i, col := range row {
			if i < len(widths) && len(col) > widths[i] {
				widths[i] = len(col)
			}
		}
	}

	// Cap max width
	for i := range widths {
		if widths[i] > 60 {
			widths[i] = 60
		}
	}

	// Print header
	headerLine := "  "
	sepLine := "  "
	for i, h := range headers {
		headerLine += C(Bold, padRight(h, widths[i])) + "  "
		sepLine += C(Dim, strings.Repeat("─", widths[i])) + "  "
	}
	fmt.Println(headerLine)
	fmt.Println(sepLine)

	// Print rows
	for _, row := range rows {
		line := "  "
		for i := range headers {
			val := ""
			if i < len(row) {
				val = row[i]
			}
			if len(val) > 60 {
				val = val[:57] + "..."
			}
			line += padRight(val, widths[i]) + "  "
		}
		fmt.Println(line)
	}
}

func padRight(s string, length int) string {
	if len(s) >= length {
		return s
	}
	return s + strings.Repeat(" ", length-len(s))
}

// FormatDate formats an ISO 8601 date string into a human-readable format
func FormatDate(isoDate string) string {
	if isoDate == "" {
		return C(Dim, "—")
	}
	t, err := time.Parse(time.RFC3339, isoDate)
	if err != nil {
		return isoDate
	}
	diff := time.Until(t)

	formatted := t.Format("Jan 02 15:04")

	if diff < 0 {
		return C(Red, formatted+" (overdue)")
	} else if diff < 48*time.Hour {
		return C(Yellow, formatted+" (soon)")
	}
	return formatted
}

// StatusColor returns a colored status string
func StatusColor(status string) string {
	switch strings.ToLower(status) {
	case "submitted", "graded", "complete", "completed", "active":
		return C(Green, status)
	case "missing", "overdue", "late":
		return C(Red, status)
	case "pending", "unsubmitted", "not_submitted":
		return C(Yellow, status)
	default:
		return status
	}
}

func Truncate(s string, max int) string {
	if len(s) <= max {
		return s
	}
	return s[:max-3] + "..."
}


path: cmd/discussions.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"net/url"
	"os"

	"canvas-cli/internal/ui"
)

func runDiscussions(args []string) {
	if len(args) == 0 {
		ui.Error("usage: canvas-cli discussions <course_id> [topic_id] [--reply \"message\"]")
		os.Exit(1)
	}

	courseID := args[0]
	remaining := args[1:]

	if len(remaining) == 0 {
		listDiscussions(courseID)
		return
	}

	topicID := remaining[0]
	remaining = remaining[1:]

	// Check for --reply flag
	for i := 0; i < len(remaining); i++ {
		if remaining[i] == "--reply" && i+1 < len(remaining) {
			postReply(courseID, topicID, remaining[i+1])
			return
		}
	}

	showDiscussion(courseID, topicID)
}

func listDiscussions(courseID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/discussion_topics?order_by=recent_activity", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var topics []struct {
		ID            int    `json:"id"`
		Title         string `json:"title"`
		PostedAt      string `json:"posted_at"`
		LastReplyAt   string `json:"last_reply_at"`
		DiscussionSubentryCount int `json:"discussion_subentry_count"`
		ReadState     string `json:"read_state"`
		UnreadCount   int    `json:"unread_count"`
		Pinned        bool   `json:"pinned"`
		Locked        bool   `json:"locked"`
	}
	if err := json.Unmarshal(data, &topics); err != nil {
		ui.Error("parsing discussions: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Discussions — Course %s", courseID))

	rows := make([][]string, 0, len(topics))
	for _, t := range topics {
		unread := ""
		if t.UnreadCount > 0 {
			unread = ui.C(ui.Yellow, fmt.Sprintf("%d new", t.UnreadCount))
		}
		pinned := ""
		if t.Pinned {
			pinned = "pinned"
		}
		rows = append(rows, []string{
			fmt.Sprintf("%d", t.ID),
			ui.Truncate(t.Title, 40),
			fmt.Sprintf("%d", t.DiscussionSubentryCount),
			unread,
			ui.FormatDate(t.LastReplyAt),
			pinned,
		})
	}

	ui.Table([]string{"ID", "TITLE", "REPLIES", "UNREAD", "LAST REPLY", "FLAG"}, rows)
	fmt.Println()
}

func showDiscussion(courseID, topicID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/discussion_topics/%s/view", courseID, topicID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var view struct {
		Participants []struct {
			ID          int    `json:"id"`
			DisplayName string `json:"display_name"`
		} `json:"participants"`
		View []struct {
			ID        int    `json:"id"`
			UserID    int    `json:"user_id"`
			Message   string `json:"message"`
			CreatedAt string `json:"created_at"`
			Replies   []struct {
				ID        int    `json:"id"`
				UserID    int    `json:"user_id"`
				Message   string `json:"message"`
				CreatedAt string `json:"created_at"`
			} `json:"replies"`
		} `json:"view"`
	}
	if err := json.Unmarshal(data, &view); err != nil {
		ui.Error("parsing discussion: " + err.Error())
		os.Exit(1)
	}

	// Build participant name map
	names := make(map[int]string)
	for _, p := range view.Participants {
		names[p.ID] = p.DisplayName
	}

	ui.Header(fmt.Sprintf("Discussion %s", topicID))

	for _, entry := range view.View {
		name := names[entry.UserID]
		if name == "" {
			name = fmt.Sprintf("User %d", entry.UserID)
		}
		fmt.Printf("\n  %s  %s\n", ui.C(ui.Bold, name), ui.C(ui.Dim, ui.FormatDate(entry.CreatedAt)))
		fmt.Printf("  %s\n", ui.Truncate(stripHTML(entry.Message), 500))

		for _, reply := range entry.Replies {
			rName := names[reply.UserID]
			if rName == "" {
				rName = fmt.Sprintf("User %d", reply.UserID)
			}
			fmt.Printf("\n    %s %s  %s\n", ui.C(ui.Dim, "└─"), ui.C(ui.Bold, rName), ui.C(ui.Dim, ui.FormatDate(reply.CreatedAt)))
			fmt.Printf("       %s\n", ui.Truncate(stripHTML(reply.Message), 400))
		}
	}
	fmt.Println()
}

func postReply(courseID, topicID, message string) {
	form := url.Values{
		"message": {message},
	}
	data, err := client.POST(fmt.Sprintf("/courses/%s/discussion_topics/%s/entries", courseID, topicID), form)
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	ui.Success("Reply posted!")
	fmt.Println()
}

// stripHTML removes basic HTML tags from a string (simple version)
func stripHTML(s string) string {
	result := ""
	inTag := false
	for _, r := range s {
		if r == '<' {
			inTag = true
			continue
		}
		if r == '>' {
			inTag = false
			continue
		}
		if !inTag {
			result += string(r)
		}
	}
	return result
}


path: cmd/grades.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

func runGrades(args []string) {
	if len(args) > 0 {
		showCourseGrades(args[0])
		return
	}
	showAllGrades()
}

func showAllGrades() {
	data, err := client.GET("/courses?enrollment_state=active&include[]=total_scores&include[]=enrollments")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var courses []Course
	if err := json.Unmarshal(data, &courses); err != nil {
		ui.Error("parsing courses: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Grades Overview")

	rows := make([][]string, 0, len(courses))
	for _, c := range courses {
		currentScore := ""
		currentGrade := ""
		finalScore := ""
		if len(c.Enrollments) > 0 {
			e := c.Enrollments[0]
			if e.ComputedCurrentScore > 0 {
				currentScore = fmt.Sprintf("%.1f%%", e.ComputedCurrentScore)
			}
			if e.ComputedCurrentGrade != "" {
				currentGrade = e.ComputedCurrentGrade
			}
			if e.ComputedFinalScore > 0 {
				finalScore = fmt.Sprintf("%.1f%%", e.ComputedFinalScore)
			}
		}

		// Color the grade
		gradeDisplay := currentGrade
		if currentGrade != "" {
			switch currentGrade[0] {
			case 'A':
				gradeDisplay = ui.C(ui.Green, currentGrade)
			case 'B':
				gradeDisplay = ui.C(ui.Cyan, currentGrade)
			case 'C':
				gradeDisplay = ui.C(ui.Yellow, currentGrade)
			case 'D', 'F':
				gradeDisplay = ui.C(ui.Red, currentGrade)
			}
		}

		rows = append(rows, []string{
			fmt.Sprintf("%d", c.ID),
			ui.Truncate(c.Name, 35),
			currentScore,
			gradeDisplay,
			finalScore,
		})
	}

	ui.Table([]string{"ID", "COURSE", "CURRENT %", "GRADE", "FINAL %"}, rows)
	fmt.Println()
}

func showCourseGrades(courseID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/assignments?include[]=submission&order_by=due_at", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var assignments []Assignment
	if err := json.Unmarshal(data, &assignments); err != nil {
		ui.Error("parsing assignments: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Grades — Course %s", courseID))

	var totalEarned, totalPossible float64
	rows := make([][]string, 0, len(assignments))

	for _, a := range assignments {
		score := ""
		pct := ""
		if a.Submission != nil && a.Submission.Score != nil {
			score = fmt.Sprintf("%.1f/%.1f", *a.Submission.Score, a.PointsPossible)
			if a.PointsPossible > 0 {
				p := (*a.Submission.Score / a.PointsPossible) * 100
				if p >= 90 {
					pct = ui.C(ui.Green, fmt.Sprintf("%.0f%%", p))
				} else if p >= 70 {
					pct = ui.C(ui.Yellow, fmt.Sprintf("%.0f%%", p))
				} else {
					pct = ui.C(ui.Red, fmt.Sprintf("%.0f%%", p))
				}
			}
			totalEarned += *a.Submission.Score
			totalPossible += a.PointsPossible
		} else if a.PointsPossible > 0 {
			score = fmt.Sprintf("—/%.1f", a.PointsPossible)
		}

		rows = append(rows, []string{
			ui.Truncate(a.Name, 40),
			ui.FormatDate(a.DueAt),
			score,
			pct,
		})
	}

	ui.Table([]string{"ASSIGNMENT", "DUE", "SCORE", "%"}, rows)

	if totalPossible > 0 {
		overallPct := (totalEarned / totalPossible) * 100
		fmt.Println()
		fmt.Printf("  %s  %.1f / %.1f (%.1f%%)\n",
			ui.C(ui.Bold, "Overall:"),
			totalEarned, totalPossible, overallPct)
	}
	fmt.Println()
}


path: cmd/files.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"

	"canvas-cli/internal/ui"
)

func runFiles(args []string) {
	if len(args) == 0 {
		ui.Error("usage: canvas-cli files <course_id>")
		os.Exit(1)
	}

	courseID := args[0]

	data, err := client.GET(fmt.Sprintf("/courses/%s/files?sort=updated_at&order=desc", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var files []struct {
		ID          int    `json:"id"`
		DisplayName string `json:"display_name"`
		Filename    string `json:"filename"`
		Size        int    `json:"size"`
		ContentType string `json:"content_type"`
		CreatedAt   string `json:"created_at"`
		UpdatedAt   string `json:"updated_at"`
		FolderID    int    `json:"folder_id"`
	}
	if err := json.Unmarshal(data, &files); err != nil {
		ui.Error("parsing files: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Files — Course %s", courseID))

	rows := make([][]string, 0, len(files))
	for _, f := range files {
		size := formatSize(f.Size)
		rows = append(rows, []string{
			fmt.Sprintf("%d", f.ID),
			ui.Truncate(f.DisplayName, 40),
			size,
			f.ContentType,
			ui.FormatDate(f.UpdatedAt),
		})
	}

	ui.Table([]string{"ID", "NAME", "SIZE", "TYPE", "UPDATED"}, rows)
	fmt.Println()
}

func runDownload(args []string) {
	if len(args) == 0 {
		ui.Error("usage: canvas-cli download <file_id> [-o output_path]")
		os.Exit(1)
	}

	fileID := args[0]
	outputPath := ""

	for i := 1; i < len(args); i++ {
		if args[i] == "-o" && i+1 < len(args) {
			outputPath = args[i+1]
			i++
		}
	}

	// Get file metadata
	data, err := client.GET(fmt.Sprintf("/files/%s", fileID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	var file struct {
		ID          int    `json:"id"`
		DisplayName string `json:"display_name"`
		Filename    string `json:"filename"`
		URL         string `json:"url"`
		Size        int    `json:"size"`
	}
	if err := json.Unmarshal(data, &file); err != nil {
		ui.Error("parsing file info: " + err.Error())
		os.Exit(1)
	}

	if file.URL == "" {
		ui.Error("no download URL available for this file")
		os.Exit(1)
	}

	if outputPath == "" {
		outputPath = file.DisplayName
		if outputPath == "" {
			outputPath = file.Filename
		}
	}

	// Resolve to absolute path if relative
	if !filepath.IsAbs(outputPath) {
		cwd, _ := os.Getwd()
		outputPath = filepath.Join(cwd, outputPath)
	}

	ui.Info(fmt.Sprintf("Downloading %s (%s)...", file.DisplayName, formatSize(file.Size)))

	// Download the file
	resp, err := http.Get(file.URL)
	if err != nil {
		ui.Error("download failed: " + err.Error())
		os.Exit(1)
	}
	defer resp.Body.Close()

	out, err := os.Create(outputPath)
	if err != nil {
		ui.Error("creating file: " + err.Error())
		os.Exit(1)
	}
	defer out.Close()

	written, err := io.Copy(out, resp.Body)
	if err != nil {
		ui.Error("writing file: " + err.Error())
		os.Exit(1)
	}

	ui.Success(fmt.Sprintf("Downloaded %s (%s)", outputPath, formatSize(int(written))))
	fmt.Println()
}

func formatSize(bytes int) string {
	switch {
	case bytes >= 1024*1024*1024:
		return fmt.Sprintf("%.1f GB", float64(bytes)/(1024*1024*1024))
	case bytes >= 1024*1024:
		return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
	case bytes >= 1024:
		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
	default:
		return fmt.Sprintf("%d B", bytes)
	}
}


path: cmd/notifications.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

func runNotifications() {
	data, err := client.GET("/users/self/activity_stream?only_active_courses=true")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var items []struct {
		ID          int    `json:"id"`
		Title       string `json:"title"`
		Message     string `json:"message"`
		Type        string `json:"type"`
		ReadState   bool   `json:"read_state"`
		CreatedAt   string `json:"created_at"`
		UpdatedAt   string `json:"updated_at"`
		ContextType string `json:"context_type"`
		CourseName  string `json:"course_name,omitempty"`
		HTMLURL     string `json:"html_url"`
	}
	if err := json.Unmarshal(data, &items); err != nil {
		ui.Error("parsing activity stream: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Activity Stream")

	if len(items) == 0 {
		fmt.Println(ui.C(ui.Dim, "  No recent activity."))
		fmt.Println()
		return
	}

	for _, item := range items {
		unread := ""
		if !item.ReadState {
			unread = ui.C(ui.Yellow, " [NEW]")
		}

		typeLabel := item.Type
		switch item.Type {
		case "Announcement":
			typeLabel = ui.C(ui.Magenta, "ANNOUNCE")
		case "DiscussionTopic":
			typeLabel = ui.C(ui.Cyan, "DISCUSS")
		case "Submission":
			typeLabel = ui.C(ui.Green, "SUBMIT")
		case "Conversation":
			typeLabel = ui.C(ui.Blue, "MESSAGE")
		case "Message":
			typeLabel = ui.C(ui.Blue, "MESSAGE")
		}

		fmt.Printf("\n  %s  %s%s\n", typeLabel, ui.C(ui.Bold, item.Title), unread)
		if item.CourseName != "" {
			fmt.Printf("  %s  %s\n", ui.C(ui.Dim, item.CourseName), ui.C(ui.Dim, ui.FormatDate(item.CreatedAt)))
		} else {
			fmt.Printf("  %s\n", ui.C(ui.Dim, ui.FormatDate(item.CreatedAt)))
		}
		if item.Message != "" {
			msg := stripHTML(item.Message)
			if len(msg) > 150 {
				msg = msg[:147] + "..."
			}
			fmt.Printf("  %s\n", msg)
		}
	}
	fmt.Println()
}


path: cmd/debug.go
content: package cmd

import (
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

func runDebugLogin() {
	fmt.Println()
	ui.Header("Debug Login Flow")
	fmt.Println()

	client.Debug = true
	err := client.Login()
	if err != nil {
		fmt.Println()
		ui.Error(err.Error())
		os.Exit(1)
	}

	fmt.Println()
	ui.Success("Login successful! API access confirmed.")
	fmt.Println()
}


path: cmd/whoami.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

func runWhoami() {
	data, err := client.GET("/users/self/profile")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var profile struct {
		ID        int    `json:"id"`
		Name      string `json:"name"`
		ShortName string `json:"short_name"`
		Email     string `json:"primary_email"`
		LoginID   string `json:"login_id"`
		Bio       string `json:"bio"`
		AvatarURL string `json:"avatar_url"`
		Locale    string `json:"locale"`
		TimeZone  string `json:"time_zone"`
	}
	if err := json.Unmarshal(data, &profile); err != nil {
		ui.Error("parsing profile: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Your Profile")
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Name:"), profile.Name)
	fmt.Printf("  %s  %d\n", ui.C(ui.Bold, "ID:"), profile.ID)
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Email:"), profile.Email)
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Login:"), profile.LoginID)
	if profile.Bio != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Bio:"), profile.Bio)
	}
	if profile.TimeZone != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "TZ:"), profile.TimeZone)
	}
	fmt.Println()
}


path: cmd/calendar.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"
	"time"

	"canvas-cli/internal/ui"
)

func runCalendar(args []string) {
	startDate := time.Now().Format("2006-01-02")
	endDate := time.Now().AddDate(0, 0, 30).Format("2006-01-02")

	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--start":
			if i+1 < len(args) {
				startDate = args[i+1]
				i++
			}
		case "--end":
			if i+1 < len(args) {
				endDate = args[i+1]
				i++
			}
		}
	}

	endpoint := fmt.Sprintf("/calendar_events?type=event&start_date=%s&end_date=%s&all_events=true", startDate, endDate)
	data, err := client.GET(endpoint)
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	// Also fetch assignments in calendar
	assignEndpoint := fmt.Sprintf("/calendar_events?type=assignment&start_date=%s&end_date=%s&all_events=true", startDate, endDate)
	assignData, _ := client.GET(assignEndpoint)

	if jsonOutput {
		fmt.Printf("[%s,%s]\n", string(data), string(assignData))
		return
	}

	var events []struct {
		ID          int    `json:"id"`
		Title       string `json:"title"`
		StartAt     string `json:"start_at"`
		EndAt       string `json:"end_at"`
		Description string `json:"description"`
		ContextCode string `json:"context_code"`
		ContextName string `json:"context_name"`
		Type        string `json:"type"`
		HTMLURL     string `json:"html_url"`
		Assignment  *struct {
			Name  string `json:"name"`
			DueAt string `json:"due_at"`
		} `json:"assignment"`
	}
	json.Unmarshal(data, &events)

	var assignEvents []struct {
		ID          int    `json:"id"`
		Title       string `json:"title"`
		StartAt     string `json:"start_at"`
		ContextName string `json:"context_name"`
		Type        string `json:"type"`
		Assignment  *struct {
			Name  string `json:"name"`
			DueAt string `json:"due_at"`
		} `json:"assignment"`
	}
	json.Unmarshal(assignData, &assignEvents)

	ui.Header(fmt.Sprintf("Calendar  %s → %s", startDate, endDate))

	rows := make([][]string, 0)

	for _, e := range events {
		rows = append(rows, []string{
			"event",
			ui.Truncate(e.ContextName, 20),
			ui.Truncate(e.Title, 35),
			ui.FormatDate(e.StartAt),
		})
	}

	for _, e := range assignEvents {
		title := e.Title
		date := e.StartAt
		if e.Assignment != nil {
			title = e.Assignment.Name
			date = e.Assignment.DueAt
		}
		rows = append(rows, []string{
			ui.C(ui.Magenta, "assign"),
			ui.Truncate(e.ContextName, 20),
			ui.Truncate(title, 35),
			ui.FormatDate(date),
		})
	}

	if len(rows) == 0 {
		fmt.Println(ui.C(ui.Dim, "  No events in this period."))
	} else {
		ui.Table([]string{"TYPE", "COURSE", "TITLE", "DATE"}, rows)
	}
	fmt.Println()
}


path: cmd/assignments.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"

	"canvas-cli/internal/ui"
)

type Assignment struct {
	ID                 int      `json:"id"`
	Name               string   `json:"name"`
	Description        string   `json:"description"`
	DueAt              string   `json:"due_at"`
	PointsPossible     float64  `json:"points_possible"`
	SubmissionTypes    []string `json:"submission_types"`
	HasSubmittedSubs   bool     `json:"has_submitted_submissions"`
	CourseID           int      `json:"course_id"`
	HTMLURL            string   `json:"html_url"`
	LockAt             string   `json:"lock_at"`
	UnlockAt           string   `json:"unlock_at"`
	Published          bool     `json:"published"`
	GradingType        string   `json:"grading_type"`
	AllowedAttempts    int      `json:"allowed_attempts"`
	Submission         *struct {
		Score         *float64 `json:"score"`
		Grade         string   `json:"grade"`
		WorkflowState string   `json:"workflow_state"`
		SubmittedAt   string   `json:"submitted_at"`
		Late          bool     `json:"late"`
		Missing       bool     `json:"missing"`
		Attempt       int      `json:"attempt"`
	} `json:"submission"`
}

func runAssignments(args []string) {
	if len(args) == 0 {
		ui.Error("usage: canvas-cli assignments <course_id> [assignment_id]")
		os.Exit(1)
	}

	courseID := args[0]

	if len(args) > 1 {
		showAssignment(courseID, args[1])
		return
	}

	listAssignments(courseID)
}

func listAssignments(courseID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/assignments?include[]=submission&order_by=due_at", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var assignments []Assignment
	if err := json.Unmarshal(data, &assignments); err != nil {
		ui.Error("parsing assignments: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Assignments — Course %s", courseID))

	rows := make([][]string, 0, len(assignments))
	for _, a := range assignments {
		status := ""
		score := ""
		if a.Submission != nil {
			switch a.Submission.WorkflowState {
			case "submitted", "graded":
				status = ui.StatusColor("submitted")
			case "unsubmitted":
				if a.Submission.Missing {
					status = ui.StatusColor("missing")
				} else {
					status = ui.StatusColor("unsubmitted")
				}
			default:
				status = a.Submission.WorkflowState
			}
			if a.Submission.Score != nil {
				score = fmt.Sprintf("%.1f/%.1f", *a.Submission.Score, a.PointsPossible)
			}
		}

		rows = append(rows, []string{
			fmt.Sprintf("%d", a.ID),
			ui.Truncate(a.Name, 40),
			ui.FormatDate(a.DueAt),
			fmt.Sprintf("%.0f", a.PointsPossible),
			status,
			score,
		})
	}

	ui.Table([]string{"ID", "NAME", "DUE", "PTS", "STATUS", "SCORE"}, rows)
	fmt.Println()
}

func showAssignment(courseID, assignID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/assignments/%s?include[]=submission", courseID, assignID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var a Assignment
	if err := json.Unmarshal(data, &a); err != nil {
		ui.Error("parsing assignment: " + err.Error())
		os.Exit(1)
	}

	ui.Header(a.Name)
	fmt.Printf("  %s  %d\n", ui.C(ui.Bold, "ID:"), a.ID)
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Due:"), ui.FormatDate(a.DueAt))
	fmt.Printf("  %s  %.1f\n", ui.C(ui.Bold, "Points:"), a.PointsPossible)
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Types:"), strings.Join(a.SubmissionTypes, ", "))
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Grading:"), a.GradingType)
	if a.AllowedAttempts > 0 {
		fmt.Printf("  %s  %d\n", ui.C(ui.Bold, "Attempts:"), a.AllowedAttempts)
	}
	if a.UnlockAt != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Unlocks:"), ui.FormatDate(a.UnlockAt))
	}
	if a.LockAt != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Locks:"), ui.FormatDate(a.LockAt))
	}
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "URL:"), a.HTMLURL)

	if a.Submission != nil {
		fmt.Println()
		fmt.Printf("  %s\n", ui.C(ui.Bold+ui.Cyan, "Submission"))
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Status:"), ui.StatusColor(a.Submission.WorkflowState))
		if a.Submission.Score != nil {
			fmt.Printf("  %s  %.1f / %.1f\n", ui.C(ui.Bold, "Score:"), *a.Submission.Score, a.PointsPossible)
		}
		if a.Submission.Grade != "" {
			fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Grade:"), a.Submission.Grade)
		}
		if a.Submission.SubmittedAt != "" {
			fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Submitted:"), ui.FormatDate(a.Submission.SubmittedAt))
		}
		if a.Submission.Late {
			fmt.Printf("  %s\n", ui.C(ui.Red, "  ⚠ Late submission"))
		}
	}
	fmt.Println()
}


path: cmd/announcements.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"time"

	"canvas-cli/internal/ui"
)

func runAnnouncements(args []string) {
	if len(args) > 0 {
		listCourseAnnouncements(args[0])
		return
	}
	listAllAnnouncements()
}

func listAllAnnouncements() {
	// First get active courses to build context_codes
	coursesData, err := client.GET("/courses?enrollment_state=active")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	var courses []struct {
		ID int `json:"id"`
	}
	json.Unmarshal(coursesData, &courses)

	if len(courses) == 0 {
		ui.Header("Announcements")
		fmt.Println(ui.C(ui.Dim, "  No active courses found."))
		fmt.Println()
		return
	}

	// Build context_codes parameter
	var codes []string
	for _, c := range courses {
		codes = append(codes, fmt.Sprintf("context_codes[]=course_%d", c.ID))
	}

	startDate := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
	endpoint := fmt.Sprintf("/announcements?%s&start_date=%s&latest_only=false", strings.Join(codes, "&"), startDate)

	data, err := client.GET(endpoint)
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	displayAnnouncements(data, "Recent Announcements")
}

func listCourseAnnouncements(courseID string) {
	startDate := time.Now().AddDate(0, 0, -60).Format("2006-01-02")
	endpoint := fmt.Sprintf("/announcements?context_codes[]=course_%s&start_date=%s", courseID, startDate)

	data, err := client.GET(endpoint)
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	displayAnnouncements(data, fmt.Sprintf("Announcements — Course %s", courseID))
}

func displayAnnouncements(data []byte, title string) {
	var announcements []struct {
		ID          int    `json:"id"`
		Title       string `json:"title"`
		Message     string `json:"message"`
		PostedAt    string `json:"posted_at"`
		ContextCode string `json:"context_code"`
		AuthorName  string `json:"author"`
		Author      struct {
			DisplayName string `json:"display_name"`
		} `json:"author"`
		ReadState   string `json:"read_state"`
	}
	if err := json.Unmarshal(data, &announcements); err != nil {
		ui.Error("parsing announcements: " + err.Error())
		os.Exit(1)
	}

	ui.Header(title)

	if len(announcements) == 0 {
		fmt.Println(ui.C(ui.Dim, "  No announcements."))
		fmt.Println()
		return
	}

	for _, a := range announcements {
		unread := ""
		if a.ReadState == "unread" {
			unread = ui.C(ui.Yellow, " [NEW]")
		}
		author := a.Author.DisplayName
		if author == "" {
			author = a.AuthorName
		}

		fmt.Printf("\n  %s%s\n", ui.C(ui.Bold, a.Title), unread)
		fmt.Printf("  %s  %s\n", ui.C(ui.Dim, ui.FormatDate(a.PostedAt)), ui.C(ui.Cyan, author))
		msg := stripHTML(a.Message)
		if len(msg) > 200 {
			msg = msg[:197] + "..."
		}
		fmt.Printf("  %s\n", msg)
	}
	fmt.Println()
}


path: cmd/todo.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

func runTodo() {
	data, err := client.GET("/users/self/todo")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var items []struct {
		Type       string `json:"type"`
		Assignment struct {
			ID             int     `json:"id"`
			Name           string  `json:"name"`
			DueAt          string  `json:"due_at"`
			PointsPossible float64 `json:"points_possible"`
			CourseID       int     `json:"course_id"`
			HTMLURL        string  `json:"html_url"`
		} `json:"assignment"`
		ContextName string `json:"context_name"`
		HTMLURL     string `json:"html_url"`
	}
	if err := json.Unmarshal(data, &items); err != nil {
		ui.Error("parsing todo: " + err.Error())
		os.Exit(1)
	}

	ui.Header("To-Do Items")

	if len(items) == 0 {
		fmt.Println(ui.C(ui.Green, "  All caught up! Nothing to do."))
		fmt.Println()
		return
	}

	rows := make([][]string, 0, len(items))
	for _, item := range items {
		rows = append(rows, []string{
			item.Type,
			ui.Truncate(item.ContextName, 25),
			ui.Truncate(item.Assignment.Name, 35),
			ui.FormatDate(item.Assignment.DueAt),
			fmt.Sprintf("%.0f pts", item.Assignment.PointsPossible),
		})
	}

	ui.Table([]string{"TYPE", "COURSE", "ASSIGNMENT", "DUE", "POINTS"}, rows)
	fmt.Println()
}

func runUpcoming() {
	data, err := client.GET("/users/self/upcoming_events")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var events []struct {
		ID        int    `json:"id"`
		Title     string `json:"title"`
		StartAt   string `json:"start_at"`
		EndAt     string `json:"end_at"`
		Type      string `json:"type"`
		HTMLURL   string `json:"html_url"`
		ContextCode string `json:"context_code"`
		Assignment *struct {
			ID             int     `json:"id"`
			Name           string  `json:"name"`
			DueAt          string  `json:"due_at"`
			PointsPossible float64 `json:"points_possible"`
		} `json:"assignment"`
	}
	if err := json.Unmarshal(data, &events); err != nil {
		ui.Error("parsing events: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Upcoming Events & Assignments")

	if len(events) == 0 {
		fmt.Println(ui.C(ui.Green, "  Nothing upcoming!"))
		fmt.Println()
		return
	}

	rows := make([][]string, 0, len(events))
	for _, e := range events {
		title := e.Title
		date := ui.FormatDate(e.StartAt)
		eventType := e.Type
		if e.Assignment != nil {
			title = e.Assignment.Name
			date = ui.FormatDate(e.Assignment.DueAt)
			eventType = "assignment"
		}
		rows = append(rows, []string{
			eventType,
			ui.Truncate(title, 45),
			date,
		})
	}

	ui.Table([]string{"TYPE", "TITLE", "DATE"}, rows)
	fmt.Println()
}

func runMissing() {
	data, err := client.GET("/users/self/missing_submissions?include[]=course")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var assignments []struct {
		ID             int     `json:"id"`
		Name           string  `json:"name"`
		DueAt          string  `json:"due_at"`
		PointsPossible float64 `json:"points_possible"`
		CourseID       int     `json:"course_id"`
		HTMLURL        string  `json:"html_url"`
		Course         *struct {
			Name string `json:"name"`
		} `json:"course"`
	}
	if err := json.Unmarshal(data, &assignments); err != nil {
		ui.Error("parsing missing: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Missing Submissions")

	if len(assignments) == 0 {
		fmt.Println(ui.C(ui.Green, "  No missing submissions!"))
		fmt.Println()
		return
	}

	rows := make([][]string, 0, len(assignments))
	for _, a := range assignments {
		courseName := fmt.Sprintf("%d", a.CourseID)
		if a.Course != nil {
			courseName = a.Course.Name
		}
		rows = append(rows, []string{
			fmt.Sprintf("%d", a.ID),
			ui.Truncate(courseName, 25),
			ui.Truncate(a.Name, 35),
			ui.FormatDate(a.DueAt),
			ui.C(ui.Red, fmt.Sprintf("%.0f pts", a.PointsPossible)),
		})
	}

	ui.Table([]string{"ID", "COURSE", "ASSIGNMENT", "DUE", "POINTS"}, rows)
	fmt.Printf("\n  %s %d missing submission(s)\n\n", ui.C(ui.Red, "!"), len(assignments))
}


path: cmd/submissions.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"net/url"
	"os"

	"canvas-cli/internal/ui"
)

func runSubmissions(args []string) {
	if len(args) < 2 {
		ui.Error("usage: canvas-cli submissions <course_id> <assignment_id>")
		os.Exit(1)
	}

	courseID := args[0]
	assignID := args[1]

	data, err := client.GET(fmt.Sprintf("/courses/%s/assignments/%s/submissions/self?include[]=submission_comments&include[]=rubric_assessment", courseID, assignID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var sub struct {
		ID            int      `json:"id"`
		Score         *float64 `json:"score"`
		Grade         string   `json:"grade"`
		WorkflowState string   `json:"workflow_state"`
		SubmittedAt   string   `json:"submitted_at"`
		Late          bool     `json:"late"`
		Missing       bool     `json:"missing"`
		Attempt       int      `json:"attempt"`
		Body          string   `json:"body"`
		URL           string   `json:"url"`
		PreviewURL    string   `json:"preview_url"`
		Attachments   []struct {
			ID          int    `json:"id"`
			DisplayName string `json:"display_name"`
			URL         string `json:"url"`
			Size        int    `json:"size"`
		} `json:"attachments"`
		SubmissionComments []struct {
			ID         int    `json:"id"`
			AuthorName string `json:"author_name"`
			Comment    string `json:"comment"`
			CreatedAt  string `json:"created_at"`
		} `json:"submission_comments"`
	}
	if err := json.Unmarshal(data, &sub); err != nil {
		ui.Error("parsing submission: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Your Submission")
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Status:"), ui.StatusColor(sub.WorkflowState))
	if sub.SubmittedAt != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Submitted:"), ui.FormatDate(sub.SubmittedAt))
	}
	if sub.Score != nil {
		fmt.Printf("  %s  %.1f\n", ui.C(ui.Bold, "Score:"), *sub.Score)
	}
	if sub.Grade != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Grade:"), sub.Grade)
	}
	if sub.Attempt > 0 {
		fmt.Printf("  %s  %d\n", ui.C(ui.Bold, "Attempt:"), sub.Attempt)
	}
	if sub.Late {
		fmt.Printf("  %s\n", ui.C(ui.Red, "  Late submission"))
	}
	if sub.Missing {
		fmt.Printf("  %s\n", ui.C(ui.Red, "  Missing"))
	}
	if sub.Body != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Body:"), ui.Truncate(sub.Body, 200))
	}
	if sub.URL != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "URL:"), sub.URL)
	}

	if len(sub.Attachments) > 0 {
		fmt.Println()
		fmt.Printf("  %s\n", ui.C(ui.Bold+ui.Cyan, "Attachments"))
		for _, att := range sub.Attachments {
			fmt.Printf("    • %s (ID: %d, %d bytes)\n", att.DisplayName, att.ID, att.Size)
		}
	}

	if len(sub.SubmissionComments) > 0 {
		fmt.Println()
		fmt.Printf("  %s\n", ui.C(ui.Bold+ui.Cyan, "Comments"))
		for _, c := range sub.SubmissionComments {
			fmt.Printf("    %s %s\n", ui.C(ui.Bold, c.AuthorName), ui.C(ui.Dim, ui.FormatDate(c.CreatedAt)))
			fmt.Printf("    %s\n\n", c.Comment)
		}
	}
	fmt.Println()
}

func runSubmit(args []string) {
	if len(args) < 2 {
		ui.Error("usage: canvas-cli submit <course_id> <assignment_id> --text \"content\" | --url <url>")
		os.Exit(1)
	}

	courseID := args[0]
	assignID := args[1]
	remaining := args[2:]

	var submissionType, submissionBody, submissionURL string

	for i := 0; i < len(remaining); i++ {
		switch remaining[i] {
		case "--text":
			if i+1 < len(remaining) {
				submissionType = "online_text_entry"
				submissionBody = remaining[i+1]
				i++
			}
		case "--url":
			if i+1 < len(remaining) {
				submissionType = "online_url"
				submissionURL = remaining[i+1]
				i++
			}
		case "--file":
			ui.Error("File upload is not yet supported via CLI. Use --text or --url, or submit via Canvas web.")
			os.Exit(1)
		}
	}

	if submissionType == "" {
		ui.Error("specify submission type: --text \"content\" or --url <url>")
		os.Exit(1)
	}

	form := url.Values{
		"submission[submission_type]": {submissionType},
	}
	if submissionBody != "" {
		form.Set("submission[body]", submissionBody)
	}
	if submissionURL != "" {
		form.Set("submission[url]", submissionURL)
	}

	endpoint := fmt.Sprintf("/courses/%s/assignments/%s/submissions", courseID, assignID)
	data, err := client.POST(endpoint, form)
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var result struct {
		ID            int    `json:"id"`
		WorkflowState string `json:"workflow_state"`
		SubmittedAt   string `json:"submitted_at"`
	}
	json.Unmarshal(data, &result)

	ui.Success(fmt.Sprintf("Submitted! (ID: %d)", result.ID))
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Status:"), ui.StatusColor(result.WorkflowState))
	if result.SubmittedAt != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Time:"), ui.FormatDate(result.SubmittedAt))
	}

	fmt.Println()
}


path: cmd/root.go
content: package cmd

import (
	"fmt"
	"os"

	"canvas-cli/internal/api"
	"canvas-cli/internal/config"
	"canvas-cli/internal/ui"
)

var (
	jsonOutput bool
	perPage    int
	client     *api.Client
)

func Execute() {
	if len(os.Args) < 2 {
		printUsage()
		os.Exit(0)
	}

	// Parse global flags first
	args := os.Args[1:]
	var filteredArgs []string
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--json":
			jsonOutput = true
		case "--per-page":
			if i+1 < len(args) {
				fmt.Sscanf(args[i+1], "%d", &perPage)
				i++
			}
		case "--help", "-h":
			printUsage()
			os.Exit(0)
		default:
			filteredArgs = append(filteredArgs, args[i])
		}
	}

	if perPage == 0 {
		perPage = 50
	}

	if len(filteredArgs) == 0 {
		printUsage()
		os.Exit(0)
	}

	command := filteredArgs[0]
	cmdArgs := filteredArgs[1:]

	// Commands that don't need auth
	switch command {
	case "configure":
		runConfigure()
		return
	case "help":
		printUsage()
		return
	case "version":
		fmt.Println("canvas-cli v1.0.0")
		return
	}

	// Load config and create API client
	cfg, err := config.Load()
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}
	client = api.NewClient(cfg)
	client.PerPage = perPage

	// Route commands
	switch command {
	case "debug-login":
		runDebugLogin()
	case "whoami":
		runWhoami()
	case "courses":
		runCourses(cmdArgs)
	case "assignments":
		runAssignments(cmdArgs)
	case "grades":
		runGrades(cmdArgs)
	case "submissions":
		runSubmissions(cmdArgs)
	case "submit":
		runSubmit(cmdArgs)
	case "todo":
		runTodo()
	case "upcoming":
		runUpcoming()
	case "missing":
		runMissing()
	case "modules":
		runModules(cmdArgs)
	case "calendar":
		runCalendar(cmdArgs)
	case "discussions":
		runDiscussions(cmdArgs)
	case "announcements":
		runAnnouncements(cmdArgs)
	case "files":
		runFiles(cmdArgs)
	case "download":
		runDownload(cmdArgs)
	case "notifications":
		runNotifications()
	default:
		ui.Error(fmt.Sprintf("unknown command: %s", command))
		fmt.Println()
		printUsage()
		os.Exit(1)
	}
}

func printUsage() {
	fmt.Println(ui.C(ui.Bold+ui.Cyan, `
   ██████╗ █████╗ ███╗   ██╗██╗   ██╗ █████╗ ███████╗
  ██╔════╝██╔══██╗████╗  ██║██║   ██║██╔══██╗██╔════╝
  ██║     ███████║██╔██╗ ██║██║   ██║███████║███████╗
  ██║     ██╔══██║██║╚██╗██║╚██╗ ██╔╝██╔══██║╚════██║
  ╚██████╗██║  ██║██║ ╚████║ ╚████╔╝ ██║  ██║███████║
   ╚═════╝╚═╝  ╚═╝╚═╝  ╚═══╝  ╚═══╝  ╚═╝  ╚═╝╚══════╝  CLI`))

	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "  Canvas LMS command-line client"))
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "SETUP"))
	fmt.Println("  configure              Set up Canvas URL, username & password")
	fmt.Println("  whoami                 Show your profile info")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "COURSES"))
	fmt.Println("  courses                List your active courses")
	fmt.Println("  courses <id>           Show course details")
	fmt.Println("  courses <id> users     List users in a course")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "ASSIGNMENTS & GRADES"))
	fmt.Println("  assignments <course>             List assignments")
	fmt.Println("  assignments <course> <id>        Show assignment details")
	fmt.Println("  grades                           Grades overview (all courses)")
	fmt.Println("  grades <course>                  Grades for a course")
	fmt.Println("  submissions <course> <assign>    View your submission")
	fmt.Println("  submit <course> <assign> [opts]  Submit work")
	fmt.Println("    --text \"content\"               Submit text")
	fmt.Println("    --url <url>                    Submit a URL")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "PRODUCTIVITY"))
	fmt.Println("  todo                   Pending to-do items")
	fmt.Println("  upcoming               Upcoming events & assignments")
	fmt.Println("  missing                Missing/late submissions")
	fmt.Println("  calendar               Upcoming calendar events")
	fmt.Println("    --start <date>       Start date (YYYY-MM-DD)")
	fmt.Println("    --end <date>         End date (YYYY-MM-DD)")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "CONTENT"))
	fmt.Println("  modules <course>            List modules")
	fmt.Println("  modules <course> <id>       List module items")
	fmt.Println("  discussions <course>        List discussions")
	fmt.Println("  discussions <course> <id>   View discussion")
	fmt.Println("    --reply \"message\"         Post a reply")
	fmt.Println("  announcements               Recent announcements")
	fmt.Println("  announcements <course>      Course announcements")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "FILES"))
	fmt.Println("  files <course>         List course files")
	fmt.Println("  download <file_id>     Download a file")
	fmt.Println("    -o <path>            Output path")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "OTHER"))
	fmt.Println("  notifications          Activity stream")
	fmt.Println()
	fmt.Println(ui.C(ui.Bold, "FLAGS"))
	fmt.Println("  --json                 Output raw JSON")
	fmt.Println("  --per-page <n>         Results per page (default 50)")
	fmt.Println("  -h, --help             Show this help")
	fmt.Println()
}

func runConfigure() {
	_, err := config.RunSetup()
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}
	ui.Success("Configuration complete!")
}


path: cmd/courses.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

type Course struct {
	ID              int    `json:"id"`
	Name            string `json:"name"`
	CourseCode      string `json:"course_code"`
	WorkflowState   string `json:"workflow_state"`
	EnrollmentTermID int   `json:"enrollment_term_id"`
	StartAt         string `json:"start_at"`
	EndAt           string `json:"end_at"`
	TotalStudents   int    `json:"total_students"`
	DefaultView     string `json:"default_view"`
	Enrollments     []struct {
		Type                   string  `json:"type"`
		ComputedCurrentScore   float64 `json:"computed_current_score"`
		ComputedCurrentGrade   string  `json:"computed_current_grade"`
		ComputedFinalScore     float64 `json:"computed_final_score"`
		ComputedFinalGrade     string  `json:"computed_final_grade"`
	} `json:"enrollments"`
}

func runCourses(args []string) {
	if len(args) == 0 {
		listCourses()
		return
	}

	courseID := args[0]
	if len(args) > 1 {
		switch args[1] {
		case "users":
			listCourseUsers(courseID)
		default:
			ui.Error(fmt.Sprintf("unknown subcommand: courses %s %s", courseID, args[1]))
			os.Exit(1)
		}
		return
	}

	showCourse(courseID)
}

func listCourses() {
	data, err := client.GET("/courses?enrollment_state=active&include[]=total_scores&include[]=enrollments")
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var courses []Course
	if err := json.Unmarshal(data, &courses); err != nil {
		ui.Error("parsing courses: " + err.Error())
		os.Exit(1)
	}

	ui.Header("Your Courses")

	rows := make([][]string, 0, len(courses))
	for _, c := range courses {
		grade := ""
		if len(c.Enrollments) > 0 {
			e := c.Enrollments[0]
			if e.ComputedCurrentGrade != "" {
				grade = fmt.Sprintf("%s (%.1f%%)", e.ComputedCurrentGrade, e.ComputedCurrentScore)
			} else if e.ComputedCurrentScore > 0 {
				grade = fmt.Sprintf("%.1f%%", e.ComputedCurrentScore)
			}
		}
		rows = append(rows, []string{
			fmt.Sprintf("%d", c.ID),
			c.CourseCode,
			ui.Truncate(c.Name, 45),
			grade,
		})
	}

	ui.Table([]string{"ID", "CODE", "NAME", "GRADE"}, rows)
	fmt.Println()
}

func showCourse(courseID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s?include[]=total_scores&include[]=enrollments&include[]=syllabus_body&include[]=term", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var c struct {
		Course
		SyllabusBody string `json:"syllabus_body"`
		Term         struct {
			Name string `json:"name"`
		} `json:"term"`
	}
	if err := json.Unmarshal(data, &c); err != nil {
		ui.Error("parsing course: " + err.Error())
		os.Exit(1)
	}

	ui.Header(c.Name)
	fmt.Printf("  %s  %d\n", ui.C(ui.Bold, "ID:"), c.ID)
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Code:"), c.CourseCode)
	fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "State:"), ui.StatusColor(c.WorkflowState))
	if c.Term.Name != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Term:"), c.Term.Name)
	}
	if c.StartAt != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Start:"), ui.FormatDate(c.StartAt))
	}
	if c.EndAt != "" {
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "End:"), ui.FormatDate(c.EndAt))
	}
	if len(c.Enrollments) > 0 {
		e := c.Enrollments[0]
		fmt.Printf("  %s  %s\n", ui.C(ui.Bold, "Role:"), e.Type)
		if e.ComputedCurrentScore > 0 {
			fmt.Printf("  %s  %.1f%% (%s)\n", ui.C(ui.Bold, "Grade:"), e.ComputedCurrentScore, e.ComputedCurrentGrade)
		}
	}
	fmt.Println()
}

func listCourseUsers(courseID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/users?include[]=email&include[]=enrollments", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var users []struct {
		ID          int    `json:"id"`
		Name        string `json:"name"`
		Email       string `json:"email"`
		Enrollments []struct {
			Type string `json:"type"`
		} `json:"enrollments"`
	}
	if err := json.Unmarshal(data, &users); err != nil {
		ui.Error("parsing users: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Users in Course %s", courseID))

	rows := make([][]string, 0, len(users))
	for _, u := range users {
		role := ""
		if len(u.Enrollments) > 0 {
			role = u.Enrollments[0].Type
		}
		rows = append(rows, []string{
			fmt.Sprintf("%d", u.ID),
			u.Name,
			u.Email,
			role,
		})
	}

	ui.Table([]string{"ID", "NAME", "EMAIL", "ROLE"}, rows)
	fmt.Println()
}


path: cmd/modules.go
content: package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"canvas-cli/internal/ui"
)

func runModules(args []string) {
	if len(args) == 0 {
		ui.Error("usage: canvas-cli modules <course_id> [module_id]")
		os.Exit(1)
	}

	courseID := args[0]

	if len(args) > 1 {
		listModuleItems(courseID, args[1])
		return
	}

	listModules(courseID)
}

func listModules(courseID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/modules?include[]=items_count", courseID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var modules []struct {
		ID             int    `json:"id"`
		Name           string `json:"name"`
		Position       int    `json:"position"`
		State          string `json:"state"`
		ItemsCount     int    `json:"items_count"`
		CompletedAt    string `json:"completed_at"`
		WorkflowState  string `json:"workflow_state"`
	}
	if err := json.Unmarshal(data, &modules); err != nil {
		ui.Error("parsing modules: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Modules — Course %s", courseID))

	rows := make([][]string, 0, len(modules))
	for _, m := range modules {
		state := m.State
		if m.CompletedAt != "" {
			state = "completed"
		}
		rows = append(rows, []string{
			fmt.Sprintf("%d", m.ID),
			ui.Truncate(m.Name, 45),
			fmt.Sprintf("%d", m.ItemsCount),
			ui.StatusColor(state),
		})
	}

	ui.Table([]string{"ID", "NAME", "ITEMS", "STATE"}, rows)
	fmt.Println()
}

func listModuleItems(courseID, moduleID string) {
	data, err := client.GET(fmt.Sprintf("/courses/%s/modules/%s/items?include[]=content_details", courseID, moduleID))
	if err != nil {
		ui.Error(err.Error())
		os.Exit(1)
	}

	if jsonOutput {
		fmt.Println(string(data))
		return
	}

	var items []struct {
		ID              int    `json:"id"`
		Title           string `json:"title"`
		Type            string `json:"type"`
		Position        int    `json:"position"`
		HTMLURL         string `json:"html_url"`
		ContentID       int    `json:"content_id"`
		CompletionReq   *struct {
			Type      string `json:"type"`
			Completed bool   `json:"completed"`
		} `json:"completion_requirement"`
		ContentDetails  *struct {
			DueAt          string  `json:"due_at"`
			PointsPossible float64 `json:"points_possible"`
		} `json:"content_details"`
	}
	if err := json.Unmarshal(data, &items); err != nil {
		ui.Error("parsing module items: " + err.Error())
		os.Exit(1)
	}

	ui.Header(fmt.Sprintf("Module %s Items", moduleID))

	rows := make([][]string, 0, len(items))
	for _, item := range items {
		status := ""
		if item.CompletionReq != nil {
			if item.CompletionReq.Completed {
				status = ui.C(ui.Green, "done")
			} else {
				status = ui.C(ui.Yellow, item.CompletionReq.Type)
			}
		}
		due := ""
		if item.ContentDetails != nil && item.ContentDetails.DueAt != "" {
			due = ui.FormatDate(item.ContentDetails.DueAt)
		}

		rows = append(rows, []string{
			fmt.Sprintf("%d", item.ID),
			item.Type,
			ui.Truncate(item.Title, 40),
			due,
			status,
		})
	}

	ui.Table([]string{"ID", "TYPE", "TITLE", "DUE", "STATUS"}, rows)
	fmt.Println()
}


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)