Agent-first CLI for Universidad Norbert Wiener student portals. One binary
(wiener), two backends (intranet ASP + Canvas LMS), single canonical JSON
contract. Built for an agent to autonomously answer day-to-day student
questions ("qué tengo ahora", "qué debo entregar pronto", "cómo voy", "qué
subió el profe") in one command.
Why this exists
Wiener admin hides 7 Canvas sidebar tabs from the student UI: Tareas,
Discusiones, Páginas, Archivos, Quizzes, Conferencias, Programa del curso. The
underlying REST API at /api/v1/* is NOT locked down — wiener surfaces what
the UI hides. Canvas Personal Access Token generation is enabled for students.
Backends
| Backend | Domain | Auth | Data |
|---|---|---|---|
| Intranet (Wienernet) | intranet.uwiener.edu.pe |
ASP cookie session, intranet-only password | Notas oficiales, horario, asistencia, plan, pagos, trámites |
| Canvas (Aula Virtual) | campus.uwiener.edu.pe |
Personal Access Token (Microsoft Entra–backed account) | Cursos, tareas, calificaciones, anuncios, archivos, módulos, syllabus, páginas, discusiones, quizzes, conferencias, calendario, inbox |
The two backends use different passwords. Intranet auth alone is sufficient for intranet commands. Canvas commands require a PAT (one-time manual setup).
Trust Ladder
| Level | Commands | Friction |
|---|---|---|
| T0 (auto) | All read commands. wiener solo (panorama), hoy, ahora, semana, nuevo, doctor, schema, config, auth status/logout, cursos*, tareas (read), planner, calificaciones*, notas*, historial, horario*, asistencia, plan*, examenes, matricula, perfil, pagos*, tramite list, anuncios*, archivos list/arbol/download (≤50MB), modulos, syllabus, paginas, discusiones, quizzes, conferencias, calendario*, inbox* |
None — runs silently |
| T2 (confirm) | auth login, auth canvas set-token, auth canvas pat new, cursos aliases (wizard), tramite generar (creates billable obligation), tareas submit (uploads assignment to Canvas), archivos download (>50MB), archivos sync (bulk), watch --detach |
Show preview, require --yes (or interactive confirm in TTY) |
Pass --yes to skip T2 confirmations in scripted/agent flows. Always preview
with --dry-run first when uncertain.
Most common workflows (in order of frequency)
"What do I need to know right now?" — the morning ritual
wiener
Returns: bloque actual, próximo bloque, tareas vencidas/hoy, week summary, and diff since last invocation (new grades posted, new files uploaded, new announcements). This is the canonical entry point — most invocations.
Quick targeted queries
wiener hoy # today's schedule + tareas due today
wiener ahora # current class + next class with ETA
wiener semana # week schedule + tareas + quizzes
wiener nuevo # diff since last call
wiener nuevo --abrir # diff and open each item URL in browser
Course-specific (smart resolver)
The CLI accepts course code, alias, substring, or fuzzy match. All these work:
wiener tareas FB6N1 # exact code
wiener tareas terapeutica # auto-alias
wiener tareas farma # custom alias if user set one
wiener tareas terap # substring match
wiener tareas frama # fuzzy → in TTY confirms; in --json → error course-ambiguous
In JSON/agent mode (--json or --no-input):
- Top-1 fuzzy score >0.92 → executes silently
- Otherwise → exit 1 with
error.code = "course-ambiguous"+ candidates array (agent must disambiguate)
Force exact match for paranoid scripts:
wiener tareas FB6N1 --exact
Course aliases (one-time customization)
wiener cursos aliases # interactive wizard, walks course-by-course
wiener cursos aliases list # show current aliases
wiener cursos aliases reset FB6M4 # back to auto
Hidden Canvas surfaces (what the UI doesn't show)
wiener tareas FB6N1 # all assignments — UI hides this tab
wiener archivos FB6N1 # flat file list — UI forces module drilling
wiener archivos arbol FB6N1 # folder tree
wiener quizzes FB6N1 # quizzes — UI hides
wiener discusiones FB6N1 # forums — UI hides
wiener syllabus FB6N1 # course syllabus — UI hides
wiener conferencias FB6N1 # past BBB/Zoom links — UI hides
wiener modulos FB6N1 # module + items tree
wiener paginas FB6N1 # course wiki pages
Cross-course aggregation
wiener tareas # all pending across all courses
wiener tareas hoy # due today or overdue
wiener tareas semana # next 7 days
wiener planner # /api/v1/planner/items — richer than tareas
wiener calificaciones # current_grade per course (Canvas)
wiener notas # official period grades (intranet)
wiener anuncios --ultimos 3 # 3 most recent announcements per course
wiener calendario --dias 14 # next 14 days
wiener calendario --ics # .ics export of all courses
Submit an assignment (T2 upload)
# Upload one file
wiener tareas submit ciencia informe ./informe.pdf
# → preview shown; must add --yes or confirm interactively
# Upload multiple files
wiener tareas submit ciencia informe doc1.pdf doc2.pdf cover.pdf --yes
# Text entry (HTML or markdown body)
cat reflexion.md | wiener tareas submit ciencia ensayo --type online_text_entry --yes
# URL submission
wiener tareas submit ciencia link --type online_url --url https://example.com --yes
# Dry-run to preview without submitting
wiener tareas submit ciencia informe ./informe.pdf --dry-run --json
The CLI auto-detects submission type from the assignment's submission_types
when there's only one. If multiple are allowed, agent must pass --type.
Hard errors: file doesn't exist, lock_at past, no attempts left, extension not
allowed.
Bulk download
wiener archivos download <file_id> --out ./material/
wiener archivos sync FB6N1 --dir ./farmaco/ # bulk all course files (T2 — shows total size first)
Background watching (opt-in)
wiener watch # foreground, NDJSON stream, Ctrl+C to stop
wiener watch --detach # background daemon, OS notif on changes
wiener watch --detach --whatsapp # background, route notif to WhatsApp via Kapso
wiener watch stop # kill background daemon
Composability with jq
# "Did my AC4061 grade come in?"
wiener notas --json | jq '.cursos[] | select(.codigo=="AC4061") | {nombre, nota_final, estado}'
# "What's due in the next 48 hours, sorted by deadline?"
wiener tareas --json | jq '.tareas | map(select(.due_at <= (now + 172800 | todate))) | sort_by(.due_at)'
# Daily morning brief
wiener --json | jq -c '{ahora, proximo: .ahora.proximo, pendientes: .pendiente_hoy | length}'
Initial setup (first run)
Step 1: Intranet login
wiener auth login
# Interactive: prompts for usuario, contraseña, perfil (Alumno/Docente/Administrativo)
# Or set env vars: WIENER_INTRANET_USER, WIENER_INTRANET_PASS, WIENER_INTRANET_PERFIL=A
Step 2: Canvas PAT (one-time, requires browser + MFA)
wiener auth canvas pat new
# Opens https://campus.uwiener.edu.pe/profile/settings in default browser.
# User authenticates via MS Entra (number-matching MFA push, "Stay signed in" 7d).
# User clicks "+ Nuevo token de acceso", names it "wiener-cli", copies token.
# Pastes back to CLI prompt. CLI validates against /api/v1/users/self and stores in OS keychain.
# Or paste token directly if generated already:
wiener auth canvas set-token <pat>
Step 3: Verify everything works
wiener doctor --json
# → { ok: true, checks: [{name, ok, detail}, ...] }
# Surfaces: network reachability, intranet session validity, canvas token validity,
# csrfToken stability, dead-domain bug presence, env detection (isCi/isWsl).
Mirror namespaces
wiener intranet * and wiener canvas * mirror the relevant subsets:
wiener intranet --help # shows intranet-only commands
wiener intranet notas # equivalent to: wiener notas
wiener canvas --help # shows Canvas-only commands
wiener canvas tareas FB6N1 # equivalent to: wiener tareas FB6N1
The canonical user invocation is top-level plano (wiener tareas FB6N1).
Namespaces exist for filtered help and disambiguation.
Output contract
Every command supports --json for machine-parseable output. Default is human
table with color (picocolors + cli-table3).
Success envelope
{
"ok": true,
"data": { /* command-specific */ },
"meta": {
"duration_ms": 412,
"rate_limit_remaining": 698.5, // Canvas only
"from_cache": false
}
}
Error envelope
{
"ok": false,
"error": {
"code": "course-ambiguous",
"message": "Multiple courses match \"labo\"",
"hint": "Try `wiener cursos` to see exact aliases",
"details": {
"candidates": [
{ "code": "FB6M4", "alias": "labo", "name": "LABORATORIO Y DIAGNÓSTICO II", "score": 0.98 }
]
},
"next_steps": ["wiener cursos --json"] // structured hints for agents
}
}
Canonical error codes (always use these in agent error handling)
| Code | When | Recovery |
|---|---|---|
auth-required |
No session, command needs one | run wiener auth login (T2) |
auth-expired |
Intranet session timed out | run wiener auth login (T2) |
auth-invalid-credentials |
Wrong intranet password | check creds, retry login |
canvas-not-configured |
No PAT set, Canvas command attempted | run wiener auth canvas pat new (T2) |
canvas-token-invalid |
PAT rejected (401) | run wiener auth canvas pat new (T2) — token revoked |
course-not-found |
Resolver returned 0 candidates | check wiener cursos |
course-ambiguous |
Resolver returned >1 with no clear winner | use exact code or --exact |
assignment-not-found |
Submit: assignment ref didn't resolve | use wiener tareas <course> to see ids |
submission-locked |
Assignment lock_at is in the past | submission window closed; can't submit |
submission-no-attempts |
All attempts used | check Canvas for instructor extension |
submission-invalid-extension |
File ext not in allowed_extensions |
use accepted format |
file-not-found |
Local file path doesn't exist | check path, retry |
network-error |
DNS/TCP/TLS issue | check network, retry |
rate-limited |
Canvas rlr < 50 + retry-after present |
wait, retry later |
parse-error |
HTML parser failed (intranet shape changed) | run wiener doctor; may need CLI update |
validation-error |
Input failed schema | check args |
not-implemented |
Command exists but feature deferred | n/a |
Schema introspection (use this when constructing complex --params)
wiener schema notas # full I/O JSON schema for `wiener notas`
wiener schema --list # list all commands
wiener schema tareas submit # for nested commands
Agents should call wiener schema <cmd> instead of relying on --help text
when constructing complex --params '<json>' invocations.
Gotchas (read before using)
-
Two passwords: intranet password ≠ Microsoft/Canvas password. If the user only has intranet access, Canvas commands fail with
error.code = "canvas-not-configured". Suggestwiener auth canvas pat new. -
MS Entra requires number-matching MFA: cannot be automated from CLI. Always direct the user to do PAT generation manually once via
wiener auth canvas pat new. After that, the PAT works headlessly forever (until revoked). -
Wrong-password 200: intranet returns HTTP 200 for bad credentials with
estado: "0"JSON. CLI handles this —auth loginaborts cleanly witherror.code = "auth-invalid-credentials". -
Session expiry: intranet ASP session ~30 min idle. CLI detects expiry via
SiguNet.htmNXDOMAIN-redirect signature, wipes stored session, returnserror.code = "auth-expired". Agent should runwiener auth login(T2, requires interactive confirm). -
Course splits T/P/PD: each Wiener course splits into theory + practice as separate Canvas courses with same
course_code. CLI groups by default (e.g. 8 logical courses from 11 Canvas courses). Use--allto flatten or--seccion T/--seccion P1to filter. -
Wiener customizations in Canvas:
- 2 LTI tabs per course (
Microsoft Education,Búsqueda con IgniteAI) — opaque to API. Usewiener cursos abrir <ref>to open in browser. - 7 sidebar tabs admin-hidden but API works.
- 6 institutional global announcements visible on dashboard — reachable
via
wiener anuncios globales.
- 2 LTI tabs per course (
-
Wiener admin endpoint restrictions (v0.4.0+): Some Canvas endpoints are blocked at the institutional level — not by the PAT. The CLI distinguishes these from token errors:
GET /courses/{id}/files— blocked.wiener archivos listautomatically falls back to module items (/modules?include[]=items) as the source. Files attached to modules appear; files uploaded directly to the Files tab do not.GET /courses/{id}/files/{id}— blocked.wiener archivos downloadshows a helpful message and instructs the user to copy the download URL fromwiener archivos listoutput and use the--urlflag instead.- Per-course features (pages, quizzes, conferences) can be disabled by the
instructor. If so, those commands show a friendly message and redirect to
wiener modulos <ref>. wiener doctorincludes a Capabilities matrix that probes all endpoints and shows what works vs. what is restricted, with 1h cache.- Error codes:
wiener-restricted-endpoint(admin block) vs.canvas-not-configured(token problem) — these are intentionally different.
-
Canvas rate limit: 3000 req/hour per token. CLI tracks via
X-Canvas-Metarlr=header. Approaching limit (rlr < 100) surfaces a warning in--verbosemode but continues. -
No bulk write of
tramite generar: rate-limited to 1/min internally even with--yes. Same fortareas submit. -
Submission upload preserves filenames: Canvas accepts files via 3-step API (prepare → upload → submit). The displayed filename in Canvas is the local filename, not a sanitized version.
-
watchis opt-in: never auto-starts at login. Single instance enforced via~/.wiener/watch.pid. Stop withwiener watch stop. -
TLS strict: CLI never disables cert verification.
-
Dead
sso.wienergroup.comdomain: known Wiener intranet bug — unauth deep-links 302-redirect to a meta-refresh into the deadsso.wienergroup.comdomain. CLI detects this signature and treats it as auth-failure.
Environment variables
| Var | Required | Description |
|---|---|---|
WIENER_INTRANET_USER |
No | Auto-login bootstrap. Format: letter + 9 digits. |
WIENER_INTRANET_PASS |
No | Auto-login bootstrap. Held in memory only, never persisted. |
WIENER_INTRANET_PERFIL |
No | A (Alumno, default), D (Docente), P (Administrativo). |
WIENER_CANVAS_TOKEN |
No | Per-invocation Canvas PAT override. |
WIENER_PROFILE |
No | Default profile name (default default). |
WIENER_CONFIG_DIR |
No | Override config dir. |
WIENER_WATCH_WHATSAPP_URL |
No | Kapso webhook for wiener watch --whatsapp. |
Files and storage
OS-aware paths (cligentic xdg-paths block):
- macOS:
~/Library/Application Support/wiener/ - Linux:
$XDG_CONFIG_HOME/wiener/(falls back to~/.config/wiener/)
Layout:
config.json— global configaudit/YYYY-MM-DD.jsonl— daily-rotated audit log of T2 + verbose T0state.json— snapshots backingwiener nuevodiffaliases.json— custom course aliases per profilewatch.pid+watch.log— background watchcache/— 5-min TTL cache for Canvas responses<profile>/— per-profile state (CSRF token, periodos, doctor results, sessions if no keychain)
macOS Keychain items (preferred over file fallback):
wiener-cli.intranet.<profile>— ASP cookie + perfil + codigowiener-cli.canvas.<profile>— PAT + validatedAt + userId
Global flags (every command)
| Flag | Purpose |
|---|---|
--json |
JSON envelope to stdout |
--ndjson |
One JSON object per line (paginated/streaming) |
--dry-run |
Preview T2 mutations without executing |
--verbose |
Detailed stderr + audit-log T0 commands |
--quiet |
Suppress stderr |
--no-input |
Force non-interactive (auto-on if !isTTY(stdin)) |
--yes |
Skip T2 confirmations |
--exact |
Course resolver: exact match only, no fuzzy |
--fields a,b,c |
Project specific keys in JSON output |
--params '<json>' |
Canonical input override (wins over sugar flags) |
--config PATH |
Override config dir |
--profile NAME |
Use named profile |
--help, -h |
Help |
--version, -v |
Version |
When NOT to use this CLI
- Paying tuition or trámite obligations — generate the order with
wiener tramite generarthen pay in the user's bank app. The CLI does not handle banking integrations. - Course enrollment / matrícula — separate portal at
matricula.uwiener.edu.pe, not in scope. - Posting to discussions / replying to inbox — read-only in v0.2.
- Modifying any academic record — locked down by design and by API.
- Operating on another student's data — only authenticated user's own records.
- Bypassing Microsoft Entra MFA — manual PAT generation is mandatory.
Installation
# Recommended: bunx (no install needed)
bunx @railly/wiener-cli@latest
# Or install globally
bun install -g @railly/wiener-cli
wiener --help
# Or from source
git clone https://github.com/Railly/wiener-cli
cd wiener-cli
bun install
bun run build:bin
./bin/wiener --help
Repo: https://github.com/Railly/wiener-cli (public) npm: https://npmjs.com/package/@railly/wiener-cli
Agent decision tree
When user asks something Wiener-related, route as follows:
"qué tengo / qué se viene / qué hay / cómo va mi día" → wiener
"qué clase tengo ahora / a qué hora termina" → wiener ahora
"qué entregas tengo / qué debo hacer" → wiener tareas hoy or wiener tareas semana
"qué nota saqué en X" → wiener notas (intranet, official) or wiener calificaciones <ref> (Canvas, formative)
"cuándo es el examen de X" → wiener examenes
"cómo voy en X" → wiener calificaciones <ref>
"qué subió el profe / hay material nuevo" → wiener nuevo or wiener archivos <ref>
"descárgame todos los pdfs de X" → wiener archivos sync <ref> (T2)
"sube esta tarea por mí" → wiener tareas submit <course> <assignment> <files...> (T2)
"pago / debo / cuánto debo" → wiener pagos
"plan de estudios / cuántos créditos llevo" → wiener plan avance
"asistencias / cuántas faltas" → wiener asistencia
"cualquier cosa de Canvas que la web no muestra" → check the hidden-tabs commands above
Always pass --json when the agent needs to parse output. Always run
wiener doctor --json first if user reports something not working.