campus-cli
Advanced tools
| /** Best-effort analytics: failures never affect the campus client. */ | ||
| export declare function track(event: string, properties?: Record<string, unknown>, userId?: string): void; |
| "use strict"; | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.track = track; | ||
| const node_crypto_1 = __importDefault(require("node:crypto")); | ||
| const node_fs_1 = __importDefault(require("node:fs")); | ||
| const node_os_1 = __importDefault(require("node:os")); | ||
| const node_path_1 = __importDefault(require("node:path")); | ||
| const POSTHOG_HOST = (process.env.POSTHOG_HOST ?? 'https://us.i.posthog.com').replace(/\/$/, ''); | ||
| const POSTHOG_KEY = process.env.POSTHOG_API_KEY ?? 'phc_mVYDii8qujKLxaCagZomJjR4B2Cd53FieYqDyBPe4zGw'; | ||
| const INSTALL_FILE = node_path_1.default.join(node_os_1.default.homedir(), '.blackboard-cli', 'analytics-id'); | ||
| function installId() { | ||
| try { | ||
| if (node_fs_1.default.existsSync(INSTALL_FILE)) | ||
| return node_fs_1.default.readFileSync(INSTALL_FILE, 'utf8').trim(); | ||
| const id = node_crypto_1.default.randomUUID(); | ||
| node_fs_1.default.mkdirSync(node_path_1.default.dirname(INSTALL_FILE), { recursive: true, mode: 0o700 }); | ||
| node_fs_1.default.writeFileSync(INSTALL_FILE, id, { mode: 0o600 }); | ||
| return id; | ||
| } | ||
| catch { | ||
| return node_crypto_1.default.randomUUID(); | ||
| } | ||
| } | ||
| /** Best-effort analytics: failures never affect the campus client. */ | ||
| function track(event, properties = {}, userId) { | ||
| if (process.env.POSTHOG_DISABLED === '1' || !POSTHOG_KEY) | ||
| return; | ||
| const distinctId = userId ? `bb:${userId}` : `install:${installId()}`; | ||
| void fetch(`${POSTHOG_HOST}/capture/`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| api_key: POSTHOG_KEY, | ||
| event, | ||
| distinct_id: distinctId, | ||
| properties: { ...properties, app: 'campus-cli', version: process.env.npm_package_version ?? '1.1.2' }, | ||
| }), | ||
| }).catch(() => { }); | ||
| } |
| import { chromium, type BrowserContext } from 'playwright'; | ||
| type PersistentContextOptions = Parameters<typeof chromium.launchPersistentContext>[1]; | ||
| export declare function launchPersistentContextSafe(profileDir: string, options: PersistentContextOptions): Promise<BrowserContext>; | ||
| export {}; |
| export declare function sequentializeByKey<T>(queues: Map<string, Promise<void>>, key: string, fn: () => Promise<T>): Promise<T>; |
| #!/usr/bin/env node | ||
| export {}; |
| export declare function startMcpServer(): Promise<void>; |
| import type { AxiosInstance } from 'axios'; | ||
| export interface GradeColumn { | ||
| id: string; | ||
| name: string; | ||
| contentId?: string; | ||
| score?: { | ||
| possible: number; | ||
| }; | ||
| availability?: { | ||
| available: string; | ||
| }; | ||
| grading?: { | ||
| type: 'Attempts' | 'Manual' | 'Calculated'; | ||
| due?: string; | ||
| attemptsAllowed?: number; | ||
| scoringModel?: string; | ||
| }; | ||
| gradebookCategoryId?: string; | ||
| scoreProviderHandle?: string; | ||
| includeInCalculations?: boolean; | ||
| } | ||
| export interface Attempt { | ||
| id: string; | ||
| userId?: string; | ||
| status: string; | ||
| displayGrade?: { | ||
| score?: number; | ||
| text?: string; | ||
| }; | ||
| score?: number; | ||
| text?: string; | ||
| studentComments?: string; | ||
| studentSubmission?: string; | ||
| created?: string; | ||
| modified?: string; | ||
| attemptDate?: string; | ||
| files?: Array<{ | ||
| id: string; | ||
| fileName: string; | ||
| mimeType: string; | ||
| }>; | ||
| instructorFeedback?: string; | ||
| feedback?: string; | ||
| } | ||
| export interface AttemptFile { | ||
| id: string; | ||
| name: string; | ||
| mimeType?: string; | ||
| size?: number; | ||
| href?: string; | ||
| } | ||
| export interface SubmitAttemptBody { | ||
| studentComments?: string; | ||
| studentSubmission?: string; | ||
| fileUploadIds?: string[]; | ||
| status?: string; | ||
| } | ||
| export declare function listAssignments(client: AxiosInstance, courseId: string): Promise<GradeColumn[]>; | ||
| export declare function getAssignment(client: AxiosInstance, courseId: string, columnId: string): Promise<GradeColumn>; | ||
| export declare function listAttempts(client: AxiosInstance, courseId: string, columnId: string): Promise<Attempt[]>; | ||
| export declare function getAttempt(client: AxiosInstance, courseId: string, columnId: string, attemptId: string): Promise<Attempt>; | ||
| export declare function uploadFile(client: AxiosInstance, filePath: string): Promise<string>; | ||
| export declare function submitAttempt(client: AxiosInstance, courseId: string, columnId: string, body: SubmitAttemptBody): Promise<Attempt>; | ||
| export declare function getAttemptFiles(client: AxiosInstance, courseId: string, columnId: string, attemptId: string): Promise<AttemptFile[]>; | ||
| export declare function getMyGrade(client: AxiosInstance, courseId: string, columnId: string, userId: string): Promise<any>; |
| import { AxiosInstance } from 'axios'; | ||
| import type { Session } from '../types.js'; | ||
| export declare function createClient(session: Session): AxiosInstance; |
| import type { AxiosInstance } from 'axios'; | ||
| import type { Course, UserCourse, PaginatedResponse } from '../types.js'; | ||
| export declare function getMe(client: AxiosInstance): Promise<any>; | ||
| export declare function getMyCourses(client: AxiosInstance, userId: string, opts?: { | ||
| limit?: number; | ||
| offset?: number; | ||
| }): Promise<PaginatedResponse<UserCourse & { | ||
| course?: Course; | ||
| }>>; | ||
| export declare function getCourse(client: AxiosInstance, courseId: string): Promise<Course>; | ||
| export declare function listCourses(client: AxiosInstance, opts?: { | ||
| limit?: number; | ||
| offset?: number; | ||
| }): Promise<PaginatedResponse<Course>>; | ||
| export declare function getCourseContents(client: AxiosInstance, courseId: string, parentId?: string): Promise<PaginatedResponse<any>>; | ||
| export declare function getCourseAnnouncements(client: AxiosInstance, courseId: string): Promise<PaginatedResponse<any>>; | ||
| export declare function getGradeColumns(client: AxiosInstance, courseId: string): Promise<PaginatedResponse<any>>; | ||
| export declare function getGrades(client: AxiosInstance, courseId: string, userId: string): Promise<PaginatedResponse<any>>; | ||
| export declare function getCourseMemberships(client: AxiosInstance, courseId: string): Promise<PaginatedResponse<any>>; | ||
| export declare function getSystemVersion(client: AxiosInstance): Promise<any>; |
| import type { Session, Cookie } from '../types.js'; | ||
| export interface LoginOptions { | ||
| headless?: boolean; | ||
| username?: string; | ||
| password?: string; | ||
| timeout?: number; | ||
| } | ||
| export declare class SilentLoginFailed extends Error { | ||
| constructor(reason: string); | ||
| } | ||
| export declare function resolveDisplayName(userData: any): string | undefined; | ||
| export declare function getSsoExpiry(cookies: Cookie[]): number | undefined; | ||
| export declare function isBlackboardUltraUrl(value: string | URL): boolean; | ||
| export declare function login(opts?: LoginOptions): Promise<Session>; | ||
| export declare function silentRelogin(previousSession?: Session | null): Promise<Session>; |
| import type { Session } from '../types.js'; | ||
| export declare function saveSession(session: Session): void; | ||
| export declare function loadSession(): Session | null; | ||
| export declare function clearSession(opts?: { | ||
| keepProfile?: boolean; | ||
| }): void; | ||
| export declare function clearBrowserProfile(): void; | ||
| export declare function isSessionValid(session: Session | null): boolean; | ||
| export declare function loadOrRefreshSession(): Promise<Session | null>; |
| import { Command } from 'commander'; | ||
| export declare function apiDocsCommand(program: Command): void; |
| import { Command } from 'commander'; | ||
| export declare function isPendingAssignment(grade: any): boolean; | ||
| export declare function assignmentsCommand(program: Command): void; |
| import { Command } from 'commander'; | ||
| export declare function coursesCommand(program: Command): void; |
| import { Command } from 'commander'; | ||
| export declare function downloadCommand(program: Command): void; |
| import { Command } from 'commander'; | ||
| export declare function loginCommand(program: Command): void; |
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerBlackboardTools(server: McpServer): void; |
| export interface Session { | ||
| cookies: Cookie[]; | ||
| xsrfToken: string; | ||
| userId?: string; | ||
| userName?: string; | ||
| expiresAt: number; | ||
| } | ||
| export interface Cookie { | ||
| name: string; | ||
| value: string; | ||
| domain: string; | ||
| path: string; | ||
| expires?: number; | ||
| httpOnly?: boolean; | ||
| secure?: boolean; | ||
| sameSite?: string; | ||
| } | ||
| export interface Course { | ||
| id: string; | ||
| courseId: string; | ||
| name: string; | ||
| description?: string; | ||
| externalId?: string; | ||
| created?: string; | ||
| modified?: string; | ||
| term?: { | ||
| id: string; | ||
| name: string; | ||
| }; | ||
| availability?: { | ||
| available: string; | ||
| }; | ||
| enrollment?: { | ||
| type: string; | ||
| }; | ||
| ultraStatus?: string; | ||
| } | ||
| export interface UserCourse { | ||
| userId: string; | ||
| courseId: string; | ||
| dataSourceId?: string; | ||
| created?: string; | ||
| modified?: string; | ||
| availability?: { | ||
| available: string; | ||
| }; | ||
| courseRoleId?: string; | ||
| lastAccessDate?: string; | ||
| childCourseId?: string; | ||
| course?: Course; | ||
| } | ||
| export interface CourseContent { | ||
| id: string; | ||
| parentId?: string; | ||
| title: string; | ||
| body?: string; | ||
| created?: string; | ||
| modified?: string; | ||
| position?: number; | ||
| hasChildren?: boolean; | ||
| launchInNewWindow?: boolean; | ||
| availability?: { | ||
| available: string; | ||
| adaptiveRelease?: object; | ||
| }; | ||
| contentHandler?: { | ||
| id: string; | ||
| url?: string; | ||
| file?: { | ||
| uploadId: string; | ||
| fileName: string; | ||
| mimeType: string; | ||
| size: number; | ||
| }; | ||
| }; | ||
| } | ||
| export interface Announcement { | ||
| id: string; | ||
| title: string; | ||
| body: string; | ||
| creator?: string; | ||
| created?: string; | ||
| modified?: string; | ||
| availability?: { | ||
| available: string; | ||
| duration?: { | ||
| type: string; | ||
| start?: string; | ||
| end?: string; | ||
| }; | ||
| }; | ||
| showReorder?: boolean; | ||
| } | ||
| export interface GradeColumn { | ||
| id: string; | ||
| externalId?: string; | ||
| name: string; | ||
| displayName?: string; | ||
| description?: string; | ||
| externalGrade?: boolean; | ||
| created?: string; | ||
| score?: { | ||
| possible: number; | ||
| decimalPlaces: number; | ||
| }; | ||
| availability?: { | ||
| available: string; | ||
| }; | ||
| gradingPeriodId?: string; | ||
| contentId?: string; | ||
| formula?: { | ||
| formulaType: string; | ||
| }; | ||
| includeInCalculations?: boolean; | ||
| showStatisticsToStudents?: boolean; | ||
| } | ||
| export interface PaginatedResponse<T> { | ||
| results: T[]; | ||
| paging?: { | ||
| nextPage?: string; | ||
| }; | ||
| } | ||
| export interface BBError { | ||
| status: number; | ||
| message: string; | ||
| extraInfo?: string; | ||
| } |
| export declare const upcRed: import("chalk").ChalkInstance; | ||
| export declare const upcRedBold: import("chalk").ChalkInstance; | ||
| export declare const dim: import("chalk").ChalkInstance; | ||
| export declare const ok: (s: string) => string; | ||
| export declare const fail: (s: string) => string; | ||
| export declare const warn: (s: string) => string; | ||
| export declare const hint: (s: string) => string; | ||
| export declare const bold: (s: string) => string; | ||
| export declare const gray: (s: string) => string; | ||
| export declare const BANNER: string; | ||
| export declare function formatSessionLifetime(bbExpiresAt: number, ssoExpiresAt?: number): { | ||
| summary: string; | ||
| note: string; | ||
| }; | ||
| export declare function whatNext(): void; |
+11
-0
@@ -7,2 +7,13 @@ # Changelog | ||
| ## [1.2.0] — 2026-08-05 | ||
| ### Added | ||
| - `blackboard_list_people` — lista los docentes y compañeros de un curso, resolviendo el id interno de usuario que traen anuncios y notas a un nombre real. Con `search`, busca a una persona puntual y devuelve su email; sin `search`, separa instructores de compañeros (compañeros solo por nombre, sin contacto, salvo que se pida uno en particular). | ||
| - Analítica de uso opcional vía PostHog: inicio de la CLI, comandos ejecutados, logins, sesiones vencidas, uso de tools MCP, entregas de tareas, etc. No se envían cookies, contraseñas, cursos, tareas ni calificaciones — solo el id de Blackboard como identificador estable. Se puede desactivar con `POSTHOG_DISABLED=1` o apuntar a un proyecto propio con `POSTHOG_API_KEY`/`POSTHOG_HOST` (ver README). | ||
| ### Notes | ||
| - El backend de trabajo en progreso (dashboard local, relay MCP hospedado, worker de navegador remoto, founders) se movió a un repo privado aparte que consume `campus-cli` como dependencia; nunca estuvo expuesto en una versión publicada, así que no hay cambio visible para quien ya usa el CLI o el MCP. | ||
| --- | ||
| ## [1.1.2] — 2026-07-29 | ||
@@ -9,0 +20,0 @@ |
+27
-1
@@ -40,2 +40,4 @@ #!/usr/bin/env node | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| const fs_1 = __importDefault(require("fs")); | ||
| const path_1 = __importDefault(require("path")); | ||
| const commander_1 = require("commander"); | ||
@@ -53,7 +55,30 @@ const chalk_1 = __importDefault(require("chalk")); | ||
| const theme_js_1 = require("./ui/theme.js"); | ||
| const analytics_js_1 = require("./analytics.js"); | ||
| const { version } = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '../package.json'), 'utf-8')); | ||
| const program = new commander_1.Command(); | ||
| (0, analytics_js_1.track)('cli_started', { | ||
| command: process.argv.slice(2, 4).filter((arg) => !arg.startsWith('-')).join(' ') || 'help', | ||
| }, (0, session_js_1.loadSession)()?.userId); | ||
| // Commander hooks give every CLI command the same success/error/latency | ||
| // coverage, including commands that do not call Blackboard directly here. | ||
| program.hook('preAction', (thisCommand, actionCommand) => { | ||
| actionCommand.__analyticsStartedAt = Date.now(); | ||
| actionCommand.__analyticsName = actionCommand.name(); | ||
| (0, analytics_js_1.track)('cli_command_started', { | ||
| command: actionCommand.name(), | ||
| parent_command: thisCommand.name(), | ||
| }, (0, session_js_1.loadSession)()?.userId); | ||
| }); | ||
| program.hook('postAction', (thisCommand, actionCommand) => { | ||
| (0, analytics_js_1.track)('cli_command_completed', { | ||
| command: actionCommand.name(), | ||
| parent_command: thisCommand.name(), | ||
| success: true, | ||
| duration_ms: Date.now() - (actionCommand.__analyticsStartedAt ?? Date.now()), | ||
| }, (0, session_js_1.loadSession)()?.userId); | ||
| }); | ||
| program | ||
| .name('campus') | ||
| .description('CLI no oficial para tu campus universitario (Blackboard, Canvas, Moodle...)') | ||
| .version('1.0.0') | ||
| .version(version) | ||
| .addHelpText('beforeAll', theme_js_1.BANNER); | ||
@@ -150,4 +175,5 @@ // Auth commands | ||
| program.parseAsync(process.argv).catch((err) => { | ||
| (0, analytics_js_1.track)('cli_error', { error_type: err instanceof Error ? err.name : 'CommandError', command: process.argv[2] ?? 'unknown' }, (0, session_js_1.loadSession)()?.userId); | ||
| console.error(chalk_1.default.red(err.message)); | ||
| process.exit(1); | ||
| }); |
@@ -7,2 +7,3 @@ "use strict"; | ||
| const mcp_tools_js_1 = require("../providers/blackboard/mcp-tools.js"); | ||
| const analytics_js_1 = require("../analytics.js"); | ||
| const INSTRUCTIONS = ` | ||
@@ -33,2 +34,3 @@ campus-cli conecta el campus universitario del estudiante con su agente de IA. | ||
| async function startMcpServer() { | ||
| (0, analytics_js_1.track)('mcp_started'); | ||
| const server = new mcp_js_1.McpServer({ | ||
@@ -35,0 +37,0 @@ name: 'campus-cli', |
@@ -48,2 +48,3 @@ "use strict"; | ||
| const os_1 = __importDefault(require("os")); | ||
| const analytics_js_1 = require("../../../analytics.js"); | ||
| const SESSION_DIR = path_1.default.join(os_1.default.homedir(), '.blackboard-cli'); | ||
@@ -65,2 +66,3 @@ const SESSION_FILE = path_1.default.join(SESSION_DIR, 'session.json'); | ||
| if (session.expiresAt && Date.now() > session.expiresAt) { | ||
| (0, analytics_js_1.track)('session_expired', {}, session.userId); | ||
| return null; // expired | ||
@@ -67,0 +69,0 @@ } |
@@ -16,2 +16,3 @@ "use strict"; | ||
| const assignments_js_1 = require("../api/assignments.js"); | ||
| const analytics_js_1 = require("../../../analytics.js"); | ||
| function requireSession() { | ||
@@ -214,2 +215,3 @@ const session = (0, session_js_1.loadSession)(); | ||
| const attempts = await (0, assignments_js_1.listAttempts)(client, courseId, columnId); | ||
| (0, analytics_js_1.track)('attempts_viewed', { success: true, attempts_count: attempts.length }, session.userId); | ||
| spinner.succeed(`${attempts.length} attempt(s)`); | ||
@@ -248,2 +250,3 @@ if (opts.json) { | ||
| catch (err) { | ||
| (0, analytics_js_1.track)('attempts_view_error', { success: false, error_type: err?.name ?? 'AttemptsError' }, session.userId); | ||
| spinner.fail(err.message); | ||
@@ -265,2 +268,5 @@ process.exit(1); | ||
| const client = (0, client_js_1.createClient)(session); | ||
| const startedAt = Date.now(); | ||
| const mode = opts.draft ? 'draft' : 'submit'; | ||
| (0, analytics_js_1.track)('assignment_submission_started', { mode, has_file: !!opts.file, has_text: !!opts.text, has_comments: !!opts.comments }, session.userId); | ||
| if (!opts.file && !opts.text && !opts.comments) { | ||
@@ -283,5 +289,7 @@ console.error(chalk_1.default.red('Provide at least --file, --text, or --comments')); | ||
| fileUploadIds = [uploadId]; | ||
| (0, analytics_js_1.track)('assignment_file_uploaded', { success: true, mode, duration_ms: Date.now() - startedAt }, session.userId); | ||
| uploadSpinner.succeed(`File uploaded (id: ${uploadId})`); | ||
| } | ||
| catch (e) { | ||
| (0, analytics_js_1.track)('assignment_file_upload_error', { success: false, mode, error_type: e?.name ?? 'UploadError', status_code: e?.response?.status }, session.userId); | ||
| uploadSpinner.fail(`Upload failed: ${e.message}`); | ||
@@ -300,2 +308,5 @@ process.exit(1); | ||
| submitSpinner.succeed(`Submitted! Attempt ID: ${attempt.id}`); | ||
| (0, analytics_js_1.track)(opts.draft ? 'assignment_draft_saved' : 'assignment_submitted', { | ||
| success: true, mode, duration_ms: Date.now() - startedAt, has_file: !!fileUploadIds, | ||
| }, session.userId); | ||
| if (opts.json) { | ||
@@ -313,2 +324,6 @@ console.log(JSON.stringify(attempt, null, 2)); | ||
| catch (err) { | ||
| (0, analytics_js_1.track)('assignment_submission_error', { | ||
| success: false, mode, duration_ms: Date.now() - startedAt, | ||
| error_type: err?.name ?? 'SubmissionError', status_code: err?.response?.status, | ||
| }, session.userId); | ||
| const body = err.response?.data; | ||
@@ -315,0 +330,0 @@ console.error(chalk_1.default.red(`\n✗ ${err.message}`)); |
@@ -14,2 +14,3 @@ "use strict"; | ||
| const theme_js_1 = require("../../../ui/theme.js"); | ||
| const analytics_js_1 = require("../../../analytics.js"); | ||
| function loginCommand(program) { | ||
@@ -35,2 +36,3 @@ program | ||
| console.log(chalk_1.default.gray('A browser window will open. Complete the login and it will close automatically.\n')); | ||
| (0, analytics_js_1.track)('login_started', { method: 'microsoft_sso' }, existing?.userId); | ||
| try { | ||
@@ -42,2 +44,3 @@ const session = await (0, login_js_1.login)({ | ||
| }); | ||
| (0, analytics_js_1.track)('login_success', { method: 'microsoft_sso' }, session.userId); | ||
| const ssoExpiresAt = (0, login_js_1.getSsoExpiry)(session.cookies); | ||
@@ -55,2 +58,3 @@ const { summary, note } = (0, theme_js_1.formatSessionLifetime)(session.expiresAt, ssoExpiresAt); | ||
| catch (err) { | ||
| (0, analytics_js_1.track)('login_failed', { method: 'microsoft_sso', error_type: err?.name ?? 'LoginError' }); | ||
| console.error(chalk_1.default.red(`\n✗ Login failed: ${err.message}`)); | ||
@@ -57,0 +61,0 @@ process.exit(1); |
@@ -14,2 +14,3 @@ "use strict"; | ||
| const assignments_js_1 = require("./api/assignments.js"); | ||
| const analytics_js_1 = require("../../analytics.js"); | ||
| const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50MB | ||
@@ -24,4 +25,28 @@ async function getClient() { | ||
| function registerBlackboardTools(server) { | ||
| // Keep usage analytics at the tool boundary. Arguments and Blackboard | ||
| // responses are deliberately not included in the event. | ||
| const registerTrackedTool = (name, ...parts) => { | ||
| const handler = parts.pop(); | ||
| return server.registerTool(name, ...parts, async (...input) => { | ||
| const startedAt = Date.now(); | ||
| let session; | ||
| try { | ||
| session = await (0, session_js_1.loadOrRefreshSession)(); | ||
| const result = await handler(...input); | ||
| (0, analytics_js_1.track)('mcp_tool_used', { tool: name, success: true, duration_ms: Date.now() - startedAt }, session?.userId); | ||
| return result; | ||
| } | ||
| catch (error) { | ||
| (0, analytics_js_1.track)('mcp_tool_error', { | ||
| tool: name, | ||
| success: false, | ||
| duration_ms: Date.now() - startedAt, | ||
| error_type: error instanceof Error ? error.name : 'UnknownError', | ||
| }, session?.userId); | ||
| throw error; | ||
| } | ||
| }); | ||
| }; | ||
| // ── blackboard_whoami ───────────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_whoami', { description: 'Get the currently authenticated UPC student info' }, async () => { | ||
| registerTrackedTool('blackboard_whoami', { description: 'Get the currently authenticated UPC student info' }, async () => { | ||
| const { client } = await getClient(); | ||
@@ -32,3 +57,3 @@ const me = await (0, courses_js_1.getMe)(client); | ||
| // ── blackboard_system_version ───────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_system_version', { description: 'Get Blackboard Learn server version' }, async () => { | ||
| registerTrackedTool('blackboard_system_version', { description: 'Get Blackboard Learn server version' }, async () => { | ||
| const { client } = await getClient(); | ||
@@ -39,3 +64,3 @@ const v = await (0, courses_js_1.getSystemVersion)(client); | ||
| // ── blackboard_list_courses ──────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_list_courses', { description: 'List all enrolled courses for the current student' }, async () => { | ||
| registerTrackedTool('blackboard_list_courses', { description: 'List all enrolled courses for the current student' }, async () => { | ||
| const { client, session } = await getClient(); | ||
@@ -51,3 +76,3 @@ let userId = session.userId; | ||
| // ── blackboard_get_course ────────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_get_course', { | ||
| registerTrackedTool('blackboard_get_course', { | ||
| description: 'Get details of a specific course by its Blackboard ID (e.g. _529580_1)', | ||
@@ -61,3 +86,3 @@ inputSchema: { courseId: zod_1.z.string().describe('Blackboard course ID like _529580_1') }, | ||
| // ── blackboard_list_contents ─────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_list_contents', { | ||
| registerTrackedTool('blackboard_list_contents', { | ||
| description: 'List content items inside a course or folder. Use parentId to navigate into subfolders.', | ||
@@ -74,3 +99,3 @@ inputSchema: { | ||
| // ── blackboard_list_announcements ────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_list_announcements', { | ||
| registerTrackedTool('blackboard_list_announcements', { | ||
| description: 'List recent announcements for a course', | ||
@@ -83,4 +108,56 @@ inputSchema: { courseId: zod_1.z.string().describe('Blackboard course ID') }, | ||
| }); | ||
| // ── blackboard_list_people ───────────────────────────────────────────────────────────── | ||
| // Announcements and grades carry an internal user id and nothing else, so | ||
| // without this the professor is unnameable. Contact details are held back for | ||
| // classmates unless the student asks for one by name — see the cloud | ||
| // executor, which applies the same rule. | ||
| registerTrackedTool('blackboard_list_people', { | ||
| description: "Instructors and classmates of a course. Use it to resolve an internal user id into a person's name. Pass search to look up one person and get their contact details.", | ||
| inputSchema: { | ||
| courseId: zod_1.z.string().describe('Blackboard course ID'), | ||
| search: zod_1.z.string().optional().describe('Name of one person in the course'), | ||
| }, | ||
| }, async ({ courseId, search }) => { | ||
| const { client } = await getClient(); | ||
| const response = await client.get(`/learn/api/public/v1/courses/${courseId}/users`, { | ||
| // Only the fields we use: the full object also carries avatars and | ||
| // every classmate's last-access timestamp. | ||
| params: { | ||
| expand: 'user', | ||
| limit: 200, | ||
| fields: 'courseRoleId,user.name.given,user.name.family,user.contact.email', | ||
| }, | ||
| }); | ||
| const members = (response.data?.results ?? []); | ||
| const nameOf = (member) => [member.user?.name?.given, member.user?.name?.family].filter(Boolean).join(' ').trim(); | ||
| const term = search?.trim().toLowerCase(); | ||
| const data = term | ||
| ? { | ||
| query: search, | ||
| matches: members | ||
| .filter((member) => nameOf(member).toLowerCase().includes(term)) | ||
| .map((member) => ({ | ||
| name: nameOf(member), | ||
| role: member.courseRoleId === 'Student' ? 'compañero' : 'docente', | ||
| email: member.user?.contact?.email ?? null, | ||
| })), | ||
| } | ||
| : { | ||
| instructors: members | ||
| .filter((member) => member.courseRoleId !== 'Student') | ||
| .map((member) => ({ | ||
| name: nameOf(member), | ||
| role: member.courseRoleId, | ||
| email: member.user?.contact?.email ?? null, | ||
| })), | ||
| classmates: members | ||
| .filter((member) => member.courseRoleId === 'Student') | ||
| .map((member) => nameOf(member)) | ||
| .filter(Boolean) | ||
| .sort((a, b) => a.localeCompare(b, 'es')), | ||
| }; | ||
| return { content: [{ type: 'text', text: JSON.stringify(data) }] }; | ||
| }); | ||
| // ── blackboard_list_assignments ──────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_list_assignments', { | ||
| registerTrackedTool('blackboard_list_assignments', { | ||
| description: 'List assignments and tasks in a course with due dates, scores and submission status', | ||
@@ -94,3 +171,3 @@ inputSchema: { courseId: zod_1.z.string().describe('Blackboard course ID') }, | ||
| // ── blackboard_list_attempts ─────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_list_attempts', { | ||
| registerTrackedTool('blackboard_list_attempts', { | ||
| description: 'List submission attempts for a specific assignment (gradebook column)', | ||
@@ -107,3 +184,3 @@ inputSchema: { | ||
| // ── blackboard_get_grades ────────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_get_grades', { | ||
| registerTrackedTool('blackboard_get_grades', { | ||
| description: 'Get all grades for the current student in a course', | ||
@@ -130,3 +207,3 @@ inputSchema: { courseId: zod_1.z.string().describe('Blackboard course ID') }, | ||
| // ── blackboard_download_attachment ───────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_download_attachment', { | ||
| registerTrackedTool('blackboard_download_attachment', { | ||
| description: 'Download a file from a course content item and save it to disk. attachmentId can be a Blackboard attachment ID (for x-bb-file) or a full bbcswebdav URL (for x-bb-document embedded files). Saves to outputDir (default: current working directory).', | ||
@@ -165,3 +242,3 @@ inputSchema: { | ||
| // ── blackboard_list_attachments ──────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_list_attachments', { | ||
| registerTrackedTool('blackboard_list_attachments', { | ||
| description: 'List file attachments for a course content item. Works for x-bb-file (REST API) and x-bb-document (embedded files in body HTML).', | ||
@@ -215,3 +292,3 @@ inputSchema: { | ||
| // ── blackboard_download_file_url ─────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_download_file_url', { | ||
| registerTrackedTool('blackboard_download_file_url', { | ||
| description: 'Download a file directly from a Blackboard bbcswebdav URL and save it to disk. Saves to outputDir (default: current working directory).', | ||
@@ -245,3 +322,3 @@ inputSchema: { | ||
| // ── blackboard_upload_attempt_file ───────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_upload_attempt_file', { | ||
| registerTrackedTool('blackboard_upload_attempt_file', { | ||
| description: 'Upload a local file (image, PDF, doc, etc.) to Blackboard and get back a fileUploadId. ' + | ||
@@ -272,3 +349,3 @@ 'This only uploads the file — it does NOT attach it to an attempt yet. ' + | ||
| // ── blackboard_save_attempt_draft ────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_save_attempt_draft', { | ||
| registerTrackedTool('blackboard_save_attempt_draft', { | ||
| description: 'Save progress on an assignment attempt WITHOUT submitting it — text, attached files, or both. ' + | ||
@@ -296,3 +373,3 @@ 'The attempt stays open (status InProgress) so the student can keep editing it later. ' + | ||
| // ── blackboard_submit_attempt ────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_submit_attempt', { | ||
| registerTrackedTool('blackboard_submit_attempt', { | ||
| description: 'Submit (finalize) an assignment attempt for grading — text, attached files, or both. ' + | ||
@@ -320,3 +397,3 @@ 'ALWAYS confirm with the user before submitting, showing exactly what will be sent. ' + | ||
| // ── blackboard_get_assignment_feedback ───────────────────────────────────────────────── | ||
| server.registerTool('blackboard_get_assignment_feedback', { | ||
| registerTrackedTool('blackboard_get_assignment_feedback', { | ||
| description: 'Get professor feedback and scores for all assignments in a course. ' + | ||
@@ -373,3 +450,3 @@ 'For each graded submission, shows score, instructor comments, and any feedback files attached by the professor.', | ||
| // ── blackboard_download_feedback_file ─────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_download_feedback_file', { | ||
| registerTrackedTool('blackboard_download_feedback_file', { | ||
| description: '[EXPERIMENTAL] Download a feedback file that a professor attached to a graded attempt. ' + | ||
@@ -409,3 +486,3 @@ 'Use the fileId from blackboard_get_assignment_feedback → attempt.feedbackFiles. ' + | ||
| // ── blackboard_raw_api ───────────────────────────────────────────────────────────────── | ||
| server.registerTool('blackboard_raw_api', { | ||
| registerTrackedTool('blackboard_raw_api', { | ||
| description: 'Make a raw REST API call to Blackboard Learn. Use for any endpoint not covered by other tools.', | ||
@@ -412,0 +489,0 @@ inputSchema: { |
+23
-2
| { | ||
| "name": "campus-cli", | ||
| "version": "1.1.2", | ||
| "version": "1.2.0", | ||
| "description": "CLI/MCP no oficial para el campus universitario (Blackboard, Canvas, Moodle...) — acceso desde la terminal y MCP para IA", | ||
| "main": "run.js", | ||
| "exports": { | ||
| ".": "./run.js", | ||
| "./session": "./dist/providers/blackboard/auth/session.js", | ||
| "./login": "./dist/providers/blackboard/auth/login.js", | ||
| "./client": "./dist/providers/blackboard/api/client.js", | ||
| "./courses": "./dist/providers/blackboard/api/courses.js", | ||
| "./assignments-api": "./dist/providers/blackboard/api/assignments.js", | ||
| "./assignments": "./dist/providers/blackboard/commands/assignments.js", | ||
| "./types": "./dist/providers/blackboard/types.js", | ||
| "./mcp-tools": "./dist/providers/blackboard/mcp-tools.js", | ||
| "./analytics": "./dist/analytics.js" | ||
| }, | ||
| "files": [ | ||
@@ -23,3 +35,4 @@ "dist", | ||
| "dev": "tsx src/index.ts", | ||
| "start": "node dist/index.js" | ||
| "start": "node dist/index.js", | ||
| "landing:css": "esbuild landing/styles.css --minify --outfile=landing/styles.min.css --allow-overwrite" | ||
| }, | ||
@@ -48,3 +61,6 @@ "keywords": [ | ||
| "dependencies": { | ||
| "@browserbasehq/sdk": "^2.16.0", | ||
| "@modelcontextprotocol/node": "^2.0.0", | ||
| "@modelcontextprotocol/sdk": "^1.28.0", | ||
| "@modelcontextprotocol/server": "^2.0.0", | ||
| "@types/inquirer": "^9.0.9", | ||
@@ -56,9 +72,14 @@ "@types/node": "^22.0.0", | ||
| "form-data": "^4.0.5", | ||
| "google-auth-library": "^9.15.1", | ||
| "inquirer": "^10.1.0", | ||
| "ora": "^8.1.0", | ||
| "playwright": "^1.47.0", | ||
| "playwright-core": "^1.62.0", | ||
| "tsx": "^4.19.0", | ||
| "typescript": "^5.5.0", | ||
| "zod": "^4.3.6" | ||
| }, | ||
| "devDependencies": { | ||
| "@modelcontextprotocol/client": "^2.0.0" | ||
| } | ||
| } |
+14
-1
@@ -331,3 +331,3 @@ # campus-cli | ||
| - La sesión local se guarda en `~/.blackboard-cli/session.json` con permisos restrictivos. | ||
| - No se envían cookies, credenciales ni datos del campus a servidores externos del proyecto. | ||
| - No se envían cookies, credenciales ni datos académicos a servidores externos; la analítica opcional de PostHog solo recibe eventos de uso. | ||
| - Úsalo solo con tu propia cuenta y respeta las reglas de tu universidad. | ||
@@ -413,2 +413,15 @@ | ||
| ## Analítica de uso con PostHog | ||
| El cliente registra de forma anónima el inicio de la CLI, los logins exitosos y la apertura del dashboard en PostHog. Se usa el ID de Blackboard únicamente como identificador estable; no se envían cookies, contraseñas, cursos, tareas ni calificaciones. | ||
| La clave pública del proyecto está configurada por defecto. Para cambiar el proyecto o desactivar la analítica: | ||
| ```bash | ||
| POSTHOG_API_KEY=phc_... POSTHOG_HOST=https://us.i.posthog.com campus status | ||
| POSTHOG_DISABLED=1 campus status | ||
| ``` | ||
| En PostHog puedes consultar `login_started`, `login_success`, `login_failed`, `session_expired`, `cli_started`, `cli_command_started`, `cli_command_completed`, `cli_error`, `mcp_tool_used`, `mcp_tool_error`, `dashboard_opened`, `dashboard_loaded`, `dashboard_error`, `attempts_viewed`, `assignment_submission_started`, `assignment_file_uploaded`, `assignment_file_upload_error`, `assignment_draft_saved`, `assignment_submitted` y `assignment_submission_error`. Las propiedades `tool`, `command`, `mode`, `success`, `duration_ms`, `error_type` y `status_code` permiten analizar usuarios nuevos, retención, abandono del login, sesiones vencidas, errores, tiempos de respuesta, herramientas y comandos más usados, borradores y entregas finales. | ||
| Las contribuciones más útiles ahora son: | ||
@@ -415,0 +428,0 @@ |
Network access
Supply chain riskThis module accesses the network.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
166992
15.59%40
90.48%2784
19.03%437
3.07%18
38.46%1
Infinity%20
66.67%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed