@josetra/dutic-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,223 +1,226 @@
1
- # dutic-mcp
2
-
3
- **Servidor MCP + CLI para el aula virtual DUTIC (Moodle) de la UNSA.**
4
- Tus tareas —incluidas las **ocultas**—, notas, materiales de estudio y compañeros, en la terminal
5
- y disponibles para agentes de IA (Claude Code, Antigravity, OpenCode, mimocode…).
6
-
7
- ```
8
- ┌─────────────────────────────────────────────┐
9
- │ 24 tareas · 10 SIN ENTREGAR │
10
- │ 18 ocultas que el calendario no te muestra │
11
- └─────────────────────────────────────────────┘
12
- ```
13
-
14
- ## Por qué existe
15
-
16
- El timeline de Moodle sólo muestra tareas **accionables** (futuras y sin entregar). Las tareas sin
17
- fecha de calendario, ya vencidas o ya entregadas **desaparecen de la vista** — y así se pierden
18
- entregas. `dutic` barre todos los cursos, encuentra esas tareas, las marca como `OCULTA` y ordena
19
- lo pendiente por urgencia.
20
-
21
- ---
22
-
23
- ## Instalación
24
-
25
- **Requisitos:** [Node.js](https://nodejs.org) ≥ 20 y Google Chrome (o Edge) instalado.
26
- No hace falta descargar Chromium: se usa el navegador que ya tienes.
27
-
28
- ### Un solo comando
29
-
30
- ```bash
31
- npm install -g @joswetra/dutic-mcp
32
- ```
33
-
34
- Esto deja `dutic` y `dutic-mcp` en tu PATH.
35
-
36
- <details>
37
- <summary>Instalar desde el código fuente</summary>
38
-
39
- ```bash
40
- git clone https://github.com/JOSETRA44/dutic-mcp.git
41
- cd dutic-mcp
42
- npm install # compila automáticamente (script prepare)
43
- npm install -g . # deja los comandos en el PATH
44
- ```
45
- </details>
46
-
47
- ---
48
-
49
- ## Primeros pasos
50
-
51
- ### 1. Configura tus agentes (una vez)
52
-
53
- ```bash
54
- dutic setup
55
- ```
56
-
57
- Registra el servidor MCP en los agentes que tengas instalados y copia la skill `dutic` a sus
58
- directorios. Hace copia de seguridad (`*.dutic-bak`) y **no toca** el resto de tu configuración.
59
-
60
- ```
61
- [OK] Claude Code (MCP) C:\Users\tu-usuario\.claude.json
62
- [OK] Antigravity (MCP) ...\.antigravity\config\mcp_config.json
63
- [OK] OpenCode (MCP) ...\.config\opencode\opencode.jsonc
64
- [OK] mimocode (MCP) ...\.config\mimocode\mimocode.jsonc
65
- [OK] Claude Code (skill) ...\.claude\skills\dutic
66
- ```
67
-
68
- > Reinicia cada agente para que cargue el servidor.
69
-
70
- ### 2. Inicia sesión (una vez)
71
-
72
- ```bash
73
- dutic login
74
- ```
75
-
76
- Se abre tu Chrome en el aula virtual. Pulsa **«Ingresar con Correo UNSA»**, elige tu cuenta de
77
- Google y espera: la ventana se cierra sola al capturar la sesión. El perfil del navegador queda
78
- guardado, así que las renovaciones posteriores suelen ser automáticas.
79
-
80
- ### 3. Compruébalo
81
-
82
- ```bash
83
- dutic status # ¿sesión válida?
84
- dutic tasks --all # tus tareas, incluidas las ocultas
85
- ```
86
-
87
- Si ves tus tareas, ya está todo listo. Pídeselo también a tu agente:
88
- *«¿tengo alguna tarea pendiente en el aula virtual?»*
89
-
90
- ---
91
-
92
- ## Uso — CLI
93
-
94
- | Comando | Qué hace |
95
- |---|---|
96
- | `dutic tasks` | Tareas próximas del timeline (rápido) |
97
- | `dutic tasks --all` | **+ barrido de cursos → incluye las ocultas** |
98
- | `dutic tasks --hidden` | Sólo las ocultas |
99
- | `dutic task <cmid>` | Detalle: consigna, fechas, adjuntos, conflicto de fechas |
100
- | `dutic grades [id]` | Notas: resumen de todos los cursos, o detalle de uno |
101
- | `dutic courses` | Cursos matriculados |
102
- | `dutic materials <id> [--section "Tema 2"]` | Archivos del curso, por unidad |
103
- | `dutic study <id> [--section "Tema 2"]` | Baja materiales y convierte PDFs a Markdown |
104
- | `dutic read <url>` | Lee un recurso (PDF→Markdown) para analizarlo |
105
- | `dutic md <archivo.pdf>` | Convierte un PDF local a Markdown |
106
- | `dutic people <id> [--email]` | Compañeros del curso (con correo) |
107
- | `dutic person <texto>` | Busca a alguien por nombre o correo |
108
- | `dutic teachers <id>` | Docentes del curso |
109
- | `dutic pull <id>` | Descarga todos los materiales |
110
- | `dutic setup` / `dutic login` / `dutic status` | Configuración y sesión |
111
-
112
- Añade `--json` a la mayoría de comandos para salida estructurada.
113
-
114
- ### Ejemplos
115
-
116
- ```bash
117
- # ¿Qué me falta entregar?
118
- dutic tasks --all
119
-
120
- # Preparar sólo la unidad que voy a estudiar
121
- dutic study 2279 --section "Tema 2" --dest ./materiales
122
-
123
- # ¿Qué pide exactamente esta tarea?
124
- dutic task 385686
125
-
126
- # El correo de mi compañero de grupo
127
- dutic person "Piero"
128
- ```
129
-
130
- ---
131
-
132
- ## Uso — con agentes (MCP)
133
-
134
- Tras `dutic setup` no hay nada más que hacer: pregúntale a tu agente por tus tareas, notas o
135
- materiales y usará las herramientas del servidor.
136
-
137
- <details>
138
- <summary>Configuración manual (otros clientes MCP)</summary>
139
-
140
- ```json
141
- {
142
- "mcpServers": {
143
- "dutic": {
144
- "command": "dutic-mcp",
145
- "env": { "DUTIC_SEMESTER": "2026A" }
146
- }
147
- }
148
- }
149
- ```
150
-
151
- Si tu cliente no resuelve comandos del PATH, usa la ruta absoluta que imprime `dutic setup`:
152
- `{ "command": "node", "args": ["<ruta>/dist/mcp/server.js"] }`
153
- </details>
154
-
155
- **19 herramientas**: tareas (`dutic_list_tasks`, `dutic_get_assignment_detail`, …), notas
156
- (`dutic_get_grades`), materiales (`dutic_list_course_materials`, `dutic_study_course`,
157
- `dutic_read_resource`, `dutic_pdf_to_markdown`), personas (`dutic_list_participants`,
158
- `dutic_find_person`, `dutic_get_course_teachers`) y sesión.
159
-
160
- ---
161
-
162
- ## Configuración
163
-
164
- | Variable | Para qué | Por defecto |
165
- |---|---|---|
166
- | `DUTIC_SEMESTER` | Semestre en la URL del aula (`2026A`, `2026B`…) | `2026A` |
167
- | `DUTIC_BROWSER_CHANNEL` | Navegador para el login: `chrome`, `msedge`, `chromium` | `chrome` |
168
- | `DUTIC_DATA_DIR` | Dónde guardar sesión y perfil | `~/.dutic` |
169
-
170
- El semestre sólo se usa para la URL de login: tras iniciar sesión **se auto-detecta** del propio
171
- aula, así que al cambiar de período normalmente no hay que tocar nada.
172
-
173
- ---
174
-
175
- ## Cómo funciona
176
-
177
- No usa la API pública de web services (la UNSA la tiene bloqueada). Captura la cookie
178
- `MoodleSession` y el token `sesskey` tras el login de Google (Playwright manejando tu Chrome) y con
179
- ellos llama al endpoint AJAX interno de Moodle, complementado con scraping donde hace falta.
180
-
181
- | Necesidad | Fuente | Estado |
182
- |---|---|---|
183
- | Descubrir todas las tareas | `core_courseformat_get_state` | ✅ |
184
- | Cursos matriculados | `core_course_get_enrolled_courses_by_timeline_classification` | |
185
- | Timeline y fechas | `core_calendar_get_action_events_by_timesort` | ✅ (sólo accionables) |
186
- | Estado de entrega, consigna, adjuntos | scraping de `mod/assign/view.php` | ✅ |
187
- | Notas | scraping de `grade/report/user/index.php` | ✅ |
188
- | Personas y correos | scraping de `user/index.php` y `user/view.php` | ✅ |
189
- | `core_course_get_contents`, `mod_assign_*`, `gradereport_*` | | bloqueadas por la UNSA |
190
-
191
- **Fechas contradictorias:** algunas consignas mencionan una fecha distinta a la configurada en
192
- Moodle. `dutic task <cmid>` compara ambas y avisa (`dateConflict`) es la causa típica de entregas
193
- perdidas.
194
-
195
- ---
196
-
197
- ## Publicar en npm
198
-
199
- ```bash
200
- npm login # cuenta de npm
201
- npm version patch # o minor / major
202
- npm publish # el paquete es scoped y público (publishConfig.access)
203
- git push --follow-tags
204
- ```
205
-
206
- `prepublishOnly` compila antes de publicar y `files` limita el tarball a `dist/` y `skills/`.
207
-
208
- > Si tu scope de npm no es `@joswetra`, cambia el campo `name` en `package.json` por
209
- > `@tu-scope/dutic-mcp` (o un nombre sin scope que esté libre).
210
-
211
- ---
212
-
213
- ## Privacidad y seguridad
214
-
215
- - La sesión (`~/.dutic/session.json`) y el perfil del navegador contienen credenciales de tu
216
- cuenta: no se versionan y el archivo se crea con permisos restrictivos.
217
- - El certificado de `aulavirtual.unsa.edu.pe` (CA privada de la UNSA) se acepta **sólo** para ese host.
218
- - La herramienta accede únicamente a lo que ya ves en el aula. Donde Moodle oculta información
219
- (docentes en el listado, compañeros de otros grupos) se respeta esa restricción.
220
-
221
- ## Licencia
222
-
223
- MIT © JOSETRA44
1
+ # dutic-mcp
2
+
3
+ **Servidor MCP + CLI para el aula virtual DUTIC (Moodle) de la UNSA.**
4
+ Tus tareas —incluidas las **ocultas**—, notas, materiales de estudio y compañeros, en la terminal
5
+ y disponibles para agentes de IA (Claude Code, Antigravity, OpenCode, mimocode…).
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────┐
9
+ │ 24 tareas · 10 SIN ENTREGAR │
10
+ │ 18 ocultas que el calendario no te muestra │
11
+ └─────────────────────────────────────────────┘
12
+ ```
13
+
14
+ ## Por qué existe
15
+
16
+ El timeline de Moodle sólo muestra tareas **accionables** (futuras y sin entregar). Las tareas sin
17
+ fecha de calendario, ya vencidas o ya entregadas **desaparecen de la vista** — y así se pierden
18
+ entregas. `dutic` barre todos los cursos, encuentra esas tareas, las marca como `OCULTA` y ordena
19
+ lo pendiente por urgencia.
20
+
21
+ ---
22
+
23
+ ## Instalación
24
+
25
+ **Requisitos:** [Node.js](https://nodejs.org) ≥ 20 y Google Chrome (o Edge) instalado.
26
+ No hace falta descargar Chromium: se usa el navegador que ya tienes.
27
+
28
+ ### Un solo comando
29
+
30
+ ```bash
31
+ npm install -g @josetra/dutic-mcp
32
+ ```
33
+
34
+ Esto deja `dutic` y `dutic-mcp` en tu PATH.
35
+
36
+ <details>
37
+ <summary>Instalar desde el código fuente</summary>
38
+
39
+ ```bash
40
+ git clone https://github.com/JOSETRA44/dutic-mcp.git
41
+ cd dutic-mcp
42
+ npm install # compila automáticamente (script prepare)
43
+ npm install -g . # deja los comandos en el PATH
44
+ ```
45
+ </details>
46
+
47
+ ---
48
+
49
+ ## Primeros pasos
50
+
51
+ ### 1. Configura tus agentes (una vez)
52
+
53
+ ```bash
54
+ dutic setup
55
+ ```
56
+
57
+ Registra el servidor MCP en los agentes que tengas instalados y copia la skill `dutic` a sus
58
+ directorios. Hace copia de seguridad (`*.dutic-bak`) y **no toca** el resto de tu configuración.
59
+
60
+ ```
61
+ [OK] Claude Code (MCP) C:\Users\tu-usuario\.claude.json
62
+ [OK] Antigravity (MCP) ...\.antigravity\config\mcp_config.json
63
+ [OK] OpenCode (MCP) ...\.config\opencode\opencode.jsonc
64
+ [OK] mimocode (MCP) ...\.config\mimocode\mimocode.jsonc
65
+ [OK] Claude Code (skill) ...\.claude\skills\dutic
66
+ ```
67
+
68
+ > Reinicia cada agente para que cargue el servidor.
69
+
70
+ ### 2. Inicia sesión (una vez)
71
+
72
+ ```bash
73
+ dutic login
74
+ ```
75
+
76
+ Se abre tu Chrome en el aula virtual. Pulsa **«Ingresar con Correo UNSA»**, elige tu cuenta de
77
+ Google y espera: la ventana se cierra sola al capturar la sesión. El perfil del navegador queda
78
+ guardado, así que las renovaciones posteriores suelen ser automáticas.
79
+
80
+ ### 3. Compruébalo
81
+
82
+ ```bash
83
+ dutic status # ¿sesión válida?
84
+ dutic tasks --all # tus tareas, incluidas las ocultas
85
+ ```
86
+
87
+ Si ves tus tareas, ya está todo listo. Pídeselo también a tu agente:
88
+ *«¿tengo alguna tarea pendiente en el aula virtual?»*
89
+
90
+ ---
91
+
92
+ ## Uso — CLI
93
+
94
+ | Comando | Qué hace |
95
+ |---|---|
96
+ | `dutic tasks` | Tareas próximas del timeline (rápido) |
97
+ | `dutic tasks --all` | **+ barrido de cursos → incluye las ocultas** |
98
+ | `dutic tasks --hidden` | Sólo las ocultas |
99
+ | `dutic task <cmid>` | Detalle: consigna, fechas, adjuntos, conflicto de fechas |
100
+ | `dutic grades [id]` | Notas: resumen de todos los cursos, o detalle de uno |
101
+ | `dutic courses` | Cursos matriculados |
102
+ | `dutic materials <id> [--section "Tema 2"]` | Archivos del curso, por unidad |
103
+ | `dutic study <id> [--section "Tema 2"]` | Baja materiales y convierte PDFs a Markdown |
104
+ | `dutic read <url>` | Lee un recurso (PDF→Markdown) para analizarlo |
105
+ | `dutic md <archivo.pdf>` | Convierte un PDF local a Markdown |
106
+ | `dutic people <id>` | Todos los compañeros del curso, con correo (`--no-email` para omitir) |
107
+ | `dutic person <texto>` | Busca por nombre/correo: su correo y **sus cursos reales** (con grupo), marcando cuáles compartes |
108
+ | `dutic profile <userId>` | Perfil de cualquier id (docentes incluidos): correo y sus cursos |
109
+ | `dutic fetch <url>` | Explora cualquier página del aula por URL (cambiar ids, ver lo que no tiene botón) |
110
+ | `dutic teachers <id>` | Docentes del curso |
111
+ | `dutic pull <id>` | Descarga todos los materiales |
112
+ | `dutic setup` / `dutic login` / `dutic status` | Configuración y sesión |
113
+
114
+ Añade `--json` a la mayoría de comandos para salida estructurada.
115
+
116
+ ### Ejemplos
117
+
118
+ ```bash
119
+ # ¿Qué me falta entregar?
120
+ dutic tasks --all
121
+
122
+ # Preparar sólo la unidad que voy a estudiar
123
+ dutic study 2279 --section "Tema 2" --dest ./materiales
124
+
125
+ # ¿Qué pide exactamente esta tarea?
126
+ dutic task 385686
127
+
128
+ # ¿Quién es y en qué cursos coincido con él? (correo + todos los cursos compartidos)
129
+ dutic person "Piero"
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Uso con agentes (MCP)
135
+
136
+ Tras `dutic setup` no hay nada más que hacer: pregúntale a tu agente por tus tareas, notas o
137
+ materiales y usará las herramientas del servidor.
138
+
139
+ <details>
140
+ <summary>Configuración manual (otros clientes MCP)</summary>
141
+
142
+ ```json
143
+ {
144
+ "mcpServers": {
145
+ "dutic": {
146
+ "command": "dutic-mcp",
147
+ "env": { "DUTIC_SEMESTER": "2026A" }
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ Si tu cliente no resuelve comandos del PATH, usa la ruta absoluta que imprime `dutic setup`:
154
+ `{ "command": "node", "args": ["<ruta>/dist/mcp/server.js"] }`
155
+ </details>
156
+
157
+ **20 herramientas**: tareas (`dutic_list_tasks`, `dutic_get_assignment_detail`, …), notas
158
+ (`dutic_get_grades`), materiales (`dutic_list_course_materials`, `dutic_study_course`,
159
+ `dutic_read_resource`, `dutic_pdf_to_markdown`), personas (`dutic_list_participants`,
160
+ `dutic_find_person`, `dutic_get_person_profile`, `dutic_get_course_teachers`), exploración por URL
161
+ (`dutic_fetch_page`) y sesión.
162
+
163
+ ---
164
+
165
+ ## Configuración
166
+
167
+ | Variable | Para qué | Por defecto |
168
+ |---|---|---|
169
+ | `DUTIC_SEMESTER` | Semestre en la URL del aula (`2026A`, `2026B`…) | `2026A` |
170
+ | `DUTIC_BROWSER_CHANNEL` | Navegador para el login: `chrome`, `msedge`, `chromium` | `chrome` |
171
+ | `DUTIC_DATA_DIR` | Dónde guardar sesión y perfil | `~/.dutic` |
172
+
173
+ El semestre sólo se usa para la URL de login: tras iniciar sesión **se auto-detecta** del propio
174
+ aula, así que al cambiar de período normalmente no hay que tocar nada.
175
+
176
+ ---
177
+
178
+ ## Cómo funciona
179
+
180
+ No usa la API pública de web services (la UNSA la tiene bloqueada). Captura la cookie
181
+ `MoodleSession` y el token `sesskey` tras el login de Google (Playwright manejando tu Chrome) y con
182
+ ellos llama al endpoint AJAX interno de Moodle, complementado con scraping donde hace falta.
183
+
184
+ | Necesidad | Fuente | Estado |
185
+ |---|---|---|
186
+ | Descubrir todas las tareas | `core_courseformat_get_state` | ✅ |
187
+ | Cursos matriculados | `core_course_get_enrolled_courses_by_timeline_classification` | ✅ |
188
+ | Timeline y fechas | `core_calendar_get_action_events_by_timesort` | ✅ (sólo accionables) |
189
+ | Estado de entrega, consigna, adjuntos | scraping de `mod/assign/view.php` | |
190
+ | Notas | scraping de `grade/report/user/index.php` | ✅ |
191
+ | Personas y correos | scraping de `user/index.php` y `user/view.php` | ✅ |
192
+ | `core_course_get_contents`, `mod_assign_*`, `gradereport_*` | | bloqueadas por la UNSA |
193
+
194
+ **Fechas contradictorias:** algunas consignas mencionan una fecha distinta a la configurada en
195
+ Moodle. `dutic task <cmid>` compara ambas y avisa (`dateConflict`) — es la causa típica de entregas
196
+ perdidas.
197
+
198
+ ---
199
+
200
+ ## Publicar en npm
201
+
202
+ ```bash
203
+ npm login # cuenta de npm
204
+ npm version patch # o minor / major
205
+ npm publish # el paquete es scoped y público (publishConfig.access)
206
+ git push --follow-tags
207
+ ```
208
+
209
+ `prepublishOnly` compila antes de publicar y `files` limita el tarball a `dist/` y `skills/`.
210
+
211
+ > Si tu scope de npm no es `@joswetra`, cambia el campo `name` en `package.json` por
212
+ > `@tu-scope/dutic-mcp` (o un nombre sin scope que esté libre).
213
+
214
+ ---
215
+
216
+ ## Privacidad y seguridad
217
+
218
+ - La sesión (`~/.dutic/session.json`) y el perfil del navegador contienen credenciales de tu
219
+ cuenta: no se versionan y el archivo se crea con permisos restrictivos.
220
+ - El certificado de `aulavirtual.unsa.edu.pe` (CA privada de la UNSA) se acepta **sólo** para ese host.
221
+ - La herramienta accede únicamente a lo que tú ya ves en el aula. Donde Moodle oculta información
222
+ (docentes en el listado, compañeros de otros grupos) se respeta esa restricción.
223
+
224
+ ## Licencia
225
+
226
+ MIT © JOSETRA44
package/dist/cli/index.js CHANGED
@@ -12,8 +12,11 @@ import { convertLocalPdfToMarkdown, readResourceAsMarkdown, studyCourseMaterials
12
12
  import { getAllGrades, getCourseGrades } from "../domain/grades.js";
13
13
  import { getAssignDetail } from "../domain/assign.js";
14
14
  import { findPeople, getCourseTeachers, getPersonProfile, listCourseParticipants, } from "../domain/people.js";
15
+ import { fetchAulaPage } from "../domain/fetch.js";
16
+ import { parseCourseName } from "../core/coursename.js";
17
+ import { humanizeAgo } from "../core/dates.js";
15
18
  import { formatTaskLine } from "./format.js";
16
- import { banner, c, mark, progressBar, rule, table } from "./ui.js";
19
+ import { banner, c, mark, progressBar, rule, statusLine, table } from "./ui.js";
17
20
  import { MCP_SERVER_PATH, runSetup } from "./setup.js";
18
21
  import { readFileSync } from "node:fs";
19
22
  import { dirname, join, resolve } from "node:path";
@@ -158,20 +161,18 @@ program
158
161
  });
159
162
  program
160
163
  .command("people <courseId>")
161
- .description("Participantes visibles del curso (compañeros de tu grupo).")
162
- .option("--email", "Resuelve el correo de cada uno (más lento).")
164
+ .description("Participantes del curso, con su correo (recorre todas las páginas).")
165
+ .option("--no-email", "No resolver los correos (más rápido).")
163
166
  .option("--json", "Salida en JSON.")
164
167
  .action(async (courseId, opts) => {
165
168
  await withSession(async (session) => {
166
- const ppl = await listCourseParticipants(session, Number(courseId));
167
- const rows = await Promise.all(ppl.map(async (p) => {
168
- let email = "";
169
- if (opts.email) {
170
- const prof = await getPersonProfile(session, p.userId, Number(courseId), p.name).catch(() => null);
171
- email = prof?.email ?? "—";
172
- }
173
- return [p.name, p.role ?? "—", p.group ?? "—", p.lastAccess ?? "—", ...(opts.email ? [email] : [])];
174
- }));
169
+ const withEmail = opts.email !== false; // commander: --no-email ⇒ opts.email === false
170
+ const status = statusLine();
171
+ const ppl = await listCourseParticipants(session, Number(courseId), {
172
+ withEmail,
173
+ onProgress: ({ phase, done, total, label }) => status.set(`${phase} ${done}/${total} ${c.dim((label ?? "").slice(0, 34))}`),
174
+ });
175
+ status.done();
175
176
  if (opts.json)
176
177
  return out(JSON.stringify(ppl, null, 2));
177
178
  out(banner("Participantes", `${ppl.length} · curso ${courseId}`));
@@ -180,8 +181,14 @@ program
180
181
  { header: "rol", color: c.dim },
181
182
  { header: "grupo", color: c.dim },
182
183
  { header: "último acceso", color: c.dim },
183
- ...(opts.email ? [{ header: "correo", color: c.cyan }] : []),
184
- ], rows));
184
+ ...(withEmail ? [{ header: "correo", color: c.cyan }] : []),
185
+ ], ppl.map((p) => [
186
+ p.name,
187
+ p.role ?? "—",
188
+ p.group ?? "—",
189
+ p.lastAccess ?? "—",
190
+ ...(withEmail ? [p.email ?? "—"] : []),
191
+ ])));
185
192
  }, { login: { onStatus: log } });
186
193
  });
187
194
  program
@@ -190,16 +197,68 @@ program
190
197
  .option("--json", "Salida en JSON.")
191
198
  .action(async (query, opts) => {
192
199
  await withSession(async (session) => {
193
- const found = await findPeople(session, query);
200
+ const status = statusLine();
201
+ const found = await findPeople(session, query, {
202
+ onProgress: ({ phase, done, total, label }) => status.set(`${phase} ${done}/${total} ${c.dim((label ?? "").slice(0, 38))}`),
203
+ });
204
+ status.done();
194
205
  if (opts.json)
195
206
  return out(JSON.stringify(found, null, 2));
196
207
  out(banner("Personas", `"${query}" · ${found.length} resultado(s)`));
197
208
  for (const p of found) {
198
- out(`${mark.arrow()} ${c.bold(p.name)}`);
199
- out(` ${c.dim("curso:")} ${p.courseName} ${c.dim("grupo:")} ${p.group ?? ""}`);
200
- if (p.email)
201
- out(` ${c.dim("correo:")} ${c.cyan(p.email)}`);
202
- out(` ${c.dim("último acceso:")} ${p.lastAccess ?? "—"}`);
209
+ out(`\n${mark.arrow()} ${c.bold(p.name)}`);
210
+ out(` ${c.dim("correo:")} ${p.email ? c.cyan(p.email) : c.gray("no visible")}`);
211
+ out(` ${c.dim("visto:")} ${p.lastAccess ?? c.gray("—")}` +
212
+ ` ${c.dim(`(${humanizeAgo(p.lastSeenAgoSeconds)}, lo más reciente)`)}`);
213
+ out(` ${c.dim("cursos:")} ${c.bold(String(p.courses.length))} en total · ` +
214
+ `${c.green(String(p.sharedCount))} contigo`);
215
+ for (const cr of p.courses) {
216
+ const grp = cr.group ? c.dim(` · ${cr.group}`) : "";
217
+ const flag = cr.shared ? c.green("✓ contigo") : c.gray("· su curso");
218
+ const acc = cr.shared && cr.lastAccess ? c.dim(` visto ${cr.lastAccess}`) : "";
219
+ out(` ${cr.shared ? c.green("●") : c.gray("○")} ${cr.subject}${grp} ${flag}${acc}`);
220
+ }
221
+ }
222
+ }, { login: { onStatus: log } });
223
+ });
224
+ program
225
+ .command("profile <userId>")
226
+ .description("Perfil de cualquier usuario por id: correo y sus cursos (sirve para docentes).")
227
+ .option("--course <id>", "Curso de contexto que revela sus cursos (usa uno que compartas).")
228
+ .option("--json", "Salida en JSON.")
229
+ .action(async (userId, opts) => {
230
+ await withSession(async (session) => {
231
+ const prof = await getPersonProfile(session, Number(userId), opts.course ? Number(opts.course) : undefined);
232
+ if (opts.json)
233
+ return out(JSON.stringify(prof, null, 2));
234
+ out(banner("Perfil", prof.name));
235
+ out(` ${c.dim("id:")} ${prof.userId}`);
236
+ out(` ${c.dim("correo:")} ${prof.email ? c.cyan(prof.email) : c.gray("no visible")}`);
237
+ out(` ${c.dim("zona:")} ${prof.timezone ?? "—"}`);
238
+ out(` ${c.dim("cursos:")} ${c.bold(String(prof.courses.length))}`);
239
+ for (const cr of prof.courses) {
240
+ out(` ${mark.bullet()} ${cr.subject}${cr.group ? c.dim(` · ${cr.group}`) : ""} ${c.gray(`(id ${cr.courseId})`)}`);
241
+ }
242
+ if (!prof.courses.length) {
243
+ log(c.dim(" (sin cursos visibles; prueba --course <id de un curso que compartas>)"));
244
+ }
245
+ }, { login: { onStatus: log } });
246
+ });
247
+ program
248
+ .command("fetch <url>")
249
+ .description("Descarga cualquier página del aula con tu sesión (explorar por URL, cambiar ids…).")
250
+ .option("--format <f>", "text | html | links", "text")
251
+ .option("--max <n>", "Máximo de caracteres.", "20000")
252
+ .action(async (url, opts) => {
253
+ await withSession(async (session) => {
254
+ const r = await fetchAulaPage(session, url, opts.format, Number(opts.max));
255
+ log(c.dim(`# ${r.finalUrl} (${r.status})`));
256
+ if (opts.format === "links" && r.links) {
257
+ for (const l of r.links)
258
+ out(`${c.cyan(l.href)} ${c.dim(l.text)}`);
259
+ }
260
+ else {
261
+ out(r.content);
203
262
  }
204
263
  }, { login: { onStatus: log } });
205
264
  });
@@ -249,8 +308,17 @@ program
249
308
  out(table([
250
309
  { header: "id", align: "right", color: c.dim },
251
310
  { header: "curso" },
311
+ { header: "grupo", color: c.dim },
252
312
  { header: "docente(s)", color: c.dim },
253
- ], courses.map((cr) => [String(cr.id), cr.fullname, cr.contacts.join(", ") || "—"])));
313
+ ], courses.map((cr) => {
314
+ const parsed = parseCourseName(cr.fullname);
315
+ return [
316
+ String(cr.id),
317
+ parsed.subject,
318
+ parsed.group ?? "—",
319
+ cr.contacts.join(", ") || "—",
320
+ ];
321
+ })));
254
322
  }, { login: { onStatus: log } });
255
323
  });
256
324
  const course = program.command("course").description("Operaciones sobre un curso.");