Sign In

campus-cli

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

campus-cli - npm Package Compare versions

Comparing version
1.1.2
to
1.2.0
+2
dist/analytics.d.ts
/** 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: {

{
"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"
}
}

@@ -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 @@